@serifpersia/dango 3.3.6 → 3.3.7
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-WlhU88fn.js} +1 -1
- package/client/dist/assets/{AnimeInfo-BCyLw0yz.js → AnimeInfo-BCLagpbu.js} +1 -1
- package/client/dist/assets/{AnimeInfoPage-BZWl-TJY.js → AnimeInfoPage-o0U_OXKP.js} +2 -2
- package/client/dist/assets/{Asmr-4S6uoevp.js → Asmr-ieGJ_BcQ.js} +1 -1
- package/client/dist/assets/{AsmrResetProgressModal-C8i6weqW.js → AsmrResetProgressModal-BwaoVski.js} +1 -1
- package/client/dist/assets/{AvSyncCalibrator-oK6LlG61.js → AvSyncCalibrator-CXvBuY4l.js} +1 -1
- package/client/dist/assets/Home-DjLpvsp8.js +1 -0
- package/client/dist/assets/Insights-Dc8xLmsr.css +1 -0
- package/client/dist/assets/Insights-lQcQoeES.js +1 -0
- package/client/dist/assets/LibraryListPage-B2nhFFrc.js +1 -0
- package/client/dist/assets/{ListeningList-Dxkhy6E2.js → ListeningList-okIpsKCr.js} +1 -1
- package/client/dist/assets/{Manga-pJEpcUpo.js → Manga-3u9nVNZC.js} +1 -1
- package/client/dist/assets/{MangaInfoPage-BEQsjTHH.js → MangaInfoPage-Cat-B487.js} +1 -1
- package/client/dist/assets/{MangaReadPage-C0WAtK-1.js → MangaReadPage-CyLRxzvl.js} +1 -1
- package/client/dist/assets/{MangaResetProgressModal-WjKH_G_f.js → MangaResetProgressModal-Ch8RjwbO.js} +1 -1
- package/client/dist/assets/Mature-pINwl5wj.js +1 -0
- package/client/dist/assets/{MediaCard-DszKDA4s.js → MediaCard-DDiYEAm8.js} +1 -1
- package/client/dist/assets/{Music-CU6TBHF5.js → Music-B1swlVLI.js} +1 -1
- package/client/dist/assets/{OptionListMenu-CkK2BvEz.js → OptionListMenu-CzH2VSPh.js} +1 -1
- package/client/dist/assets/Player-SKPWb-o8.js +56 -0
- package/client/dist/assets/PlayerSettings-jrmY95-i.js +1 -0
- package/client/dist/assets/{QueueOptionsButton-DKWi-9kd.js → QueueOptionsButton-CcVP7Vi6.js} +1 -1
- package/client/dist/assets/{QueueRail-DAcIex5B.js → QueueRail-Cj-PU9EV.js} +1 -1
- package/client/dist/assets/{Radio-BhTsgGGd.js → Radio-CRlbaxQt.js} +1 -1
- package/client/dist/assets/{ReadingList-BLnkwB--.js → ReadingList-wRtHGNo8.js} +1 -1
- package/client/dist/assets/{ResetProgressModal-8mcLXaAb.js → ResetProgressModal-BokfoYFB.js} +1 -1
- package/client/dist/assets/Search-BOOBeTVA.js +1 -0
- package/client/dist/assets/{Settings-bSA7BG7h.js → Settings-BgrcswJD.js} +1 -1
- package/client/dist/assets/SkeletonGrid-Bge3aRl2.css +1 -0
- package/client/dist/assets/SkeletonGrid-C6S7OYHn.js +1 -0
- package/client/dist/assets/{Trackers-BXSONaRD.js → Trackers-DLcTEJJW.js} +1 -1
- package/client/dist/assets/{Tv-DJTbg4VW.js → Tv-ByVckozq.js} +1 -1
- package/client/dist/assets/{TvInfo-Z0c1Wh3i.js → TvInfo-fJ9SFMK8.js} +1 -1
- package/client/dist/assets/{TvPopup-CP41CncZ.js → TvPopup-yfH7p0y0.js} +1 -1
- package/client/dist/assets/TvSearch-CBNWDByW.js +1 -0
- package/client/dist/assets/{TvWatchlist-COCWPkgh.js → TvWatchlist-BqjZ2-4A.js} +1 -1
- package/client/dist/assets/Watchlist-B_Q2hEiJ.js +1 -0
- package/client/dist/assets/{hls-B_yo8-yR.js → hls-kIF7bMpF.js} +1 -1
- package/client/dist/assets/{index-lo-bz7f2.js → index-C65i4HWv.js} +2 -2
- package/client/dist/assets/{useAnimeInfoData-CbNnHRj8.js → useAnimeInfoData-BKSrS8eN.js} +1 -1
- package/client/dist/assets/{useAsmr-BEcgHwxg.js → useAsmr-Byj8Qiwp.js} +1 -1
- package/client/dist/assets/{useAsmrPopup-BXD3y2m1.js → useAsmrPopup-CLcWCCXf.js} +1 -1
- package/client/dist/assets/{useMangaLibrary-BXhgN-dl.js → useMangaLibrary-U7tUo8nx.js} +1 -1
- package/client/dist/assets/{useMangaPopup-BvgtZ53s.js → useMangaPopup-ZonOP5_c.js} +1 -1
- package/client/dist/assets/{useProviders-xlftutIo.js → useProviders-CymA42ZB.js} +1 -1
- package/client/dist/assets/{useTv-DTqrJEWp.js → useTv-CPj6Cf42.js} +1 -1
- package/client/dist/assets/{useTvLibrary-BHeWSd1F.js → useTvLibrary-COsyN8yu.js} +1 -1
- package/client/dist/index.html +1 -1
- package/package.json +2 -2
- package/server/dist/controllers/insights.controller.js +5 -1
- package/server/dist/controllers/proxy.controller.js +94 -23
- package/server/dist/controllers/watchlist.controller.js +42 -0
- package/server/dist/lib/discord-roles-sync.service.js +237 -2
- package/server/dist/repositories/insights.repository.js +10 -0
- 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/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/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/TvSearch-BlqTQYJa.js +0 -1
- package/client/dist/assets/Watchlist-BHLshSPX.js +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{T as m}from"./vendor-core-8R2fFbyN.js";import{b as y,u as o,a as u}from"./vendor-query-BXRECSI6.js";import{q as i,z as a}from"./index-
|
|
1
|
+
import{T as m}from"./vendor-core-8R2fFbyN.js";import{b as y,u as o,a as u}from"./vendor-query-BXRECSI6.js";import{q as i,z as a}from"./index-C65i4HWv.js";const d=e=>String(e).toLowerCase()==="movie"?"movie":"tv",g=(e,t)=>`${d(e)}:${t}`,K=(e,t)=>{const r=d(e);return`/tv/${t}?type=${r}`},Q=(e,t,r,s)=>{const n=new URLSearchParams;return n.set("type",d(e)),r!==void 0&&n.set("s",String(r)),s!==void 0&&n.set("e",String(s)),`/tv/${t}/watch?${n.toString()}`},$=e=>e.adult===!0||e.adult===1,f=["Watching","Completed","On-Hold","Dropped","Planned"],l=(e,t)=>g(e,t),w=(e="All",t=1,r=24)=>y({queryKey:["tv-library",e,t,r],queryFn:()=>{const s=new URLSearchParams;return s.set("status",e),s.set("page",String(t)),s.set("limit",String(r)),i(`/api/tv/library?${s.toString()}`)}}),p=()=>y({queryKey:["tv-library-ids"],queryFn:()=>i("/api/tv/library/ids"),staleTime:1e3*60}),P=e=>y({queryKey:["tv-library-check",e],queryFn:()=>i(`/api/tv/library/check/${encodeURIComponent(e||"")}`),enabled:!!e,staleTime:1e3*60}),F=(e=24)=>y({queryKey:["tv-continue-watching",e],queryFn:()=>i(`/api/tv/continue-watching?limit=${e}`)}),O=e=>y({queryKey:["tv-progress",e],queryFn:()=>i(`/api/tv/progress/${encodeURIComponent(e||"")}`),enabled:!!e}),C=(e,t,r)=>y({queryKey:["tv-progress-latest",e,t,r],queryFn:()=>{const s=new URLSearchParams;t!==void 0&&s.set("season",String(t)),r!==void 0&&s.set("episode",String(r));const n=s.toString();return i(`/api/tv/progress/${encodeURIComponent(e||"")}/latest${n?`?${n}`:""}`)},enabled:!!e,staleTime:0}),q=()=>{const e=o();return u({mutationFn:async t=>{const{silent:r,...s}=t;return i("/api/tv/library/add",{method:"POST",body:JSON.stringify(s)})},onSuccess:(t,r)=>{r.silent||a.success("Added to TV watchlist"),e.invalidateQueries({queryKey:["tv-library"]}),e.invalidateQueries({queryKey:["tv-library-ids"]}),e.invalidateQueries({queryKey:["tv-library-check",l(r.mediaType,r.tmdbId)]})},onError:t=>{a.error(`Failed to add: ${t.message}`)}})},b=()=>{const e=o();return u({mutationFn:async t=>{await i("/api/tv/library/remove",{method:"POST",body:JSON.stringify({id:t})})},onSuccess:(t,r)=>{a.success("Removed from TV watchlist"),e.invalidateQueries({queryKey:["tv-library"]}),e.invalidateQueries({queryKey:["tv-library-ids"]}),e.invalidateQueries({queryKey:["tv-continue-watching"]}),e.invalidateQueries({queryKey:["tv-library-check",r]})},onError:t=>{a.error(`Failed to remove: ${t.message}`)}})},R=()=>{const e=o();return u({mutationFn:async({id:t,status:r})=>{await i("/api/tv/library/status",{method:"POST",body:JSON.stringify({id:t,status:r})})},onSuccess:()=>{a.success("Status updated"),e.invalidateQueries({queryKey:["tv-library"]}),e.invalidateQueries({queryKey:["tv-continue-watching"]})},onError:t=>{a.error(`Failed to update status: ${t.message}`)}})},k=()=>{const e=o();return u({mutationFn:async t=>{await i("/api/tv/progress",{method:"POST",body:JSON.stringify(t)})},onSuccess:(t,r)=>{e.invalidateQueries({queryKey:["tv-progress",r.mediaId]}),e.invalidateQueries({queryKey:["tv-progress-latest",r.mediaId]}),e.invalidateQueries({queryKey:["tv-continue-watching"]}),e.invalidateQueries({queryKey:["tv-library"]}),e.invalidateQueries({queryKey:["tv-library-ids"]})}})},L=()=>{const e=o();return u({mutationFn:async({mediaId:t,season:r,episode:s})=>{await i("/api/tv/progress/remove",{method:"POST",body:JSON.stringify({mediaId:t,season:r,episode:s})})},onSuccess:()=>{a.success("Progress reset"),e.invalidateQueries({queryKey:["tv-progress"]}),e.invalidateQueries({queryKey:["tv-continue-watching"]}),e.invalidateQueries({queryKey:["tv-library"]})},onError:t=>{a.error(`Failed to reset progress: ${t.message}`)}})},I=()=>{const e=o();return u({mutationFn:async({ids:t,status:r})=>i("/api/tv/library/batch-status",{method:"POST",body:JSON.stringify({ids:t,status:r})}),onSuccess:t=>{a.success(`Status updated for ${t.updated??0} items`),e.invalidateQueries({queryKey:["tv-library"]}),e.invalidateQueries({queryKey:["tv-continue-watching"]})},onError:t=>{a.error(`Failed to update statuses: ${t.message}`)}})},U=()=>{const e=o();return u({mutationFn:async t=>i("/api/tv/library/remove-many",{method:"POST",body:JSON.stringify({ids:t})}),onSuccess:t=>{const r=t.removed??0;a.success(`Removed ${r} ${r===1?"item":"items"} from TV watchlist`),e.invalidateQueries({queryKey:["tv-library"]}),e.invalidateQueries({queryKey:["tv-library-ids"]}),e.invalidateQueries({queryKey:["tv-continue-watching"]})},onError:t=>{a.error(`Failed to remove: ${t.message}`)}})},E=()=>{const e=o();return u({mutationFn:async t=>i("/api/tv/progress/remove-many",{method:"POST",body:JSON.stringify({ids:t})}),onSuccess:t=>{const r=t.removed??0;a.success(`Reset progress for ${r} ${r===1?"item":"items"}`),e.invalidateQueries({queryKey:["tv-progress"]}),e.invalidateQueries({queryKey:["tv-continue-watching"]}),e.invalidateQueries({queryKey:["tv-library"]})},onError:t=>{a.error(`Failed to reset progress: ${t.message}`)}})},J=()=>{const e=q(),t=b(),{data:r}=p(),s=m(()=>new Set(r?.ids||[]),[r]);return{toggle:c=>{const v=l(c.mediaType,c.tmdbId);s.has(v)?t.mutate(v):e.mutate({...c,poster:c.poster||""})},bookmarkedIds:s,pending:e.isPending||t.isPending}};export{f as T,b as a,L as b,K as c,g as d,P as e,J as f,k as g,R as h,$ as i,C as j,O as k,w as l,I as m,d as n,U as o,E as p,Q as t,F as u};
|
package/client/dist/index.html
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
background-color: #0c0d12;
|
|
34
34
|
}
|
|
35
35
|
</style>
|
|
36
|
-
<script type="module" crossorigin src="/assets/index-
|
|
36
|
+
<script type="module" crossorigin src="/assets/index-C65i4HWv.js"></script>
|
|
37
37
|
<link rel="modulepreload" crossorigin href="/assets/vendor-core-8R2fFbyN.js">
|
|
38
38
|
<link rel="modulepreload" crossorigin href="/assets/vendor-query-BXRECSI6.js">
|
|
39
39
|
<link rel="modulepreload" crossorigin href="/assets/vendor-icons-CDMuNDeL.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serifpersia/dango",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.7",
|
|
4
4
|
"description": "A web application for watching anime.",
|
|
5
5
|
"main": "orchestrator.js",
|
|
6
6
|
"repository": {
|
|
@@ -81,5 +81,5 @@
|
|
|
81
81
|
"allowScripts": {
|
|
82
82
|
"esbuild@0.28.2": true
|
|
83
83
|
},
|
|
84
|
-
"versionDate": "2026-09-
|
|
84
|
+
"versionDate": "2026-09-25T14:47:03.564Z"
|
|
85
85
|
}
|
|
@@ -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;
|
|
@@ -195,4 +195,8 @@ export class InsightsController {
|
|
|
195
195
|
const db = req.db;
|
|
196
196
|
res.json(await computeDiscordSyncStats(db));
|
|
197
197
|
};
|
|
198
|
+
getRecommendations = async (req, res) => {
|
|
199
|
+
const db = req.db;
|
|
200
|
+
res.json({ candidates: await getCachedRecommendations(db) });
|
|
201
|
+
};
|
|
198
202
|
}
|
|
@@ -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;
|
|
@@ -466,6 +467,47 @@ export class WatchlistController {
|
|
|
466
467
|
}
|
|
467
468
|
}));
|
|
468
469
|
db.scheduleSave();
|
|
470
|
+
await this.sweepOfflinePosters(db);
|
|
471
|
+
}
|
|
472
|
+
async sweepOfflinePosters(db) {
|
|
473
|
+
const [wlMissing, metaMissing] = await Promise.all([
|
|
474
|
+
WatchlistRepository.getMissingThumbnails(db),
|
|
475
|
+
ShowsMetaRepository.getShowIdsMissingMeta(db),
|
|
476
|
+
]);
|
|
477
|
+
const ids = [
|
|
478
|
+
...new Set([...wlMissing.map((r) => r.id), ...metaMissing.map((r) => r.id)]),
|
|
479
|
+
].slice(0, 500);
|
|
480
|
+
if (ids.length === 0)
|
|
481
|
+
return;
|
|
482
|
+
for (const id of ids) {
|
|
483
|
+
const poster = this.resolveOfflinePoster(id);
|
|
484
|
+
if (poster)
|
|
485
|
+
await this.applyPoster(db, id, poster);
|
|
486
|
+
}
|
|
487
|
+
db.scheduleSave();
|
|
488
|
+
}
|
|
489
|
+
resolveOfflinePoster(id) {
|
|
490
|
+
if (/^\d+$/.test(id)) {
|
|
491
|
+
return offlineDb.getByAnilistId(Number(id))?.thumbnail?.trim() || null;
|
|
492
|
+
}
|
|
493
|
+
const mal = /^mal-(\d+)$/i.exec(id)?.[1];
|
|
494
|
+
if (mal)
|
|
495
|
+
return offlineDb.getByMalId(Number(mal))?.thumbnail?.trim() || null;
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
async applyPoster(db, id, poster) {
|
|
499
|
+
try {
|
|
500
|
+
ShowsMetaRepository.upsert(db, { id, thumbnail: poster });
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// ignore
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
await WatchlistRepository.updateThumbnail(db, id, poster);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// ignore
|
|
510
|
+
}
|
|
469
511
|
}
|
|
470
512
|
async getContinueWatchingData(req, limit) {
|
|
471
513
|
const rows = await WatchedEpisodesRepository.getContinueWatching(req.db, limit);
|
|
@@ -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,
|
|
@@ -82,4 +82,14 @@ export const InsightsRepository = {
|
|
|
82
82
|
sm.thumbnail
|
|
83
83
|
FROM watched_episodes we
|
|
84
84
|
JOIN shows_meta sm ON we.showId = sm.id`),
|
|
85
|
+
getLibraryShowsWithGenres: (db) => dbAll(db, `SELECT
|
|
86
|
+
w.id,
|
|
87
|
+
w.status,
|
|
88
|
+
COALESCE(NULLIF(sm.englishName, ''), sm.name, w.name) as title,
|
|
89
|
+
sm.genres,
|
|
90
|
+
(SELECT COUNT(*) FROM watched_episodes we WHERE we.showId = w.id) as episodesWatched,
|
|
91
|
+
COALESCE(sm.popularityScore, 0) as popularityScore
|
|
92
|
+
FROM watchlist w
|
|
93
|
+
LEFT JOIN shows_meta sm ON sm.id = w.id`),
|
|
94
|
+
getAllAnilistIds: (db) => dbAll(db, 'SELECT anilistId FROM shows_meta WHERE anilistId IS NOT NULL'),
|
|
85
95
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dbGet, dbRun } from '../utils/db-utils.js';
|
|
1
|
+
import { dbAll, dbGet, dbRun } from '../utils/db-utils.js';
|
|
2
2
|
export const ShowsMetaRepository = {
|
|
3
3
|
getById: (db, id) => dbGet(db, 'SELECT * FROM shows_meta WHERE id = ?', [id]),
|
|
4
4
|
getStatus: async (db, id) => {
|
|
@@ -39,4 +39,7 @@ export const ShowsMetaRepository = {
|
|
|
39
39
|
updateEpisodeCount: (db, id, episodeCount) => dbRun(db, 'UPDATE shows_meta SET episodeCount = ? WHERE id = ?', [episodeCount, id]),
|
|
40
40
|
updateStatus: (db, id, status) => dbRun(db, 'UPDATE shows_meta SET status = ? WHERE id = ?', [status, id]),
|
|
41
41
|
cleanupOrphanedMeta: (db) => dbRun(db, 'DELETE FROM shows_meta WHERE id NOT IN (SELECT id FROM watchlist) AND id NOT IN (SELECT showId FROM queue)'),
|
|
42
|
+
getShowIdsMissingMeta: (db) => dbAll(db, `SELECT DISTINCT we.showId as id FROM watched_episodes we
|
|
43
|
+
LEFT JOIN shows_meta sm ON sm.id = we.showId
|
|
44
|
+
WHERE sm.thumbnail IS NULL OR TRIM(sm.thumbnail) = ''`),
|
|
42
45
|
};
|
|
@@ -68,4 +68,5 @@ export const WatchlistRepository = {
|
|
|
68
68
|
]);
|
|
69
69
|
},
|
|
70
70
|
getWatchingShows: (db) => dbAll(db, "SELECT id, name, thumbnail, nativeName, englishName FROM watchlist WHERE status = 'Watching'"),
|
|
71
|
+
getMissingThumbnails: (db) => dbAll(db, "SELECT id, name FROM watchlist WHERE thumbnail IS NULL OR TRIM(thumbnail) = ''"),
|
|
71
72
|
};
|
|
@@ -7,6 +7,7 @@ export function createInsightsRouter() {
|
|
|
7
7
|
const controller = new InsightsController();
|
|
8
8
|
router.get('/insights', controller.getWatchInsights);
|
|
9
9
|
router.get('/insights/genre-cards', controller.getGenreCards);
|
|
10
|
+
router.get('/insights/recommendations', controller.getRecommendations);
|
|
10
11
|
router.get('/insights/discord-sync-stats', controller.getDiscordSyncStats);
|
|
11
12
|
router.get('/discord-roles-config', (_req, res) => {
|
|
12
13
|
res.json({ workerUrl: CONFIG.DISCORD_ROLES_WORKER_URL || null });
|