@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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-OKU7LaSN.js → AnimeCard-WlhU88fn.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-BCyLw0yz.js → AnimeInfo-BCLagpbu.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-BZWl-TJY.js → AnimeInfoPage-o0U_OXKP.js} +2 -2
  5. package/client/dist/assets/{Asmr-4S6uoevp.js → Asmr-ieGJ_BcQ.js} +1 -1
  6. package/client/dist/assets/{AsmrResetProgressModal-C8i6weqW.js → AsmrResetProgressModal-BwaoVski.js} +1 -1
  7. package/client/dist/assets/{AvSyncCalibrator-oK6LlG61.js → AvSyncCalibrator-CXvBuY4l.js} +1 -1
  8. package/client/dist/assets/Home-DjLpvsp8.js +1 -0
  9. package/client/dist/assets/Insights-Dc8xLmsr.css +1 -0
  10. package/client/dist/assets/Insights-lQcQoeES.js +1 -0
  11. package/client/dist/assets/LibraryListPage-B2nhFFrc.js +1 -0
  12. package/client/dist/assets/{ListeningList-Dxkhy6E2.js → ListeningList-okIpsKCr.js} +1 -1
  13. package/client/dist/assets/{Manga-pJEpcUpo.js → Manga-3u9nVNZC.js} +1 -1
  14. package/client/dist/assets/{MangaInfoPage-BEQsjTHH.js → MangaInfoPage-Cat-B487.js} +1 -1
  15. package/client/dist/assets/{MangaReadPage-C0WAtK-1.js → MangaReadPage-CyLRxzvl.js} +1 -1
  16. package/client/dist/assets/{MangaResetProgressModal-WjKH_G_f.js → MangaResetProgressModal-Ch8RjwbO.js} +1 -1
  17. package/client/dist/assets/Mature-pINwl5wj.js +1 -0
  18. package/client/dist/assets/{MediaCard-DszKDA4s.js → MediaCard-DDiYEAm8.js} +1 -1
  19. package/client/dist/assets/{Music-CU6TBHF5.js → Music-B1swlVLI.js} +1 -1
  20. package/client/dist/assets/{OptionListMenu-CkK2BvEz.js → OptionListMenu-CzH2VSPh.js} +1 -1
  21. package/client/dist/assets/Player-SKPWb-o8.js +56 -0
  22. package/client/dist/assets/PlayerSettings-jrmY95-i.js +1 -0
  23. package/client/dist/assets/{QueueOptionsButton-DKWi-9kd.js → QueueOptionsButton-CcVP7Vi6.js} +1 -1
  24. package/client/dist/assets/{QueueRail-DAcIex5B.js → QueueRail-Cj-PU9EV.js} +1 -1
  25. package/client/dist/assets/{Radio-BhTsgGGd.js → Radio-CRlbaxQt.js} +1 -1
  26. package/client/dist/assets/{ReadingList-BLnkwB--.js → ReadingList-wRtHGNo8.js} +1 -1
  27. package/client/dist/assets/{ResetProgressModal-8mcLXaAb.js → ResetProgressModal-BokfoYFB.js} +1 -1
  28. package/client/dist/assets/Search-BOOBeTVA.js +1 -0
  29. package/client/dist/assets/{Settings-bSA7BG7h.js → Settings-BgrcswJD.js} +1 -1
  30. package/client/dist/assets/SkeletonGrid-Bge3aRl2.css +1 -0
  31. package/client/dist/assets/SkeletonGrid-C6S7OYHn.js +1 -0
  32. package/client/dist/assets/{Trackers-BXSONaRD.js → Trackers-DLcTEJJW.js} +1 -1
  33. package/client/dist/assets/{Tv-DJTbg4VW.js → Tv-ByVckozq.js} +1 -1
  34. package/client/dist/assets/{TvInfo-Z0c1Wh3i.js → TvInfo-fJ9SFMK8.js} +1 -1
  35. package/client/dist/assets/{TvPopup-CP41CncZ.js → TvPopup-yfH7p0y0.js} +1 -1
  36. package/client/dist/assets/TvSearch-CBNWDByW.js +1 -0
  37. package/client/dist/assets/{TvWatchlist-COCWPkgh.js → TvWatchlist-BqjZ2-4A.js} +1 -1
  38. package/client/dist/assets/Watchlist-B_Q2hEiJ.js +1 -0
  39. package/client/dist/assets/{hls-B_yo8-yR.js → hls-kIF7bMpF.js} +1 -1
  40. package/client/dist/assets/{index-lo-bz7f2.js → index-C65i4HWv.js} +2 -2
  41. package/client/dist/assets/{useAnimeInfoData-CbNnHRj8.js → useAnimeInfoData-BKSrS8eN.js} +1 -1
  42. package/client/dist/assets/{useAsmr-BEcgHwxg.js → useAsmr-Byj8Qiwp.js} +1 -1
  43. package/client/dist/assets/{useAsmrPopup-BXD3y2m1.js → useAsmrPopup-CLcWCCXf.js} +1 -1
  44. package/client/dist/assets/{useMangaLibrary-BXhgN-dl.js → useMangaLibrary-U7tUo8nx.js} +1 -1
  45. package/client/dist/assets/{useMangaPopup-BvgtZ53s.js → useMangaPopup-ZonOP5_c.js} +1 -1
  46. package/client/dist/assets/{useProviders-xlftutIo.js → useProviders-CymA42ZB.js} +1 -1
  47. package/client/dist/assets/{useTv-DTqrJEWp.js → useTv-CPj6Cf42.js} +1 -1
  48. package/client/dist/assets/{useTvLibrary-BHeWSd1F.js → useTvLibrary-COsyN8yu.js} +1 -1
  49. package/client/dist/index.html +1 -1
  50. package/package.json +2 -2
  51. package/server/dist/controllers/insights.controller.js +5 -1
  52. package/server/dist/controllers/proxy.controller.js +94 -23
  53. package/server/dist/controllers/watchlist.controller.js +42 -0
  54. package/server/dist/lib/discord-roles-sync.service.js +237 -2
  55. package/server/dist/repositories/insights.repository.js +10 -0
  56. package/server/dist/repositories/shows-meta.repository.js +4 -1
  57. package/server/dist/repositories/watchlist.repository.js +1 -0
  58. package/server/dist/routes/insights.routes.js +1 -0
  59. package/server/package.json +1 -1
  60. package/client/dist/assets/Home-CQ4Ikeja.js +0 -1
  61. package/client/dist/assets/Insights-DKtoVe6L.css +0 -1
  62. package/client/dist/assets/Insights-DiAo0UAc.js +0 -1
  63. package/client/dist/assets/LibraryListPage-B0MDTnEc.js +0 -1
  64. package/client/dist/assets/Mature-Ckx99wFj.js +0 -1
  65. package/client/dist/assets/Player-C3yWQkXb.js +0 -56
  66. package/client/dist/assets/PlayerSettings-CBdTzo0E.js +0 -1
  67. package/client/dist/assets/Search-DxX021dj.js +0 -1
  68. package/client/dist/assets/SkeletonGrid-DhPYefXr.css +0 -1
  69. package/client/dist/assets/SkeletonGrid-Du10CNfq.js +0 -1
  70. package/client/dist/assets/TvSearch-BlqTQYJa.js +0 -1
  71. 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-lo-bz7f2.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};
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};
@@ -33,7 +33,7 @@
33
33
  background-color: #0c0d12;
34
34
  }
35
35
  </style>
36
- <script type="module" crossorigin src="/assets/index-lo-bz7f2.js"></script>
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.6",
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-25T11:33:57.632Z"
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 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;
@@ -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
- 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,
@@ -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 });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dango-server",
3
3
  "private": true,
4
- "version": "3.3.6",
4
+ "version": "3.3.7",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=22.12.0"