Repository navigation
Expand file tree
/
Copy pathsimkl.ts
More file actions
298 lines (267 loc) · 11.8 KB
/
Copy pathsimkl.ts
File metadata and controls
298 lines (267 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
import { env } from '$env/dynamic/private';
import { getCachedDetails, upsertDetail, cacheKey, getLibrarySnapshot, saveLibrarySnapshot } from './simkl-cache';
import { APP_NAME, APP_VERSION, getSimklAccessToken, simklAuthMode } from './simkl-auth';
export type LibraryItem = {
title: string;
posterUrl: string | null;
href: string;
watchedEpisodes: number;
totalEpisodes: number | null;
// 0-10, unset until the user rates the title on Simkl.
rating: number | null;
lastWatchedAt: string | null;
// e.g. "S03E01" — the next unwatched episode, if any.
nextToWatch: string | null;
addedAt: string;
simklId: number;
mediaType: SimklType;
// Filled in by enrichLibrary() from the per-title detail endpoint + a
// local cache — empty/null until that's run (or if it hasn't been able
// to fetch this particular title yet, see enrichLibrary's comment).
genres: string[];
overview: string | null;
// Minutes per episode (or per movie) — same enrichment path/cache as genres.
runtime: number | null;
};
export type Library = {
watching: LibraryItem[];
completed: LibraryItem[];
planToWatch: LibraryItem[];
onHold: LibraryItem[];
dropped: LibraryItem[];
};
export function simklConfigured(): boolean {
return simklAuthMode() !== null;
}
// Simkl asks for these on every request, catalog and user alike
// (https://api.simkl.org/conventions/headers).
function appParams(extra: Record<string, string> = {}): URLSearchParams {
return new URLSearchParams({
client_id: env.SIMKL_CLIENT_ID ?? '',
'app-name': APP_NAME,
'app-version': APP_VERSION,
...extra
});
}
// Composes a full poster image URL from the path fragment the API returns
// (e.g. "16/16913426086fc13") — see https://api.simkl.org/conventions/images.
// "_c" (170x250) matches a compact card in a grid.
function posterUrl(path: string | null | undefined): string | null {
if (!path) return null;
return `https://wsrv.nl/?url=https://simkl.in/posters/${path}_c.webp&q=90`;
}
const SIMKL_PATH = { shows: 'tv', anime: 'anime', movies: 'movies' } as const;
export type SimklType = keyof typeof SIMKL_PATH;
type AllItemsEntry = {
status: string;
watched_episodes_count: number;
total_episodes_count: number;
user_rating: number | null;
last_watched_at: string | null;
next_to_watch?: string;
added_to_watchlist_at: string;
// Mutually exclusive: shows/anime nest under `show`, movies under `movie`.
show?: { title: string; poster?: string | null; ids: { slug: string; simkl: number } };
movie?: { title: string; poster?: string | null; ids: { slug: string; simkl: number } };
};
async function fetchAll(type: SimklType): Promise<{ status: string; item: LibraryItem }[]> {
// status=all returns every bucket (watching/completed/plantowatch/hold/dropped)
// in one call — cheaper than one request per bucket we care about.
const request = (token: string) =>
fetch(`https://api.simkl.com/sync/all-items/${type}/all?${appParams()}`, {
headers: {
Authorization: `Bearer ${token}`,
'User-Agent': `${APP_NAME}/${APP_VERSION}`
},
signal: AbortSignal.timeout(10_000)
});
const token = await getSimklAccessToken();
let res = await request(token);
// A 401 on V2 usually means the 7-day access token expired early (or was
// replaced) — refresh once and retry. getSimklAccessToken dedupes, so the
// three parallel fetchAll calls share one refresh.
if (res.status === 401 && simklAuthMode() === 'v2') {
res = await request(await getSimklAccessToken({ rejected: token }));
}
if (!res.ok) throw new Error(`Simkl ${type} fetch failed: ${res.status}`);
const data = await res.json();
const entries: AllItemsEntry[] = data[type] ?? [];
// One malformed entry (missing both show and movie) shouldn't sink the
// whole library fetch — skip it and keep the rest.
return entries.flatMap((entry) => {
const media = entry.show ?? entry.movie;
if (!media) return [];
return {
status: entry.status,
item: {
title: media.title,
posterUrl: posterUrl(media.poster),
// Simkl's URLs require the numeric id, not just the slug —
// "/tv/<slug>" alone 404s into the generic discover page.
href: `https://simkl.com/${SIMKL_PATH[type]}/${media.ids.simkl}/${media.ids.slug}`,
watchedEpisodes: entry.watched_episodes_count,
// 0 on movies (not a meaningful episode count) — treat as absent.
totalEpisodes: entry.total_episodes_count || null,
rating: entry.user_rating ?? null,
lastWatchedAt: entry.last_watched_at,
nextToWatch: entry.next_to_watch ?? null,
addedAt: entry.added_to_watchlist_at,
simklId: media.ids.simkl,
mediaType: type,
genres: [],
overview: null,
runtime: null
}
};
});
}
// Korean dramas and movies mostly live under "shows"/"movies" on Simkl (see
// simkl.com/tv/korean-drama), not "anime" — but querying all three buckets
// means the page still works if any title happens to be filed as anime.
export async function getLibrary(): Promise<Library> {
const results = await Promise.all([fetchAll('shows'), fetchAll('anime'), fetchAll('movies')]);
const all = results.flat();
const byStatus = (status: string) => all.filter((e) => e.status === status).map((e) => e.item);
return {
watching: byStatus('watching'),
completed: byStatus('completed'),
planToWatch: byStatus('plantowatch'),
onHold: byStatus('hold'),
dropped: byStatus('dropped')
};
}
// Genres/overview aren't in the bulk sync-all-items response above — only
// available from a per-title detail call. Fetching one per title on every
// page load (~250 titles) would be both slow and a lot of load on Simkl's
// public endpoint, so results are cached in SQLite (simkl-cache.ts) and this
// only ever tops up a small, bounded batch of missing/stale entries per
// request. A library this size fully warms over a handful of page loads;
// titles beyond the batch just render without genres/overview until their
// turn comes up, same "degrade gracefully" approach as the rest of the page.
const DETAIL_BATCH_SIZE = 15;
const STALE_AFTER_MS = 60 * 24 * 60 * 60 * 1000; // 60 days — genres/overview rarely change
async function fetchDetail(
simklId: number,
type: SimklType
): Promise<{ genres: string[]; overview: string; runtime: number | null } | null> {
// No Authorization here on purpose: catalog summaries are Cloudflare-cached
// and a token would bypass that cache. They also don't need one on V2 and
// don't count against the per-user daily quota.
const res = await fetch(`https://api.simkl.com/${SIMKL_PATH[type]}/${simklId}?${appParams({ extended: 'full' })}`, {
headers: { 'User-Agent': `${APP_NAME}/${APP_VERSION}` },
signal: AbortSignal.timeout(10_000)
});
if (!res.ok) return null;
const data = await res.json();
return {
genres: Array.isArray(data.genres) ? data.genres : [],
overview: typeof data.overview === 'string' ? data.overview : '',
runtime: typeof data.runtime === 'number' ? data.runtime : null
};
}
// Re-fetches and re-caches a single title on demand — used by the private
// cache inspector page's "force refresh" action, bypassing the normal
// 60-day staleness window.
export async function refreshCachedDetail(simklId: number, type: SimklType): Promise<boolean> {
const detail = await fetchDetail(simklId, type);
if (!detail) return false;
upsertDetail(simklId, type, detail.genres, detail.overview, detail.runtime);
return true;
}
// Title lookup keyed the same way as the cache, sourced from the last saved
// library snapshot — the simkl_details cache itself has no title column.
export function getLibraryTitleMap(): Map<string, string> {
const snapshot = getLibrarySnapshot<Library>();
const map = new Map<string, string>();
if (!snapshot) return map;
const all = [
...snapshot.data.watching,
...snapshot.data.completed,
...snapshot.data.planToWatch,
...snapshot.data.onHold,
...snapshot.data.dropped
];
for (const item of all) map.set(cacheKey(item.simklId, item.mediaType), item.title);
return map;
}
export async function enrichLibrary(library: Library): Promise<Library> {
const buckets = [...library.watching, ...library.completed, ...library.planToWatch, ...library.onHold, ...library.dropped];
if (buckets.length === 0) return library;
const keys = buckets.map((item) => ({ simklId: item.simklId, mediaType: item.mediaType }));
const cached = getCachedDetails(keys);
const now = Date.now();
const stale = buckets
.filter((item) => {
const hit = cached.get(cacheKey(item.simklId, item.mediaType));
// Also refetch entries cached before `runtime` was added — otherwise
// they'd wait out the full 60-day staleness window before backfilling.
return !hit || hit.runtime == null || now - new Date(hit.fetchedAt).getTime() > STALE_AFTER_MS;
})
// Recently-watched titles jump the queue so a fresh watch's runtime
// backfills within a page load or two instead of sitting behind
// older stale entries and silently undercounting watch time.
.sort((a, b) => {
const aTime = a.lastWatchedAt ? new Date(a.lastWatchedAt).getTime() : 0;
const bTime = b.lastWatchedAt ? new Date(b.lastWatchedAt).getTime() : 0;
return bTime - aTime;
});
const toFetch = stale.slice(0, DETAIL_BATCH_SIZE);
// One flaky detail request shouldn't sink the whole page — the library
// itself already loaded successfully by this point, enrichment is a bonus.
await Promise.allSettled(
toFetch.map(async (item) => {
const detail = await fetchDetail(item.simklId, item.mediaType);
if (detail) upsertDetail(item.simklId, item.mediaType, detail.genres, detail.overview, detail.runtime);
})
);
const merged = toFetch.length > 0 ? getCachedDetails(keys) : cached;
const applyDetails = (items: LibraryItem[]): LibraryItem[] =>
items.map((item) => {
const hit = merged.get(cacheKey(item.simklId, item.mediaType));
return hit ? { ...item, genres: hit.genres, overview: hit.overview || null, runtime: hit.runtime } : item;
});
return {
watching: applyDetails(library.watching),
completed: applyDetails(library.completed),
planToWatch: applyDetails(library.planToWatch),
onHold: applyDetails(library.onHold),
dropped: applyDetails(library.dropped)
};
}
// Fetches + enriches the live library, then persists it as a fallback
// snapshot — see getLibraryWithFallback below. Also called on a timer (see
// simkl-refresh.ts) to keep the snapshot warm independent of page traffic.
export async function refreshLibrarySnapshot(): Promise<Library> {
const library = await enrichLibrary(await getLibrary());
saveLibrarySnapshot(library);
return library;
}
export type LibraryResult = { library: Library; stale: boolean; staleSince: string | null };
// How long a snapshot counts as "fresh enough to serve without asking Simkl".
// Page loads inside this window never wait on Simkl at all; the 24h
// background loop (simkl-refresh.ts) plus post-window page loads keep it
// current. Watchlist changes show up within this window at worst.
const SNAPSHOT_FRESH_MS = 15 * 60 * 1000;
// Simkl's rules explicitly encourage local caching of user data
// (https://api.simkl.org/api-rules), so a full-library snapshot is kept in
// SQLite and served here. Serving order:
// 1. a fresh-enough snapshot — no network at all, so homepage/watchlist
// latency is never coupled to Simkl's,
// 2. a live refresh (which re-saves the snapshot),
// 3. a stale snapshot if the live call fails — better than an empty page
// during a Simkl outage.
// Rethrows only if there's no snapshot at all (first run before anything's
// ever succeeded).
export async function getLibraryWithFallback(): Promise<LibraryResult> {
const snapshot = getLibrarySnapshot<Library>();
if (snapshot && Date.now() - new Date(snapshot.fetchedAt).getTime() < SNAPSHOT_FRESH_MS) {
return { library: snapshot.data, stale: false, staleSince: null };
}
try {
const library = await refreshLibrarySnapshot();
return { library, stale: false, staleSince: null };
} catch (err) {
if (snapshot) return { library: snapshot.data, stale: true, staleSince: snapshot.fetchedAt };
throw err;
}
}