@serifpersia/dango 3.3.7 → 3.3.9

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-WlhU88fn.js → AnimeCard-URCiiy5W.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-BCLagpbu.js → AnimeInfo-e3z3er9t.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-o0U_OXKP.js → AnimeInfoPage-B7Djlmy_.js} +2 -2
  5. package/client/dist/assets/{Asmr-ieGJ_BcQ.js → Asmr-C28TfSvX.js} +1 -1
  6. package/client/dist/assets/{AsmrResetProgressModal-BwaoVski.js → AsmrResetProgressModal-6Xh71AgR.js} +1 -1
  7. package/client/dist/assets/{AvSyncCalibrator-CXvBuY4l.js → AvSyncCalibrator-BwCtRW0P.js} +1 -1
  8. package/client/dist/assets/{Home-DjLpvsp8.js → Home-BJdbHYja.js} +1 -1
  9. package/client/dist/assets/Insights-CjRy42YE.js +1 -0
  10. package/client/dist/assets/{LibraryListPage-B2nhFFrc.js → LibraryListPage-B0qWqhb_.js} +1 -1
  11. package/client/dist/assets/{ListeningList-okIpsKCr.js → ListeningList-CSqD8j1C.js} +1 -1
  12. package/client/dist/assets/{Manga-3u9nVNZC.js → Manga-Cg2YuObF.js} +1 -1
  13. package/client/dist/assets/MangaInfoPage-DcArhEsy.js +1 -0
  14. package/client/dist/assets/MangaReadPage-YCETZRte.js +1 -0
  15. package/client/dist/assets/{MangaResetProgressModal-Ch8RjwbO.js → MangaResetProgressModal-D_yT3d1v.js} +1 -1
  16. package/client/dist/assets/{Mature-pINwl5wj.js → Mature-BGxRWkV5.js} +1 -1
  17. package/client/dist/assets/{MediaCard-DDiYEAm8.js → MediaCard-CAzoxsRS.js} +1 -1
  18. package/client/dist/assets/Music-Cgo-yQ7E.js +1 -0
  19. package/client/dist/assets/{OptionListMenu-CzH2VSPh.js → OptionListMenu--Owe9Jle.js} +1 -1
  20. package/client/dist/assets/Player-yRXG2BrP.js +56 -0
  21. package/client/dist/assets/{PlayerSettings-jrmY95-i.js → PlayerSettings-DrZLa0CC.js} +1 -1
  22. package/client/dist/assets/{QueueOptionsButton-CcVP7Vi6.js → QueueOptionsButton-BVZyPyKf.js} +1 -1
  23. package/client/dist/assets/{QueueRail-Cj-PU9EV.js → QueueRail-B7f1-e6i.js} +1 -1
  24. package/client/dist/assets/{Radio-CRlbaxQt.js → Radio-BFjxlzuF.js} +1 -1
  25. package/client/dist/assets/{ReadingList-wRtHGNo8.js → ReadingList-BaIem8H1.js} +1 -1
  26. package/client/dist/assets/{ResetProgressModal-BokfoYFB.js → ResetProgressModal-DES5Y-Fl.js} +1 -1
  27. package/client/dist/assets/{Search-BOOBeTVA.js → Search-BOBo0Ogh.js} +1 -1
  28. package/client/dist/assets/{Settings-BgrcswJD.js → Settings-DOvNQ0gX.js} +1 -1
  29. package/client/dist/assets/{SkeletonGrid-C6S7OYHn.js → SkeletonGrid-BxfgjP4i.js} +1 -1
  30. package/client/dist/assets/Trackers-DZ9KqOWv.js +2 -0
  31. package/client/dist/assets/{Tv-ByVckozq.js → Tv-5mATEU4p.js} +1 -1
  32. package/client/dist/assets/{TvInfo-fJ9SFMK8.js → TvInfo-CF-5C5Fs.js} +1 -1
  33. package/client/dist/assets/{TvPopup-yfH7p0y0.js → TvPopup-Dfqifj3s.js} +1 -1
  34. package/client/dist/assets/{TvSearch-CBNWDByW.js → TvSearch-BWDwWnHe.js} +1 -1
  35. package/client/dist/assets/{TvWatchlist-BqjZ2-4A.js → TvWatchlist-D3n5_4MI.js} +1 -1
  36. package/client/dist/assets/{Watchlist-B_Q2hEiJ.js → Watchlist-B8MHNsAU.js} +1 -1
  37. package/client/dist/assets/{hls-kIF7bMpF.js → hls-BO_L1WoN.js} +1 -1
  38. package/client/dist/assets/index-D2RkUJIN.js +178 -0
  39. package/client/dist/assets/{useAnimeInfoData-BKSrS8eN.js → useAnimeInfoData-BWShDHqE.js} +1 -1
  40. package/client/dist/assets/{useAsmr-Byj8Qiwp.js → useAsmr-FyfD9G7b.js} +1 -1
  41. package/client/dist/assets/{useAsmrPopup-CLcWCCXf.js → useAsmrPopup-D_o9-QlR.js} +1 -1
  42. package/client/dist/assets/useMangaLibrary-BJU5x8Vz.js +1 -0
  43. package/client/dist/assets/{useMangaPopup-ZonOP5_c.js → useMangaPopup-TFzkC-tG.js} +1 -1
  44. package/client/dist/assets/{useProviders-CymA42ZB.js → useProviders-DDRxa8vl.js} +1 -1
  45. package/client/dist/assets/{useTv-CPj6Cf42.js → useTv-Bh6WmNnZ.js} +1 -1
  46. package/client/dist/assets/{useTvLibrary-COsyN8yu.js → useTvLibrary-_Pqm8re1.js} +1 -1
  47. package/client/dist/index.html +1 -1
  48. package/package.json +2 -2
  49. package/server/dist/controllers/data.controller.js +1 -0
  50. package/server/dist/controllers/insights.controller.js +3 -2
  51. package/server/dist/controllers/manga-library.controller.js +54 -0
  52. package/server/dist/controllers/music.controller.js +123 -50
  53. package/server/dist/controllers/watchlist.controller.js +13 -1
  54. package/server/dist/lib/anilist.js +36 -3
  55. package/server/dist/lib/mal.js +69 -0
  56. package/server/dist/lib/tracker/anilist-tracker.js +66 -0
  57. package/server/dist/lib/tracker/manga-sync.service.js +792 -0
  58. package/server/dist/lib/ytmusic.js +15 -2
  59. package/server/dist/repositories/manga.repository.js +17 -2
  60. package/server/dist/routes/manga-library.routes.js +2 -0
  61. package/server/dist/routes/tracker.routes.js +123 -0
  62. package/server/dist/sync.js +8 -1
  63. package/server/package.json +1 -1
  64. package/client/dist/assets/Insights-lQcQoeES.js +0 -1
  65. package/client/dist/assets/MangaInfoPage-Cat-B487.js +0 -1
  66. package/client/dist/assets/MangaReadPage-CyLRxzvl.js +0 -1
  67. package/client/dist/assets/Music-B1swlVLI.js +0 -1
  68. package/client/dist/assets/Player-SKPWb-o8.js +0 -56
  69. package/client/dist/assets/Trackers-DLcTEJJW.js +0 -2
  70. package/client/dist/assets/index-C65i4HWv.js +0 -178
  71. package/client/dist/assets/useMangaLibrary-U7tUo8nx.js +0 -1
