@serifpersia/dango 3.4.4 → 3.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-4CEz8Buu.js → AnimeCard-BVdH0EeW.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-DyX9qTwg.js → AnimeInfo-Dw2Nop6Q.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-Dvp_c8vK.js → AnimeInfoPage-Cw-Tq3YX.js} +2 -2
  5. package/client/dist/assets/{Asmr-qg3lMAJn.js → Asmr-BrjzzyOi.js} +1 -1
  6. package/client/dist/assets/{AsmrResetProgressModal-BpPlg_Vv.js → AsmrResetProgressModal-CYjpcYpb.js} +1 -1
  7. package/client/dist/assets/{AvSyncCalibrator-FJllOgOH.js → AvSyncCalibrator-BuHurVgo.js} +3 -3
  8. package/client/dist/assets/AvSyncCalibrator-D0FYSwiz.css +1 -0
  9. package/client/dist/assets/{Home-A7gCeEST.js → Home-KdnP7sNM.js} +1 -1
  10. package/client/dist/assets/{Insights-G9IXepHJ.js → Insights-DDWry-t1.js} +1 -1
  11. package/client/dist/assets/{LibraryListPage-Bh2qCJPh.js → LibraryListPage-CV8Z9JlO.js} +1 -1
  12. package/client/dist/assets/{ListeningList-Czv4cTRl.js → ListeningList-DSMbMZT7.js} +1 -1
  13. package/client/dist/assets/{Manga-qdAAS1z7.js → Manga-J6XoqEKq.js} +1 -1
  14. package/client/dist/assets/{MangaInfoPage-1rTgZzf2.js → MangaInfoPage-N7TxsN5u.js} +1 -1
  15. package/client/dist/assets/{MangaReadPage-CeyxPG6w.js → MangaReadPage-D_VzSNWq.js} +1 -1
  16. package/client/dist/assets/{MangaResetProgressModal-C_ppotmh.js → MangaResetProgressModal-DyhQYBDy.js} +1 -1
  17. package/client/dist/assets/{Mature-Bkhxnr3K.js → Mature-Db53R-LR.js} +1 -1
  18. package/client/dist/assets/{MediaCard-BQZsutEo.js → MediaCard-BcLjlpfR.js} +1 -1
  19. package/client/dist/assets/{Music-C0rGzSbi.js → Music-DjxQjJOl.js} +1 -1
  20. package/client/dist/assets/{OptionListMenu-CyfpCBEy.js → OptionListMenu-DngTgHxH.js} +1 -1
  21. package/client/dist/assets/Player-Be_7Ne5p.js +56 -0
  22. package/client/dist/assets/{PlayerSettings-icy4SfQa.js → PlayerSettings-D47ItGDR.js} +1 -1
  23. package/client/dist/assets/{QueueOptionsButton-5eloKF5J.js → QueueOptionsButton-ClVTpH8J.js} +1 -1
  24. package/client/dist/assets/{QueueRail-DaMjlKL4.js → QueueRail-DeJKVRs5.js} +1 -1
  25. package/client/dist/assets/{Radio-FxGqCxR4.js → Radio-DU0ma3GA.js} +1 -1
  26. package/client/dist/assets/{ReadingList-CCm3IAbt.js → ReadingList-qXW7Dk5z.js} +1 -1
  27. package/client/dist/assets/{ResetProgressModal-CeI4V3Y5.js → ResetProgressModal-C6ukct_c.js} +1 -1
  28. package/client/dist/assets/{Search-qpILaAiQ.js → Search-DjjKA_Wu.js} +1 -1
  29. package/client/dist/assets/{Settings-Bw9M3NGz.js → Settings-DQt_S5Qw.js} +1 -1
  30. package/client/dist/assets/{SkeletonGrid-0dY_xMq3.js → SkeletonGrid-nx2HEi4P.js} +1 -1
  31. package/client/dist/assets/{Trackers-Zy5GOs0P.js → Trackers-BcGzeqj2.js} +1 -1
  32. package/client/dist/assets/Tv-COvyHdeJ.js +4 -0
  33. package/client/dist/assets/{TvInfo-CHIsVzZ0.js → TvInfo-CUd1agFy.js} +1 -1
  34. package/client/dist/assets/{TvPopup-D_tVTyZ2.js → TvPopup-CJeu3tgc.js} +1 -1
  35. package/client/dist/assets/{TvSearch-BVxedBW-.js → TvSearch-BIypjbU3.js} +1 -1
  36. package/client/dist/assets/{TvWatchlist-BEIwq9R9.js → TvWatchlist-DZrBdiqZ.js} +1 -1
  37. package/client/dist/assets/{Watchlist-CSngjqlz.js → Watchlist-Dw8cPe0n.js} +1 -1
  38. package/client/dist/assets/{hls-D48GVTkk.js → hls-B3_1VuDP.js} +1 -1
  39. package/client/dist/assets/{index-CVB5dYhT.js → index-gMxawd-E.js} +2 -2
  40. package/client/dist/assets/{useAnimeInfoData-DxdOg_B7.js → useAnimeInfoData-_iOLEAa8.js} +1 -1
  41. package/client/dist/assets/{useAsmr-ClckxnPe.js → useAsmr-Blk4v41t.js} +1 -1
  42. package/client/dist/assets/{useAsmrPopup-Bbgxefy_.js → useAsmrPopup-D9SQIgZa.js} +1 -1
  43. package/client/dist/assets/{useMangaLibrary-B3_BUKVk.js → useMangaLibrary-B60at6ni.js} +1 -1
  44. package/client/dist/assets/{useMangaPopup-Celk4lzf.js → useMangaPopup-DEfGjWSu.js} +1 -1
  45. package/client/dist/assets/{useProviders-pwxPZPHh.js → useProviders-Ca0bZWt7.js} +1 -1
  46. package/client/dist/assets/{useTv-D9sQrDop.js → useTv-WJCDL5_5.js} +1 -1
  47. package/client/dist/assets/{useTvLibrary-UsQpgBTB.js → useTvLibrary-Fgn5i9rJ.js} +1 -1
  48. package/client/dist/index.html +1 -1
  49. package/package.json +4 -2
  50. package/server/dist/app-hono.js +2 -0
  51. package/server/dist/hono/asmr.js +10 -1
  52. package/server/dist/hono/music.js +122 -332
  53. package/server/dist/hono/proxy.js +105 -99
  54. package/server/dist/hono/tv.js +5 -58
  55. package/server/dist/hono/watchlist.js +2 -291
  56. package/server/dist/lib/anilist.js +0 -3
  57. package/server/dist/lib/mal.js +4 -41
  58. package/server/dist/lib/offline-db.js +0 -6
  59. package/server/dist/lib/potoken.js +135 -0
  60. package/server/dist/lib/tracker/manga-sync.service.js +3 -22
  61. package/server/dist/lib/tracker/sync-state.js +21 -0
  62. package/server/dist/lib/tracker/sync.service.js +3 -22
  63. package/server/dist/lib/ump.js +106 -0
  64. package/server/dist/providers/remote-loader.js +17 -27
  65. package/server/dist/providers/title-matching.js +5 -5
  66. package/server/dist/repositories/asmr.repository.js +4 -53
  67. package/server/dist/repositories/library.repository.js +46 -0
  68. package/server/dist/repositories/manga.repository.js +3 -53
  69. package/server/dist/repositories/settings.repository.js +8 -0
  70. package/server/dist/repositories/tv.repository.js +3 -46
  71. package/server/dist/repositories/watched-episodes.repository.js +0 -2
  72. package/server/dist/server.js +5 -1
  73. package/server/dist/sync.js +3 -5
  74. package/server/dist/trpc/routers/asmr-library.js +2 -12
  75. package/server/dist/trpc/routers/asmr-progress.js +10 -22
  76. package/server/dist/trpc/routers/data.js +17 -36
  77. package/server/dist/trpc/routers/discord.js +1 -8
  78. package/server/dist/trpc/routers/insights.js +15 -16
  79. package/server/dist/trpc/routers/manga-library.js +5 -22
  80. package/server/dist/trpc/routers/manga-progress.js +8 -20
  81. package/server/dist/trpc/routers/music.js +6 -11
  82. package/server/dist/trpc/routers/queue.js +1 -1
  83. package/server/dist/trpc/routers/tracker.js +1 -7
  84. package/server/dist/trpc/routers/tv-library.js +2 -12
  85. package/server/dist/trpc/routers/tv-progress.js +16 -28
  86. package/server/dist/trpc/routers/tv.js +5 -21
  87. package/server/dist/trpc/routers/watchlist.js +1 -8
  88. package/server/dist/trpc/validation.js +35 -0
  89. package/server/dist/utils/db-utils.js +8 -0
  90. package/server/dist/utils/http.utils.js +12 -0
  91. package/server/dist/utils/machine-id.js +1 -2
  92. package/server/dist/utils/megaplay.utils.js +1 -2
  93. package/server/package.json +4 -1
  94. package/client/dist/assets/AvSyncCalibrator-C2wu1ydI.css +0 -1
  95. package/client/dist/assets/Player-yhiTlpYU.js +0 -56
  96. package/client/dist/assets/Tv-DREwc7BX.js +0 -4
