feat: debug logging

This commit is contained in:
ThaUnknown 2025-07-27 18:22:13 +02:00
parent 28283725e0
commit 7ca22198b3
No known key found for this signature in database
15 changed files with 603 additions and 13 deletions

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "6.4.89",
"version": "6.4.90",
"license": "BUSL-1.1",
"private": true,
"packageManager": "pnpm@9.15.5",

View file

@ -1,13 +1,11 @@
<script lang='ts'>
import { persisted } from 'svelte-persisted-store'
import Wrapper from './wrapper.svelte'
import native from '$lib/modules/native'
import { click } from '$lib/modules/navigate'
import { SUPPORTS } from '$lib/modules/settings'
import { debug, SUPPORTS } from '$lib/modules/settings'
const debug = persisted('debug', '')
function tabindex (node: HTMLElement) {
node.tabIndex = -1
}

View file

@ -1,4 +1,5 @@
import { queryStore, type OperationResultState, gql as _gql } from '@urql/svelte'
import Debug from 'debug'
import lavenshtein from 'js-levenshtein'
import { derived, readable, writable, type Writable } from 'svelte/store'
@ -12,6 +13,8 @@ import type { AnyVariables, OperationContext, RequestPolicy, TypedDocumentNode }
import { arrayEqual } from '$lib/utils'
const debug = Debug('ui:anilist')
function getDistanceFromTitle (media: Media & {lavenshtein?: number}, name: string) {
const titles = Object.values(media.title ?? {}).filter(v => v).map(title => lavenshtein(title?.toLowerCase() ?? '', name.toLowerCase()))
const synonyms = (media.synonyms ?? []).filter(v => v).map(title => lavenshtein(title?.toLowerCase() ?? '', name.toLowerCase()) + 2)
@ -25,18 +28,24 @@ class AnilistClient {
client: typeof urqlClient = urqlClient
constructor () {
// hacky but prevents query from re-running
this.userlists.subscribe(() => undefined)
this.continueIDs.subscribe(() => undefined)
// the debug logging is added after an empty useless subscription, don't delete this subscription!
this.userlists.subscribe(ids => {
debug('userlists: ', ids?.data?.MediaListCollection)
})
this.continueIDs.subscribe(ids => {
debug('continueIDs: ', ids)
})
}
userlists = derived<typeof this.client.viewer, OperationResultState<ResultOf<typeof UserLists>>>(this.client.viewer, (store, set) => {
return queryStore({ client: this.client, query: UserLists, variables: { id: store?.viewer?.id } }).subscribe(set)
return queryStore({ client: this.client, query: UserLists, variables: { id: store?.viewer?.id }, context: { requestPolicy: 'cache-and-network' } }).subscribe(set)
})
// WARN: these 3 sections are hacky, i use oldvalue to prevent re-running loops, I DO NOT KNOW WHY THE LOOPS HAPPEN!
continueIDs = readable<number[]>([], set => {
let oldvalue: number[] = []
return this.userlists.subscribe(values => {
debug('continueIDs: checking for IDs')
if (!values.data?.MediaListCollection?.lists) return
const mediaList = values.data.MediaListCollection.lists.reduce<NonNullable<NonNullable<NonNullable<NonNullable<ResultOf<typeof UserLists>['MediaListCollection']>['lists']>[0]>['entries']>>((filtered, list) => {
return (list?.status === 'CURRENT' || list?.status === 'REPEATING') ? filtered.concat(list.entries) : filtered
@ -49,8 +58,10 @@ class AnilistClient {
return progress < (entry?.media?.nextAiringEpisode?.episode ?? (progress + 2)) - 1
}).map(entry => entry?.media?.id) as number[]
debug('continueIDs: found IDs', ids)
if (arrayEqual(oldvalue, ids)) return
oldvalue = ids
debug('continueIDs: updated IDs')
set(ids)
})
})
@ -58,6 +69,7 @@ class AnilistClient {
sequelIDs = readable<number[]>([], set => {
let oldvalue: number[] = []
return this.userlists.subscribe(values => {
debug('sequelIDs: checking for IDs')
if (!values.data?.MediaListCollection?.lists) return
const mediaList = values.data.MediaListCollection.lists.find(list => list?.status === 'COMPLETED')?.entries
if (!mediaList) return []
@ -66,8 +78,10 @@ class AnilistClient {
return entry?.media?.relations?.edges?.filter(edge => edge?.relationType === 'SEQUEL')
}).map(edge => edge?.node?.id))] as number[]
debug('sequelIDs: found IDs', ids)
if (arrayEqual(oldvalue, ids)) return
oldvalue = ids
debug('sequelIDs: updated IDs')
set(ids)
})
})
@ -75,13 +89,16 @@ class AnilistClient {
planningIDs = readable<number[]>([], set => {
let oldvalue: number[] = []
return this.userlists.subscribe(userLists => {
debug('planningIDs: checking for IDs')
if (!userLists.data?.MediaListCollection?.lists) return
const mediaList = userLists.data.MediaListCollection.lists.find(list => list?.status === 'PLANNING')?.entries
if (!mediaList) return []
const ids = mediaList.map(entry => entry?.media?.id) as number[]
debug('planningIDs: found IDs', ids)
if (arrayEqual(oldvalue, ids)) return
oldvalue = ids
debug('planningIDs: updated IDs')
set(ids)
})
})
@ -92,6 +109,7 @@ class AnilistClient {
async searchCompound (flattenedTitles: Array<{key: string, title: string, year?: string, isAdult: boolean}>) {
if (!flattenedTitles.length) return []
debug('searchCompound: searching for', flattenedTitles)
// isAdult doesn't need an extra variable, as the title is the same regardless of type, so we re-use the same variable for adult and non-adult requests
const requestVariables = flattenedTitles.reduce<Record<`v${number}`, string>>((obj, { title, isAdult }, i) => {
@ -129,6 +147,8 @@ class AnilistClient {
const res = await this.client.query<Record<string, {media: Media[]}>>(query, requestVariables)
debug('searchCompound: received response', res)
if (!res.data) return []
const searchResults: Record<string, number> = {}
@ -140,7 +160,9 @@ class AnilistClient {
}
const ids = Object.values(searchResults)
debug('searchCompound: found IDs', ids)
const search = await this.client.query(Search, { ids, perPage: 50 })
debug('searchCompound: search query result', search)
if (!search.data?.Page?.media) return []
return Object.entries(searchResults).map(([filename, id]) => [filename, search.data!.Page!.media!.find(media => media?.id === id)]) as Array<[string, Media | undefined]>
}
@ -181,43 +203,53 @@ class AnilistClient {
}
async toggleFav (id: number) {
debug('toggleFav: toggling favourite for ID', id)
return await this.client.mutation(ToggleFavourite, { id })
}
async deleteEntry (media: Media) {
debug('deleteEntry: deleting entry for media', media)
if (!media.mediaListEntry?.id) return
return await this.client.mutation(DeleteEntry, { id: media.mediaListEntry.id })
}
async entry (variables: VariablesOf<typeof Entry>) {
debug('entry: updating entry for media', variables)
return await this.client.mutation(Entry, variables)
}
async single (id: number, requestPolicy: RequestPolicy = 'cache-first') {
debug('single: fetching media with ID', id)
return await this.client.query(IDMedia, { id }, { requestPolicy })
}
following (animeID: number) {
debug('following: fetching following for anime with ID', animeID)
return queryStore({ client: this.client, query: Following, variables: { id: animeID } })
}
threads (animeID: number, page = 1) {
debug('threads: fetching threads for anime with ID', animeID, 'on page', page)
return queryStore({ client: this.client, query: Threads, variables: { id: animeID, page, perPage: 16 } })
}
comments (threadId: number, page = 1) {
debug('comments: fetching comments for thread with ID', threadId, 'on page', page)
return queryStore({ client: this.client, query: Comments, variables: { threadId, page } })
}
async toggleLike (id: number, type: 'THREAD' | 'THREAD_COMMENT' | 'ACTIVITY' | 'ACTIVITY_REPLY', wasLiked: boolean) {
debug('toggleLike: toggling like for ID', id, 'type', type, 'wasLiked', wasLiked)
return await this.client.mutation(ToggleLike, { id, type, wasLiked })
}
async comment (variables: VariablesOf<typeof SaveThreadComment> & { rootCommentId?: number }) {
debug('comment: saving comment for thread', variables)
return await this.client.mutation(SaveThreadComment, variables)
}
async deleteComment (id: number, rootCommentId: number) {
debug('deleteComment: deleting comment with ID', id, 'rootCommentId', rootCommentId)
return await this.client.mutation(DeleteThreadComment, { id, rootCommentId })
}
}

View file

@ -3,6 +3,7 @@ import { offlineExchange } from '@urql/exchange-graphcache'
import { makeDefaultStorage } from '@urql/exchange-graphcache/default-storage'
import { Client, fetchExchange } from '@urql/svelte'
import Bottleneck from 'bottleneck'
import Debug from 'debug'
import { writable as _writable } from 'simple-store-svelte'
import { toast } from 'svelte-sonner'
@ -17,6 +18,8 @@ import { dev } from '$app/environment'
import native from '$lib/modules/native'
import { safeLocalStorage, sleep } from '$lib/utils'
const debug = Debug('ui:urql')
interface ViewerData { viewer: ResultOf<typeof Viewer>['Viewer'], token: string, expires: string }
class FetchError extends Error {
@ -36,6 +39,10 @@ export const storage = makeDefaultStorage({
maxAge: 31 // The maximum age of the persisted data in days
})
storagePromise.promise.finally(() => {
debug('Graphcache storage initialized')
})
export default new class URQLClient extends Client {
limiter = new Bottleneck({
reservoir: 90,
@ -52,12 +59,14 @@ export default new class URQLClient extends Client {
// await sleep(1000)
const res = await fetch(req, opts)
if (!res.ok && (res.status === 429 || res.status === 500)) {
debug('Rate limit exceeded', res)
throw new FetchError(res)
}
return res
})
async token () {
debug('Requesting Anilist token')
const res = await native.authAL(`https://anilist.co/api/v2/oauth/authorize?client_id=${dev ? 26159 : 3461}&response_type=token`)
const token = res.access_token
const expires = '' + (Date.now() + (parseInt(res.expires_in) * 1000))
@ -66,12 +75,14 @@ export default new class URQLClient extends Client {
}
async auth (oauth = this.token()) {
debug('Authenticating Anilist')
const { token, expires } = await oauth
const viewerRes = await this.query(Viewer, {}, { fetchOptions: { headers: { Authorization: `Bearer ${token}` } } })
if (!viewerRes.data?.Viewer) throw new Error('Failed to fetch viewer data')
this.viewer.value = { viewer: viewerRes.data.Viewer, token, expires }
localStorage.setItem('ALViewer', JSON.stringify(this.viewer.value))
debug('Anilist viewer data', this.viewer.value.viewer)
const lists = viewerRes.data.Viewer.mediaListOptions?.animeList?.customLists ?? []
if (!lists.includes('Watched using Hayase')) {
@ -80,12 +91,14 @@ export default new class URQLClient extends Client {
}
async logout () {
debug('Logging out from Anilist')
await storage.clear()
localStorage.removeItem('ALViewer')
native.restart()
}
setRateLimit (sec: number) {
debug('Setting rate limit', sec)
toast.error('Anilist Error', { description: 'Rate limit exceeded, retrying in ' + Math.round(sec / 1000) + ' seconds.' })
this.rateLimitPromise ??= sleep(sec).then(() => { this.rateLimitPromise = null })
return sec
@ -106,6 +119,7 @@ export default new class URQLClient extends Client {
updates: {
Mutation: {
ToggleFavourite (result: ResultOf<typeof ToggleFavourite>, args, cache) {
debug('cache update ToggleFavourite', result, args)
if (!result.ToggleFavourite?.anime?.nodes) return result
const id = args.animeId as number
@ -115,8 +129,10 @@ export default new class URQLClient extends Client {
cache.writeFragment(gql('fragment Med on Media {id, isFavourite}'), { id, isFavourite: !!exists })
},
DeleteMediaListEntry: (_, { id }, cache) => {
debug('cache update DeleteMediaListEntry', id)
cache.writeFragment(FullMediaList, { id: id as number, progress: null, repeat: null, status: null, customLists: null, score: null })
cache.updateQuery({ query: UserLists, variables: { id: this.viewer.value?.viewer?.id } }, data => {
debug('cache update DeleteMediaListEntry, UserLists', data)
if (!data?.MediaListCollection?.lists) return data
const oldLists = data.MediaListCollection.lists
@ -132,20 +148,24 @@ export default new class URQLClient extends Client {
})
},
SaveMediaListEntry: (result: ResultOf<typeof Entry>, { mediaId }, cache) => {
debug('cache update SaveMediaListEntry', result, mediaId)
const media = gql('fragment Med on Media {id, mediaListEntry {status, progress, repeat, score, customLists }}')
const entry = result.SaveMediaListEntry
if (entry?.customLists) entry.customLists = (entry.customLists as string[]).map(name => ({ enabled: true, name }))
debug('SaveMediaListEntry entry', entry)
cache.writeFragment(media, {
id: mediaId as number,
mediaListEntry: entry ?? null
})
cache.updateQuery({ query: UserLists, variables: { id: this.viewer.value?.viewer?.id } }, data => {
debug('cache update SaveMediaListEntry, UserLists', data)
if (!data?.MediaListCollection?.lists) return data
const oldLists = data.MediaListCollection.lists
const oldEntry = oldLists.flatMap(list => list?.entries).find(entry => entry?.media?.id === mediaId) ?? { id: -1, media: cache.readFragment(FullMedia, { id: mediaId as number, __typename: 'Media' }) }
if (!oldEntry.media) return data
debug('oldEntry', oldEntry)
const lists = oldLists.map(list => {
if (!list?.entries) return list
@ -154,6 +174,7 @@ export default new class URQLClient extends Client {
entries: list.entries.filter(entry => entry?.media?.id !== mediaId)
}
})
debug('lists', lists)
const status = result.SaveMediaListEntry?.status ?? oldEntry.media.mediaListEntry?.status ?? 'PLANNING' as const
@ -163,12 +184,14 @@ export default new class URQLClient extends Client {
lists.push(fallback)
targetList = fallback
}
debug('targetList', targetList)
targetList.entries ??= []
targetList.entries.push(oldEntry)
return { ...data, MediaListCollection: { ...data.MediaListCollection, lists } }
})
},
SaveThreadComment: (_result, args, cache, _info) => {
debug('cache update SaveThreadComment', args)
if (_info.variables.rootCommentId) {
const id = _info.variables.rootCommentId as number
cache.invalidate({
@ -180,6 +203,7 @@ export default new class URQLClient extends Client {
}
},
DeleteThreadComment: (_result, args, cache, _info) => {
debug('cache update DeleteThreadComment', args)
const id = (_info.variables.rootCommentId ?? args.id) as number
cache.invalidate({
__typename: 'ThreadComment',
@ -196,6 +220,7 @@ export default new class URQLClient extends Client {
},
optimistic: {
ToggleFavourite ({ animeId }, cache, info) {
debug('optimistic ToggleFavourite', animeId)
const id = animeId as number
const media = cache.readFragment(FullMedia, { id, __typename: 'Media' })
info.partial = true
@ -210,13 +235,16 @@ export default new class URQLClient extends Client {
}
},
DeleteMediaListEntry () {
debug('optimistic DeleteMediaListEntry')
return { deleted: true, __typename: 'Deleted' }
},
SaveMediaListEntry (args, cache, info) {
debug('optimistic SaveMediaListEntry', args)
const id = args.mediaId as number
const media = cache.readFragment(FullMedia, { id, __typename: 'Media' })
if (!media) return null
info.partial = true
debug('optimistic SaveMediaListEntry media', media)
return {
status: 'PLANNING' as const,
@ -232,6 +260,7 @@ export default new class URQLClient extends Client {
}
},
ToggleLikeV2 ({ id, type }, cache, info) {
debug('optimistic ToggleLikeV2', id, type)
const threadOrCommentId = id as number
const likable = type as 'THREAD' | 'THREAD_COMMENT' | 'ACTIVITY' | 'ACTIVITY_REPLY'
@ -240,6 +269,7 @@ export default new class URQLClient extends Client {
const likableUnion = cache.readFragment(likable === 'THREAD' ? ThreadFrag : CommentFrag, { id: threadOrCommentId, __typename: typename })
if (!likableUnion) return null
debug('optimistic ToggleLikeV2 likableUnion', likableUnion)
return {
id: threadOrCommentId,
@ -295,6 +325,7 @@ export default new class URQLClient extends Client {
})
this.limiter.on('failed', async (error: FetchError | Error, jobInfo) => {
debug('Bottleneck onfailed', error, jobInfo)
// urql has some weird bug that first error is always an AbortError ???
if (error.name === 'AbortError') return undefined
if (jobInfo.retryCount > 8) return undefined

View file

@ -1,7 +1,12 @@
import Debug from 'debug'
import type { AnimeThemesResponse } from './types'
import { safefetch } from '$lib/utils'
const debug = Debug('ui:animethemes')
export function themes (id: number, _fetch = fetch) {
debug('fetching themes for id', id)
return safefetch<AnimeThemesResponse>(_fetch, `https://api.animethemes.moe/anime/?fields[audio]=id,basename,link,size&fields[video]=id,basename,link,tags&filter[external_id]=${id}&filter[has]=resources&filter[site]=AniList&include=animethemes.animethemeentries.videos,animethemes.song,animethemes.song.artists`)
}

View file

@ -1,22 +1,30 @@
import Debug from 'debug'
import type { EpisodesResponse, MappingsResponse } from './types'
import { safefetch } from '$lib/utils'
const debug = Debug('ui:anizip')
// const episodes = safefetch<EpisodesResponse>(`https://hayase.ani.zip/v1/episodes?anilist_id=${params.id}`)
// const mappings = safefetch<MappingsResponse>(fetch, `https://hayase.ani.zip/v1/mappings?anilist_id=${params.id}`)
export async function episodes (id: number, _fetch = fetch) {
debug('fetching episodes for id', id)
return await safefetch<EpisodesResponse>(_fetch, `https://hayase.ani.zip/v1/episodes?anilist_id=${id}`)
}
export async function mappings (id: number, _fetch = fetch) {
debug('fetching mappings for id', id)
return await safefetch<MappingsResponse>(_fetch, `https://hayase.ani.zip/v1/mappings?anilist_id=${id}`)
}
export async function mappingsByKitsuId (kitsuId: number, _fetch = fetch) {
debug('fetching mappings for kitsu id', kitsuId)
return await safefetch<MappingsResponse>(_fetch, `https://hayase.ani.zip/v1/mappings?kitsu_id=${kitsuId}`)
}
export async function mappingsByMalId (malId: number, _fetch = fetch) {
debug('fetching mappings for mal id', malId)
return await safefetch<MappingsResponse>(_fetch, `https://hayase.ani.zip/v1/mappings?mal_id=${malId}`)
}

View file

@ -1,3 +1,4 @@
import Debug from 'debug'
import { writable } from 'simple-store-svelte'
import { derived, get, readable } from 'svelte/store'
import { persisted } from 'svelte-persisted-store'
@ -13,6 +14,8 @@ import type { ResultOf, VariablesOf } from 'gql.tada'
import { arrayEqual } from '$lib/utils'
const debug = Debug('ui:kitsu')
const ENDPOINTS = {
API_OAUTH: 'https://kitsu.app/api/oauth/token',
API_USER_FETCH: 'https://kitsu.app/api/edge/users',
@ -50,6 +53,7 @@ export default new class KitsuSync {
continueIDs = readable<number[]>([], set => {
let oldvalue: number[] = []
const sub = this.userlist.subscribe(values => {
debug('continueIDs: checking for IDs')
const entries = Object.entries(values)
if (!entries.length) return []
@ -61,8 +65,10 @@ export default new class KitsuSync {
}
}
debug('continueIDs: found IDs', ids)
if (arrayEqual(oldvalue, ids)) return
oldvalue = ids
debug('continueIDs: setting IDs', ids)
set(ids)
})
return sub
@ -71,6 +77,7 @@ export default new class KitsuSync {
planningIDs = readable<number[]>([], set => {
let oldvalue: number[] = []
const sub = this.userlist.subscribe(values => {
debug('planningIDs: checking for IDs')
const entries = Object.entries(values)
if (!entries.length) return []
@ -82,8 +89,10 @@ export default new class KitsuSync {
}
}
debug('planningIDs: found IDs', ids)
if (arrayEqual(oldvalue, ids)) return
oldvalue = ids
debug('planningIDs: setting IDs', ids)
set(ids)
})
return sub
@ -164,6 +173,7 @@ export default new class KitsuSync {
}
async _refresh () {
debug('refreshing Kitsu auth token')
const auth = get(this.auth)
const data = await this._post<OAuth>(
ENDPOINTS.API_OAUTH,
@ -179,6 +189,7 @@ export default new class KitsuSync {
}
async login (username: string, password: string) {
debug('logging in to Kitsu with username', username)
const data = await this._request<OAuth>(
ENDPOINTS.API_OAUTH,
'POST',
@ -190,6 +201,7 @@ export default new class KitsuSync {
)
if ('access_token' in data) {
debug('Kitsu login successful, setting auth data')
this.auth.set(data)
}
}
@ -201,6 +213,7 @@ export default new class KitsuSync {
}
async _user () {
debug('fetching Kitsu user data')
const res = await this._get<Res<User>>(
ENDPOINTS.API_USER_FETCH,
{
@ -219,6 +232,8 @@ export default new class KitsuSync {
const { id, attributes } = res.data[0]
debug('Kitsu user data fetched, setting viewer data')
this.viewer.set({
id: Number(id),
name: attributes.name ?? '',
@ -250,6 +265,8 @@ export default new class KitsuSync {
_entriesToML (res: Res<KEntry | User, Anime | Mapping | KEntry | Fav>) {
const entryMap = this.userlist.value
debug('Kitsu entries to MediaList conversion started for', res.data.length, 'entries')
const { included } = res
const relations = {
@ -290,6 +307,8 @@ export default new class KitsuSync {
entryMap[anilistId] = this._kitsuEntryToAl(entry)
}
debug('Kitsu entries to MediaList conversion finished, found', Object.keys(entryMap).length, 'entries')
for (const [id, fav] of relations.favorites.entries()) {
const data = fav.relationships!.item!.data as { id: string }
const animeId = data.id
@ -297,6 +316,8 @@ export default new class KitsuSync {
this._getAlId(+animeId)
}
debug('Kitsu favorites loaded, found', Object.keys(this.favorites.value).length, 'favorites')
this.userlist.value = entryMap
}
@ -307,6 +328,7 @@ export default new class KitsuSync {
}
async _makeFavourite (kitsuAnimeId: string) {
debug('making Kitsu favorite for anime ID', kitsuAnimeId)
const viewer = get(this.viewer)
const data = await this._post<ResSingle<Fav>>(
ENDPOINTS.API_FAVOURITES,
@ -327,6 +349,7 @@ export default new class KitsuSync {
}
async _addEntry (id: string, attributes: Omit<KEntry, 'createdAt' | 'updatedAt'>, alId: number) {
debug('adding Kitsu entry for anime ID', id, 'with attributes', attributes)
const viewer = get(this.viewer)
const data = await this._post<ResSingle<KEntry>>(
ENDPOINTS.API_USER_LIBRARY,
@ -348,6 +371,7 @@ export default new class KitsuSync {
}
async _updateEntry (id: number, attributes: Omit<KEntry, 'createdAt' | 'updatedAt'>, alId: number) {
debug('updating Kitsu entry for anime ID', id, 'with attributes', attributes)
const data = await this._patch<ResSingle<KEntry>>(
`${ENDPOINTS.API_USER_LIBRARY}/${id}`,
{
@ -395,10 +419,12 @@ export default new class KitsuSync {
schedule (onList = true) {
const ids = Object.keys(this.userlist.value).map(id => parseInt(id))
debug('Kitsu schedule called with onList:', onList, 'and ids:', ids)
return client.schedule(onList && ids.length ? ids : undefined)
}
async toggleFav (id: number) {
debug('toggling Kitsu favorite for anime ID', id)
const kitsuId = await this._getKitsuId(id)
if (!kitsuId) {
toast.error('Kitsu Sync', {
@ -421,6 +447,7 @@ export default new class KitsuSync {
}
async deleteEntry (media: Media) {
debug('deleting Kitsu entry for media ID', media.id)
const id = this.userlist.value[media.id]?.id
if (!id) return
const res = await this._delete<undefined>(`${ENDPOINTS.API_USER_LIBRARY}/${id}`)
@ -446,6 +473,7 @@ export default new class KitsuSync {
}
async entry (variables: VariablesOf<typeof Entry>) {
debug('updating Kitsu entry for media ID', variables.id, 'with variables', variables)
const targetMediaId = variables.id
const kitsuEntry = this.userlist.value[targetMediaId]

View file

@ -1,3 +1,4 @@
import Debug from 'debug'
import { writable } from 'simple-store-svelte'
import { derived, get, readable } from 'svelte/store'
import { persisted } from 'svelte-persisted-store'
@ -14,6 +15,8 @@ import type { ResultOf, VariablesOf } from 'gql.tada'
import { dev } from '$app/environment'
import { arrayEqual } from '$lib/utils'
const debug = Debug('ui:mal')
type ALMediaStatus = 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING'
const MAL_TO_AL_STATUS: Record<MALMediaStatus, ALMediaStatus> = {
@ -109,6 +112,7 @@ export default new class MALSync {
continueIDs = readable<number[]>([], set => {
let oldvalue: number[] = []
const sub = this.userlist.subscribe(values => {
debug('continueIDs: checking for IDs')
const entries = Object.entries(values)
if (!entries.length) return []
@ -120,8 +124,10 @@ export default new class MALSync {
}
}
debug('continueIDs: found IDs', ids)
if (arrayEqual(oldvalue, ids)) return
oldvalue = ids
debug('continueIDs: setting new IDs', ids)
set(ids)
})
return sub
@ -130,6 +136,7 @@ export default new class MALSync {
planningIDs = readable<number[]>([], set => {
let oldvalue: number[] = []
const sub = this.userlist.subscribe(values => {
debug('planningIDs: checking for IDs')
const entries = Object.entries(values)
if (!entries.length) return []
@ -141,8 +148,10 @@ export default new class MALSync {
}
}
debug('planningIDs: found IDs', ids)
if (arrayEqual(oldvalue, ids)) return
oldvalue = ids
debug('planningIDs: setting new IDs', ids)
set(ids)
})
return sub
@ -232,6 +241,7 @@ export default new class MALSync {
}
async _refresh () {
debug('Refreshing MAL token')
const auth = get(this.auth)
if (!auth?.refresh_token) return
@ -252,6 +262,7 @@ export default new class MALSync {
}
async login () {
debug('Logging in to MAL')
const state = crypto.randomUUID().replaceAll('-', '')
const challenge = (crypto.randomUUID() + crypto.randomUUID()).replaceAll('-', '')
const clientID = 'd93b624a92e431a9b6dfe7a66c0c5bbb'
@ -286,12 +297,15 @@ export default new class MALSync {
}
async _user () {
debug('Fetching MAL user data')
const res = await this._get<MALUser>(ENDPOINTS.API_USER, {
fields: 'anime_statistics'
})
if ('error' in res) return
debug('MAL user data fetched successfully', res)
this.viewer.set({
id: res.id,
name: res.name,
@ -319,6 +333,7 @@ export default new class MALSync {
}
async _loadUserList () {
debug('Loading MAL user list')
const entryMap: Record<string, ResultOf<typeof FullMediaList>> = {}
let hasNextPage = true
@ -345,6 +360,8 @@ export default new class MALSync {
const ids = data.map(item => item.node.id)
debug('MAL user list loaded with', data.length, 'entries and IDs:', ids)
const malToAl = await client.malIdsCompound(ids)
for (const item of data) {
const malId = item.node.id
@ -358,6 +375,8 @@ export default new class MALSync {
entryMap[alId] = this._malEntryToAl(item.node.my_list_status, item.node.id)
}
debug('MAL user list entries mapped to AL IDs:', Object.keys(entryMap))
this.userlist.set(entryMap)
}
@ -403,6 +422,7 @@ export default new class MALSync {
}
profile (): ResultOf<typeof UserFrag> | undefined {
debug('Fetching MAL user profile')
return get(this.viewer)
}
@ -410,6 +430,7 @@ export default new class MALSync {
schedule (onList = true) {
const ids = Object.keys(this.userlist.value).map(id => parseInt(id))
debug('Fetching MAL schedule with IDs:', ids)
return client.schedule(onList && ids.length ? ids : undefined)
}
@ -418,6 +439,7 @@ export default new class MALSync {
}
async deleteEntry (media: Media) {
debug('Deleting MAL entry for media ID', media.id)
const malId = media.idMal ?? await this._getMalId(media.id)
if (!malId) return
@ -434,6 +456,7 @@ export default new class MALSync {
}
async entry (variables: VariablesOf<typeof Entry>) {
debug('Updating MAL entry for media ID', variables.id, 'with variables', variables)
const targetMediaId = variables.id
const malId = (await client.single(targetMediaId)).data?.Media?.idMal ?? await this._getMalId(targetMediaId)

View file

@ -1,4 +1,5 @@
import anitomyscript, { type AnitomyResult } from 'anitomyscript'
import Debug from 'debug'
import { get } from 'svelte/store'
import { dedupeAiring, episodes, isMovie, type Media, getParentForSpecial, isSingleEpisode } from '../anilist'
@ -29,8 +30,7 @@ if (!('audioTracks' in HTMLVideoElement.prototype)) {
exclusions.push('MULTI')
}
video.remove()
const debug = console.log
const debug = Debug('ui:extensions')
export let fillerEpisodes: Record<number, number[] | undefined> = {}
@ -162,6 +162,7 @@ export const extensions = new class Extensions {
}
async getResultsFromExtensions ({ media, episode, resolution }: { media: Media, episode: number, resolution: keyof typeof videoResolutions }) {
debug(`Fetching results for ${media.id}:${media.title?.userPreferred} ${episode} ${resolution}`)
await storage.modules
const workers = storage.workers
if (!Object.values(workers).length) {
@ -199,6 +200,8 @@ export const extensions = new class Extensions {
const checkMovie = !singleEp && movie
const checkBatch = !singleEp && !movie
debug(`Checking ${Object.keys(workers).length} extensions for ${media.id}:${media.title?.userPreferred} ${episode} ${resolution} ${checkMovie ? 'movie' : ''} ${checkBatch ? 'batch' : ''}`)
for (const [id, worker] of Object.entries(workers)) {
if (!extopts[id]!.enabled) continue
if (configs[id]!.type !== 'torrent') continue
@ -229,7 +232,7 @@ export const extensions = new class Extensions {
}
}
debug(`Found ${results.length} results`)
debug(`Found ${results.length} results, online ${navigator.onLine}`)
const deduped = this.dedupe(results)

View file

@ -1,5 +1,6 @@
import { releaseProxy, type Remote } from 'abslink'
import { wrap } from 'abslink/w3c'
import Debug from 'debug'
import { set, getMany, delMany, del } from 'idb-keyval'
import { get } from 'svelte/store'
import { persisted } from 'svelte-persisted-store'
@ -10,6 +11,8 @@ import Worker from './worker?worker'
import type extensionLoader from './worker'
import type { ExtensionConfig } from 'hayase-extensions'
const debug = Debug('ui:extensions')
type SavedExtensions = Record<ExtensionConfig['id'], ExtensionConfig>
type ExtensionsOptions = {
@ -81,6 +84,7 @@ export const storage = new class Storage {
constructor () {
saved.subscribe(async value => {
debug('saved extensions changed', value)
this.modules = this.load(value)
await this.modules
this.update(value)
@ -88,6 +92,7 @@ export const storage = new class Storage {
}
async reload () {
debug('reloading extensions')
for (const worker of Object.values(this.workers)) {
worker[releaseProxy]()
}
@ -96,6 +101,7 @@ export const storage = new class Storage {
}
async delete (id: string) {
debug('deleting extension', id)
if (id in this.workers) this.workers[id]![releaseProxy]()
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.workers[id]
@ -108,11 +114,13 @@ export const storage = new class Storage {
}
async import (url: string) {
debug('importing extension from', url)
const config = await safejson<ExtensionConfig[]>(url)
if (!config) throw new Error('Make sure the link you provided is a valid JSON config for Hayase', { cause: 'Invalid extension URI' })
for (const c of config) {
if (!this._validateConfig(c)) throw new Error('Make sure the link you provided is a valid extension config for Hayase', { cause: 'Invalid extension config' })
}
debug('imported config', config)
for (const c of config) {
saved.update(value => {
value[c.id] = c
@ -127,6 +135,8 @@ export const storage = new class Storage {
const modules: Record<string, string> = {}
debug('loading extensions', ids)
options.update(options => {
for (const id of ids) {
if (!(id in options)) {
@ -143,12 +153,14 @@ export const storage = new class Storage {
if (module) {
modules[id] = module
} else {
debug('loading module', id)
const module = await safejs(config[id]!.code)
if (!module) continue
modules[id] = module
set(id, module)
}
if (!(id in this.workers)) {
debug('creating worker for', id)
const worker = new Worker({ name: id })
const Loader = wrap<typeof extensionLoader>(worker)
try {
@ -156,6 +168,7 @@ export const storage = new class Storage {
this.workers[id] = Loader as unknown as Remote<typeof extensionLoader>
await Loader.test()
} catch (e) {
debug('failed to load extension', id, e)
// worker.terminate()
console.error(e, id)
toast.error(`Failed to load extension ${config[id]!.name}`, { description: (e as Error).message })
@ -168,6 +181,7 @@ export const storage = new class Storage {
async update (config: Record<string, ExtensionConfig>) {
const ids = Object.keys(config)
const configs = Object.values(config)
debug('updating extensions', ids)
const updateURLs = new Set<string>(configs.map(({ update }) => update).filter(e => e != null))
@ -176,6 +190,7 @@ export const storage = new class Storage {
const toDelete = ids.filter(id => newconfig[id]?.version !== config[id]!.version)
if (toDelete.length) {
debug('deleting old extensions', toDelete)
await delMany(toDelete)
for (const id of toDelete) {
if (id in this.workers) {

View file

@ -1,3 +1,4 @@
import Debug from 'debug'
import { derived, type Readable } from 'svelte/store'
import { persisted } from 'svelte-persisted-store'
@ -5,12 +6,19 @@ import native from '../native'
import { defaults } from '.'
const _debug = Debug('ui:settings')
export const settings = persisted('settings', defaults, { beforeRead: value => ({ ...defaults, ...value }) })
export const debug = persisted('debug', '')
debug.subscribe((value) => {
native.debug(value)
Debug.enable(value)
})
settings.subscribe((value) => {
_debug('settings changed', value)
})
function derivedDeep<T, U> (store: Readable<T>, fn: (value: T) => U) {

View file

@ -1,3 +1,4 @@
import Debug from 'debug'
import { readable, writable } from 'simple-store-svelte'
import { get } from 'svelte/store'
import { persisted } from 'svelte-persisted-store'
@ -9,6 +10,8 @@ import { w2globby } from '../w2g/lobby'
import type { Media } from '../anilist'
import type { TorrentFile, TorrentInfo } from 'native'
const debug = Debug('ui:torrent-client')
const defaultTorrentInfo: TorrentInfo = {
name: '',
progress: 0,
@ -58,7 +61,10 @@ export const server = new class ServerClient {
constructor () {
const last = get(this.last)
if (last) this.play(last.id, last.media, last.episode)
if (last) {
this.play(last.id, last.media, last.episode)
debug('restored last torrent', last.id, last.media.title, last.episode)
}
this.stats.subscribe((stats) => {
native.downloadProgress(stats.progress)
@ -66,10 +72,12 @@ export const server = new class ServerClient {
}
async cachedSet () {
debug('fetching cached torrents')
return new Set(await native.cachedTorrents())
}
play (id: string, media: Media, episode: number) {
debug('playing torrent', id, media.id, episode)
this.last.set({ id, media, episode })
this.active.value = this._play(id, media, episode)
w2globby.value?.mediaChange({ episode, mediaId: media.id, torrent: id })
@ -78,6 +86,7 @@ export const server = new class ServerClient {
async _play (id: string, media: Media, episode: number) {
const result = { id, media, episode, files: await native.playTorrent(id, media.id, episode) }
debug('torrent play result', result)
this.downloaded.value = this.cachedSet()
return result
}

428
src/patches/debug.ts Normal file
View file

@ -0,0 +1,428 @@
/* eslint-env browser */
/* eslint-disable */
// @ts-nocheck
// patched version of debug because there's actually not a way to disable colors globally!
import humanize from 'ms'
/**
* This is the web browser implementation of `debug()`.
*/
const exports = {}
const module = { exports }
exports.formatArgs = formatArgs
exports.save = save
exports.load = load
exports.humanize = humanize
exports.useColors = useColors
exports.storage = localstorage()
exports.destroy = (() => {
let warned = false
return () => {
if (!warned) {
warned = true
console.warn('Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.')
}
}
})()
/**
* Colors.
*/
exports.colors = [6, 2, 3, 4, 5, 1]
const { formatters = {} } = module.exports
/**
* Currently only WebKit-based Web Inspectors, Firefox >= v31,
* and the Firebug extension (any Firefox version) are known
* to support "%c" CSS customizations.
*
* TODO: add a `localStorage` variable to explicitly enable/disable colors
*/
function useColors () {
return true
}
/**
* Colorize log arguments if enabled.
*
* @api public
*/
function formatArgs (args) {
const { namespace: name, useColors } = this
if (useColors) {
const c = this.color
const colorCode = '\u001B[3' + (c < 8 ? c : '8;5;' + c)
const prefix = ` ${colorCode};1m${name} \u001B[0m`
args[0] = prefix + args[0].split('\n').join('\n' + prefix) + ' ' + colorCode + ';1m+' + module.exports.humanize(this.diff) + ' \u001B[0m'
} else {
args[0] = new Date().toISOString() + ' ' + name + ' ' + args[0]
}
}
/**
* Invokes `console.debug()` when available.
* No-op when `console.debug` is not a "function".
* If `console.debug` is not available, falls back
* to `console.log`.
*
* @api public
*/
exports.log = console.log || console.debug || (() => {})
/**
* Save `namespaces`.
*
* @param {String} namespaces
* @api private
*/
function save (namespaces) {
try {
if (namespaces) {
exports.storage.setItem('debug', namespaces)
} else {
exports.storage.removeItem('debug')
}
} catch (error) {
// Swallow
// XXX (@Qix-) should we be logging these?
}
}
/**
* Load `namespaces`.
*
* @return {String} returns the previously persisted debug modes
* @api private
*/
function load () {
let r
try {
r = exports.storage.getItem('debug')
} catch (error) {
// Swallow
// XXX (@Qix-) should we be logging these?
}
// If debug isn't set in LS, and we're in Electron, try to load $DEBUG
if (!r && typeof process !== 'undefined' && 'env' in process) {
r = process.env.DEBUG
}
return r
}
/**
* Localstorage attempts to return the localstorage.
*
* This is necessary because safari throws
* when a user disables cookies/localstorage
* and you attempt to access it.
*
* @return {LocalStorage}
* @api private
*/
function localstorage () {
try {
// TVMLKit (Apple TV JS Runtime) does not have a window object, just localStorage in the global context
// The Browser also has localStorage in the global context.
return localStorage
} catch (error) {
// Swallow
// XXX (@Qix-) should we be logging these?
}
}
/**
* Map %j to `JSON.stringify()`, since no Web Inspectors do that by default.
*/
formatters.j = function (v) {
try {
return JSON.stringify(v)
} catch (error) {
return '[UnexpectedJSONParseError]: ' + error.message
}
}
/**
* This is the common logic for both the Node.js and web browser
* implementations of `debug()`.
*/
function setup (env) {
createDebug.debug = createDebug
createDebug.default = createDebug
createDebug.coerce = coerce
createDebug.disable = disable
createDebug.enable = enable
createDebug.enabled = enabled
createDebug.humanize = humanize
createDebug.destroy = destroy
Object.keys(env).forEach(key => {
createDebug[key] = env[key]
})
/**
* The currently active debug mode names, and names to skip.
*/
createDebug.names = []
createDebug.skips = []
/**
* Map of special "%n" handling functions, for the debug "format" argument.
*
* Valid key names are a single, lower or upper-case letter, i.e. "n" and "N".
*/
createDebug.formatters = {}
/**
* Selects a color for a debug namespace
* @param {String} namespace The namespace string for the debug instance to be colored
* @return {Number|String} An ANSI color code for the given namespace
* @api private
*/
function selectColor (namespace) {
let hash = 0
for (let i = 0; i < namespace.length; i++) {
hash = ((hash << 5) - hash) + namespace.charCodeAt(i)
hash |= 0 // Convert to 32bit integer
}
return createDebug.colors[Math.abs(hash) % createDebug.colors.length]
}
createDebug.selectColor = selectColor
/**
* Create a debugger with the given `namespace`.
*
* @param {String} namespace
* @return {Function}
* @api public
*/
function createDebug (namespace) {
let prevTime
let enableOverride = null
let namespacesCache
let enabledCache
function debug (...args) {
// Disabled?
if (!debug.enabled) {
return
}
const self = debug
// Set `diff` timestamp
const curr = Number(new Date())
const ms = curr - (prevTime || curr)
self.diff = ms
self.prev = prevTime
self.curr = curr
prevTime = curr
args[0] = createDebug.coerce(args[0])
if (typeof args[0] !== 'string') {
// Anything else let's inspect with %O
args.unshift('%O')
}
// Apply any `formatters` transformations
let index = 0
args[0] = args[0].replace(/%([a-zA-Z%])/g, (match, format) => {
// If we encounter an escaped % then don't increase the array index
if (match === '%%') {
return '%'
}
index++
const formatter = createDebug.formatters[format]
if (typeof formatter === 'function') {
const val = args[index]
match = formatter.call(self, val)
// Now we need to remove `args[index]` since it's inlined in the `format`
args.splice(index, 1)
index--
}
return match
})
// Apply env-specific formatting (colors, etc.)
createDebug.formatArgs.call(self, args)
const logFn = self.log || createDebug.log
logFn.apply(self, args)
}
debug.namespace = namespace
debug.useColors = createDebug.useColors()
debug.color = createDebug.selectColor(namespace)
debug.extend = extend
debug.destroy = createDebug.destroy // XXX Temporary. Will be removed in the next major release.
Object.defineProperty(debug, 'enabled', {
enumerable: true,
configurable: false,
get: () => {
if (enableOverride !== null) {
return enableOverride
}
if (namespacesCache !== createDebug.namespaces) {
namespacesCache = createDebug.namespaces
enabledCache = createDebug.enabled(namespace)
}
return enabledCache
},
set: v => {
enableOverride = v
}
})
// Env-specific initialization logic for debug instances
if (typeof createDebug.init === 'function') {
createDebug.init(debug)
}
return debug
}
function extend (namespace, delimiter) {
const newDebug = createDebug(this.namespace + (typeof delimiter === 'undefined' ? ':' : delimiter) + namespace)
newDebug.log = this.log
return newDebug
}
/**
* Enables a debug mode by namespaces. This can include modes
* separated by a colon and wildcards.
*
* @param {String} namespaces
* @api public
*/
function enable (namespaces) {
createDebug.save(namespaces)
createDebug.namespaces = namespaces
createDebug.names = []
createDebug.skips = []
let i
const split = (typeof namespaces === 'string' ? namespaces : '').split(/[\s,]+/)
const len = split.length
for (i = 0; i < len; i++) {
if (!split[i]) {
// ignore empty strings
continue
}
namespaces = split[i].replace(/\*/g, '.*?')
if (namespaces[0] === '-') {
createDebug.skips.push(new RegExp('^' + namespaces.slice(1) + '$'))
} else {
createDebug.names.push(new RegExp('^' + namespaces + '$'))
}
}
}
/**
* Disable debug output.
*
* @return {String} namespaces
* @api public
*/
function disable () {
const namespaces = [
...createDebug.names.map(toNamespace),
...createDebug.skips.map(toNamespace).map(namespace => '-' + namespace)
].join(',')
createDebug.enable('')
return namespaces
}
/**
* Returns true if the given mode name is enabled, false otherwise.
*
* @param {String} name
* @return {Boolean}
* @api public
*/
function enabled (name) {
if (name[name.length - 1] === '*') {
return true
}
let i
let len
for (i = 0, len = createDebug.skips.length; i < len; i++) {
if (createDebug.skips[i].test(name)) {
return false
}
}
for (i = 0, len = createDebug.names.length; i < len; i++) {
if (createDebug.names[i].test(name)) {
return true
}
}
return false
}
/**
* Convert regexp to namespace
*
* @param {RegExp} regxep
* @return {String} namespace
* @api private
*/
function toNamespace (regexp) {
return regexp.toString()
.substring(2, regexp.toString().length - 2)
.replace(/\.\*\?$/, '*')
}
/**
* Coerce `val`.
*
* @param {Mixed} val
* @return {Mixed}
* @api private
*/
function coerce (val) {
if (val instanceof Error) {
return val.stack ?? val.message
}
return val
}
/**
* XXX DO NOT USE. This is a temporary stub function.
* XXX It WILL be removed in the next major release.
*/
function destroy () {
console.warn('Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.')
}
createDebug.enable(createDebug.load())
return createDebug
}
export default setup(exports)

View file

@ -57,7 +57,8 @@
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.json",
"src/**/*.svelte"
"src/**/*.svelte",
"src/patches/debug.ts"
],
"exclude": [
"../node_modules/**"

View file

@ -33,7 +33,8 @@ export default defineConfig({
'./IORedisConnection': resolve(import.meta.dirname, 'src/patches/empty.cjs'),
'./Scripts': resolve(import.meta.dirname, 'src/patches/empty.cjs'),
// no exports :/
'bittorrent-tracker/lib/client/websocket-tracker.js': resolve(import.meta.dirname, 'node_modules/bittorrent-tracker/lib/client/websocket-tracker.js')
'bittorrent-tracker/lib/client/websocket-tracker.js': resolve(import.meta.dirname, 'node_modules/bittorrent-tracker/lib/client/websocket-tracker.js'),
debug: resolve(import.meta.dirname, 'src/patches/debug.ts')
}
},
server: { port: 7344 },