import type { StatsAnkiNoteInfo, StatsExcludedWord, StatsCoverImagesData, StatsAnilistAssignment, StatsAnkiNotesInfoRequest, StatsCoverImagesRequest, StatsDeleteSessionsRequest, StatsExcludedWordsRequest, StatsHttpClient, StatsJsonResponseMap, StatsTrendGroupBy, StatsTrendRange, StatsVideoWatchedRequest, } from '../types/stats'; import type { StatsMineCardParams, StatsMineCardResponse } from './mining'; import { appendCoverRetryToken } from './cover-retry'; import { trackDelete } from './delete-progress'; type StatsLocationLike = Pick; export function resolveStatsBaseUrl(location?: StatsLocationLike): string { const resolvedLocation = location ?? (typeof window === 'undefined' ? { protocol: 'file:', origin: 'null', search: '' } : window.location); const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim(); if (queryApiBase) { return queryApiBase; } return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin; } export const BASE_URL = resolveStatsBaseUrl(); async function fetchResponse(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, init); if (!res.ok) { let body = ''; try { body = (await res.text()).trim(); } catch { body = ''; } throw new Error( body ? `Stats API error: ${res.status} ${body}` : `Stats API error: ${res.status}`, ); } return res; } async function fetchJson( _key: Key, path: string, ): Promise { const res = await fetchResponse(path); return res.json() as Promise; } function uniquePositiveIds(ids: number[]): number[] { const uniqueIds = new Set(); for (const id of ids) { if (Number.isFinite(id) && id > 0) { uniqueIds.add(Math.floor(id)); } } return Array.from(uniqueIds).sort((a, b) => a - b); } export const apiClient = { getOverview: () => fetchJson('overview', '/api/stats/overview'), getDailyRollups: (limit = 60) => fetchJson('dailyRollups', `/api/stats/daily-rollups?limit=${limit}`), getMonthlyRollups: (limit = 24) => fetchJson('monthlyRollups', `/api/stats/monthly-rollups?limit=${limit}`), getSessions: (limit = 50) => fetchJson('sessions', `/api/stats/sessions?limit=${limit}`), getSessionTimeline: (id: number, limit?: number) => fetchJson( 'sessionTimeline', limit === undefined ? `/api/stats/sessions/${id}/timeline` : `/api/stats/sessions/${id}/timeline?limit=${limit}`, ), getSessionEvents: (id: number, limit = 500, eventTypes?: number[]) => { const params = new URLSearchParams({ limit: String(limit) }); if (eventTypes && eventTypes.length > 0) { params.set('types', eventTypes.join(',')); } return fetchJson('sessionEvents', `/api/stats/sessions/${id}/events?${params.toString()}`); }, getSessionKnownWordsTimeline: (id: number) => fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`), getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`), getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'), setExcludedWords: async (words: StatsExcludedWord[]): Promise => { await fetchResponse('/api/stats/excluded-words', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ words } satisfies StatsExcludedWordsRequest), }); }, getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) => fetchJson( 'wordOccurrences', `/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`, ), searchSentences: (query: string, limit = 50, searchByHeadword = true) => fetchJson( 'sentenceSearch', `/api/stats/sentences/search?${new URLSearchParams({ q: query, limit: String(limit), headword: String(searchByHeadword), }).toString()}`, ), getKanji: (limit = 100) => fetchJson('kanji', `/api/stats/kanji?limit=${limit}`), getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) => fetchJson( 'kanjiOccurrences', `/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`, ), getMediaLibrary: () => fetchJson('mediaLibrary', '/api/stats/media'), getMediaDetail: (videoId: number) => fetchJson('mediaDetail', `/api/stats/media/${videoId}`), getAnimeLibrary: () => fetchJson('animeLibrary', '/api/stats/anime'), getAnimeDetail: (animeId: number) => fetchJson('animeDetail', `/api/stats/anime/${animeId}`), getAnimeWords: (animeId: number, limit = 50) => fetchJson('animeWords', `/api/stats/anime/${animeId}/words?limit=${limit}`), getAnimeRollups: (animeId: number, limit = 90) => fetchJson('animeRollups', `/api/stats/anime/${animeId}/rollups?limit=${limit}`), getAnimeCoverUrl: (animeId: number, retryToken = 0) => appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken), getCoverImages: async (params: StatsCoverImagesRequest): Promise => { const res = await fetchResponse('/api/stats/covers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ animeIds: uniquePositiveIds(params.animeIds), videoIds: uniquePositiveIds(params.videoIds), } satisfies StatsCoverImagesRequest), }); return res.json() as Promise; }, getStreakCalendar: (days = 90) => fetchJson('streakCalendar', `/api/stats/streak-calendar?days=${days}`), getEpisodesPerDay: (limit = 90) => fetchJson('episodesPerDay', `/api/stats/trends/episodes-per-day?limit=${limit}`), getNewAnimePerDay: (limit = 90) => fetchJson('newAnimePerDay', `/api/stats/trends/new-anime-per-day?limit=${limit}`), getWatchTimePerAnime: (limit = 90) => fetchJson('watchTimePerAnime', `/api/stats/trends/watch-time-per-anime?limit=${limit}`), getTrendsDashboard: (range: StatsTrendRange, groupBy: StatsTrendGroupBy, fillEmpty = true) => fetchJson( 'trendsDashboard', `/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`, ), getWordDetail: (wordId: number) => fetchJson('wordDetail', `/api/stats/vocabulary/${wordId}/detail`), getKanjiDetail: (kanjiId: number) => fetchJson('kanjiDetail', `/api/stats/kanji/${kanjiId}/detail`), getEpisodeDetail: (videoId: number) => fetchJson('episodeDetail', `/api/stats/episode/${videoId}/detail`), setVideoWatched: async (videoId: number, watched: boolean): Promise => { await fetchResponse(`/api/stats/media/${videoId}/watched`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ watched } satisfies StatsVideoWatchedRequest), }); }, deleteSession: async (sessionId: number): Promise => { await trackDelete('Deleting session', () => fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' }), ); }, deleteSessions: async (sessionIds: number[]): Promise => { const label = `Deleting ${sessionIds.length} session${sessionIds.length === 1 ? '' : 's'}`; await trackDelete(label, () => fetchResponse('/api/stats/sessions', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest), }), ); }, deleteVideo: async (videoId: number): Promise => { await trackDelete('Deleting episode', () => fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }), ); }, deleteAnime: async (animeId: number): Promise => { await trackDelete('Deleting library entry', () => fetchResponse(`/api/stats/anime/${animeId}`, { method: 'DELETE' }), ); }, getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'), getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'), getAnimeKnownWordsSummary: (animeId: number) => fetchJson('animeKnownWordsSummary', `/api/stats/anime/${animeId}/known-words-summary`), getMediaKnownWordsSummary: (videoId: number) => fetchJson('mediaKnownWordsSummary', `/api/stats/media/${videoId}/known-words-summary`), searchAnilist: (query: string) => fetchJson('anilistSearch', `/api/stats/anilist/search?q=${encodeURIComponent(query)}`), reassignAnimeAnilist: async (animeId: number, info: StatsAnilistAssignment): Promise => { await fetchResponse(`/api/stats/anime/${animeId}/anilist`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(info), }); }, mineCard: async (params: StatsMineCardParams): Promise => { const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), }); return res.json(); }, ankiBrowse: async (noteId: number): Promise => { await fetchResponse(`/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' }); }, ankiNotesInfo: async (noteIds: number[]): Promise => { const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteIds } satisfies StatsAnkiNotesInfoRequest), }); if (!res.ok) throw new Error(`Stats API error: ${res.status}`); return res.json(); }, } satisfies StatsHttpClient;