@@ -1 +1 @@
1
- import{u as e,S as T,L as N,d as P,A as y,q as o}from"./vendor-core-8R2fFbyN.js";import{I as c}from"./vendor-icons-CDMuNDeL.js";import{g as I,r as A}from"./useMangaLibrary-U7tUo8nx.js";import{d as L}from"./index-C65i4HWv.js";import{p as s,a as M}from"./MediaCard-DDiYEAm8.js";const S=({data:l,anchorRect:t,bookmarked:a,onToggleBookmark:i,onMouseEnter:d,onMouseLeave:m,onRequestClose:p})=>{const{data:n,isLoading:h}=I(l.provider,l.mangaId,l.rating||"safe",l.mature||!1),{titlePreference:f}=L(),u=n?.chapters.length??0,v=[n?.status,n?.type].filter(Boolean).join(" · "),g=A({title:l.title,altTitle:n?.altTitle??l.altTitle},f);return e(M,{anchorRect:t,onMouseEnter:d,onMouseLeave:m,onRequestClose:p,children:h?e("div",{className:s.loading,children:[e("div",{className:s.spinner}),e("span",{children:"Fetching details..."})]}):e(T,{children:[e("div",{className:s.header,children:[e("div",{className:s.title,children:g}),n?.altTitle&&n.altTitle!==g&&e("div",{className:s.synopsis,children:["Also known as: ",n.altTitle]})]}),e("div",{className:s.body,children:[e("div",{className:s.metaRow,children:[v&&e("div",{className:s.metaItem,children:[e(c,{name:"book",size:14}),e("span",{children:v})]}),u>0&&e("div",{className:s.metaItem,children:e("span",{children:[u," chapter",u===1?"":"s"]})}),l.progressLabel&&e("div",{className:s.metaItem,children:[e(c,{name:"bookmark",size:12}),e("span",{children:l.progressLabel})]})]}),e("div",{className:s.synopsis,children:n?.description?n.description:"No synopsis available."}),Array.isArray(n?.genres)&&(n?.genres?.length??0)>0&&e("div",{className:s.details,children:e("div",{className:s.genres,children:n.genres.filter(Boolean).slice(0,4).map(r=>e("span",{className:s.genre,children:r},r))})})]}),e("div",{className:s.footer,children:[e("div",{className:s.primaryAction,children:e(N,{to:l.readTarget,className:s.watchBtn,children:[e(c,{name:"book",size:14}),"Read now"]})}),e("div",{className:s.secondaryActions,children:[e("button",{className:`${s.watchlistBtn} ${a?s.active:""}`,onClick:r=>{r.preventDefault(),r.stopPropagation(),i()},children:[a?e(c,{name:"check",size:12}):e(c,{name:"plus",size:12}),e("span",{children:a?"Remove":"Bookmark"})]}),e(N,{to:`/manga/${l.provider}/${encodeURIComponent(l.mangaId)}`,className:s.detailsBtn,children:"Read more"})]})]})]})})},b=300;function $(){const[l,t]=P(null),a=y(null),i=o(()=>{a.current&&(clearTimeout(a.current),a.current=null)},[]),d=o((n,h)=>{i(),t({data:h,rect:n})},[i]),m=o(()=>{i(),a.current=setTimeout(()=>{a.current=null,t(null)},b)},[i]),p=o(()=>{i(),t(null)},[i]);return{popup:l,openPopup:d,scheduleClose:m,cancelClose:i,closePopup:p}}export{S as M,$ as u};
1
+ import{u as e,S as T,L as N,d as P,A as y,q as o}from"./vendor-core-8R2fFbyN.js";import{I as c}from"./vendor-icons-CDMuNDeL.js";import{k as I,r as k}from"./useMangaLibrary-BJU5x8Vz.js";import{d as A}from"./index-D2RkUJIN.js";import{p as s,a as L}from"./MediaCard-CAzoxsRS.js";const S=({data:l,anchorRect:t,bookmarked:a,onToggleBookmark:i,onMouseEnter:d,onMouseLeave:m,onRequestClose:p})=>{const{data:n,isLoading:h}=I(l.provider,l.mangaId,l.rating||"safe",l.mature||!1),{titlePreference:f}=A(),u=n?.chapters.length??0,v=[n?.status,n?.type].filter(Boolean).join(" · "),g=k({title:l.title,altTitle:n?.altTitle??l.altTitle},f);return e(L,{anchorRect:t,onMouseEnter:d,onMouseLeave:m,onRequestClose:p,children:h?e("div",{className:s.loading,children:[e("div",{className:s.spinner}),e("span",{children:"Fetching details..."})]}):e(T,{children:[e("div",{className:s.header,children:[e("div",{className:s.title,children:g}),n?.altTitle&&n.altTitle!==g&&e("div",{className:s.synopsis,children:["Also known as: ",n.altTitle]})]}),e("div",{className:s.body,children:[e("div",{className:s.metaRow,children:[v&&e("div",{className:s.metaItem,children:[e(c,{name:"book",size:14}),e("span",{children:v})]}),u>0&&e("div",{className:s.metaItem,children:e("span",{children:[u," chapter",u===1?"":"s"]})}),l.progressLabel&&e("div",{className:s.metaItem,children:[e(c,{name:"bookmark",size:12}),e("span",{children:l.progressLabel})]})]}),e("div",{className:s.synopsis,children:n?.description?n.description:"No synopsis available."}),Array.isArray(n?.genres)&&(n?.genres?.length??0)>0&&e("div",{className:s.details,children:e("div",{className:s.genres,children:n.genres.filter(Boolean).slice(0,4).map(r=>e("span",{className:s.genre,children:r},r))})})]}),e("div",{className:s.footer,children:[e("div",{className:s.primaryAction,children:e(N,{to:l.readTarget,className:s.watchBtn,children:[e(c,{name:"book",size:14}),"Read now"]})}),e("div",{className:s.secondaryActions,children:[e("button",{className:`${s.watchlistBtn} ${a?s.active:""}`,onClick:r=>{r.preventDefault(),r.stopPropagation(),i()},children:[a?e(c,{name:"check",size:12}):e(c,{name:"plus",size:12}),e("span",{children:a?"Remove":"Bookmark"})]}),e(N,{to:`/manga/${l.provider}/${encodeURIComponent(l.mangaId)}`,className:s.detailsBtn,children:"Read more"})]})]})]})})},M=300;function $(){const[l,t]=P(null),a=y(null),i=o(()=>{a.current&&(clearTimeout(a.current),a.current=null)},[]),d=o((n,h)=>{i(),t({data:h,rect:n})},[i]),m=o(()=>{i(),a.current=setTimeout(()=>{a.current=null,t(null)},M)},[i]),p=o(()=>{i(),t(null)},[i]);return{popup:l,openPopup:d,scheduleClose:m,cancelClose:i,closePopup:p}}export{S as M,$ as u};
@@ -1 +1 @@
1
- import{T as o}from"./vendor-core-8R2fFbyN.js";import{b as a}from"./vendor-query-BXRECSI6.js";import{q as i}from"./index-C65i4HWv.js";const n=300*1e3;function u(r){return{value:r.id,label:r.label,mature:r.mature,kind:r.kind,sub:r.sub,tier:r.tier,browse:r.browse,facets:r.facets,servers:r.servers}}const d=()=>{const r=a({queryKey:["providers"],queryFn:()=>i("/api/providers"),staleTime:n,retry:1}),e=Array.isArray(r.data)?r.data:null,t=o(()=>e&&e.length>0?e.filter(s=>s.loaded).map(u):[],[e]);return{...r,options:t,isFallback:!e||e.length===0}};export{d as u};
1
+ import{T as o}from"./vendor-core-8R2fFbyN.js";import{b as a}from"./vendor-query-BXRECSI6.js";import{q as i}from"./index-D2RkUJIN.js";const n=300*1e3;function u(r){return{value:r.id,label:r.label,mature:r.mature,kind:r.kind,sub:r.sub,tier:r.tier,browse:r.browse,facets:r.facets,servers:r.servers}}const d=()=>{const r=a({queryKey:["providers"],queryFn:()=>i("/api/providers"),staleTime:n,retry:1}),e=Array.isArray(r.data)?r.data:null,t=o(()=>e&&e.length>0?e.filter(s=>s.loaded).map(u):[],[e]);return{...r,options:t,isFallback:!e||e.length===0}};export{d as u};
@@ -1 +1 @@
1
- import{N as d,u as o,S as v}from"./vendor-core-8R2fFbyN.js";import{I as g}from"./vendor-icons-CDMuNDeL.js";import{M as f}from"./MediaCard-DDiYEAm8.js";import{T as m}from"./TvPopup-yfH7p0y0.js";import{c as T,i as h}from"./useTvLibrary-COsyN8yu.js";import{b as u}from"./vendor-query-BXRECSI6.js";import{q as y}from"./index-C65i4HWv.js";const b=d(({item:e})=>{const r=e.type==="tv"||e.type==="tvSeries"||e.type==="tvMiniSeries",t=T(e.type,e.id);return o(f,{item:{id:String(e.id),title:e.title,thumbnail:e.image,typeBadge:r?"TV":"Movie",isAdult:h(e)},linkTo:t,hoverIcon:"info",showInfoButton:!0,metaRow:o(v,{children:[e.year&&o("span",{style:{opacity:.75},children:e.year}),e.vote_average!=null&&o("span",{style:{display:"inline-flex",alignItems:"center",gap:3},children:[o(g,{name:"star",size:10}),Number(e.vote_average).toFixed(1)]})]}),renderPopup:(n,a)=>o(m,{item:e,anchorRect:n,onMouseEnter:a.onMouseEnter,onMouseLeave:a.onMouseLeave,onRequestClose:a.close})})}),c=1e3*60*5,w=e=>{const{query:r,type:t,genre:n,year:a,sort_by:i,page:l}=e,s=new URLSearchParams;r?.trim()&&s.set("q",r.trim()),t&&t!=="multi"&&s.set("type",t),n&&s.set("genre",n),a&&a!=="ALL"&&s.set("year",a),i&&i!=="popularity.desc"&&s.set("sort_by",i),s.set("page",String(l||1));const p=s.toString();return u({queryKey:["tv-search-paginated",p],queryFn:()=>y(`/api/tv/search?${p}`),staleTime:c,retry:1})},A=(e="all",r="week",t=1,n=!0)=>u({queryKey:["tv-trending",e,r,t],queryFn:()=>y(`/api/tv/trending?media_type=${e}&time_window=${r}&page=${t}`),enabled:n,staleTime:c,retry:1});export{b as T,w as a,A as u};
1
+ import{N as d,u as o,S as v}from"./vendor-core-8R2fFbyN.js";import{I as g}from"./vendor-icons-CDMuNDeL.js";import{M as f}from"./MediaCard-CAzoxsRS.js";import{T as m}from"./TvPopup-Dfqifj3s.js";import{c as T,i as h}from"./useTvLibrary-_Pqm8re1.js";import{b as u}from"./vendor-query-BXRECSI6.js";import{q as y}from"./index-D2RkUJIN.js";const b=d(({item:e})=>{const r=e.type==="tv"||e.type==="tvSeries"||e.type==="tvMiniSeries",t=T(e.type,e.id);return o(f,{item:{id:String(e.id),title:e.title,thumbnail:e.image,typeBadge:r?"TV":"Movie",isAdult:h(e)},linkTo:t,hoverIcon:"info",showInfoButton:!0,metaRow:o(v,{children:[e.year&&o("span",{style:{opacity:.75},children:e.year}),e.vote_average!=null&&o("span",{style:{display:"inline-flex",alignItems:"center",gap:3},children:[o(g,{name:"star",size:10}),Number(e.vote_average).toFixed(1)]})]}),renderPopup:(n,a)=>o(m,{item:e,anchorRect:n,onMouseEnter:a.onMouseEnter,onMouseLeave:a.onMouseLeave,onRequestClose:a.close})})}),c=1e3*60*5,w=e=>{const{query:r,type:t,genre:n,year:a,sort_by:i,page:l}=e,s=new URLSearchParams;r?.trim()&&s.set("q",r.trim()),t&&t!=="multi"&&s.set("type",t),n&&s.set("genre",n),a&&a!=="ALL"&&s.set("year",a),i&&i!=="popularity.desc"&&s.set("sort_by",i),s.set("page",String(l||1));const p=s.toString();return u({queryKey:["tv-search-paginated",p],queryFn:()=>y(`/api/tv/search?${p}`),staleTime:c,retry:1})},A=(e="all",r="week",t=1,n=!0)=>u({queryKey:["tv-trending",e,r,t],queryFn:()=>y(`/api/tv/trending?media_type=${e}&time_window=${r}&page=${t}`),enabled:n,staleTime:c,retry:1});export{b as T,w as a,A as u};
@@ -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-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};
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-D2RkUJIN.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-C65i4HWv.js"></script>
36
+ <script type="module" crossorigin src="/assets/index-D2RkUJIN.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.7",
3
+ "version": "3.3.9",
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-25T14:47:03.564Z"
84
+ "versionDate": "2026-09-25T21:08:55.146Z"
85
85
  }
