@serifpersia/dango 3.3.6 → 3.3.8

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 (86) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-OKU7LaSN.js → AnimeCard-D4qo4289.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-BCyLw0yz.js → AnimeInfo-BdRRm4So.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-BZWl-TJY.js → AnimeInfoPage-DeJNAKMR.js} +2 -2
  5. package/client/dist/assets/{Asmr-4S6uoevp.js → Asmr-D0EGRYsv.js} +1 -1
  6. package/client/dist/assets/{AsmrResetProgressModal-C8i6weqW.js → AsmrResetProgressModal-Gs0GmVaT.js} +1 -1
  7. package/client/dist/assets/{AvSyncCalibrator-oK6LlG61.js → AvSyncCalibrator-D8hqarVB.js} +1 -1
  8. package/client/dist/assets/Home-BnBzjFkW.js +1 -0
  9. package/client/dist/assets/Insights-Dc8xLmsr.css +1 -0
  10. package/client/dist/assets/Insights-i9QgY3nE.js +1 -0
  11. package/client/dist/assets/LibraryListPage-D3hTqTRU.js +1 -0
  12. package/client/dist/assets/{ListeningList-Dxkhy6E2.js → ListeningList-BgWsfu40.js} +1 -1
  13. package/client/dist/assets/{Manga-pJEpcUpo.js → Manga-Dn-CYins.js} +1 -1
  14. package/client/dist/assets/MangaInfoPage-EmQoK7Bv.js +1 -0
  15. package/client/dist/assets/MangaReadPage-xJB8K2YU.js +1 -0
  16. package/client/dist/assets/{MangaResetProgressModal-WjKH_G_f.js → MangaResetProgressModal-Dian37PQ.js} +1 -1
  17. package/client/dist/assets/Mature-DQtXKOXc.js +1 -0
  18. package/client/dist/assets/{MediaCard-DszKDA4s.js → MediaCard-DpKoY-1j.js} +1 -1
  19. package/client/dist/assets/{Music-CU6TBHF5.js → Music-BP43Zqmq.js} +1 -1
  20. package/client/dist/assets/{OptionListMenu-CkK2BvEz.js → OptionListMenu-Bj9ebsW3.js} +1 -1
  21. package/client/dist/assets/Player-O2Cy2GF1.js +56 -0
  22. package/client/dist/assets/PlayerSettings-DYZ272Yp.js +1 -0
  23. package/client/dist/assets/{QueueOptionsButton-DKWi-9kd.js → QueueOptionsButton-lv26-6jp.js} +1 -1
  24. package/client/dist/assets/{QueueRail-DAcIex5B.js → QueueRail-B7DeSFoA.js} +1 -1
  25. package/client/dist/assets/{Radio-BhTsgGGd.js → Radio-qQBYnVfY.js} +1 -1
  26. package/client/dist/assets/{ReadingList-BLnkwB--.js → ReadingList-BT8kUjCB.js} +1 -1
  27. package/client/dist/assets/{ResetProgressModal-8mcLXaAb.js → ResetProgressModal-DWeiO-hs.js} +1 -1
  28. package/client/dist/assets/Search-BkvPYN33.js +1 -0
  29. package/client/dist/assets/{Settings-bSA7BG7h.js → Settings-CyZEBF6S.js} +1 -1
  30. package/client/dist/assets/SkeletonGrid-B5loz7ii.js +1 -0
  31. package/client/dist/assets/SkeletonGrid-Bge3aRl2.css +1 -0
  32. package/client/dist/assets/Trackers-JK-aXBYC.js +2 -0
  33. package/client/dist/assets/{Tv-DJTbg4VW.js → Tv-BwXWJxMy.js} +1 -1
  34. package/client/dist/assets/{TvInfo-Z0c1Wh3i.js → TvInfo-Dzyq6ucH.js} +1 -1
  35. package/client/dist/assets/{TvPopup-CP41CncZ.js → TvPopup-PlCU30FO.js} +1 -1
  36. package/client/dist/assets/TvSearch-BYwCf5kO.js +1 -0
  37. package/client/dist/assets/{TvWatchlist-COCWPkgh.js → TvWatchlist-BiJU6pKT.js} +1 -1
  38. package/client/dist/assets/Watchlist-BOEeGXH9.js +1 -0
  39. package/client/dist/assets/{hls-B_yo8-yR.js → hls-C11FOWHj.js} +1 -1
  40. package/client/dist/assets/index-CtoKRNLc.js +178 -0
  41. package/client/dist/assets/{useAnimeInfoData-CbNnHRj8.js → useAnimeInfoData-kr3WOIWH.js} +1 -1
  42. package/client/dist/assets/{useAsmr-BEcgHwxg.js → useAsmr-CB9THf0m.js} +1 -1
  43. package/client/dist/assets/{useAsmrPopup-BXD3y2m1.js → useAsmrPopup-cT_Uu41e.js} +1 -1
  44. package/client/dist/assets/useMangaLibrary-rM6z3_0E.js +1 -0
  45. package/client/dist/assets/{useMangaPopup-BvgtZ53s.js → useMangaPopup-PnmSctHu.js} +1 -1
  46. package/client/dist/assets/{useProviders-xlftutIo.js → useProviders-B3VB7A9M.js} +1 -1
  47. package/client/dist/assets/{useTv-DTqrJEWp.js → useTv-BTUKMH7Q.js} +1 -1
  48. package/client/dist/assets/{useTvLibrary-BHeWSd1F.js → useTvLibrary-C0sX-ilZ.js} +1 -1
  49. package/client/dist/index.html +1 -1
  50. package/package.json +2 -2
  51. package/server/dist/controllers/data.controller.js +1 -0
  52. package/server/dist/controllers/insights.controller.js +8 -3
  53. package/server/dist/controllers/manga-library.controller.js +54 -0
  54. package/server/dist/controllers/proxy.controller.js +94 -23
  55. package/server/dist/controllers/watchlist.controller.js +55 -1
  56. package/server/dist/lib/anilist.js +36 -3
  57. package/server/dist/lib/discord-roles-sync.service.js +237 -2
  58. package/server/dist/lib/mal.js +69 -0
  59. package/server/dist/lib/tracker/anilist-tracker.js +66 -0
  60. package/server/dist/lib/tracker/manga-sync.service.js +792 -0
  61. package/server/dist/repositories/insights.repository.js +10 -0
  62. package/server/dist/repositories/manga.repository.js +17 -2
  63. package/server/dist/repositories/shows-meta.repository.js +4 -1
  64. package/server/dist/repositories/watchlist.repository.js +1 -0
  65. package/server/dist/routes/insights.routes.js +1 -0
  66. package/server/dist/routes/manga-library.routes.js +2 -0
  67. package/server/dist/routes/tracker.routes.js +123 -0
  68. package/server/dist/sync.js +8 -1
  69. package/server/package.json +1 -1
  70. package/client/dist/assets/Home-CQ4Ikeja.js +0 -1
  71. package/client/dist/assets/Insights-DKtoVe6L.css +0 -1
  72. package/client/dist/assets/Insights-DiAo0UAc.js +0 -1
  73. package/client/dist/assets/LibraryListPage-B0MDTnEc.js +0 -1
  74. package/client/dist/assets/MangaInfoPage-BEQsjTHH.js +0 -1
  75. package/client/dist/assets/MangaReadPage-C0WAtK-1.js +0 -1
  76. package/client/dist/assets/Mature-Ckx99wFj.js +0 -1
  77. package/client/dist/assets/Player-C3yWQkXb.js +0 -56
  78. package/client/dist/assets/PlayerSettings-CBdTzo0E.js +0 -1
  79. package/client/dist/assets/Search-DxX021dj.js +0 -1
  80. package/client/dist/assets/SkeletonGrid-DhPYefXr.css +0 -1
  81. package/client/dist/assets/SkeletonGrid-Du10CNfq.js +0 -1
  82. package/client/dist/assets/Trackers-BXSONaRD.js +0 -2
  83. package/client/dist/assets/TvSearch-BlqTQYJa.js +0 -1
  84. package/client/dist/assets/Watchlist-BHLshSPX.js +0 -1
  85. package/client/dist/assets/index-lo-bz7f2.js +0 -178
  86. package/client/dist/assets/useMangaLibrary-BXhgN-dl.js +0 -1