@@ -1,13 +1,11 @@
1
1
  import logger from '../logger.js';
2
2
  import { WatchlistRepository } from '../repositories/watchlist.repository.js';
3
- import { WatchedEpisodesRepository, } from '../repositories/watched-episodes.repository.js';
3
+ import { WatchedEpisodesRepository } from '../repositories/watched-episodes.repository.js';
4
4
  import { ShowsMetaRepository } from '../repositories/shows-meta.repository.js';
5
5
  import { NotificationsRepository } from '../repositories/notifications.repository.js';
6
- import { SettingsRepository } from '../repositories/settings.repository.js';
7
6
  import { dbAll } from '../utils/db-utils.js';
8
7
  import { AppCache } from '../utils/cache.utils.js';
9
- import { searchAnilist, getAiredEpisodesForShows, getAnilistEpisodes, getShowMetaById, isAnilistRateLimited, batchGetShowStatuses, } from '../lib/anilist.js';
10
- import { offlineDb } from '../lib/offline-db.js';
8
+ import { getAiredEpisodesForShows, isAnilistRateLimited, batchGetShowStatuses, } from '../lib/anilist.js';
11
9
  import { parseJsonBody } from '../utils/http.utils.js';
12
10
  import { pickBestMatch } from '../providers/title-matching.js';
13
11
  const BACKGROUND_DISCOVERY_INTERVAL_MS = 5 * 60 * 1000;
@@ -258,286 +256,6 @@ export function startWatchlistDiscovery(getDb) {
258
256
  runDiscovery(false);
259
257
  }, BACKGROUND_DISCOVERY_INTERVAL_MS);
260
258
  }
261
- async function showsMetaChanged(db, showId, candidate) {
262
- const existing = (await ShowsMetaRepository.getById(db, showId));
263
- if (!existing)
264
- return true;
265
- const differs = (incoming, stored) => {
266
- if (incoming === undefined || incoming === null || incoming === '')
267
- return false;
268
- return String(incoming) !== String(stored ?? '');
269
- };
270
- return (differs(candidate.name, existing.name) ||
271
- differs(candidate.thumbnail, existing.thumbnail) ||
272
- differs(candidate.nativeName, existing.nativeName) ||
273
- differs(candidate.englishName, existing.englishName) ||
274
- differs(candidate.genres, existing.genres) ||
275
- differs(candidate.popularityScore, existing.popularityScore) ||
276
- differs(candidate.status, existing.status) ||
277
- differs(candidate.episodeCount, existing.episodeCount) ||
278
- differs(candidate.type, existing.type) ||
279
- differs(candidate.anilistId, existing.anilistId) ||
280
- differs(candidate.isAdult, existing.isAdult) ||
281
- differs(candidate.episodeDuration, existing.episodeDuration));
282
- }
283
- function normalizeFilterValue(value) {
284
- if (typeof value !== 'string')
285
- return undefined;
286
- const trimmed = value.trim();
287
- return trimmed && trimmed !== 'ALL' ? trimmed : undefined;
288
- }
289
- function getWatchlistFilters(query) {
290
- return {
291
- query: normalizeFilterValue(query['query']),
292
- type: normalizeFilterValue(query['type']),
293
- season: normalizeFilterValue(query['season']),
294
- year: normalizeFilterValue(query['year']),
295
- genres: normalizeFilterValue(query['genres']),
296
- excludeGenres: normalizeFilterValue(query['excludeGenres']),
297
- sortBy: normalizeFilterValue(query['sortBy']),
298
- titlePreference: ['name', 'nativeName', 'englishName'].includes(String(query['titlePreference']))
299
- ? query['titlePreference']
300
- : 'name',
301
- };
302
- }
303
- function matchesLocalFilters(row, filters) {
304
- if (filters.query) {
305
- const queryWords = new Set(filters.query
306
- .toLowerCase()
307
- .split(/\s+/)
308
- .filter((w) => w.length >= 2));
309
- const rowTitle = (row.englishName || row.name || row.nativeName || '').toLowerCase();
310
- const titleWords = rowTitle.split(/\s+/);
311
- const overlap = titleWords.filter((w) => queryWords.has(w)).length;
312
- if (overlap < queryWords.size)
313
- return false;
314
- }
315
- return true;
316
- }
317
- function sortFilteredRows(rows, filters) {
318
- const getSortTitle = (row) => {
319
- const preferredTitle = filters.titlePreference ? row[filters.titlePreference] : undefined;
320
- return preferredTitle || row.name || '';
321
- };
322
- if (filters.sortBy === 'name_asc') {
323
- return [...rows].sort((a, b) => getSortTitle(a).localeCompare(getSortTitle(b)));
324
- }
325
- if (filters.sortBy === 'name_desc') {
326
- return [...rows].sort((a, b) => getSortTitle(b).localeCompare(getSortTitle(a)));
327
- }
328
- return rows;
329
- }
330
- async function getAnilistSeasonYearMatches(season, year) {
331
- const matched = new Map();
332
- if (!year || year === 'ALL') {
333
- return matched;
334
- }
335
- const seasonYear = parseInt(year);
336
- if (Number.isNaN(seasonYear))
337
- return matched;
338
- const perPage = 50;
339
- let page = 1;
340
- while (true) {
341
- const searchVars = {
342
- seasonYear,
343
- page,
344
- perPage,
345
- };
346
- if (season && season !== 'ALL') {
347
- searchVars.season = season.toUpperCase();
348
- }
349
- const results = await searchAnilist(searchVars);
350
- for (const show of results) {
351
- if (show.anilistId) {
352
- matched.set(show.anilistId, { title: show.names || {} });
353
- }
354
- }
355
- if (results.length < perPage)
356
- break;
357
- page++;
358
- if (page > 10)
359
- break;
360
- }
361
- return matched;
362
- }
363
- function rowMatchesAnilistSeasonYear(row, anilistMatches) {
364
- if (anilistMatches.size === 0)
365
- return true;
366
- if (/^\d+$/.test(row.id) && anilistMatches.has(parseInt(row.id))) {
367
- return true;
368
- }
369
- const rowTitle = (row.englishName || row.name || row.nativeName || '').toLowerCase();
370
- if (!rowTitle)
371
- return false;
372
- const rowWords = new Set(rowTitle.split(/\s+/).filter((w) => w.length >= 2));
373
- if (rowWords.size === 0)
374
- return false;
375
- for (const [, media] of anilistMatches) {
376
- const titles = [media.title?.romaji, media.title?.english, media.title?.native].filter(Boolean);
377
- for (const title of titles) {
378
- const titleWords = new Set(title
379
- .toLowerCase()
380
- .split(/\s+/)
381
- .filter((w) => w.length >= 2));
382
- const overlap = [...rowWords].filter((w) => titleWords.has(w)).length;
383
- const minLen = Math.min(rowWords.size, titleWords.size);
384
- if (minLen >= 2 && overlap / minLen >= 0.7)
385
- return true;
386
- }
387
- }
388
- return false;
389
- }
390
- async function filterWatchlistRows(rows, filters, db) {
391
- let filtered = rows.filter((row) => matchesLocalFilters(row, filters));
392
- if (filters.type === 'ADULT' && db && filtered.length > 0) {
393
- const ids = filtered.map((r) => r.id);
394
- const placeholders = ids.map(() => '?').join(',');
395
- const adultRows = await dbAll(db, `SELECT id FROM shows_meta WHERE id IN (${placeholders}) AND isAdult = 1`, ids);
396
- const adultSet = new Set(adultRows.map((r) => r.id));
397
- filtered = filtered.filter((row) => adultSet.has(row.id));
398
- }
399
- else if (filters.type && filters.type !== 'ALL' && filters.type !== 'ADULT') {
400
- filtered = filtered.filter((row) => row.type === filters.type);
401
- }
402
- if ((filters.genres || filters.excludeGenres) && db) {
403
- const ids = filtered.map((r) => r.id);
404
- const placeholders = ids.map(() => '?').join(',');
405
- const genreRows = await dbAll(db, `SELECT id, genres FROM shows_meta WHERE id IN (${placeholders})`, ids);
406
- const idToGenres = new Map(genreRows.map((r) => [r.id, r.genres ? JSON.parse(r.genres) : []]));
407
- const includeList = filters.genres
408
- ?.split(',')
409
- .map((g) => g.trim())
410
- .filter(Boolean) || [];
411
- const excludeList = filters.excludeGenres
412
- ?.split(',')
413
- .map((g) => g.trim())
414
- .filter(Boolean) || [];
415
- filtered = filtered.filter((row) => {
416
- const rowGenres = idToGenres.get(row.id) || [];
417
- if (includeList.length && !includeList.every((g) => rowGenres.includes(g)))
418
- return false;
419
- if (excludeList.length && excludeList.some((g) => rowGenres.includes(g)))
420
- return false;
421
- return true;
422
- });
423
- }
424
- if (((filters.season && filters.season !== 'ALL') || (filters.year && filters.year !== 'ALL')) &&
425
- filtered.length > 0) {
426
- const anilistMatches = await getAnilistSeasonYearMatches(filters.season, filters.year);
427
- if (anilistMatches.size > 0) {
428
- filtered = filtered.filter((row) => rowMatchesAnilistSeasonYear(row, anilistMatches));
429
- }
430
- }
431
- return sortFilteredRows(filtered, filters);
432
- }
433
- async function backfillMissingPosters(db, rows) {
434
- const missing = rows.filter((r) => !r.thumbnail || r.thumbnail.trim() === '');
435
- if (missing.length === 0)
436
- return;
437
- const slice = missing.slice(0, 10);
438
- await Promise.allSettled(slice.map(async (row) => {
439
- try {
440
- const meta = await getShowMetaById(row.id);
441
- const poster = meta?.thumbnail?.trim();
442
- if (!poster)
443
- return;
444
- row.thumbnail = poster;
445
- const dual = row;
446
- if (dual._id !== undefined)
447
- dual._id = row.id;
448
- try {
449
- await ShowsMetaRepository.upsert(db, {
450
- id: row.id,
451
- thumbnail: poster,
452
- popularityScore: meta?.score ?? (meta?.averageScore != null ? meta.averageScore / 10 : undefined),
453
- });
454
- }
455
- catch {
456
- // ignore
457
- }
458
- try {
459
- await WatchlistRepository.updateThumbnail(db, row.id, poster);
460
- }
461
- catch {
462
- // ignore
463
- }
464
- }
465
- catch {
466
- // ignore
467
- }
468
- }));
469
- await sweepOfflinePosters(db);
470
- }
471
- async function sweepOfflinePosters(db) {
472
- try {
473
- db.run(`UPDATE shows_meta SET name = (SELECT w.name FROM watchlist w WHERE w.id = shows_meta.id)
474
- WHERE (name IS NULL OR TRIM(name) = '')
475
- AND EXISTS (SELECT 1 FROM watchlist w WHERE w.id = shows_meta.id AND w.name IS NOT NULL AND TRIM(w.name) != '')`);
476
- }
477
- catch {
478
- // ignore
479
- }
480
- const [wlMissing, metaMissing] = await Promise.all([
481
- WatchlistRepository.getMissingThumbnails(db),
482
- ShowsMetaRepository.getShowIdsMissingMeta(db),
483
- ]);
484
- const ids = [...new Set([...wlMissing.map((r) => r.id), ...metaMissing.map((r) => r.id)])].slice(0, 500);
485
- if (ids.length === 0)
486
- return;
487
- for (const id of ids) {
488
- const poster = resolveOfflinePoster(id);
489
- if (poster)
490
- await applyPoster(db, id, poster);
491
- }
492
- }
493
- function resolveOfflinePoster(id) {
494
- if (/^\d+$/.test(id)) {
495
- return offlineDb.getByAnilistId(Number(id))?.thumbnail?.trim() || null;
496
- }
497
- const mal = /^mal-(\d+)$/i.exec(id)?.[1];
498
- if (mal)
499
- return offlineDb.getByMalId(Number(mal))?.thumbnail?.trim() || null;
500
- return null;
501
- }
502
- async function applyPoster(db, id, poster) {
503
- try {
504
- await ShowsMetaRepository.upsert(db, { id, thumbnail: poster });
505
- }
506
- catch {
507
- // ignore
508
- }
509
- try {
510
- await WatchlistRepository.updateThumbnail(db, id, poster);
511
- }
512
- catch {
513
- // ignore
514
- }
515
- }
516
- async function getContinueWatchingData(db, limit) {
517
- const rows = await WatchedEpisodesRepository.getContinueWatching(db, limit);
518
- const ignoreAdultRow = await SettingsRepository.getByKey(db, 'ignoreAdultContent');
519
- const ignoreAdult = ignoreAdultRow ? ignoreAdultRow.value !== 'false' : true;
520
- const watchlistOnlyRow = await SettingsRepository.getByKey(db, 'cwWatchlistOnly');
521
- const watchlistOnly = watchlistOnlyRow
522
- ? watchlistOnlyRow.value === 'true' || watchlistOnlyRow.value === '1'
523
- : false;
524
- const enrichedRows = rows
525
- .filter((show) => {
526
- if (ignoreAdult && show.isAdult)
527
- return false;
528
- if (watchlistOnly && show.watchlistStatus !== 'Watching')
529
- return false;
530
- return true;
531
- })
532
- .map((show) => ({
533
- ...show,
534
- episodeCount: show.episodeCount,
535
- type: show.type || show.smType,
536
- thumbnail: show.thumbnail ?? '',
537
- }));
538
- await backfillMissingPosters(db, enrichedRows);
539
- return enrichedRows;
540
- }
541
259
  export async function getAdultNonWatchlistShowIds(db) {
542
260
  const rows = await dbAll(db, `SELECT DISTINCT we.showId as showId
543
261
  FROM watched_episodes we
@@ -546,13 +264,6 @@ export async function getAdultNonWatchlistShowIds(db) {
546
264
  WHERE w.id IS NULL`);
547
265
  return rows.map((r) => r.showId);
548
266
  }
