mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-03-11 18:16:19 +00:00
This commit is contained in:
parent
8f17d9f3ca
commit
28283725e0
14 changed files with 335 additions and 320 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "6.4.88",
|
||||
"version": "6.4.89",
|
||||
"license": "BUSL-1.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.5",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
let childComments: Array<ResultOf<typeof CommentFrag>>
|
||||
$: childComments = comment.childComments as Array<ResultOf<typeof CommentFrag>> | null ?? []
|
||||
|
||||
const viewer = client.viewer
|
||||
const viewer = client.client.viewer
|
||||
</script>
|
||||
|
||||
<div class='rounded-md {depth % 2 === 1 ? 'bg-black' : 'bg-neutral-950'} text-secondary-foreground flex w-full py-4 px-6 flex-col'>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
export let isLocked = false
|
||||
|
||||
const viewer = client.viewer
|
||||
const viewer = client.client.viewer
|
||||
|
||||
export let threadId: number | undefined = undefined
|
||||
export let parentCommentId: number | undefined = undefined
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<script lang='ts'>
|
||||
import Interface from './interface.svelte'
|
||||
|
||||
const viewer = client.viewer.value
|
||||
const viewer = client.client.viewer.value
|
||||
|
||||
let ident: { nick: string, id: string, pfpid: string, type: 'al' | 'guest' }
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
|
||||
let isMac = false
|
||||
|
||||
if (highEntropyValues) highEntropyValues.then(({ platform }) => { isMac = platform === 'MacOS' })
|
||||
if (highEntropyValues) highEntropyValues.then(({ platform }) => { isMac = platform === 'macOS' })
|
||||
</script>
|
||||
|
||||
<svelte:document bind:visibilityState />
|
||||
|
|
|
|||
|
|
@ -1,37 +1,16 @@
|
|||
import { authExchange } from '@urql/exchange-auth'
|
||||
import { offlineExchange } from '@urql/exchange-graphcache'
|
||||
import { makeDefaultStorage } from '@urql/exchange-graphcache/default-storage'
|
||||
import { Client, fetchExchange, queryStore, type OperationResultState, gql as _gql } from '@urql/svelte'
|
||||
import Bottleneck from 'bottleneck'
|
||||
import { queryStore, type OperationResultState, gql as _gql } from '@urql/svelte'
|
||||
import lavenshtein from 'js-levenshtein'
|
||||
import { writable as _writable } from 'simple-store-svelte'
|
||||
import { derived, readable, writable, type Writable } from 'svelte/store'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
import gql from './gql'
|
||||
import { CommentFrag, Comments, CustomLists, DeleteEntry, DeleteThreadComment, Entry, Following, FullMedia, FullMediaList, IDMedia, SaveThreadComment, Schedule, Search, ThreadFrag, Threads, ToggleFavourite, ToggleLike, UserLists, Viewer } from './queries'
|
||||
import { refocusExchange } from './refocus'
|
||||
import schema from './schema.json' with { type: 'json' }
|
||||
import { Comments, DeleteEntry, DeleteThreadComment, Entry, Following, IDMedia, SaveThreadComment, Schedule, Search, Threads, ToggleFavourite, ToggleLike, UserLists } from './queries'
|
||||
import urqlClient from './urql-client'
|
||||
import { currentSeason, currentYear, lastSeason, lastYear, nextSeason, nextYear } from './util'
|
||||
|
||||
import type { Media } from './types'
|
||||
import type { ResultOf, VariablesOf } from 'gql.tada'
|
||||
import type { AnyVariables, OperationContext, RequestPolicy, TypedDocumentNode } from 'urql'
|
||||
|
||||
import { dev } from '$app/environment'
|
||||
import native from '$lib/modules/native'
|
||||
import { arrayEqual, safeLocalStorage, sleep } from '$lib/utils'
|
||||
|
||||
class FetchError extends Error {
|
||||
res
|
||||
|
||||
constructor (res: Response, message?: string, opts?: ErrorOptions) {
|
||||
super(message, opts)
|
||||
this.res = res
|
||||
}
|
||||
}
|
||||
|
||||
interface ViewerData { viewer: ResultOf<typeof Viewer>['Viewer'], token: string, expires: string }
|
||||
import { arrayEqual } from '$lib/utils'
|
||||
|
||||
function getDistanceFromTitle (media: Media & {lavenshtein?: number}, name: string) {
|
||||
const titles = Object.values(media.title ?? {}).filter(v => v).map(title => lavenshtein(title?.toLowerCase() ?? '', name.toLowerCase()))
|
||||
|
|
@ -43,286 +22,14 @@ function getDistanceFromTitle (media: Media & {lavenshtein?: number}, name: stri
|
|||
}
|
||||
|
||||
class AnilistClient {
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
storagePromise = Promise.withResolvers<void>()
|
||||
storage = makeDefaultStorage({
|
||||
idbName: 'graphcache-v3',
|
||||
onCacheHydrated: () => this.storagePromise.resolve(),
|
||||
maxAge: 31 // The maximum age of the persisted data in days
|
||||
})
|
||||
|
||||
client = new Client({
|
||||
url: 'https://graphql.anilist.co',
|
||||
// fetch: dev ? fetch : (req: RequestInfo | URL, opts?: RequestInit) => this.handleRequest(req, opts),
|
||||
fetch: (req: RequestInfo | URL, opts?: RequestInit) => this.handleRequest(req, opts),
|
||||
exchanges: [
|
||||
refocusExchange(60_000),
|
||||
offlineExchange({
|
||||
schema: schema as Parameters<typeof offlineExchange>[0]['schema'],
|
||||
storage: this.storage,
|
||||
updates: {
|
||||
Mutation: {
|
||||
ToggleFavourite (result: ResultOf<typeof ToggleFavourite>, args, cache) {
|
||||
if (!result.ToggleFavourite?.anime?.nodes) return result
|
||||
const id = args.animeId as number
|
||||
|
||||
// we check if exists, because AL always returns false for isFavourite, so we need to check if it exists in the list
|
||||
const exists = result.ToggleFavourite.anime.nodes.find(n => n?.id === id)
|
||||
|
||||
cache.writeFragment(gql('fragment Med on Media {id, isFavourite}'), { id, isFavourite: !!exists })
|
||||
},
|
||||
DeleteMediaListEntry: (_, { id }, cache) => {
|
||||
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 => {
|
||||
if (!data?.MediaListCollection?.lists) return data
|
||||
const oldLists = data.MediaListCollection.lists
|
||||
|
||||
data.MediaListCollection.lists = oldLists.map(list => {
|
||||
if (!list?.entries) return list
|
||||
return {
|
||||
...list,
|
||||
entries: list.entries.filter(entry => entry?.media?.mediaListEntry?.id !== id)
|
||||
}
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
},
|
||||
SaveMediaListEntry: (result: ResultOf<typeof Entry>, { mediaId }, cache) => {
|
||||
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 }))
|
||||
cache.writeFragment(media, {
|
||||
id: mediaId as number,
|
||||
mediaListEntry: entry ?? null
|
||||
})
|
||||
cache.updateQuery({ query: UserLists, variables: { id: this.viewer.value?.viewer?.id } }, 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
|
||||
|
||||
const lists = oldLists.map(list => {
|
||||
if (!list?.entries) return list
|
||||
return {
|
||||
...list,
|
||||
entries: list.entries.filter(entry => entry?.media?.id !== mediaId)
|
||||
}
|
||||
})
|
||||
|
||||
const status = result.SaveMediaListEntry?.status ?? oldEntry.media.mediaListEntry?.status ?? 'PLANNING' as const
|
||||
|
||||
const fallback: NonNullable<typeof oldLists[0]> = { status, entries: [] }
|
||||
let targetList = lists.find(list => list?.status === status)
|
||||
if (!targetList) {
|
||||
lists.push(fallback)
|
||||
targetList = fallback
|
||||
}
|
||||
targetList.entries ??= []
|
||||
targetList.entries.push(oldEntry)
|
||||
return { ...data, MediaListCollection: { ...data.MediaListCollection, lists } }
|
||||
})
|
||||
},
|
||||
SaveThreadComment: (_result, args, cache, _info) => {
|
||||
if (_info.variables.rootCommentId) {
|
||||
const id = _info.variables.rootCommentId as number
|
||||
cache.invalidate({
|
||||
__typename: 'ThreadComment',
|
||||
id
|
||||
})
|
||||
} else {
|
||||
cache.invalidate('ThreadComment')
|
||||
}
|
||||
},
|
||||
DeleteThreadComment: (_result, args, cache, _info) => {
|
||||
const id = (_info.variables.rootCommentId ?? args.id) as number
|
||||
cache.invalidate({
|
||||
__typename: 'ThreadComment',
|
||||
id
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
resolvers: {
|
||||
Query: {
|
||||
Media: (parent, { id }) => ({ __typename: 'Media', id }),
|
||||
Thread: (parent, { id }) => ({ __typename: 'Thread', id })
|
||||
}
|
||||
},
|
||||
optimistic: {
|
||||
ToggleFavourite ({ animeId }, cache, info) {
|
||||
const id = animeId as number
|
||||
const media = cache.readFragment(FullMedia, { id, __typename: 'Media' })
|
||||
info.partial = true
|
||||
|
||||
const nodes = media?.isFavourite ? [] : [{ id, __typename: 'Media' }]
|
||||
return {
|
||||
anime: {
|
||||
nodes,
|
||||
__typename: 'MediaConnection'
|
||||
},
|
||||
__typename: 'Favourites'
|
||||
}
|
||||
},
|
||||
DeleteMediaListEntry () {
|
||||
return { deleted: true, __typename: 'Deleted' }
|
||||
},
|
||||
SaveMediaListEntry (args, cache, info) {
|
||||
const id = args.mediaId as number
|
||||
const media = cache.readFragment(FullMedia, { id, __typename: 'Media' })
|
||||
if (!media) return null
|
||||
info.partial = true
|
||||
|
||||
return {
|
||||
status: 'PLANNING' as const,
|
||||
progress: 0,
|
||||
repeat: 0,
|
||||
score: 0,
|
||||
id: -1,
|
||||
...media.mediaListEntry,
|
||||
customLists: (args.customLists as string[]).map(name => ({ enabled: true, name })),
|
||||
...args,
|
||||
media,
|
||||
__typename: 'MediaList'
|
||||
}
|
||||
},
|
||||
ToggleLikeV2 ({ id, type }, cache, info) {
|
||||
const threadOrCommentId = id as number
|
||||
const likable = type as 'THREAD' | 'THREAD_COMMENT' | 'ACTIVITY' | 'ACTIVITY_REPLY'
|
||||
|
||||
const typename = likable === 'THREAD' ? 'Thread' : 'ThreadComment'
|
||||
|
||||
const likableUnion = cache.readFragment(likable === 'THREAD' ? ThreadFrag : CommentFrag, { id: threadOrCommentId, __typename: typename })
|
||||
|
||||
if (!likableUnion) return null
|
||||
|
||||
return {
|
||||
id: threadOrCommentId,
|
||||
isLiked: !likableUnion.isLiked,
|
||||
likeCount: likableUnion.likeCount + (likableUnion.isLiked ? -1 : 1),
|
||||
__typename: typename
|
||||
}
|
||||
}
|
||||
},
|
||||
keys: {
|
||||
FuzzyDate: () => null,
|
||||
PageInfo: () => null,
|
||||
Page: () => null,
|
||||
MediaTitle: () => null,
|
||||
MediaCoverImage: () => null,
|
||||
AiringSchedule: () => null,
|
||||
MediaListCollection: e => (e.user as {id: string | null}).id,
|
||||
MediaListGroup: e => e.status as string | null,
|
||||
UserAvatar: () => null,
|
||||
UserOptions: () => null,
|
||||
UserStatisticTypes: () => null,
|
||||
UserGenreStatistic: () => null,
|
||||
UserStatistics: () => null
|
||||
}
|
||||
}),
|
||||
authExchange(async utils => {
|
||||
return {
|
||||
addAuthToOperation: (operation) => {
|
||||
if (!this.viewer.value) return operation
|
||||
return utils.appendHeaders(operation, {
|
||||
Authorization: `Bearer ${this.viewer.value.token}`
|
||||
})
|
||||
},
|
||||
didAuthError (error, _operation) {
|
||||
return error.graphQLErrors.some(e => e.message === 'Invalid token')
|
||||
},
|
||||
refreshAuth: async () => {
|
||||
const oauth = this.token()
|
||||
this.auth(oauth) // TODO: this should be awaited, but it utils doesnt expose query, only mutation, so need to wait for it to be added
|
||||
await oauth
|
||||
},
|
||||
willAuthError: () => {
|
||||
if (!this.viewer.value?.expires) return false
|
||||
return parseInt(this.viewer.value.expires) < Date.now()
|
||||
}
|
||||
}
|
||||
}),
|
||||
fetchExchange
|
||||
],
|
||||
requestPolicy: 'cache-and-network'
|
||||
})
|
||||
|
||||
limiter = new Bottleneck({
|
||||
reservoir: 90,
|
||||
reservoirRefreshAmount: 90,
|
||||
reservoirRefreshInterval: 60 * 1000,
|
||||
maxConcurrent: 3,
|
||||
minTime: 200
|
||||
})
|
||||
|
||||
rateLimitPromise: Promise<void> | null = null
|
||||
|
||||
handleRequest = this.limiter.wrap<Response, RequestInfo | URL, RequestInit | undefined>(async (req: RequestInfo | URL, opts?: RequestInit) => {
|
||||
await this.rateLimitPromise
|
||||
// await sleep(1000)
|
||||
const res = await fetch(req, opts)
|
||||
if (!res.ok && (res.status === 429 || res.status === 500)) {
|
||||
throw new FetchError(res)
|
||||
}
|
||||
return res
|
||||
})
|
||||
|
||||
async 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))
|
||||
this.viewer.value = { viewer: this.viewer.value?.viewer ?? null, token, expires }
|
||||
return { token, expires }
|
||||
}
|
||||
|
||||
async auth (oauth = this.token()) {
|
||||
const { token, expires } = await oauth
|
||||
const viewerRes = await this.client.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))
|
||||
|
||||
const lists = viewerRes.data.Viewer.mediaListOptions?.animeList?.customLists ?? []
|
||||
if (!lists.includes('Watched using Hayase')) {
|
||||
await this.client.mutation(CustomLists, { lists: [...lists, 'Watched using Hayase'] })
|
||||
}
|
||||
}
|
||||
|
||||
async logout () {
|
||||
await this.storage.clear()
|
||||
localStorage.removeItem('ALViewer')
|
||||
native.restart()
|
||||
}
|
||||
|
||||
setRateLimit (sec: number) {
|
||||
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
|
||||
}
|
||||
|
||||
client: typeof urqlClient = urqlClient
|
||||
constructor () {
|
||||
this.limiter.on('failed', async (error: FetchError | 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
|
||||
|
||||
if (error.message === 'Failed to fetch') return this.setRateLimit(60000)
|
||||
if (!(error instanceof FetchError)) return 0
|
||||
if (error.res.status === 500) return 1000
|
||||
|
||||
return this.setRateLimit((parseInt(error.res.headers.get('retry-after') ?? '60') + 1) * 1000)
|
||||
})
|
||||
// hacky but prevents query from re-running
|
||||
this.userlists.subscribe(() => undefined)
|
||||
this.continueIDs.subscribe(() => undefined)
|
||||
}
|
||||
|
||||
viewer = _writable<ViewerData | undefined>(safeLocalStorage('ALViewer'))
|
||||
|
||||
userlists = derived<typeof this.viewer, OperationResultState<ResultOf<typeof UserLists>>>(this.viewer, (store, set) => {
|
||||
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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,309 @@
|
|||
import { authExchange } from '@urql/exchange-auth'
|
||||
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 { writable as _writable } from 'simple-store-svelte'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
import gql from './gql'
|
||||
import { CommentFrag, CustomLists, type Entry, FullMedia, FullMediaList, ThreadFrag, type ToggleFavourite, UserLists, Viewer } from './queries'
|
||||
import { refocusExchange } from './refocus'
|
||||
import schema from './schema.json' with { type: 'json' }
|
||||
|
||||
import type { ResultOf } from 'gql.tada'
|
||||
|
||||
import { dev } from '$app/environment'
|
||||
import native from '$lib/modules/native'
|
||||
import { safeLocalStorage, sleep } from '$lib/utils'
|
||||
|
||||
interface ViewerData { viewer: ResultOf<typeof Viewer>['Viewer'], token: string, expires: string }
|
||||
|
||||
class FetchError extends Error {
|
||||
res
|
||||
|
||||
constructor (res: Response, message?: string, opts?: ErrorOptions) {
|
||||
super(message, opts)
|
||||
this.res = res
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
export const storagePromise = Promise.withResolvers<void>()
|
||||
export const storage = makeDefaultStorage({
|
||||
idbName: 'graphcache-v3',
|
||||
onCacheHydrated: () => storagePromise.resolve(),
|
||||
maxAge: 31 // The maximum age of the persisted data in days
|
||||
})
|
||||
|
||||
export default new class URQLClient extends Client {
|
||||
limiter = new Bottleneck({
|
||||
reservoir: 90,
|
||||
reservoirRefreshAmount: 90,
|
||||
reservoirRefreshInterval: 60 * 1000,
|
||||
maxConcurrent: 3,
|
||||
minTime: 200
|
||||
})
|
||||
|
||||
rateLimitPromise: Promise<void> | null = null
|
||||
|
||||
handleRequest = this.limiter.wrap<Response, RequestInfo | URL, RequestInit | undefined>(async (req: RequestInfo | URL, opts?: RequestInit) => {
|
||||
await this.rateLimitPromise
|
||||
// await sleep(1000)
|
||||
const res = await fetch(req, opts)
|
||||
if (!res.ok && (res.status === 429 || res.status === 500)) {
|
||||
throw new FetchError(res)
|
||||
}
|
||||
return res
|
||||
})
|
||||
|
||||
async 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))
|
||||
this.viewer.value = { viewer: this.viewer.value?.viewer ?? null, token, expires }
|
||||
return { token, expires }
|
||||
}
|
||||
|
||||
async auth (oauth = this.token()) {
|
||||
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))
|
||||
|
||||
const lists = viewerRes.data.Viewer.mediaListOptions?.animeList?.customLists ?? []
|
||||
if (!lists.includes('Watched using Hayase')) {
|
||||
await this.mutation(CustomLists, { lists: [...lists, 'Watched using Hayase'] })
|
||||
}
|
||||
}
|
||||
|
||||
async logout () {
|
||||
await storage.clear()
|
||||
localStorage.removeItem('ALViewer')
|
||||
native.restart()
|
||||
}
|
||||
|
||||
setRateLimit (sec: number) {
|
||||
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
|
||||
}
|
||||
|
||||
viewer = _writable<ViewerData | undefined>(safeLocalStorage('ALViewer'))
|
||||
|
||||
constructor () {
|
||||
super({
|
||||
url: 'https://graphql.anilist.co',
|
||||
// fetch: dev ? fetch : (req: RequestInfo | URL, opts?: RequestInit) => this.handleRequest(req, opts),
|
||||
fetch: (req: RequestInfo | URL, opts?: RequestInit) => this.handleRequest(req, opts),
|
||||
exchanges: [
|
||||
refocusExchange(60_000),
|
||||
offlineExchange({
|
||||
schema,
|
||||
storage,
|
||||
updates: {
|
||||
Mutation: {
|
||||
ToggleFavourite (result: ResultOf<typeof ToggleFavourite>, args, cache) {
|
||||
if (!result.ToggleFavourite?.anime?.nodes) return result
|
||||
const id = args.animeId as number
|
||||
|
||||
// we check if exists, because AL always returns false for isFavourite, so we need to check if it exists in the list
|
||||
const exists = result.ToggleFavourite.anime.nodes.find(n => n?.id === id)
|
||||
|
||||
cache.writeFragment(gql('fragment Med on Media {id, isFavourite}'), { id, isFavourite: !!exists })
|
||||
},
|
||||
DeleteMediaListEntry: (_, { id }, cache) => {
|
||||
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 => {
|
||||
if (!data?.MediaListCollection?.lists) return data
|
||||
const oldLists = data.MediaListCollection.lists
|
||||
|
||||
data.MediaListCollection.lists = oldLists.map(list => {
|
||||
if (!list?.entries) return list
|
||||
return {
|
||||
...list,
|
||||
entries: list.entries.filter(entry => entry?.media?.mediaListEntry?.id !== id)
|
||||
}
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
},
|
||||
SaveMediaListEntry: (result: ResultOf<typeof Entry>, { mediaId }, cache) => {
|
||||
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 }))
|
||||
cache.writeFragment(media, {
|
||||
id: mediaId as number,
|
||||
mediaListEntry: entry ?? null
|
||||
})
|
||||
cache.updateQuery({ query: UserLists, variables: { id: this.viewer.value?.viewer?.id } }, 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
|
||||
|
||||
const lists = oldLists.map(list => {
|
||||
if (!list?.entries) return list
|
||||
return {
|
||||
...list,
|
||||
entries: list.entries.filter(entry => entry?.media?.id !== mediaId)
|
||||
}
|
||||
})
|
||||
|
||||
const status = result.SaveMediaListEntry?.status ?? oldEntry.media.mediaListEntry?.status ?? 'PLANNING' as const
|
||||
|
||||
const fallback: NonNullable<typeof oldLists[0]> = { status, entries: [] }
|
||||
let targetList = lists.find(list => list?.status === status)
|
||||
if (!targetList) {
|
||||
lists.push(fallback)
|
||||
targetList = fallback
|
||||
}
|
||||
targetList.entries ??= []
|
||||
targetList.entries.push(oldEntry)
|
||||
return { ...data, MediaListCollection: { ...data.MediaListCollection, lists } }
|
||||
})
|
||||
},
|
||||
SaveThreadComment: (_result, args, cache, _info) => {
|
||||
if (_info.variables.rootCommentId) {
|
||||
const id = _info.variables.rootCommentId as number
|
||||
cache.invalidate({
|
||||
__typename: 'ThreadComment',
|
||||
id
|
||||
})
|
||||
} else {
|
||||
cache.invalidate('ThreadComment')
|
||||
}
|
||||
},
|
||||
DeleteThreadComment: (_result, args, cache, _info) => {
|
||||
const id = (_info.variables.rootCommentId ?? args.id) as number
|
||||
cache.invalidate({
|
||||
__typename: 'ThreadComment',
|
||||
id
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
resolvers: {
|
||||
Query: {
|
||||
Media: (parent, { id }) => ({ __typename: 'Media', id }),
|
||||
Thread: (parent, { id }) => ({ __typename: 'Thread', id })
|
||||
}
|
||||
},
|
||||
optimistic: {
|
||||
ToggleFavourite ({ animeId }, cache, info) {
|
||||
const id = animeId as number
|
||||
const media = cache.readFragment(FullMedia, { id, __typename: 'Media' })
|
||||
info.partial = true
|
||||
|
||||
const nodes = media?.isFavourite ? [] : [{ id, __typename: 'Media' }]
|
||||
return {
|
||||
anime: {
|
||||
nodes,
|
||||
__typename: 'MediaConnection'
|
||||
},
|
||||
__typename: 'Favourites'
|
||||
}
|
||||
},
|
||||
DeleteMediaListEntry () {
|
||||
return { deleted: true, __typename: 'Deleted' }
|
||||
},
|
||||
SaveMediaListEntry (args, cache, info) {
|
||||
const id = args.mediaId as number
|
||||
const media = cache.readFragment(FullMedia, { id, __typename: 'Media' })
|
||||
if (!media) return null
|
||||
info.partial = true
|
||||
|
||||
return {
|
||||
status: 'PLANNING' as const,
|
||||
progress: 0,
|
||||
repeat: 0,
|
||||
score: 0,
|
||||
id: -1,
|
||||
...media.mediaListEntry,
|
||||
customLists: (args.customLists as string[]).map(name => ({ enabled: true, name })),
|
||||
...args,
|
||||
media,
|
||||
__typename: 'MediaList'
|
||||
}
|
||||
},
|
||||
ToggleLikeV2 ({ id, type }, cache, info) {
|
||||
const threadOrCommentId = id as number
|
||||
const likable = type as 'THREAD' | 'THREAD_COMMENT' | 'ACTIVITY' | 'ACTIVITY_REPLY'
|
||||
|
||||
const typename = likable === 'THREAD' ? 'Thread' : 'ThreadComment'
|
||||
|
||||
const likableUnion = cache.readFragment(likable === 'THREAD' ? ThreadFrag : CommentFrag, { id: threadOrCommentId, __typename: typename })
|
||||
|
||||
if (!likableUnion) return null
|
||||
|
||||
return {
|
||||
id: threadOrCommentId,
|
||||
isLiked: !likableUnion.isLiked,
|
||||
likeCount: likableUnion.likeCount + (likableUnion.isLiked ? -1 : 1),
|
||||
__typename: typename
|
||||
}
|
||||
}
|
||||
},
|
||||
keys: {
|
||||
FuzzyDate: () => null,
|
||||
PageInfo: () => null,
|
||||
Page: () => null,
|
||||
MediaTitle: () => null,
|
||||
MediaCoverImage: () => null,
|
||||
AiringSchedule: () => null,
|
||||
MediaListCollection: e => (e.user as {id: string | null}).id,
|
||||
MediaListGroup: e => e.status as string | null,
|
||||
UserAvatar: () => null,
|
||||
UserOptions: () => null,
|
||||
UserStatisticTypes: () => null,
|
||||
UserGenreStatistic: () => null,
|
||||
UserStatistics: () => null,
|
||||
MediaListOptions: () => null,
|
||||
MediaListTypeOptions: () => null
|
||||
}
|
||||
}),
|
||||
authExchange(async utils => {
|
||||
return {
|
||||
addAuthToOperation: (operation) => {
|
||||
if (!this.viewer.value) return operation
|
||||
return utils.appendHeaders(operation, {
|
||||
Authorization: `Bearer ${this.viewer.value.token}`
|
||||
})
|
||||
},
|
||||
didAuthError (error, _operation) {
|
||||
return error.graphQLErrors.some(e => e.message === 'Invalid token')
|
||||
},
|
||||
refreshAuth: async () => {
|
||||
const oauth = this.token()
|
||||
this.auth(oauth) // TODO: this should be awaited, but it utils doesnt expose query, only mutation, so need to wait for it to be added
|
||||
await oauth
|
||||
},
|
||||
willAuthError: () => {
|
||||
if (!this.viewer.value?.expires) return false
|
||||
return parseInt(this.viewer.value.expires) < Date.now()
|
||||
}
|
||||
}
|
||||
}),
|
||||
fetchExchange
|
||||
],
|
||||
requestPolicy: 'cache-and-network'
|
||||
})
|
||||
|
||||
this.limiter.on('failed', async (error: FetchError | 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
|
||||
|
||||
if (error.message === 'Failed to fetch') return this.setRateLimit(60000)
|
||||
if (!(error instanceof FetchError)) return 0
|
||||
if (error.res.status === 500) return 1000
|
||||
|
||||
return this.setRateLimit((parseInt(error.res.headers.get('retry-after') ?? '60') + 1) * 1000)
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
|
@ -15,7 +15,7 @@ export default new class AuthAggregator {
|
|||
hasAuth = readable(this.checkAuth(), set => {
|
||||
// add other subscriptions here for MAL, kitsu, tvdb, etc
|
||||
const unsub = [
|
||||
client.viewer.subscribe(() => set(this.checkAuth())),
|
||||
client.client.viewer.subscribe(() => set(this.checkAuth())),
|
||||
kitsu.viewer.subscribe(() => set(this.checkAuth())),
|
||||
mal.viewer.subscribe(() => set(this.checkAuth()))
|
||||
]
|
||||
|
|
@ -27,7 +27,7 @@ export default new class AuthAggregator {
|
|||
// AUTH
|
||||
|
||||
anilist () {
|
||||
return !!client.viewer.value?.viewer?.id
|
||||
return !!client.client.viewer.value?.viewer?.id
|
||||
}
|
||||
|
||||
kitsu () {
|
||||
|
|
@ -43,14 +43,14 @@ export default new class AuthAggregator {
|
|||
}
|
||||
|
||||
id () {
|
||||
if (this.anilist()) return client.viewer.value!.viewer?.id
|
||||
if (this.anilist()) return client.client.viewer.value!.viewer?.id
|
||||
if (this.kitsu()) return kitsu.id()
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
profile (): ResultOf<typeof UserFrag> | undefined {
|
||||
if (this.anilist()) return client.viewer.value?.viewer ?? undefined
|
||||
if (this.anilist()) return client.client.viewer.value?.viewer ?? undefined
|
||||
if (this.kitsu()) return kitsu.profile()
|
||||
if (this.mal()) return mal.profile()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export class W2GClient extends EventEmitter<{index: [number], player: [PlayerSta
|
|||
|
||||
messages = writable<ChatMessage[]>([])
|
||||
|
||||
self: ChatUser = client.viewer.value?.viewer ?? { id: generateRandomHexCode(16), avatar: null, mediaListOptions: null, name: 'Guest' }
|
||||
self: ChatUser = client.client.viewer.value?.viewer ?? { id: generateRandomHexCode(16), avatar: null, mediaListOptions: null, name: 'Guest' }
|
||||
peers = writable<PeerList>({ [this.self.id]: { user: this.self } })
|
||||
|
||||
get inviteLink () {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { error, redirect } from '@sveltejs/kit'
|
|||
|
||||
import { dev } from '$app/environment'
|
||||
import { SETUP_VERSION } from '$lib'
|
||||
import { client } from '$lib/modules/anilist'
|
||||
import { storagePromise } from '$lib/modules/anilist/urql-client'
|
||||
import native from '$lib/modules/native'
|
||||
import { outdatedComponent } from '$lib/modules/update'
|
||||
|
||||
|
|
@ -14,6 +14,5 @@ export async function load () {
|
|||
|
||||
// hydrating the cache re-starts all queries, it's better to wait for cache to hydrate, than waste rate limit on requests which are dumped anyways
|
||||
// this was previously in anilist/client but it was a top level await, which isn't a great solution, this *should* be better?
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
await client.storagePromise?.promise
|
||||
await storagePromise.promise
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
$: anime = data.anime
|
||||
$: media = $anime.Media!
|
||||
|
||||
const viewer = client.viewer
|
||||
const viewer = client.client.viewer
|
||||
</script>
|
||||
|
||||
<div class='flex items-center w-full'>
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
const viewer = client.viewer
|
||||
const viewer = client.client.viewer
|
||||
|
||||
let pressed = false
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
import native from '$lib/modules/native'
|
||||
import { click } from '$lib/modules/navigate'
|
||||
|
||||
const alviewer = client.viewer
|
||||
const alviewer = client.client.viewer
|
||||
|
||||
$: anilist = $alviewer
|
||||
|
||||
|
|
@ -64,9 +64,9 @@
|
|||
</div>
|
||||
<div class='bg-neutral-950 px-6 py-4 rounded-b-md flex justify-between'>
|
||||
{#if anilist?.viewer?.id}
|
||||
<Button variant='secondary' on:click={() => client.logout()}>Logout</Button>
|
||||
<Button variant='secondary' on:click={() => client.client.logout()}>Logout</Button>
|
||||
{:else}
|
||||
<Button variant='secondary' on:click={() => client.auth()}>Login</Button>
|
||||
<Button variant='secondary' on:click={() => client.client.auth()}>Login</Button>
|
||||
{/if}
|
||||
<div class='flex items-center gap-4'>
|
||||
<Tooltip.Root>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { Button } from '$lib/components/ui/button'
|
||||
import { SingleCombo } from '$lib/components/ui/combobox'
|
||||
import { Switch } from '$lib/components/ui/switch'
|
||||
import { client } from '$lib/modules/anilist'
|
||||
import { storage } from '$lib/modules/anilist/urql-client'
|
||||
import native from '$lib/modules/native'
|
||||
import { settings, SUPPORTS, debug } from '$lib/modules/settings'
|
||||
|
||||
|
|
@ -61,9 +61,9 @@
|
|||
duration: 5000
|
||||
})
|
||||
}
|
||||
function reset () {
|
||||
async function reset () {
|
||||
localStorage.clear()
|
||||
client.storage.clear()
|
||||
await storage.clear()
|
||||
native.restart()
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue