miru/src/lib/modules/auth/kitsu.ts
ThaUnknown 14720a07c6
feat: kitsu sync, local sync continue/planning list
fix: fav and bookmark icon not updating for local sync
fix: cards not showing local progress
fix: animated anilist PFPs not working
fix: menubar being selectable
fix: offline banner moving content outside of screen
fix: local sync entry deletion being problematic
2025-06-06 18:53:43 +02:00

465 lines
14 KiB
TypeScript

import { writable } from 'simple-store-svelte'
import { derived, readable } from 'svelte/store'
import { toast } from 'svelte-sonner'
import { client, type Media } from '../anilist'
import { mappings, mappingsByKitsuId } from '../anizip'
import native from '../native'
import type { Anime, Fav, KEntry, KitsuError, KitsuMediaStatus, Mapping, OAuth, Res, Resource, ResSingle, User } from './kitsu-types'
import type { Entry, FullMediaList, UserFrag } from '../anilist/queries'
import type { ResultOf, VariablesOf } from 'gql.tada'
import { arrayEqual, safeLocalStorage } from '$lib/utils'
const ENDPOINTS = {
API_OAUTH: 'https://kitsu.app/api/oauth/token',
API_USER_FETCH: 'https://kitsu.app/api/edge/users',
API_USER_LIBRARY: 'https://kitsu.app/api/edge/library-entries',
API_FAVOURITES: 'https://kitsu.app/api/edge/favorites'
} as const
type ALMediaStatus = 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING'
const KITSU_TO_AL_STATUS: Record<KitsuMediaStatus, ALMediaStatus> = {
current: 'CURRENT',
planned: 'PLANNING',
completed: 'COMPLETED',
dropped: 'DROPPED',
on_hold: 'PAUSED'
}
const AL_TO_KITSU_STATUS: Record<ALMediaStatus, KitsuMediaStatus> = {
CURRENT: 'current',
PLANNING: 'planned',
COMPLETED: 'completed',
DROPPED: 'dropped',
PAUSED: 'on_hold',
REPEATING: 'current'
}
export default new class KitsuSync {
auth = writable<OAuth | undefined>(safeLocalStorage('kitsuAuth'))
viewer = writable<ResultOf<typeof UserFrag> | undefined>(safeLocalStorage('kitsuViewer'))
userlist = writable<Record<string, ResultOf<typeof FullMediaList>>>({}) // al id to al mapped kitsu entry
favorites = writable<Record<string, string>>({}) // kitsu anime id to kitsu fav id
kitsuToAL: Record<string, string> = {}
ALToKitsu: Record<string, string> = {}
continueIDs = readable<number[]>([], set => {
let oldvalue: number[] = []
const sub = this.userlist.subscribe(values => {
const entries = Object.entries(values)
if (!entries.length) return []
const ids: number[] = []
for (const [alId, entry] of entries) {
if (entry.status === 'REPEATING' || entry.status === 'CURRENT') {
ids.push(Number(alId))
}
}
if (arrayEqual(oldvalue, ids)) return
oldvalue = ids
set(ids)
})
return sub
})
planningIDs = readable<number[]>([], set => {
let oldvalue: number[] = []
const sub = this.userlist.subscribe(values => {
const entries = Object.entries(values)
if (!entries.length) return []
const ids: number[] = []
for (const [alId, entry] of entries) {
if (entry.status === 'PLANNING') {
ids.push(Number(alId))
}
}
if (arrayEqual(oldvalue, ids)) return
oldvalue = ids
set(ids)
})
return sub
})
constructor () {
this.auth.subscribe((auth) => {
if (auth) localStorage.setItem('kitsuAuth', JSON.stringify(auth))
this._user()
})
this.viewer.subscribe((viewer) => {
if (viewer) localStorage.setItem('kitsuViewer', JSON.stringify(viewer))
})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async _request <T = object> (url: string | URL, method: string, body?: any): Promise<T | KitsuError> {
try {
if (this.auth.value) {
const expiresAt = (this.auth.value.created_at + this.auth.value.expires_in) * 1000
if (expiresAt < Date.now() - 1000 * 60 * 5) { // 5 minutes before expiry
await this._refresh()
}
}
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/vnd.api+json',
Authorization: this.auth.value ? `Bearer ${this.auth.value.access_token}` : ''
},
body: body ? JSON.stringify(body) : undefined
})
if (!res.ok) {
throw new Error(`Kitsu API Error: ${res.status} ${res.statusText}`)
}
if (method === 'DELETE') return undefined as T
const json = await res.json() as object | KitsuError
if ('error' in json) {
toast.error('Kitsu Error', { description: json.error_description })
console.error(json)
}
return json as T | KitsuError
} catch (error) {
const err = error as Error
toast.error('Kitsu Error', { description: err.message })
console.error(err)
return {
error: err.name,
error_description: err.stack ?? 'An unknown error occurred'
}
}
}
async _get <T> (target: string, body?: Record<string, unknown>): Promise<T | KitsuError> {
const url = new URL(target)
for (const [key, value] of Object.entries(body ?? {})) url.searchParams.append(key, String(value))
return await this._request<T>(url, 'GET')
}
async _delete <T> (url: string): Promise<T | KitsuError> {
return await this._request<T>(url, 'DELETE')
}
async _post <T> (url: string, body?: Record<string, unknown>): Promise<T | KitsuError> {
return await this._request<T>(url, 'POST', body)
}
async _patch <T> (url: string, body?: Record<string, unknown>): Promise<T | KitsuError> {
return await this._request<T>(url, 'PATCH', body)
}
async _refresh () {
const data = await this._post<OAuth>(
ENDPOINTS.API_OAUTH,
{
grant_type: 'refresh_token',
refresh_token: this.auth.value?.refresh_token
}
)
if ('error' in data) {
this.auth.value = undefined
} else {
this.auth.value = data
}
}
async login (username: string, password: string) {
const data = await this._request<OAuth>(
ENDPOINTS.API_OAUTH,
'POST',
{
grant_type: 'password',
username,
password
}
)
if ('error' in data) {
this.auth.value = undefined
} else {
this.auth.value = data
}
}
logout () {
localStorage.removeItem('kitsuViewer')
localStorage.removeItem('kitsuAuth')
native.restart()
}
async _user () {
const res = await this._get<Res<User>>(
ENDPOINTS.API_USER_FETCH,
{
'filter[self]': true,
include: 'favorites.item,libraryEntries.anime,libraryEntries.anime.mappings',
'fields[users]': 'name,about,avatar,coverImage,createdAt',
'fields[anime]': 'status,episodeCount,mappings',
'fields[mappings]': 'externalSite,externalId',
'fields[libraryEntries]': 'anime,progress,status,reconsumeCount,reconsuming,rating'
}
)
if ('error' in res || !res.data[0]) return
this._entriesToML(res)
const { id, attributes } = res.data[0]
this.viewer.value = {
id: Number(id),
name: attributes.name ?? '',
about: attributes.about ?? '',
avatar: {
large: attributes.avatar?.original ?? null
},
bannerImage: attributes.coverImage?.original ?? null,
createdAt: +new Date(attributes.createdAt),
isFollowing: false,
isFollower: false,
donatorBadge: null,
options: null,
statistics: null
}
}
_kitsuEntryToAl (entry: Resource<KEntry>): ResultOf<typeof FullMediaList> {
return {
id: Number(entry.id),
status: entry.attributes.reconsuming ? 'REPEATING' : entry.attributes.status ? KITSU_TO_AL_STATUS[entry.attributes.status] : null,
progress: entry.attributes.progress ?? 0,
score: Number(entry.attributes.rating) || 0,
repeat: entry.attributes.reconsumeCount ?? 0,
customLists: null
}
}
_entriesToML (res: Res<KEntry | User, Anime | Mapping | KEntry | Fav>) {
const entryMap = this.userlist.value
const { included } = res
const relations = {
anime: new Map<string, Resource<Anime>>(),
mappings: new Map<string, Resource<Mapping>>(),
favorites: new Map<string, Resource<Fav>>()
}
const entries: Array<Resource<KEntry>> = []
if (res.data[0]?.type === 'libraryEntries') {
entries.push(...res.data as Array<Resource<KEntry>>)
}
for (const entry of included ?? []) {
if (entry.type === 'anime') {
relations.anime.set(entry.id, entry as Resource<Anime>)
} else if (entry.type === 'mappings') {
const e = entry as Resource<Mapping>
if (e.attributes.externalSite !== 'anilist/anime') continue
relations.mappings.set(entry.id, entry as Resource<Mapping>)
} else if (entry.type === 'favorites') {
relations.favorites.set(entry.id, entry as Resource<Fav>)
} else {
entries.push(entry as Resource<KEntry>)
}
}
for (const entry of entries) {
const animeRes = Array.isArray(entry.relationships?.anime?.data) ? entry.relationships.anime.data[0] : entry.relationships?.anime?.data
const anime = relations.anime.get(animeRes?.id ?? '')
const ids = Array.isArray(anime?.relationships?.mappings?.data) ? anime.relationships.mappings.data : [anime?.relationships?.mappings?.data]
const anilistId = ids.map(i => i && relations.mappings.get(i.id)).filter(i => i)[0]?.attributes.externalId
if (!anilistId || !animeRes) continue
this.kitsuToAL[animeRes.id] = anilistId
this.ALToKitsu[anilistId] = animeRes.id
entryMap[anilistId] = this._kitsuEntryToAl(entry)
}
for (const [id, fav] of relations.favorites.entries()) {
const data = fav.relationships!.item!.data as { id: string }
const animeId = data.id
this.favorites.value[animeId] = id
this._getAlId(+animeId)
}
this.userlist.value = entryMap
}
isFav (alID: number) {
const kitsuId = this.ALToKitsu[alID.toString()]
if (!kitsuId) return false
return !!this.favorites.value[kitsuId]
}
async _makeFavourite (kitsuAnimeId: string) {
const data = await this._post<ResSingle<Fav>>(
ENDPOINTS.API_FAVOURITES,
{
data: {
relationships: {
user: { data: { type: 'users', id: this.viewer.value?.id.toString() ?? '' } },
item: { data: { type: 'anime', id: kitsuAnimeId } }
},
type: 'favorites'
}
}
)
if ('error' in data) return
this.favorites.value[kitsuAnimeId] = data.data.id
}
async _addEntry (id: string, attributes: Omit<KEntry, 'createdAt' | 'updatedAt'>, alId: number) {
const data = await this._post<ResSingle<KEntry>>(
ENDPOINTS.API_USER_LIBRARY,
{
data: {
attributes,
relationships: {
anime: { data: { id, type: 'anime' } },
user: { data: { type: 'users', id: this.viewer.value?.id.toString() ?? '' } }
},
type: 'library-entries'
}
}
)
if ('error' in data) return
this.userlist.value[alId] = this._kitsuEntryToAl(data.data)
}
async _updateEntry (id: number, attributes: Omit<KEntry, 'createdAt' | 'updatedAt'>, alId: number) {
const data = await this._patch<ResSingle<KEntry>>(
`${ENDPOINTS.API_USER_LIBRARY}/${id}`,
{
data: { id, attributes, type: 'library-entries' }
}
)
if ('error' in data) return
this.userlist.value[alId] = this._kitsuEntryToAl(data.data)
}
async _getKitsuId (alId: number) {
const kitsuId = this.ALToKitsu[alId.toString()]
if (kitsuId) return kitsuId
const res = await mappings(alId)
if (!res?.kitsu_id) return
this.ALToKitsu[alId.toString()] = res.kitsu_id.toString()
return res.kitsu_id.toString()
}
async _getAlId (kitsuId: number) {
const alId = this.kitsuToAL[kitsuId]
if (alId) return alId
const res = await mappingsByKitsuId(kitsuId)
if (!res?.anilist_id) return
this.kitsuToAL[kitsuId] = res.anilist_id.toString()
return res.anilist_id.toString()
}
hasAuth = derived(this.viewer, (viewer) => {
return viewer !== undefined && !!viewer.id
})
id () {
return this.viewer.value?.id ?? -1
}
profile (): ResultOf<typeof UserFrag> | undefined {
return this.viewer.value
}
// QUERIES/MUTATIONS
schedule () {
const ids = Object.keys(this.userlist.value).map(id => parseInt(id))
return client.schedule(ids.length ? ids : undefined)
}
async toggleFav (id: number) {
const kitsuId = await this._getKitsuId(id)
if (!kitsuId) {
toast.error('Kitsu Sync', {
description: 'Could not find Kitsu ID for this media.'
})
return
}
const favs = this.favorites.value
const favId = favs[kitsuId]
if (!favId) {
await this._makeFavourite(kitsuId)
} else {
const res = await this._delete<undefined>(`${ENDPOINTS.API_FAVOURITES}/${favId}`)
if (res && 'error' in res) return
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.favorites.value[kitsuId]
}
}
async deleteEntry (media: Media) {
const id = this.userlist.value[media.id]?.id
if (!id) return
const res = await this._delete<undefined>(`${ENDPOINTS.API_USER_LIBRARY}/${id}`)
if (res && 'error' in res) return
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.userlist.value[media.id]
}
following (id: number) {
// TODO
}
async entry (variables: VariablesOf<typeof Entry>) {
const targetMediaId = variables.id
const kitsuEntry = this.userlist.value[targetMediaId]
const kitsuEntryVariables = {
status: AL_TO_KITSU_STATUS[variables.status!],
progress: variables.progress ?? undefined,
rating: (variables.score ?? 0) < 2 ? undefined : variables.score!.toString(),
reconsumeCount: variables.repeat ?? undefined,
reconsuming: variables.status === 'REPEATING'
}
if (kitsuEntry) {
await this._updateEntry(kitsuEntry.id, kitsuEntryVariables, targetMediaId)
} else {
const kitsuAnimeId = await this._getKitsuId(targetMediaId)
if (!kitsuAnimeId) {
toast.error('Kitsu Sync', {
description: 'Could not find Kitsu ID for this media.'
})
return
}
await this._addEntry(kitsuAnimeId, kitsuEntryVariables, targetMediaId)
}
}
}()