@@ -656,6 +656,7 @@ export class DataController {
656
656
  thumbnail: poster,
657
657
  nativeName: meta.nativeName,
658
658
  englishName: meta.englishName,
659
+ popularityScore: meta.score ?? (meta.averageScore != null ? meta.averageScore / 10 : undefined),
659
660
  genres: meta.genres
660
661
  ? JSON.stringify(meta.genres.map((g) => (typeof g === 'string' ? g : g?.name)).filter(Boolean))
661
662
  : undefined,
@@ -21,7 +21,8 @@ export class InsightsController {
21
21
  for (let i = 1; i < allWatches.length; i++) {
22
22
  const prev = new Date(allWatches[i - 1].watchedAt).getTime();
23
23
  const curr = new Date(allWatches[i].watchedAt).getTime();
24
- if (curr - prev < 3600000) {
24
+ const gap = curr - prev;
25
+ if (Number.isFinite(gap) && gap > 0 && gap < 3600000) {
25
26
  currentSessionSeconds += allWatches[i].effectiveSeconds ?? allWatches[i].currentTime;
26
27
  }
27
28
  else {
@@ -155,7 +156,7 @@ export class InsightsController {
155
156
  genreData[genre].scores.push(score);
156
157
  genreData[genre].showWatches[row.showId] =
157
158
  (genreData[genre].showWatches[row.showId] || 0) + 1;
158
- if (row.name && row.thumbnail) {
159
+ if (row.name || row.thumbnail) {
159
160
  genreData[genre].showMeta[row.showId] = {
160
161
  name: row.name,
161
162
  nativeName: row.nativeName,
@@ -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)) });
@@ -1,6 +1,6 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import logger from '../logger.js';
3
- import { getInnertube, getAuthedInnertube, saveMusicCookie, getMusicAuthStatus, signOutMusic, isParserVariantError, } from '../lib/ytmusic.js';
3
+ import { getInnertube, getAuthedInnertube, refreshAuthedInnertube, saveMusicCookie, getMusicAuthStatus, signOutMusic, isParserVariantError, } from '../lib/ytmusic.js';
4
4
  const NAME_SUFFIXES = [
5
5
  /\s*(- topic)$/i,
6
6
  /\s*vevo$/i,
@@ -151,6 +151,19 @@ function upnextToTrack(node, trustLike = false) {
151
151
  ...(liked !== null ? { liked } : {}),
152
152
  };
153
153
  }
154
+ function parseUpnext(panel, trustLike) {
155
+ const tracks = [];
156
+ const contents = panel.contents ?? [];
157
+ for (const item of contents) {
158
+ const track = upnextToTrack(item, trustLike);
159
+ if (track)
160
+ tracks.push(track);
161
+ if (tracks.length >= 50)
162
+ break;
163
+ }
164
+ const playlistId = panel.playlist_id ?? null;
165
+ return { tracks, playlistId };
166
+ }
154
167
  function asMenuItems(menu) {
155
168
  if (!menu || typeof menu !== 'object')
156
169
  return [];
@@ -278,12 +291,7 @@ async function readLibrary(yt) {
278
291
  const UPSTREAM_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
279
292
  const audioCache = new Map();
280
293
  const AUDIO_CACHE_MS = 5 * 60 * 1000;
281
- async function decipherAudio(videoId) {
282
- const cached = audioCache.get(videoId);
283
- if (cached && Date.now() - cached.fetchedAt < AUDIO_CACHE_MS)
284
- return cached;
285
- const authed = await getAuthedInnertube().catch(() => null);
286
- const yt = authed ?? (await getInnertube());
294
+ async function decipherWith(yt, videoId) {
287
295
  const info = await yt.music.getInfo(videoId);
288
296
  const streaming = info.streaming_data;
289
297
  const formats = streaming?.adaptive_formats ?? [];
@@ -306,6 +314,30 @@ async function decipherAudio(videoId) {
306
314
  }
307
315
  throw new Error('No stream available');
308
316
  }
317
+ async function decipherAudio(videoId) {
318
+ const cached = audioCache.get(videoId);
319
+ if (cached && Date.now() - cached.fetchedAt < AUDIO_CACHE_MS)
320
+ return cached;
321
+ const authed = await getAuthedInnertube().catch(() => null);
322
+ if (authed) {
323
+ try {
324
+ return await decipherWith(authed, videoId);
325
+ }
326
+ catch (err) {
327
+ logger.warn({ err }, '[music] authed stream lookup failed, retrying with a fresh saved-cookie session');
328
+ const fresh = await refreshAuthedInnertube();
329
+ if (fresh) {
330
+ try {
331
+ return await decipherWith(fresh, videoId);
332
+ }
333
+ catch (retryErr) {
334
+ logger.warn({ err: retryErr }, '[music] refreshed stream lookup failed, falling back to public session');
335
+ }
336
+ }
337
+ }
338
+ }
339
+ return decipherWith(await getInnertube(), videoId);
340
+ }
309
341
  const PASSTHROUGH_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges'];
310
342
  export class MusicController {
311
343
  cache;
@@ -547,7 +579,7 @@ export class MusicController {
547
579
  const id = String(req.query.id || '').trim();
548
580
  if (!id)
549
581
  return res.status(400).json({ error: 'id is required', tracks: [] });
550
- const yt = await getAuthedInnertube();
582
+ const yt = await getAuthedInnertube().catch(() => null);
551
583
  if (!yt) {
552
584
  return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', tracks: [] });
553
585
  }
@@ -555,20 +587,41 @@ export class MusicController {
555
587
  res.json({ tracks: await fetchPlaylistTracks(yt, id) });
556
588
  }
557
589
  catch (err) {
558
- logger.warn({ err }, '[music] playlist failed, returning empty');
559
- res.json({ tracks: [] });
590
+ logger.warn({ err }, '[music] playlist failed, retrying with a fresh saved-cookie session');
591
+ const fresh = await refreshAuthedInnertube();
592
+ if (!fresh)
593
+ return res.json({ tracks: [] });
594
+ try {
595
+ res.json({ tracks: await fetchPlaylistTracks(fresh, id) });
596
+ }
597
+ catch (retryErr) {
598
+ logger.warn({ err: retryErr }, '[music] playlist retry failed, returning empty');
599
+ res.json({ tracks: [] });
600
+ }
560
601
  }
561
602
  };
562
603
  library = async (_req, res) => {
563
- const yt = await getAuthedInnertube();
604
+ const yt = await getAuthedInnertube().catch(() => null);
564
605
  if (!yt) {
565
606
  return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', tracks: [], playlists: [] });
566
607
  }
567
608
  try {
568
- res.json(await readLibrary(yt));
609
+ const first = await readLibrary(yt);
610
+ if (first.tracks.length > 0 || first.playlists.length > 0)
611
+ return res.json(first);
612
+ logger.warn('[music] library came back empty, retrying with a fresh saved-cookie session');
613
+ }
614
+ catch (err) {
615
+ logger.warn({ err }, '[music] library failed, retrying with a fresh saved-cookie session');
616
+ }
617
+ const fresh = await refreshAuthedInnertube();
618
+ if (!fresh)
619
+ return res.json({ tracks: [], playlists: [] });
620
+ try {
621
+ res.json(await readLibrary(fresh));
569
622
  }
570
623
  catch (err) {
571
- logger.warn({ err }, '[music] library failed, returning empty');
624
+ logger.warn({ err }, '[music] library retry failed, returning empty');
572
625
  res.json({ tracks: [], playlists: [] });
573
626
  }
574
627
  };
@@ -597,18 +650,7 @@ export class MusicController {
597
650
  return res.json(cached);
598
651
  try {
599
652
  const yt = await getInnertube();
600
- const panel = await yt.music.getUpNext(id);
601
- const tracks = [];
602
- const contents = panel.contents ?? [];
603
- for (const item of contents) {
604
- const track = upnextToTrack(item);
605
- if (track)
606
- tracks.push(track);
607
- if (tracks.length >= 50)
608
- break;
609
- }
610
- const playlistId = panel.playlist_id ?? null;
611
- const payload = { tracks, playlistId };
653
+ const payload = parseUpnext(await yt.music.getUpNext(id), false);
612
654
  this.cache.set(cacheKey, payload, 300);
613
655
  return res.json(payload);
614
656
  }
@@ -618,22 +660,27 @@ export class MusicController {
618
660
  }
619
661
  }
620
662
  try {
621
- const panel = await authed.music.getUpNext(id);
622
- const tracks = [];
623
- const contents = panel.contents ?? [];
624
- for (const item of contents) {
625
- const track = upnextToTrack(item, true);
626
- if (track)
627
- tracks.push(track);
628
- if (tracks.length >= 50)
629
- break;
663
+ return res.json(parseUpnext(await authed.music.getUpNext(id), true));
664
+ }
665
+ catch (err) {
666
+ logger.warn({ err }, '[music] authed upnext failed, retrying with a fresh saved-cookie session');
667
+ }
668
+ const fresh = await refreshAuthedInnertube();
669
+ if (fresh) {
670
+ try {
671
+ return res.json(parseUpnext(await fresh.music.getUpNext(id), true));
672
+ }
673
+ catch (retryErr) {
674
+ logger.warn({ err: retryErr }, '[music] upnext retry failed, falling back to public');
630
675
  }
631
- const playlistId = panel.playlist_id ?? null;
632
- res.json({ tracks, playlistId });
676
+ }
677
+ try {
678
+ const yt = await getInnertube();
679
+ return res.json(parseUpnext(await yt.music.getUpNext(id), false));
633
680
  }
634
681
  catch (err) {
635
682
  logger.error({ err }, '[music] upnext failed');
636
- res.json({ tracks: [], playlistId: null });
683
+ return res.json({ tracks: [], playlistId: null });
637
684
  }
638
685
  };
639
686
  likedIds = async (_req, res) => {
@@ -645,15 +692,29 @@ export class MusicController {
645
692
  const cached = this.cache.get(cacheKey);
646
693
  if (cached)
647
694
  return res.json({ likedIds: cached });
695
+ const fetchLikedIds = async (session) => {
696
+ const tracks = await fetchPlaylistTracks(session, 'LM', 500);
697
+ return tracks.map((t) => t.id);
698
+ };
648
699
  try {
649
- const tracks = await fetchPlaylistTracks(yt, 'LM', 500);
650
- const likedIds = tracks.map((t) => t.id);
700
+ const likedIds = await fetchLikedIds(yt);
651
701
  this.cache.set(cacheKey, likedIds, 120);
652
702
  res.json({ likedIds });
653
703
  }
654
704
  catch (err) {
655
- logger.warn({ err }, '[music] liked ids failed, returning empty');
656
- res.json({ likedIds: [] });
705
+ logger.warn({ err }, '[music] liked ids failed, retrying with a fresh saved-cookie session');
706
+ const fresh = await refreshAuthedInnertube();
707
+ if (!fresh)
708
+ return res.json({ likedIds: [] });
709
+ try {
710
+ const likedIds = await fetchLikedIds(fresh);
711
+ this.cache.set(cacheKey, likedIds, 120);
712
+ res.json({ likedIds });
713
+ }
714
+ catch (retryErr) {
715
+ logger.warn({ err: retryErr }, '[music] liked ids retry failed, returning empty');
716
+ res.json({ likedIds: [] });
717
+ }
657
718
  }
658
719
  };
659
720
  rate = async (req, res) => {
@@ -665,16 +726,16 @@ export class MusicController {
665
726
  if (!yt) {
666
727
  return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED' });
667
728
  }
668
- try {
669
- const actions = yt.actions ??
670
- yt.session?.actions;
729
+ const attempt = async (session) => {
730
+ const actions = session.actions ??
731
+ session.session?.actions;
671
732
  if (!actions)
672
733
  throw new Error('Actions unavailable');
673
734
  // Only ever call the toggle endpoints YouTube Music itself returns for
674
735
  // this track (payload status LIKE/INDIFFERENT). No hand-built endpoints:
675
736
  // raw video ids can differ from the canonical song id and a direct
676
737
  // /like call would orphan entries in Liked Music.
677
- const panel = await yt.music.getUpNext(id);
738
+ const panel = await session.music.getUpNext(id);
678
739
  const contents = panel.contents ?? [];
679
740
  let endpoint = null;
680
741
  for (const item of contents) {
@@ -686,13 +747,25 @@ export class MusicController {
686
747
  if (!endpoint)
687
748
  throw new Error('Like toggle not found for track');
688
749
  await endpoint.call(actions);
689
- this.cache.delete('music-liked-ids');
690
- logger.info(`[music] rate id=${id} liked=${like} via=toggle`);
691
- res.json({ success: true, liked: like });
750
+ };
751
+ try {
752
+ await attempt(yt);
692
753
  }
693
754
  catch (err) {
694
- logger.error({ err }, '[music] rate failed');
695
- res.status(502).json({ error: 'Like action failed' });
755
+ logger.warn({ err }, '[music] rate failed, retrying with a fresh saved-cookie session');
756
+ const fresh = await refreshAuthedInnertube();
757
+ if (!fresh)
758
+ return res.status(502).json({ error: 'Like action failed' });
759
+ try {
760
+ await attempt(fresh);
761
+ }
762
+ catch (retryErr) {
763
+ logger.error({ err: retryErr }, '[music] rate retry failed');
764
+ return res.status(502).json({ error: 'Like action failed' });
765
+ }
696
766
  }
767
+ this.cache.delete('music-liked-ids');
768
+ logger.info(`[music] rate id=${id} liked=${like} via=toggle`);
769
+ res.json({ success: true, liked: like });
697
770
  };
698
771
  }
@@ -450,7 +450,11 @@ export class WatchlistController {
450
450
  if (dual._id !== undefined)
451
451
  dual._id = row.id;
452
452
  try {
453
- ShowsMetaRepository.upsert(db, { id: row.id, thumbnail: poster });
453
+ ShowsMetaRepository.upsert(db, {
454
+ id: row.id,
455
+ thumbnail: poster,
456
+ popularityScore: meta?.score ?? (meta?.averageScore != null ? meta.averageScore / 10 : undefined),
457
+ });
454
458
  }
455
459
  catch {
456
460
  // ignore
@@ -470,6 +474,14 @@ export class WatchlistController {
470
474
  await this.sweepOfflinePosters(db);
471
475
  }
472
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
+ }
473
485
  const [wlMissing, metaMissing] = await Promise.all([
474
486
  WatchlistRepository.getMissingThumbnails(db),
475
487
  ShowsMetaRepository.getShowIdsMissingMeta(db),
@@ -1128,11 +1128,11 @@ function extractEpisodes(result) {
1128
1128
  }
1129
1129
  return [];
1130
1130
  }
1131
- export async function searchAnilistByTitle(title) {
1131
+ export async function searchAnilistByTitle(title, type = 'ANIME') {
1132
1132
  const query = `
1133
1133
  query ($search: String) {
1134
1134
  Page(page: 1, perPage: 5) {
1135
- media(search: $search, type: ANIME) {
1135
+ media(search: $search, type: ${type}) {
1136
1136
  id
1137
1137
  title { romaji english native }
1138
1138
  }
@@ -1164,7 +1164,7 @@ export async function searchAnilistByTitle(title) {
1164
1164
  break;
1165
1165
  }
1166
1166
  if (!media || media.length === 0) {
1167
- if (anyFailed && anilistUnavailable()) {
1167
+ if (anyFailed && type === 'ANIME' && anilistUnavailable()) {
1168
1168
  const fb = await kitsuSearchAnime({ query: title, page: 1, perPage: 5 });
1169
1169
  if (fb.length > 0) {
1170
1170
  const best = fb[0];
@@ -1196,6 +1196,39 @@ export async function searchAnilistByTitle(title) {
1196
1196
  }
1197
1197
  return { id: best.id, title: best.title };
1198
1198
  }
1199
+ export async function searchAnilistMangaByTitle(title) {
1200
+ return searchAnilistByTitle(title, 'MANGA');
1201
+ }
1202
+ const MANGA_META_FRAGMENT = `id title { romaji english native } coverImage { large }`;
1203
+ export async function getMangaMetaById(id) {
1204
+ if (!id)
1205
+ return null;
1206
+ try {
1207
+ const result = await anilistRequest(`query ($id: Int) { Media(id: $id, type: MANGA) { ${MANGA_META_FRAGMENT} } }`, { id });
1208
+ const m = result?.data?.Media;
1209
+ if (!m)
1210
+ return null;
1211
+ return { id: m.id, title: m.title ?? {}, cover: m.coverImage?.large };
1212
+ }
1213
+ catch {
1214
+ return null;
1215
+ }
1216
+ }
1217
+ export async function getMangaMetaByMalId(malId) {
1218
+ const absMal = Math.abs(malId);
1219
+ if (!absMal)
1220
+ return null;
1221
+ try {
1222
+ const result = await anilistRequest(`query ($id: Int) { Media(idMal: $id, type: MANGA) { ${MANGA_META_FRAGMENT} } }`, { id: absMal });
1223
+ const m = result?.data?.Media;
1224
+ if (!m)
1225
+ return null;
1226
+ return { id: m.id, title: m.title ?? {}, cover: m.coverImage?.large };
1227
+ }
1228
+ catch {
1229
+ return null;
1230
+ }
1231
+ }
1199
1232
  export async function getAiredEpisodesForShows(ids, startDate, endDate) {
1200
1233
  const dayStart = Math.floor(startDate.getTime() / 1000);
1201
1234
  const dayEnd = Math.floor(endDate.getTime() / 1000);
@@ -913,6 +913,75 @@ export async function fetchMalUserList(username) {
913
913
  }
914
914
  return entries;
915
915
  }
916
+ export function mapMalMangaStatusCode(status) {
917
+ switch (status) {
918
+ case 1:
919
+ return 'Reading';
920
+ case 2:
921
+ return 'Completed';
922
+ case 3:
923
+ return 'On-Hold';
924
+ case 4:
925
+ return 'Dropped';
926
+ case 6:
927
+ return 'Planned';
928
+ default:
929
+ return null;
930
+ }
931
+ }
932
+ export async function fetchMalMangaList(username) {
933
+ const user = username.trim();
934
+ if (!user || /[^A-Za-z0-9_-]/.test(user))
935
+ throw new Error('Invalid MAL username');
936
+ const entries = [];
937
+ let offset = 0;
938
+ for (let page = 0; page < MAL_USER_LIST_MAX_PAGES; page++) {
939
+ const url = `https://myanimelist.net/mangalist/${encodeURIComponent(user)}/load.json?status=7&offset=${offset}`;
940
+ await politeWait();
941
+ const res = await fetch(url, {
942
+ headers: { 'User-Agent': UA, Accept: 'application/json' },
943
+ signal: AbortSignal.timeout(TIMEOUT_MS),
944
+ redirect: 'manual',
945
+ });
946
+ if (res.status === 400 || res.status === 404)
947
+ throw new Error(`MAL user "${user}" not found`);
948
+ if (res.status === 403 || res.status === 429) {
949
+ throw new Error(`MAL blocked the request (HTTP ${res.status}) — try again later`);
950
+ }
951
+ if (res.status >= 300 && res.status < 400) {
952
+ throw new Error(`MAL list for "${user}" is private or unavailable`);
953
+ }
954
+ if (!res.ok)
955
+ throw new Error(`MAL list fetch failed (HTTP ${res.status})`);
956
+ const data = await parseJsonBody(res);
957
+ if (!Array.isArray(data) || data.length === 0)
958
+ break;
959
+ for (const item of data) {
960
+ const rec = item;
961
+ const malId = Number(rec.manga_id);
962
+ if (!malId)
963
+ continue;
964
+ const titleRaw = typeof rec.manga_title === 'string' && rec.manga_title.trim() !== ''
965
+ ? rec.manga_title
966
+ : typeof rec.manga_title_eng === 'string'
967
+ ? rec.manga_title_eng
968
+ : '';
969
+ entries.push({
970
+ malId,
971
+ title: decodeHtml(titleRaw),
972
+ titleEnglish: typeof rec.manga_title_eng === 'string' && rec.manga_title_eng.trim() !== ''
973
+ ? decodeHtml(rec.manga_title_eng)
974
+ : null,
975
+ status: Number(rec.status) || 0,
976
+ chaptersRead: Number(rec.num_read_chapters ?? rec.num_chapters_read) || 0,
977
+ });
978
+ }
979
+ if (data.length < MAL_USER_LIST_PAGE_SIZE)
980
+ break;
981
+ offset += MAL_USER_LIST_PAGE_SIZE;
982
+ }
983
+ return entries;
984
+ }
916
985
  export async function malLatestReleases(store, format = 'TV', page = 1, size = 12) {
917
986
  const week = await malScheduleWeek(store);
918
987
  const seen = new Map();