@@ -1,6 +1,6 @@
1
1
  import logger from '../logger.js';
2
2
  import { InsightsRepository } from '../repositories/insights.repository.js';
3
- import { computeDiscordSyncStats } from '../lib/discord-roles-sync.service.js';
3
+ import { computeDiscordSyncStats, getCachedRecommendations, } from '../lib/discord-roles-sync.service.js';
4
4
  export class InsightsController {
5
5
  getWatchInsights = async (req, res) => {
6
6
  const db = req.db;
@@ -21,7 +21,8 @@ export class InsightsController {
21
21
  for (let i = 1; i < allWatches.length; i++) {
22
22
  const prev = new Date(allWatches[i - 1].watchedAt).getTime();
23
23
  const curr = new Date(allWatches[i].watchedAt).getTime();
24
- if (curr - prev < 3600000) {
24
+ const gap = curr - prev;
25
+ if (Number.isFinite(gap) && gap > 0 && gap < 3600000) {
25
26
  currentSessionSeconds += allWatches[i].effectiveSeconds ?? allWatches[i].currentTime;
26
27
  }
27
28
  else {
@@ -155,7 +156,7 @@ export class InsightsController {
155
156
  genreData[genre].scores.push(score);
156
157
  genreData[genre].showWatches[row.showId] =
157
158
  (genreData[genre].showWatches[row.showId] || 0) + 1;
158
- if (row.name && row.thumbnail) {
159
+ if (row.name || row.thumbnail) {
159
160
  genreData[genre].showMeta[row.showId] = {
160
161
  name: row.name,
161
162
  nativeName: row.nativeName,
@@ -195,4 +196,8 @@ export class InsightsController {
195
196
  const db = req.db;
196
197
  res.json(await computeDiscordSyncStats(db));
197
198
  };
199
+ getRecommendations = async (req, res) => {
200
+ const db = req.db;
201
+ res.json({ candidates: await getCachedRecommendations(db) });
202
+ };
198
203
  }
@@ -42,6 +42,60 @@ export class MangaLibraryController {
42
42
  res.json({ inLibrary: false, status: null });
43
43
  }
44
44
  };
45
+ getEntry = async (req, res) => {
46
+ try {
47
+ const item = MangaLibraryRepository.getById(mangaDb(req), req.params.id);
48
+ if (!item)
49
+ return res.status(404).json({ error: 'Not in library' });
50
+ res.json({ item });
51
+ }
52
+ catch {
53
+ res.status(500).json({ error: 'Failed to load entry' });
54
+ }
55
+ };
56
+ linkAnilist = async (req, res) => {
57
+ const { id, anilistId } = req.body ?? {};
58
+ const targetId = typeof id === 'string' ? id : '';
59
+ const mediaId = Number(anilistId);
60
+ if (!targetId || !Number.isInteger(mediaId) || mediaId <= 0) {
61
+ return res.status(400).json({ error: 'id and a numeric anilistId are required' });
62
+ }
63
+ try {
64
+ const db = mangaDb(req);
65
+ const row = MangaLibraryRepository.getById(db, targetId);
66
+ if (!row)
67
+ return res.status(404).json({ error: 'Library entry not found' });
68
+ if (String(row.provider).toLowerCase() === 'anilist') {
69
+ return res
70
+ .status(400)
71
+ .json({ error: 'Link a provider bookmark to the AniList entry, not the reverse' });
72
+ }
73
+ let progressMigrated = false;
74
+ await performMangaWriteTransaction(db, (tx) => {
75
+ MangaLibraryRepository.setAnilistId(tx, targetId, mediaId, 'manual');
76
+ const orphanId = `anilist:${mediaId}`;
77
+ if (orphanId !== targetId) {
78
+ const orphan = MangaLibraryRepository.getById(tx, orphanId);
79
+ const target = MangaLibraryRepository.getById(tx, targetId);
80
+ const targetProgress = MangaProgressRepository.getByManga(tx, targetId);
81
+ if (orphan?.lastChapterNumber &&
82
+ !target?.lastChapterNumber &&
83
+ targetProgress.length === 0) {
84
+ MangaLibraryRepository.setProgressPointer(tx, targetId, orphan.lastChapterNumber);
85
+ MangaProgressRepository.moveSyntheticChapters(tx, orphanId, targetId);
86
+ progressMigrated = true;
87
+ }
88
+ MangaLibraryRepository.delete(tx, orphanId);
89
+ MangaProgressRepository.deleteByManga(tx, orphanId);
90
+ }
91
+ });
92
+ res.json({ success: true, id: targetId, progressMigrated });
93
+ }
94
+ catch (err) {
95
+ logger.error({ err }, 'Failed to link manga to AniList');
96
+ res.status(500).json({ error: 'Failed to link entry' });
97
+ }
98
+ };
45
99
  getLibraryIds = async (req, res) => {
46
100
  try {
47
101
  res.json({ ids: MangaLibraryRepository.getIds(mangaDb(req)) });
@@ -128,6 +128,40 @@ export class ProxyController {
128
128
  'vibeplayer.site',
129
129
  'aniwatchtv.site',
130
130
  ];
131
+ static NEXABLOOM_FAMILY_SUFFIXES = [
132
+ '.quavex.top',
133
+ '.nexabloom.top',
134
+ '.hiddenvertex.top',
135
+ '.ironhorizon.top',
136
+ '.solarhaven.top',
137
+ ];
138
+ static NEXABLOOM_FALLBACK_HOSTS = ['fetch.nexabloom.top'];
139
+ static nexabloomFallbackUrls(urlStr) {
140
+ let host = '';
141
+ try {
142
+ host = new URL(urlStr).hostname.toLowerCase();
143
+ }
144
+ catch {
145
+ return [urlStr];
146
+ }
147
+ if (!ProxyController.NEXABLOOM_FAMILY_SUFFIXES.some((s) => host.endsWith(s))) {
148
+ return [urlStr];
149
+ }
150
+ const urls = [urlStr];
151
+ for (const fallback of ProxyController.NEXABLOOM_FALLBACK_HOSTS) {
152
+ if (fallback === host)
153
+ continue;
154
+ try {
155
+ const candidate = new URL(urlStr);
156
+ candidate.hostname = fallback;
157
+ urls.push(candidate.href);
158
+ }
159
+ catch {
160
+ // ignore
161
+ }
162
+ }
163
+ return urls;
164
+ }
131
165
  static isGotScrapingHost(urlStr) {
132
166
  try {
133
167
  const host = new URL(urlStr).hostname.toLowerCase();
@@ -259,17 +293,26 @@ export class ProxyController {
259
293
  .set('Access-Control-Allow-Origin', '*')
260
294
  .send(cached);
261
295
  }
262
- const resp = await gotScraping({
263
- url: isNexabloomMasterUrl(urlStr) ? signNexabloomMasterUrl(urlStr) : urlStr,
264
- method: 'GET',
265
- headers,
266
- responseType: 'text',
267
- timeout: { request: 30000 },
268
- followRedirect: true,
269
- throwHttpErrors: false,
270
- });
271
- if (resp.statusCode !== 200 && resp.statusCode !== 206) {
272
- return res.status(resp.statusCode ?? 502).send('Upstream error');
296
+ const resp = await (async () => {
297
+ let last = null;
298
+ for (const candidate of ProxyController.nexabloomFallbackUrls(urlStr)) {
299
+ const attempt = await gotScraping({
300
+ url: isNexabloomMasterUrl(candidate) ? signNexabloomMasterUrl(candidate) : candidate,
301
+ method: 'GET',
302
+ headers,
303
+ responseType: 'text',
304
+ timeout: { request: 30000 },
305
+ followRedirect: true,
306
+ throwHttpErrors: false,
307
+ });
308
+ last = attempt;
309
+ if (attempt.statusCode === 200 || attempt.statusCode === 206)
310
+ return attempt;
311
+ }
312
+ return last;
313
+ })();
314
+ if (!resp || (resp.statusCode !== 200 && resp.statusCode !== 206)) {
315
+ return res.status(resp?.statusCode ?? 502).send('Upstream error');
273
316
  }
274
317
  const finalUrl = resp.url || urlStr;
275
318
  const baseUrl = new URL(finalUrl);
@@ -314,17 +357,23 @@ export class ProxyController {
314
357
  if (ProxyController.isGotScrapingHost(urlStr)) {
315
358
  if (req.headers.range)
316
359
  headers['Range'] = req.headers.range;
317
- const resp = await gotScraping({
318
- url: urlStr,
319
- method: 'GET',
320
- headers,
321
- responseType: 'buffer',
322
- timeout: { request: 30000 },
323
- followRedirect: true,
324
- throwHttpErrors: false,
325
- });
326
- if (resp.statusCode !== 200 && resp.statusCode !== 206) {
327
- return res.status(resp.statusCode ?? 502).send('Upstream error');
360
+ let resp = null;
361
+ for (const candidate of ProxyController.nexabloomFallbackUrls(urlStr)) {
362
+ const attempt = await gotScraping({
363
+ url: candidate,
364
+ method: 'GET',
365
+ headers,
366
+ responseType: 'buffer',
367
+ timeout: { request: 30000 },
368
+ followRedirect: true,
369
+ throwHttpErrors: false,
370
+ });
371
+ resp = attempt;
372
+ if (attempt.statusCode === 200 || attempt.statusCode === 206)
373
+ break;
374
+ }
375
+ if (!resp || (resp.statusCode !== 200 && resp.statusCode !== 206)) {
376
+ return res.status(resp?.statusCode ?? 502).send('Upstream error');
328
377
  }
329
378
  const ct = resp.headers['content-type'];
330
379
  const isVtt = urlStr.endsWith('.vtt') || urlStr.includes('.vtt?');
@@ -342,7 +391,29 @@ export class ProxyController {
342
391
  res.send(resp.body);
343
392
  }
344
393
  else {
345
- const upstream = await fetchWithRetry(urlStr, { method: 'GET', headers }, { retries: 3, timeoutMs: 30000, signal: abortController.signal });
394
+ let upstream = null;
395
+ for (const candidate of ProxyController.nexabloomFallbackUrls(urlStr)) {
396
+ if (abortController.signal.aborted)
397
+ break;
398
+ try {
399
+ const attempt = await fetchWithRetry(candidate, { method: 'GET', headers }, { retries: 3, timeoutMs: 30000, signal: abortController.signal });
400
+ upstream = attempt;
401
+ if (attempt.status === 200 || attempt.status === 206)
402
+ break;
403
+ try {
404
+ await attempt.body?.cancel();
405
+ }
406
+ catch {
407
+ // ignore
408
+ }
409
+ }
410
+ catch {
411
+ // ignore
412
+ }
413
+ }
414
+ if (!upstream) {
415
+ return res.status(502).send('Upstream error');
416
+ }
346
417
  const status = upstream.status;
347
418
  if (status !== 200 && status !== 206) {
348
419
  try {
@@ -11,6 +11,7 @@ import { dbAll, dbGet } from '../utils/db-utils.js';
11
11
  import { searchAnilist, searchAnilistByTitle, getAiredEpisodesForShows, getAnilistEpisodes, getShowMetaById, isAnilistRateLimited, batchGetShowStatuses, } from '../lib/anilist.js';
12
12
  import { kitsuSearchAnime } from '../lib/kitsu.js';
13
13
  import { getMigratedId } from '../lib/migration.js';
14
+ import { offlineDb } from '../lib/offline-db.js';
14
15
  const BACKGROUND_DISCOVERY_INTERVAL_MS = 5 * 60 * 1000;
15
16
  const NUDGE_THROTTLE_MS = 120 * 1000;
16
17
  const SLOW_MAX_RUN_MS = 5 * 60 * 1000;
@@ -449,7 +450,11 @@ export class WatchlistController {
449
450
  if (dual._id !== undefined)
450
451
  dual._id = row.id;
451
452
  try {
452
- ShowsMetaRepository.upsert(db, { id: row.id, thumbnail: poster });
453
+ ShowsMetaRepository.upsert(db, {
454
+ id: row.id,
455
+ thumbnail: poster,
456
+ popularityScore: meta?.score ?? (meta?.averageScore != null ? meta.averageScore / 10 : undefined),
457
+ });
453
458
  }
454
459
  catch {
455
460
  // ignore
@@ -466,6 +471,55 @@ export class WatchlistController {
466
471
  }
467
472
  }));
468
473
  db.scheduleSave();
474
+ await this.sweepOfflinePosters(db);
475
+ }
476
+ async sweepOfflinePosters(db) {
477
+ try {
478
+ db.run(`UPDATE shows_meta SET name = (SELECT w.name FROM watchlist w WHERE w.id = shows_meta.id)
479
+ WHERE (name IS NULL OR TRIM(name) = '')
480
+ AND EXISTS (SELECT 1 FROM watchlist w WHERE w.id = shows_meta.id AND w.name IS NOT NULL AND TRIM(w.name) != '')`);
481
+ }
482
+ catch {
483
+ // ignore
484
+ }
485
+ const [wlMissing, metaMissing] = await Promise.all([
486
+ WatchlistRepository.getMissingThumbnails(db),
487
+ ShowsMetaRepository.getShowIdsMissingMeta(db),
488
+ ]);
489
+ const ids = [
490
+ ...new Set([...wlMissing.map((r) => r.id), ...metaMissing.map((r) => r.id)]),
491
+ ].slice(0, 500);
492
+ if (ids.length === 0)
493
+ return;
494
+ for (const id of ids) {
495
+ const poster = this.resolveOfflinePoster(id);
496
+ if (poster)
497
+ await this.applyPoster(db, id, poster);
498
+ }
499
+ db.scheduleSave();
500
+ }
501
+ resolveOfflinePoster(id) {
502
+ if (/^\d+$/.test(id)) {
503
+ return offlineDb.getByAnilistId(Number(id))?.thumbnail?.trim() || null;
504
+ }
505
+ const mal = /^mal-(\d+)$/i.exec(id)?.[1];
506
+ if (mal)
507
+ return offlineDb.getByMalId(Number(mal))?.thumbnail?.trim() || null;
508
+ return null;
509
+ }
510
+ async applyPoster(db, id, poster) {
511
+ try {
512
+ ShowsMetaRepository.upsert(db, { id, thumbnail: poster });
513
+ }
514
+ catch {
515
+ // ignore
516
+ }
517
+ try {
518
+ await WatchlistRepository.updateThumbnail(db, id, poster);
519
+ }
520
+ catch {
521
+ // ignore
522
+ }
469
523
  }
470
524
  async getContinueWatchingData(req, limit) {
471
525
  const rows = await WatchedEpisodesRepository.getContinueWatching(req.db, limit);
@@ -1128,11 +1128,11 @@ function extractEpisodes(result) {
1128
1128
  }
1129
1129
  return [];
1130
1130
  }
1131
- export async function searchAnilistByTitle(title) {
1131
+ export async function searchAnilistByTitle(title, type = 'ANIME') {
1132
1132
  const query = `
1133
1133
  query ($search: String) {
1134
1134
  Page(page: 1, perPage: 5) {
1135
- media(search: $search, type: ANIME) {
1135
+ media(search: $search, type: ${type}) {
1136
1136
  id
1137
1137
  title { romaji english native }
1138
1138
  }
@@ -1164,7 +1164,7 @@ export async function searchAnilistByTitle(title) {
1164
1164
  break;
1165
1165
  }
1166
1166
  if (!media || media.length === 0) {
1167
- if (anyFailed && anilistUnavailable()) {
1167
+ if (anyFailed && type === 'ANIME' && anilistUnavailable()) {
1168
1168
  const fb = await kitsuSearchAnime({ query: title, page: 1, perPage: 5 });
1169
1169
  if (fb.length > 0) {
1170
1170
  const best = fb[0];
@@ -1196,6 +1196,39 @@ export async function searchAnilistByTitle(title) {
1196
1196
  }
1197
1197
  return { id: best.id, title: best.title };
1198
1198
  }
1199
+ export async function searchAnilistMangaByTitle(title) {
1200
+ return searchAnilistByTitle(title, 'MANGA');
1201
+ }
1202
+ const MANGA_META_FRAGMENT = `id title { romaji english native } coverImage { large }`;
1203
+ export async function getMangaMetaById(id) {
1204
+ if (!id)
1205
+ return null;
1206
+ try {
1207
+ const result = await anilistRequest(`query ($id: Int) { Media(id: $id, type: MANGA) { ${MANGA_META_FRAGMENT} } }`, { id });
1208
+ const m = result?.data?.Media;
1209
+ if (!m)
1210
+ return null;
1211
+ return { id: m.id, title: m.title ?? {}, cover: m.coverImage?.large };
1212
+ }
1213
+ catch {
1214
+ return null;
1215
+ }
1216
+ }
1217
+ export async function getMangaMetaByMalId(malId) {
1218
+ const absMal = Math.abs(malId);
1219
+ if (!absMal)
1220
+ return null;
1221
+ try {
1222
+ const result = await anilistRequest(`query ($id: Int) { Media(idMal: $id, type: MANGA) { ${MANGA_META_FRAGMENT} } }`, { id: absMal });
1223
+ const m = result?.data?.Media;
1224
+ if (!m)
1225
+ return null;
1226
+ return { id: m.id, title: m.title ?? {}, cover: m.coverImage?.large };
1227
+ }
1228
+ catch {
1229
+ return null;
1230
+ }
1231
+ }
1199
1232
  export async function getAiredEpisodesForShows(ids, startDate, endDate) {
1200
1233
  const dayStart = Math.floor(startDate.getTime() / 1000);
1201
1234
  const dayEnd = Math.floor(endDate.getTime() / 1000);
@@ -3,10 +3,225 @@ import { CONFIG } from '../config.js';
3
3
  import { SettingsRepository } from '../repositories/settings.repository.js';
4
4
  import { InsightsRepository } from '../repositories/insights.repository.js';
5
5
  import { parseJsonBody } from '../utils/http.utils.js';
6
+ import { anilistRequest } from '../lib/anilist.js';
7
+ function parseGenres(raw) {
8
+ if (!raw)
9
+ return [];
10
+ try {
11
+ if (raw.startsWith('[')) {
12
+ const arr = JSON.parse(raw);
13
+ return Array.isArray(arr) ? arr.map(String) : [];
14
+ }
15
+ return raw
16
+ .split(',')
17
+ .map((g) => g.trim())
18
+ .filter(Boolean);
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ }
24
+ export async function computeTasteProfile(db) {
25
+ const [rows, anilistRows, epRows] = (await Promise.all([
26
+ InsightsRepository.getLibraryShowsWithGenres(db),
27
+ InsightsRepository.getAllAnilistIds(db),
28
+ InsightsRepository.getWatchedEpisodesWithMeta(db),
29
+ ]));
30
+ const genreComp = {};
31
+ const genreDrop = {};
32
+ for (const row of rows) {
33
+ const genres = parseGenres(row.genres);
34
+ if (row.status === 'Completed') {
35
+ for (const g of genres)
36
+ genreComp[g] = (genreComp[g] || 0) + 1;
37
+ }
38
+ else if (row.status === 'Dropped') {
39
+ for (const g of genres)
40
+ genreDrop[g] = (genreDrop[g] || 0) + 1;
41
+ }
42
+ }
43
+ const watchesByGenre = {};
44
+ for (const ep of epRows) {
45
+ const title = ep.englishName || ep.name || ep.showId;
46
+ for (const g of parseGenres(ep.genres)) {
47
+ if (!watchesByGenre[g])
48
+ watchesByGenre[g] = new Map();
49
+ const entry = watchesByGenre[g].get(ep.showId) || { title, count: 0 };
50
+ entry.count += 1;
51
+ watchesByGenre[g].set(ep.showId, entry);
52
+ }
53
+ }
54
+ const exemplars = {};
55
+ const topGenres = Object.entries(genreComp)
56
+ .sort((a, b) => b[1] - a[1])
57
+ .slice(0, 6);
58
+ for (const [genre] of topGenres) {
59
+ exemplars[genre] = [...(watchesByGenre[genre]?.values() || [])]
60
+ .sort((a, b) => b.count - a.count)
61
+ .slice(0, 2)
62
+ .map((e) => e.title);
63
+ }
64
+ const knownIds = new Set();
65
+ for (const row of rows) {
66
+ if (/^\d+$/.test(row.id))
67
+ knownIds.add(Number(row.id));
68
+ }
69
+ for (const r of anilistRows) {
70
+ if (r.anilistId)
71
+ knownIds.add(r.anilistId);
72
+ }
73
+ return { genreComp, genreDrop, exemplars, knownIds: [...knownIds] };
74
+ }
75
+ const REC_FIELDS = `id title { romaji english } coverImage { large } averageScore popularity episodes seasonYear format genres isAdult status`;
76
+ function tasteWeights(genreComp, genreDrop) {
77
+ const max = Math.max(1, ...Object.values(genreComp));
78
+ const W = {};
79
+ for (const [g, c] of Object.entries(genreComp)) {
80
+ const d = genreDrop[g] || 0;
81
+ W[g] = (c / max) * (c / (c + d));
82
+ }
83
+ return W;
84
+ }
85
+ function recCombos(W) {
86
+ const top = Object.entries(W)
87
+ .sort((a, b) => b[1] - a[1])
88
+ .slice(0, 4)
89
+ .map(([g]) => g);
90
+ while (top.length < 2)
91
+ top.push('Fantasy');
92
+ const pairs = [
93
+ [top[0], top[1]],
94
+ [top[0], top[2] || top[1]],
95
+ [top[1], top[2] || top[0]],
96
+ [top[0], top[3] || top[1]],
97
+ ];
98
+ const seen = new Set();
99
+ return pairs.filter((p) => {
100
+ const key = [...p].sort().join('|');
101
+ if (seen.has(key))
102
+ return false;
103
+ seen.add(key);
104
+ return true;
105
+ });
106
+ }
107
+ function exemplarsForCandidate(exemplars, candidateGenres, n = 2) {
108
+ const votes = new Map();
109
+ for (const g of candidateGenres) {
110
+ for (const t of exemplars[g] || [])
111
+ votes.set(t, (votes.get(t) || 0) + 1);
112
+ }
113
+ return [...votes.entries()]
114
+ .sort((a, b) => b[1] - a[1])
115
+ .slice(0, n)
116
+ .map(([t]) => t);
117
+ }
118
+ export async function computeRecCandidates(taste) {
119
+ const knownIds = new Set(taste.knownIds || []);
120
+ if (knownIds.size === 0)
121
+ return [];
122
+ const W = tasteWeights(taste.genreComp, taste.genreDrop);
123
+ const DEFAULT_W = 0.15;
124
+ const wsum = Object.values(W).reduce((a, b) => a + b, 0) || 1;
125
+ const seen = new Map();
126
+ for (const combo of recCombos(W)) {
127
+ try {
128
+ const res = await anilistRequest(`query ($g: [String]) { Page(page: 1, perPage: 20) { media(genre_in: $g, type: ANIME, status: FINISHED, isAdult: false, averageScore_greater: 68, sort: SCORE_DESC) { ${REC_FIELDS} } } }`, { g: combo });
129
+ for (const m of res?.data?.Page?.media || []) {
130
+ if (!seen.has(m.id))
131
+ seen.set(m.id, m);
132
+ }
133
+ }
134
+ catch {
135
+ // ignore
136
+ }
137
+ }
138
+ if (seen.size === 0)
139
+ return [];
140
+ const scored = [];
141
+ for (const m of seen.values()) {
142
+ if (knownIds.has(m.id) || m.isAdult)
143
+ continue;
144
+ if (m.format === 'MUSIC' || m.format === 'SPECIAL')
145
+ continue;
146
+ const gs = m.genres || [];
147
+ const fit = gs.reduce((a, g) => a + (W[g] ?? DEFAULT_W), 0) / wsum;
148
+ const score = 0.65 * fit +
149
+ 0.25 * ((m.averageScore || 70) / 100) +
150
+ 0.1 * Math.min(1, (m.popularity || 0) / 200000);
151
+ scored.push({ m, score, overlap: gs.filter((g) => (W[g] ?? 0) > 0.3) });
152
+ }
153
+ scored.sort((a, b) => b.score - a.score);
154
+ const top = scored.slice(0, 15);
155
+ const relMap = new Map();
156
+ try {
157
+ const res = await anilistRequest(`query ($ids: [Int]) { Page(page: 1, perPage: 50) { media(id_in: $ids, type: ANIME) { id relations { edges { relationType node { id title { romaji english } type } } } } } }`, { ids: top.map((t) => t.m.id) });
158
+ for (const m of res?.data?.Page?.media || [])
159
+ relMap.set(m.id, m);
160
+ }
161
+ catch {
162
+ // ignore
163
+ }
164
+ return top.map((t) => {
165
+ const rel = relMap.get(t.m.id);
166
+ const pre = rel?.relations?.edges?.find((e) => e.relationType === 'PREQUEL' && e.node?.type === 'ANIME')?.node;
167
+ let adj = t.score;
168
+ let kind = 'entry';
169
+ let prequelTitle = null;
170
+ if (pre && pre.id && !knownIds.has(pre.id)) {
171
+ kind = 'sequel';
172
+ prequelTitle = pre.title?.english || pre.title?.romaji || null;
173
+ adj -= 0.3;
174
+ }
175
+ else if (pre) {
176
+ kind = 'continue';
177
+ adj += 0.05;
178
+ }
179
+ const gs = t.m.genres || [];
180
+ const title = t.m.title?.english || t.m.title?.romaji || 'Unknown';
181
+ return {
182
+ id: t.m.id,
183
+ title,
184
+ url: `https://anilist.co/anime/${t.m.id}`,
185
+ cover: t.m.coverImage?.large || null,
186
+ year: t.m.seasonYear ?? null,
187
+ format: t.m.format || 'TV',
188
+ episodes: t.m.episodes ?? null,
189
+ averageScore: t.m.averageScore ?? null,
190
+ genres: gs,
191
+ matchPct: Math.round(adj * 100),
192
+ sharedGenres: t.overlap,
193
+ exemplars: exemplarsForCandidate(taste.exemplars, gs),
194
+ kind,
195
+ prequelTitle,
196
+ };
197
+ });
198
+ }
6
199
  const SETTING_KEY = 'discordLinkedUser';
7
200
  const SYNC_INTERVAL_MS = 10 * 60 * 1000;
8
201
  let syncTimer = null;
9
202
  let lastSyncedSeconds = null;
203
+ let lastSyncedTasteHash = null;
204
+ let recCache = null;
205
+ function tasteHashOf(taste) {
206
+ return JSON.stringify([taste.genreComp, taste.genreDrop, taste.knownIds.length]);
207
+ }
208
+ async function refreshCandidatesIfNeeded(taste, hash) {
209
+ if (!recCache || recCache.hash !== hash) {
210
+ try {
211
+ const fresh = await computeRecCandidates(taste);
212
+ if (fresh.length > 0)
213
+ recCache = { hash, candidates: fresh };
214
+ }
215
+ catch (err) {
216
+ logger.warn({ err: err?.message }, 'Recommendation candidates failed, reusing previous');
217
+ }
218
+ }
219
+ return recCache?.candidates || [];
220
+ }
221
+ export async function getCachedRecommendations(db) {
222
+ const taste = await computeTasteProfile(db);
223
+ return refreshCandidatesIfNeeded(taste, tasteHashOf(taste));
224
+ }
10
225
  export async function getLinkedDiscordUser(db) {
11
226
  const row = await SettingsRepository.getByKey(db, SETTING_KEY);
12
227
  if (!row?.value)
@@ -22,6 +237,8 @@ export async function setLinkedDiscordUser(db, user) {
22
237
  if (!user) {
23
238
  await SettingsRepository.deleteByKey(db, SETTING_KEY);
24
239
  lastSyncedSeconds = null;
240
+ lastSyncedTasteHash = null;
241
+ recCache = null;
25
242
  }
26
243
  else {
27
244
  await SettingsRepository.upsert(db, SETTING_KEY, JSON.stringify(user));
@@ -54,10 +271,16 @@ export async function computeDiscordSyncStats(db) {
54
271
  .sort((a, b) => b[1] - a[1])
55
272
  .slice(0, 3)
56
273
  .map(([name]) => name);
274
+ const completedCount = core?.completedCount || 0;
275
+ const totalWatchlist = core?.totalWatchlist || 0;
57
276
  return {
58
277
  totalSeconds: core?.totalSeconds || 0,
59
278
  topGenre,
60
279
  genres,
280
+ totalEpisodes: core?.totalEpisodes || 0,
281
+ totalAnime: core?.totalAnime || 0,
282
+ completedCount,
283
+ completionRate: totalWatchlist > 0 ? Math.round((completedCount / totalWatchlist) * 100) : 0,
61
284
  };
62
285
  }
63
286
  export async function syncDiscordRoles(db, force = false) {
@@ -69,10 +292,16 @@ export async function syncDiscordRoles(db, force = false) {
69
292
  if (!user) {
70
293
  return { success: false, message: 'No Discord user linked in database' };
71
294
  }
72
- const { totalSeconds, topGenre, genres } = await computeDiscordSyncStats(db);
73
- if (!force && lastSyncedSeconds !== null && totalSeconds === lastSyncedSeconds) {
295
+ const { totalSeconds, topGenre, genres, totalEpisodes, totalAnime, completedCount, completionRate, } = await computeDiscordSyncStats(db);
296
+ const taste = await computeTasteProfile(db);
297
+ const tasteHash = tasteHashOf(taste);
298
+ if (!force &&
299
+ lastSyncedSeconds !== null &&
300
+ totalSeconds === lastSyncedSeconds &&
301
+ lastSyncedTasteHash === tasteHash) {
74
302
  return { success: true, message: 'Watch time unchanged, skipped sync', skipped: true };
75
303
  }
304
+ taste.candidates = await refreshCandidatesIfNeeded(taste, tasteHash);
76
305
  try {
77
306
  const res = await fetch(`${workerUrl}/api/sync`, {
78
307
  method: 'POST',
@@ -83,6 +312,11 @@ export async function syncDiscordRoles(db, force = false) {
83
312
  totalSeconds,
84
313
  topGenre,
85
314
  genres,
315
+ totalEpisodes,
316
+ totalAnime,
317
+ completedCount,
318
+ completionRate,
319
+ taste,
86
320
  force,
87
321
  }),
88
322
  });
@@ -97,6 +331,7 @@ export async function syncDiscordRoles(db, force = false) {
97
331
  };
98
332
  }
99
333
  lastSyncedSeconds = totalSeconds;
334
+ lastSyncedTasteHash = tasteHash;
100
335
  logger.info({
101
336
  user: user.username,
102
337
  rank: data.rank,