@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.
- package/README.md +1 -1
- package/client/dist/assets/{AnimeCard-OKU7LaSN.js → AnimeCard-D4qo4289.js} +1 -1
- package/client/dist/assets/{AnimeInfo-BCyLw0yz.js → AnimeInfo-BdRRm4So.js} +1 -1
- package/client/dist/assets/{AnimeInfoPage-BZWl-TJY.js → AnimeInfoPage-DeJNAKMR.js} +2 -2
- package/client/dist/assets/{Asmr-4S6uoevp.js → Asmr-D0EGRYsv.js} +1 -1
- package/client/dist/assets/{AsmrResetProgressModal-C8i6weqW.js → AsmrResetProgressModal-Gs0GmVaT.js} +1 -1
- package/client/dist/assets/{AvSyncCalibrator-oK6LlG61.js → AvSyncCalibrator-D8hqarVB.js} +1 -1
- package/client/dist/assets/Home-BnBzjFkW.js +1 -0
- package/client/dist/assets/Insights-Dc8xLmsr.css +1 -0
- package/client/dist/assets/Insights-i9QgY3nE.js +1 -0
- package/client/dist/assets/LibraryListPage-D3hTqTRU.js +1 -0
- package/client/dist/assets/{ListeningList-Dxkhy6E2.js → ListeningList-BgWsfu40.js} +1 -1
- package/client/dist/assets/{Manga-pJEpcUpo.js → Manga-Dn-CYins.js} +1 -1
- package/client/dist/assets/MangaInfoPage-EmQoK7Bv.js +1 -0
- package/client/dist/assets/MangaReadPage-xJB8K2YU.js +1 -0
- package/client/dist/assets/{MangaResetProgressModal-WjKH_G_f.js → MangaResetProgressModal-Dian37PQ.js} +1 -1
- package/client/dist/assets/Mature-DQtXKOXc.js +1 -0
- package/client/dist/assets/{MediaCard-DszKDA4s.js → MediaCard-DpKoY-1j.js} +1 -1
- package/client/dist/assets/{Music-CU6TBHF5.js → Music-BP43Zqmq.js} +1 -1
- package/client/dist/assets/{OptionListMenu-CkK2BvEz.js → OptionListMenu-Bj9ebsW3.js} +1 -1
- package/client/dist/assets/Player-O2Cy2GF1.js +56 -0
- package/client/dist/assets/PlayerSettings-DYZ272Yp.js +1 -0
- package/client/dist/assets/{QueueOptionsButton-DKWi-9kd.js → QueueOptionsButton-lv26-6jp.js} +1 -1
- package/client/dist/assets/{QueueRail-DAcIex5B.js → QueueRail-B7DeSFoA.js} +1 -1
- package/client/dist/assets/{Radio-BhTsgGGd.js → Radio-qQBYnVfY.js} +1 -1
- package/client/dist/assets/{ReadingList-BLnkwB--.js → ReadingList-BT8kUjCB.js} +1 -1
- package/client/dist/assets/{ResetProgressModal-8mcLXaAb.js → ResetProgressModal-DWeiO-hs.js} +1 -1
- package/client/dist/assets/Search-BkvPYN33.js +1 -0
- package/client/dist/assets/{Settings-bSA7BG7h.js → Settings-CyZEBF6S.js} +1 -1
- package/client/dist/assets/SkeletonGrid-B5loz7ii.js +1 -0
- package/client/dist/assets/SkeletonGrid-Bge3aRl2.css +1 -0
- package/client/dist/assets/Trackers-JK-aXBYC.js +2 -0
- package/client/dist/assets/{Tv-DJTbg4VW.js → Tv-BwXWJxMy.js} +1 -1
- package/client/dist/assets/{TvInfo-Z0c1Wh3i.js → TvInfo-Dzyq6ucH.js} +1 -1
- package/client/dist/assets/{TvPopup-CP41CncZ.js → TvPopup-PlCU30FO.js} +1 -1
- package/client/dist/assets/TvSearch-BYwCf5kO.js +1 -0
- package/client/dist/assets/{TvWatchlist-COCWPkgh.js → TvWatchlist-BiJU6pKT.js} +1 -1
- package/client/dist/assets/Watchlist-BOEeGXH9.js +1 -0
- package/client/dist/assets/{hls-B_yo8-yR.js → hls-C11FOWHj.js} +1 -1
- package/client/dist/assets/index-CtoKRNLc.js +178 -0
- package/client/dist/assets/{useAnimeInfoData-CbNnHRj8.js → useAnimeInfoData-kr3WOIWH.js} +1 -1
- package/client/dist/assets/{useAsmr-BEcgHwxg.js → useAsmr-CB9THf0m.js} +1 -1
- package/client/dist/assets/{useAsmrPopup-BXD3y2m1.js → useAsmrPopup-cT_Uu41e.js} +1 -1
- package/client/dist/assets/useMangaLibrary-rM6z3_0E.js +1 -0
- package/client/dist/assets/{useMangaPopup-BvgtZ53s.js → useMangaPopup-PnmSctHu.js} +1 -1
- package/client/dist/assets/{useProviders-xlftutIo.js → useProviders-B3VB7A9M.js} +1 -1
- package/client/dist/assets/{useTv-DTqrJEWp.js → useTv-BTUKMH7Q.js} +1 -1
- package/client/dist/assets/{useTvLibrary-BHeWSd1F.js → useTvLibrary-C0sX-ilZ.js} +1 -1
- package/client/dist/index.html +1 -1
- package/package.json +2 -2
- package/server/dist/controllers/data.controller.js +1 -0
- package/server/dist/controllers/insights.controller.js +8 -3
- package/server/dist/controllers/manga-library.controller.js +54 -0
- package/server/dist/controllers/proxy.controller.js +94 -23
- package/server/dist/controllers/watchlist.controller.js +55 -1
- package/server/dist/lib/anilist.js +36 -3
- package/server/dist/lib/discord-roles-sync.service.js +237 -2
- package/server/dist/lib/mal.js +69 -0
- package/server/dist/lib/tracker/anilist-tracker.js +66 -0
- package/server/dist/lib/tracker/manga-sync.service.js +792 -0
- package/server/dist/repositories/insights.repository.js +10 -0
- package/server/dist/repositories/manga.repository.js +17 -2
- package/server/dist/repositories/shows-meta.repository.js +4 -1
- package/server/dist/repositories/watchlist.repository.js +1 -0
- package/server/dist/routes/insights.routes.js +1 -0
- package/server/dist/routes/manga-library.routes.js +2 -0
- package/server/dist/routes/tracker.routes.js +123 -0
- package/server/dist/sync.js +8 -1
- package/server/package.json +1 -1
- package/client/dist/assets/Home-CQ4Ikeja.js +0 -1
- package/client/dist/assets/Insights-DKtoVe6L.css +0 -1
- package/client/dist/assets/Insights-DiAo0UAc.js +0 -1
- package/client/dist/assets/LibraryListPage-B0MDTnEc.js +0 -1
- package/client/dist/assets/MangaInfoPage-BEQsjTHH.js +0 -1
- package/client/dist/assets/MangaReadPage-C0WAtK-1.js +0 -1
- package/client/dist/assets/Mature-Ckx99wFj.js +0 -1
- package/client/dist/assets/Player-C3yWQkXb.js +0 -56
- package/client/dist/assets/PlayerSettings-CBdTzo0E.js +0 -1
- package/client/dist/assets/Search-DxX021dj.js +0 -1
- package/client/dist/assets/SkeletonGrid-DhPYefXr.css +0 -1
- package/client/dist/assets/SkeletonGrid-Du10CNfq.js +0 -1
- package/client/dist/assets/Trackers-BXSONaRD.js +0 -2
- package/client/dist/assets/TvSearch-BlqTQYJa.js +0 -1
- package/client/dist/assets/Watchlist-BHLshSPX.js +0 -1
- package/client/dist/assets/index-lo-bz7f2.js +0 -178
- 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
|
-
|
|
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
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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, {
|
|
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:
|
|
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
|
-
|
|
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,
|