@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.
- package/README.md +1 -1
- package/client/dist/assets/{AnimeCard-WlhU88fn.js → AnimeCard-URCiiy5W.js} +1 -1
- package/client/dist/assets/{AnimeInfo-BCLagpbu.js → AnimeInfo-e3z3er9t.js} +1 -1
- package/client/dist/assets/{AnimeInfoPage-o0U_OXKP.js → AnimeInfoPage-B7Djlmy_.js} +2 -2
- package/client/dist/assets/{Asmr-ieGJ_BcQ.js → Asmr-C28TfSvX.js} +1 -1
- package/client/dist/assets/{AsmrResetProgressModal-BwaoVski.js → AsmrResetProgressModal-6Xh71AgR.js} +1 -1
- package/client/dist/assets/{AvSyncCalibrator-CXvBuY4l.js → AvSyncCalibrator-BwCtRW0P.js} +1 -1
- package/client/dist/assets/{Home-DjLpvsp8.js → Home-BJdbHYja.js} +1 -1
- package/client/dist/assets/Insights-CjRy42YE.js +1 -0
- package/client/dist/assets/{LibraryListPage-B2nhFFrc.js → LibraryListPage-B0qWqhb_.js} +1 -1
- package/client/dist/assets/{ListeningList-okIpsKCr.js → ListeningList-CSqD8j1C.js} +1 -1
- package/client/dist/assets/{Manga-3u9nVNZC.js → Manga-Cg2YuObF.js} +1 -1
- package/client/dist/assets/MangaInfoPage-DcArhEsy.js +1 -0
- package/client/dist/assets/MangaReadPage-YCETZRte.js +1 -0
- package/client/dist/assets/{MangaResetProgressModal-Ch8RjwbO.js → MangaResetProgressModal-D_yT3d1v.js} +1 -1
- package/client/dist/assets/{Mature-pINwl5wj.js → Mature-BGxRWkV5.js} +1 -1
- package/client/dist/assets/{MediaCard-DDiYEAm8.js → MediaCard-CAzoxsRS.js} +1 -1
- package/client/dist/assets/Music-Cgo-yQ7E.js +1 -0
- package/client/dist/assets/{OptionListMenu-CzH2VSPh.js → OptionListMenu--Owe9Jle.js} +1 -1
- package/client/dist/assets/Player-yRXG2BrP.js +56 -0
- package/client/dist/assets/{PlayerSettings-jrmY95-i.js → PlayerSettings-DrZLa0CC.js} +1 -1
- package/client/dist/assets/{QueueOptionsButton-CcVP7Vi6.js → QueueOptionsButton-BVZyPyKf.js} +1 -1
- package/client/dist/assets/{QueueRail-Cj-PU9EV.js → QueueRail-B7f1-e6i.js} +1 -1
- package/client/dist/assets/{Radio-CRlbaxQt.js → Radio-BFjxlzuF.js} +1 -1
- package/client/dist/assets/{ReadingList-wRtHGNo8.js → ReadingList-BaIem8H1.js} +1 -1
- package/client/dist/assets/{ResetProgressModal-BokfoYFB.js → ResetProgressModal-DES5Y-Fl.js} +1 -1
- package/client/dist/assets/{Search-BOOBeTVA.js → Search-BOBo0Ogh.js} +1 -1
- package/client/dist/assets/{Settings-BgrcswJD.js → Settings-DOvNQ0gX.js} +1 -1
- package/client/dist/assets/{SkeletonGrid-C6S7OYHn.js → SkeletonGrid-BxfgjP4i.js} +1 -1
- package/client/dist/assets/Trackers-DZ9KqOWv.js +2 -0
- package/client/dist/assets/{Tv-ByVckozq.js → Tv-5mATEU4p.js} +1 -1
- package/client/dist/assets/{TvInfo-fJ9SFMK8.js → TvInfo-CF-5C5Fs.js} +1 -1
- package/client/dist/assets/{TvPopup-yfH7p0y0.js → TvPopup-Dfqifj3s.js} +1 -1
- package/client/dist/assets/{TvSearch-CBNWDByW.js → TvSearch-BWDwWnHe.js} +1 -1
- package/client/dist/assets/{TvWatchlist-BqjZ2-4A.js → TvWatchlist-D3n5_4MI.js} +1 -1
- package/client/dist/assets/{Watchlist-B_Q2hEiJ.js → Watchlist-B8MHNsAU.js} +1 -1
- package/client/dist/assets/{hls-kIF7bMpF.js → hls-BO_L1WoN.js} +1 -1
- package/client/dist/assets/index-D2RkUJIN.js +178 -0
- package/client/dist/assets/{useAnimeInfoData-BKSrS8eN.js → useAnimeInfoData-BWShDHqE.js} +1 -1
- package/client/dist/assets/{useAsmr-Byj8Qiwp.js → useAsmr-FyfD9G7b.js} +1 -1
- package/client/dist/assets/{useAsmrPopup-CLcWCCXf.js → useAsmrPopup-D_o9-QlR.js} +1 -1
- package/client/dist/assets/useMangaLibrary-BJU5x8Vz.js +1 -0
- package/client/dist/assets/{useMangaPopup-ZonOP5_c.js → useMangaPopup-TFzkC-tG.js} +1 -1
- package/client/dist/assets/{useProviders-CymA42ZB.js → useProviders-DDRxa8vl.js} +1 -1
- package/client/dist/assets/{useTv-CPj6Cf42.js → useTv-Bh6WmNnZ.js} +1 -1
- package/client/dist/assets/{useTvLibrary-COsyN8yu.js → useTvLibrary-_Pqm8re1.js} +1 -1
- package/client/dist/index.html +1 -1
- package/package.json +2 -2
- package/server/dist/controllers/data.controller.js +1 -0
- package/server/dist/controllers/insights.controller.js +3 -2
- package/server/dist/controllers/manga-library.controller.js +54 -0
- package/server/dist/controllers/music.controller.js +123 -50
- package/server/dist/controllers/watchlist.controller.js +13 -1
- package/server/dist/lib/anilist.js +36 -3
- package/server/dist/lib/mal.js +69 -0
- package/server/dist/lib/tracker/anilist-tracker.js +66 -0
- package/server/dist/lib/tracker/manga-sync.service.js +792 -0
- package/server/dist/lib/ytmusic.js +15 -2
- package/server/dist/repositories/manga.repository.js +17 -2
- package/server/dist/routes/manga-library.routes.js +2 -0
- package/server/dist/routes/tracker.routes.js +123 -0
- package/server/dist/sync.js +8 -1
- package/server/package.json +1 -1
- package/client/dist/assets/Insights-lQcQoeES.js +0 -1
- package/client/dist/assets/MangaInfoPage-Cat-B487.js +0 -1
- package/client/dist/assets/MangaReadPage-CyLRxzvl.js +0 -1
- package/client/dist/assets/Music-B1swlVLI.js +0 -1
- package/client/dist/assets/Player-SKPWb-o8.js +0 -56
- package/client/dist/assets/Trackers-DLcTEJJW.js +0 -2
- package/client/dist/assets/index-C65i4HWv.js +0 -178
- 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{
|
|
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-
|
|
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-
|
|
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-
|
|
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};
|
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-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.
|
|
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-
|
|
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
|
-
|
|
24
|
+
const gap = curr - prev;
|
|
25
|
+
if (Number.isFinite(gap) && gap > 0 && gap < 3600000) {
|
|
25
26
|
currentSessionSeconds += allWatches[i].effectiveSeconds ?? allWatches[i].currentTime;
|
|
26
27
|
}
|
|
27
28
|
else {
|
|
@@ -155,7 +156,7 @@ export class InsightsController {
|
|
|
155
156
|
genreData[genre].scores.push(score);
|
|
156
157
|
genreData[genre].showWatches[row.showId] =
|
|
157
158
|
(genreData[genre].showWatches[row.showId] || 0) + 1;
|
|
158
|
-
if (row.name
|
|
159
|
+
if (row.name || row.thumbnail) {
|
|
159
160
|
genreData[genre].showMeta[row.showId] = {
|
|
160
161
|
name: row.name,
|
|
161
162
|
nativeName: row.nativeName,
|
|
@@ -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
|
|
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,
|
|
559
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
|
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,
|
|
656
|
-
|
|
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
|
-
|
|
669
|
-
const actions =
|
|
670
|
-
|
|
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
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
750
|
+
};
|
|
751
|
+
try {
|
|
752
|
+
await attempt(yt);
|
|
692
753
|
}
|
|
693
754
|
catch (err) {
|
|
694
|
-
logger.
|
|
695
|
-
|
|
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, {
|
|
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:
|
|
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);
|
package/server/dist/lib/mal.js
CHANGED
|
@@ -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();
|