549
- async function resolveAvailableEpisodes(db, showId) {
550
- const episodeData = await getAnilistEpisodes(showId);
551
- const episodes = Array.isArray(episodeData) && episodeData.length
552
- ? [...episodeData].sort((a, b) => parseFloat(a) - parseFloat(b))
553
- : [];
554
- return episodes;
555
- }
556
267
  const dlsitePosterCache = new AppCache({ ttlSeconds: 3600, maxKeys: 2000 });
557
268
  const mangadexCoverCache = new AppCache({ ttlSeconds: 3600, maxKeys: 2000 });
558
269
  export async function getMangadexCover(title) {
@@ -21,9 +21,6 @@ export function anilistUnavailable() {
21
21
  }
22
22
  return false;
23
23
  }
24
- export function anilistIsDown() {
25
- return anilistDownUntil > Date.now();
26
- }
27
24
  export function isAnilistRateLimited() {
28
25
  return anilistRateLimited && anilistCooldownUntil > Date.now();
29
26
  }
@@ -153,44 +153,6 @@ function detailToSearchEntry(d) {
153
153
  score: d.score,
154
154
  };
155
155
  }
156
- export function toSearchEntry(e) {
157
- if (!e || !e.malId || !e.title)
158
- return null;
159
- return {
160
- id: -e.malId,
161
- idMal: e.malId,
162
- url: e.url ?? `https://myanimelist.net/anime/${e.malId}`,
163
- title: e.title,
164
- imageUrl: e.imageUrl ?? null,
165
- synopsis: e.synopsis ?? null,
166
- type: e.type ?? null,
167
- episodes: e.episodes ?? null,
168
- score: e.score ?? null,
169
- };
170
- }
171
- export function toDetail(e, malId) {
172
- if (!e || !e.title)
173
- return null;
174
- const genres = (Array.isArray(e.genres) ? e.genres : [])
175
- .map((g) => (typeof g === 'string' ? g : g?.name))
176
- .filter((g) => !!g);
177
- return {
178
- id: -malId,
179
- idMal: malId,
180
- url: e.url ?? `https://myanimelist.net/anime/${malId}`,
181
- title: e.title,
182
- titleEnglish: e.titleEnglish ?? null,
183
- titleJapanese: e.titleJapanese ?? null,
184
- imageUrl: e.imageUrl ?? e.images?.medium ?? null,
185
- synopsis: e.synopsis ?? null,
186
- type: e.type ?? null,
187
- episodes: e.episodes ?? null,
188
- status: e.status ?? null,
189
- score: e.score ?? null,
190
- rating: e.rating ?? null,
191
- genres,
192
- };
193
- }
194
156
  function mapMalStatus(status) {
195
157
  if (!status)
196
158
  return undefined;
@@ -603,9 +565,10 @@ export async function enrichCardTitles(store, items) {
603
565
  export async function malSearchMedia(store, options = {}, enrichTitles = true) {
604
566
  const { query, page = 1, format, status, season, seasonYear, genre, genre_in, genre_not_in, averageScore_greater, episodes_greater, isAdult, sort, } = options;
605
567
  const params = new URLSearchParams();
606
- const hasQuery = !!query?.trim();
607
- if (hasQuery)
608
- params.set('q', query.trim());
568
+ const trimmedQuery = query?.trim();
569
+ const hasQuery = !!trimmedQuery;
570
+ if (trimmedQuery)
571
+ params.set('q', trimmedQuery);
609
572
  params.set('cat', 'anime');
610
573
  if (page > 1)
611
574
  params.set('show', String((page - 1) * 50));
@@ -208,12 +208,6 @@ class OfflineDb {
208
208
  getByAnilistId(anilistId) {
209
209
  return this.anilistToEntry.get(anilistId) ?? null;
210
210
  }
211
- getAnilistIdByMal(malId) {
212
- return this.malToEntry.get(malId)?.anilistId ?? null;
213
- }
214
- getMalIdByAnilist(anilistId) {
215
- return this.anilistToMal.get(anilistId) ?? null;
216
- }
217
211
  async getOfflineDbInfo(db) {
218
212
  const get = async (key) => (await SettingsRepository.getByKey(db, key))?.value ?? null;
219
213
  const autoUpdate = await get('offlineDbAutoUpdateEnabled');
@@ -0,0 +1,135 @@
1
+ import logger from '../logger.js';
2
+ import { parseJsonBody } from '../utils/http.utils.js';
3
+ import { getInnertube } from './ytmusic.js';
4
+ const REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo';
5
+ const INTEGRITY_TTL_MS = 10 * 60 * 60 * 1000;
6
+ const POT_CACHE_MS = 6 * 60 * 60 * 1000;
7
+ let minter = null;
8
+ let minterFetchedAt = 0;
9
+ let bootstrapPromise = null;
10
+ let bootstrapFailedAt = 0;
11
+ const BOOTSTRAP_FAIL_COOLDOWN_MS = 60 * 1000;
12
+ const potCache = new Map();
13
+ // BotGuard needs browser globals to execute. They are installed once and kept
14
+ // for the process lifetime: the minter's callbacks read them lazily at mint
15
+ // time, long after bootstrap completes, so scoping them to bootstrap breaks
16
+ // minting with `window is not defined`.
17
+ async function setupDom() {
18
+ const g = globalThis;
19
+ if ('navigator' in g && g.yt)
20
+ return;
21
+ const { JSDOM, VirtualConsole } = await import('jsdom');
22
+ const virtualConsole = new VirtualConsole();
23
+ const dom = new JSDOM('<!DOCTYPE html><html lang="en"><head><title></title></head><body></body></html>', {
24
+ url: 'https://www.youtube.com',
25
+ referrer: 'https://www.youtube.com/',
26
+ virtualConsole,
27
+ });
28
+ Object.assign(g, {
29
+ window: dom.window,
30
+ document: dom.window.document,
31
+ location: dom.window.location,
32
+ origin: dom.window.origin,
33
+ });
34
+ if (!('navigator' in g)) {
35
+ Object.defineProperty(g, 'navigator', {
36
+ value: dom.window.navigator,
37
+ writable: true,
38
+ configurable: true,
39
+ });
40
+ }
41
+ }
42
+ async function buildMinter() {
43
+ const [{ BotGuardClient }, { WebPoMinter }, { buildURL, getHeaders, USER_AGENT }] = await Promise.all([
44
+ import('bgutils-js/botguard'),
45
+ import('bgutils-js/webpo'),
46
+ import('bgutils-js/utils'),
47
+ ]);
48
+ const yt = await getInnertube();
49
+ const pageResponse = await fetch('https://www.youtube.com', {
50
+ headers: {
51
+ accept: '*/*',
52
+ 'accept-language': 'en-US,en;q=0.7',
53
+ 'user-agent': USER_AGENT,
54
+ },
55
+ });
56
+ const pageHtml = await pageResponse.text();
57
+ const ytConfig = pageHtml.match(/ytcfg\.set\(({.+?})\);/s)?.[1];
58
+ if (ytConfig) {
59
+ const win = globalThis.window;
60
+ if (win)
61
+ win.yt = { config_: JSON.parse(ytConfig) };
62
+ }
63
+ const challenge = (await yt.getAttestationChallenge('ENGAGEMENT_TYPE_UNBOUND'));
64
+ const bgc = challenge.bg_challenge;
65
+ const interpreterUrl = bgc?.interpreter_url?.private_do_not_access_or_else_trusted_resource_url_wrapped_value;
66
+ if (!bgc?.program || !bgc.global_name || !interpreterUrl) {
67
+ throw new Error('No BotGuard challenge in response');
68
+ }
69
+ const bgScript = await (await fetch(`https:${interpreterUrl}`)).text();
70
+ if (!bgScript)
71
+ throw new Error('Could not load BotGuard VM');
72
+ new Function(bgScript)();
73
+ const botGuard = await BotGuardClient.create({
74
+ program: bgc.program,
75
+ globalName: bgc.global_name,
76
+ globalObject: globalThis,
77
+ });
78
+ const webPoSignalOutput = [];
79
+ const botguardResponse = await botGuard.snapshot({ webPoSignalOutput });
80
+ const itResponse = await fetch(buildURL('GenerateIT', true), {
81
+ method: 'POST',
82
+ headers: getHeaders(),
83
+ body: JSON.stringify([REQUEST_KEY, botguardResponse]),
84
+ });
85
+ const [integrityToken, estimatedTtlSecs, mintRefreshThreshold, websafeFallbackToken] = await parseJsonBody(itResponse);
86
+ if (!integrityToken)
87
+ throw new Error('Integrity token request failed');
88
+ logger.info('[pot] minter ready (integrity ttl %ss)', estimatedTtlSecs);
89
+ return WebPoMinter.create({
90
+ integrityToken,
91
+ estimatedTtlSecs,
92
+ mintRefreshThreshold,
93
+ websafeFallbackToken,
94
+ }, webPoSignalOutput);
95
+ }
96
+ async function getMinter() {
97
+ if (minter && Date.now() - minterFetchedAt < INTEGRITY_TTL_MS)
98
+ return minter;
99
+ if (Date.now() - bootstrapFailedAt < BOOTSTRAP_FAIL_COOLDOWN_MS) {
100
+ throw new Error('PoToken bootstrap cooling down');
101
+ }
102
+ if (!bootstrapPromise) {
103
+ bootstrapPromise = (async () => {
104
+ await setupDom();
105
+ const m = await buildMinter();
106
+ minter = m;
107
+ minterFetchedAt = Date.now();
108
+ return m;
109
+ })().catch((err) => {
110
+ bootstrapPromise = null;
111
+ bootstrapFailedAt = Date.now();
112
+ throw err;
113
+ });
114
+ }
115
+ return bootstrapPromise;
116
+ }
117
+ export async function mintPoToken(videoId) {
118
+ const cached = potCache.get(videoId);
119
+ if (cached && Date.now() - cached.fetchedAt < POT_CACHE_MS)
120
+ return cached.pot;
121
+ const m = await getMinter();
122
+ const pot = await m.mintAsWebsafeString(videoId);
123
+ potCache.set(videoId, { pot, fetchedAt: Date.now() });
124
+ return pot;
125
+ }
126
+ export function resetPoTokens(videoId) {
127
+ if (videoId)
128
+ potCache.delete(videoId);
129
+ else {
130
+ potCache.clear();
131
+ minter = null;
132
+ minterFetchedAt = 0;
133
+ bootstrapPromise = null;
134
+ }
135
+ }
@@ -5,30 +5,11 @@ import { performMangaWriteTransactionAsync, performWriteTransactionAsync } from
5
5
  import logger from '../../logger.js';
6
6
  import { searchAnilistMangaByTitle, getMangaMetaById, getMangaMetaByMalId } from '../anilist.js';
7
7
  import { fetchMalMangaList, mapMalMangaStatusCode } from '../mal.js';
8
+ import { readTrackerSyncState, resolveStatus, } from './sync-state.js';
8
9
  const TOKEN_KEY = 'tracker_anilist_token';
9
10
  const MANGA_SYNC_STATE_KEY = 'tracker_anilist_manga_sync_state';
10
11
  const MANGA_PROVIDER = 'anilist';
11
12
  const MAX_TITLE_LOOKUPS_PER_SYNC = 25;
12
- async function readMangaSyncState(db) {
13
- const row = await SettingsRepository.getByKey(db, MANGA_SYNC_STATE_KEY);
14
- if (!row?.value)
15
- return {};
16
- try {
17
- return JSON.parse(row.value);
18
- }
19
- catch {
20
- return {};
21
- }
22
- }
23
- function resolveStatus(localStatus, remoteStatus, remoteUpdated, localLastSync) {
24
- if (remoteUpdated > localLastSync && remoteStatus !== localStatus) {
25
- return { status: remoteStatus, pull: true };
26
- }
27
- if (remoteStatus !== localStatus) {
28
- return { status: localStatus, pull: false };
29
- }
30
- return { status: localStatus, pull: false };
31
- }
32
13
  function parseChapterProgress(value) {
33
14
  if (value == null)
34
15
  return 0;
@@ -122,7 +103,7 @@ export async function syncAniListManga(db, mangaDb, opts = {}) {
122
103
  const viewer = await tracker.getViewer();
123
104
  const remoteEntries = await tracker.fetchUserMangaList(viewer.id);
124
105
  const localItems = await MangaLibraryRepository.getAll(mangaDb);
125
- const syncState = await readMangaSyncState(db);
106
+ const syncState = await readTrackerSyncState(db, MANGA_SYNC_STATE_KEY);
126
107
  const remoteByMediaId = new Map();
127
108
  for (const entry of remoteEntries)
128
109
  remoteByMediaId.set(entry.mediaId, entry);
@@ -618,7 +599,7 @@ export async function insertMangaImportEntries(db, mangaDb, items, eraseAnilistR
618
599
  if (items.length === 0)
619
600
  return 0;
620
601
  const now = Math.floor(Date.now() / 1000);
621
- const syncState = await readMangaSyncState(db);
602
+ const syncState = await readTrackerSyncState(db, MANGA_SYNC_STATE_KEY);
622
603
  await performMangaWriteTransactionAsync(mangaDb, async (tx) => {
623
604
  if (eraseAnilistRows) {
624
605
  for (const row of await MangaLibraryRepository.getAll(tx)) {
@@ -0,0 +1,21 @@
1
+ import { SettingsRepository } from '../../repositories/settings.repository.js';
2
+ export async function readTrackerSyncState(db, key) {
3
+ const row = await SettingsRepository.getByKey(db, key);
4
+ if (!row?.value)
5
+ return {};
6
+ try {
7
+ return JSON.parse(row.value);
8
+ }
9
+ catch {
10
+ return {};
11
+ }
12
+ }
13
+ export function resolveStatus(localStatus, remoteStatus, remoteUpdated, localLastSync) {
14
+ if (remoteUpdated > localLastSync && remoteStatus !== localStatus) {
15
+ return { status: remoteStatus, pull: true };
16
+ }
17
+ if (remoteStatus !== localStatus) {
18
+ return { status: localStatus, pull: false };
19
+ }
20
+ return { status: localStatus, pull: false };
21
+ }
@@ -11,33 +11,14 @@ import { fetchMalUserList } from '../mal.js';
11
11
  import { offlineDb } from '../offline-db.js';
12
12
  import { getShowMetaById, getShowMetaByMalId, isAnilistRateLimited, searchAnilistByTitle, } from '../anilist.js';
13
13
  import { kitsuSearchAnime } from '../kitsu.js';
14
+ import { readTrackerSyncState, resolveStatus, } from './sync-state.js';
14
15
  const TOKEN_KEY = 'tracker_anilist_token';
15
16
  const USER_KEY = 'tracker_anilist_user';
16
17
  const SYNC_STATE_KEY = 'tracker_anilist_sync_state';
17
- async function readSyncState(db) {
18
- const row = await SettingsRepository.getByKey(db, SYNC_STATE_KEY);
19
- if (!row?.value)
20
- return {};
21
- try {
22
- return JSON.parse(row.value);
23
- }
24
- catch {
25
- return {};
26
- }
27
- }
28
18
  async function getWatchedCount(db, showId) {
29
19
  const row = await dbGet(db, 'SELECT COUNT(DISTINCT episodeNumber) as total FROM watched_episodes WHERE showId = ?', [showId]);
30
20
  return row?.total ?? 0;
31
21
  }
32
- function resolveStatus(localStatus, remoteStatus, remoteUpdated, localLastSync) {
33
- if (remoteUpdated > localLastSync && remoteStatus !== localStatus) {
34
- return { status: remoteStatus, pull: true };
35
- }
36
- if (remoteStatus !== localStatus) {
37
- return { status: localStatus, pull: false };
38
- }
39
- return { status: localStatus, pull: false };
40
- }
41
22
  export async function syncAniList(db) {
42
23
  const tokenRow = await SettingsRepository.getByKey(db, TOKEN_KEY);
43
24
  const token = tokenRow?.value;
@@ -47,7 +28,7 @@ export async function syncAniList(db) {
47
28
  const viewer = await tracker.getViewer();
48
29
  const remoteEntries = await tracker.fetchUserAnimeList(viewer.id);
49
30
  const localItems = await WatchlistRepository.getAll(db);
50
- const syncState = await readSyncState(db);
31
+ const syncState = await readTrackerSyncState(db, SYNC_STATE_KEY);
51
32
  const remoteByMediaId = new Map();
52
33
  for (const entry of remoteEntries)
53
34
  remoteByMediaId.set(entry.mediaId, entry);
@@ -299,7 +280,7 @@ export async function importFromUsername(db, username, erase = false) {
299
280
  if (entries.length === 0)
300
281
  return 0;
301
282
  const now = Math.floor(Date.now() / 1000);
302
- const syncState = await readSyncState(db);
283
+ const syncState = await readTrackerSyncState(db, SYNC_STATE_KEY);
303
284
  await performWriteTransactionAsync(db, async (tx) => {
304
285
  if (erase)
305
286
  await SettingsRepository.clearWatchlist(tx);