@serifpersia/dango 3.6.7 → 3.6.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 (156) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-C3P9zWi9.js → AnimeCard-DKDJK8Mp.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-Et6687Tl.js → AnimeInfo-CTcvfjva.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-DX4eNnlH.js → AnimeInfoPage-B_UiacIN.js} +2 -2
  5. package/client/dist/assets/{Asmr-CCdEWrFi.js → Asmr-DMSN2vxl.js} +2 -2
  6. package/client/dist/assets/{AsmrResetProgressModal-DgEWBJOw.js → AsmrResetProgressModal-CRwfOatX.js} +1 -1
  7. package/client/dist/assets/{AvSyncCalibrator-CQ2fjf2E.js → AvSyncCalibrator-CRJsi-vz.js} +1 -1
  8. package/client/dist/assets/{Home-DUkUTTTy.js → Home-pwmK9_Eh.js} +1 -1
  9. package/client/dist/assets/{Insights-QCh4xW12.js → Insights-m7kFzg2A.js} +1 -1
  10. package/client/dist/assets/{LibraryListPage-BoTxRqHz.js → LibraryListPage-C1wpPvu6.js} +1 -1
  11. package/client/dist/assets/ListeningList-OzfEI1Ga.js +1 -0
  12. package/client/dist/assets/{Manga-HydR-L7o.js → Manga-ChRCExhK.js} +1 -1
  13. package/client/dist/assets/{MangaInfoPage-CBakEI9a.js → MangaInfoPage-DSHJPo2A.js} +1 -1
  14. package/client/dist/assets/{MangaReadPage-DwAR3iPy.js → MangaReadPage-CzOVY1wA.js} +1 -1
  15. package/client/dist/assets/{MangaResetProgressModal-DGzl8vsO.js → MangaResetProgressModal-CJfzdUBb.js} +1 -1
  16. package/client/dist/assets/{Mature-Pi0TxKuF.js → Mature-CifQMfhs.js} +1 -1
  17. package/client/dist/assets/{MediaCard--cuFxOCS.js → MediaCard-CM10yB9V.js} +1 -1
  18. package/client/dist/assets/{OptionListMenu-B3D39dGS.js → OptionListMenu-6Kovbsqw.js} +1 -1
  19. package/client/dist/assets/{Player-CsH4x_I_.js → Player-CK0hT-u4.js} +2 -2
  20. package/client/dist/assets/{PlayerSettings-DGxBsi3F.js → PlayerSettings-Bb66uqT9.js} +1 -1
  21. package/client/dist/assets/{QueueOptionsButton-Djq4Cpex.js → QueueOptionsButton-C_nTj8_q.js} +1 -1
  22. package/client/dist/assets/{QueueRail-DOrYgNVX.js → QueueRail-DUK7URAE.js} +1 -1
  23. package/client/dist/assets/{ReadingList-DwYoBrNk.js → ReadingList-Ck9LJThU.js} +1 -1
  24. package/client/dist/assets/{ResetProgressModal-CMF86mX6.js → ResetProgressModal-BUphIv0k.js} +1 -1
  25. package/client/dist/assets/{Search-DFkhuk-x.js → Search-zJyP15h6.js} +1 -1
  26. package/client/dist/assets/{Settings-DBB2r0VQ.js → Settings-BYdoSEUh.js} +1 -1
  27. package/client/dist/assets/{SkeletonGrid-DJelJgAv.js → SkeletonGrid-CqgbOhjX.js} +1 -1
  28. package/client/dist/assets/{Trackers-DGYXFhYZ.js → Trackers-BVjyv6CM.js} +1 -1
  29. package/client/dist/assets/{Tv-C5MDGD2Z.js → Tv-L13ylswR.js} +1 -1
  30. package/client/dist/assets/{TvInfo-DrKuK13u.js → TvInfo-Cvcm5U2D.js} +1 -1
  31. package/client/dist/assets/{TvPopup-DtHilRDT.js → TvPopup-DhxfKvYm.js} +1 -1
  32. package/client/dist/assets/{TvSearch-CzNxdu_w.js → TvSearch-P8bFE6_X.js} +1 -1
  33. package/client/dist/assets/{TvWatchlist-D7p4br6S.js → TvWatchlist-D5gVPjtb.js} +1 -1
  34. package/client/dist/assets/{Watchlist-DrOzswl2.js → Watchlist-Cjq8l3rU.js} +1 -1
  35. package/client/dist/assets/{hls-BGCX7mJy.js → hls-BkzuANAg.js} +1 -1
  36. package/client/dist/assets/{index-CnfZwsds.js → index-CLVgzKLk.js} +19 -19
  37. package/client/dist/assets/{useAnimeInfoData-DYXWBPF-.js → useAnimeInfoData-DZJZu-so.js} +1 -1
  38. package/client/dist/assets/{useAsmr-BbhQ59bk.js → useAsmr-CvrMGHuo.js} +1 -1
  39. package/client/dist/assets/useAsmrPopup-Bimy8Ouw.js +1 -0
  40. package/client/dist/assets/{useMangaLibrary-DuesWQaE.js → useMangaLibrary--MiN11uR.js} +1 -1
  41. package/client/dist/assets/{useMangaPopup-D57OVGUr.js → useMangaPopup-CR5XhKKS.js} +1 -1
  42. package/client/dist/assets/{useProviders-mpQb_dix.js → useProviders-DqdT4fA5.js} +1 -1
  43. package/client/dist/assets/{useTv-DGflCsSI.js → useTv-BL0HMDyP.js} +1 -1
  44. package/client/dist/assets/{useTvLibrary-BqeSmyc4.js → useTvLibrary-D4U4iqN8.js} +1 -1
  45. package/client/dist/index.html +1 -1
  46. package/package.json +3 -5
  47. package/server/dist/config.js +1 -174
  48. package/server/dist/server.js +1068 -289
  49. package/server/dist/shipped-defaults.js +1 -6
  50. package/server/package.json +4 -5
  51. package/client/dist/assets/Asmr.module-CdWlioTY.js +0 -1
  52. package/client/dist/assets/ListeningList-DguKjI_t.js +0 -1
  53. package/client/dist/assets/Music-BGH_0voi.css +0 -1
  54. package/client/dist/assets/Music-CQpyXTLH.js +0 -1
  55. package/client/dist/assets/Radio-CCHNFN7R.css +0 -1
  56. package/client/dist/assets/Radio-DF6g6odd.js +0 -1
  57. package/client/dist/assets/Radio.module-DGSZLpKM.js +0 -1
  58. package/client/dist/assets/useAsmrPopup-SymV1J-b.js +0 -1
  59. package/server/dist/app-auth.js +0 -157
  60. package/server/dist/app-hono.js +0 -598
  61. package/server/dist/db/drizzle.js +0 -16
  62. package/server/dist/db/schema-anime.js +0 -61
  63. package/server/dist/db/schema-asmr.js +0 -13
  64. package/server/dist/db/schema-manga.js +0 -31
  65. package/server/dist/db/schema-tv.js +0 -18
  66. package/server/dist/db.js +0 -217
  67. package/server/dist/discord-gateway.js +0 -385
  68. package/server/dist/discord-rpc.js +0 -413
  69. package/server/dist/github-sync.js +0 -508
  70. package/server/dist/google.js +0 -741
  71. package/server/dist/hono/asmr.js +0 -10
  72. package/server/dist/hono/auth.js +0 -49
  73. package/server/dist/hono/music.js +0 -209
  74. package/server/dist/hono/proxy.js +0 -827
  75. package/server/dist/hono/tv.js +0 -200
  76. package/server/dist/hono/watchlist.js +0 -341
  77. package/server/dist/lib/anilist.js +0 -1583
  78. package/server/dist/lib/anischedule.js +0 -191
  79. package/server/dist/lib/insights.js +0 -185
  80. package/server/dist/lib/ipc.js +0 -23
  81. package/server/dist/lib/kitsu.js +0 -517
  82. package/server/dist/lib/mal-import.js +0 -291
  83. package/server/dist/lib/mal.js +0 -957
  84. package/server/dist/lib/migration.js +0 -310
  85. package/server/dist/lib/offline-db.js +0 -395
  86. package/server/dist/lib/potoken.js +0 -135
  87. package/server/dist/lib/recommendations.service.js +0 -216
  88. package/server/dist/lib/temp-ids.js +0 -3
  89. package/server/dist/lib/tmdb.js +0 -72
  90. package/server/dist/lib/tracker/anilist-tracker.js +0 -308
  91. package/server/dist/lib/tracker/manga-sync.service.js +0 -773
  92. package/server/dist/lib/tracker/sync-state.js +0 -21
  93. package/server/dist/lib/tracker/sync.service.js +0 -476
  94. package/server/dist/lib/translate.js +0 -79
  95. package/server/dist/lib/ump.js +0 -106
  96. package/server/dist/lib/ytmusic.js +0 -162
  97. package/server/dist/logger.js +0 -16
  98. package/server/dist/providers/manga/manga.types.js +0 -1
  99. package/server/dist/providers/provider.interface.js +0 -1
  100. package/server/dist/providers/remote-loader.js +0 -614
  101. package/server/dist/providers/remote-types.js +0 -159
  102. package/server/dist/providers/title-matching.js +0 -226
  103. package/server/dist/providers/tv.types.js +0 -1
  104. package/server/dist/rclone.js +0 -120
  105. package/server/dist/repositories/asmr.repository.js +0 -77
  106. package/server/dist/repositories/insights.repository.js +0 -106
  107. package/server/dist/repositories/library.repository.js +0 -94
  108. package/server/dist/repositories/mal-cache.repository.js +0 -52
  109. package/server/dist/repositories/manga.repository.js +0 -95
  110. package/server/dist/repositories/notifications.repository.js +0 -60
  111. package/server/dist/repositories/queue.repository.js +0 -72
  112. package/server/dist/repositories/settings.repository.js +0 -30
  113. package/server/dist/repositories/shows-meta.repository.js +0 -58
  114. package/server/dist/repositories/temp-show-ids.repository.js +0 -63
  115. package/server/dist/repositories/tv.repository.js +0 -93
  116. package/server/dist/repositories/watched-episodes.repository.js +0 -82
  117. package/server/dist/repositories/watchlist.repository.js +0 -60
  118. package/server/dist/scripts/backfill-episode-duration.js +0 -42
  119. package/server/dist/sync-payload.js +0 -91
  120. package/server/dist/sync.js +0 -657
  121. package/server/dist/trpc/context.js +0 -7
  122. package/server/dist/trpc/errors.js +0 -15
  123. package/server/dist/trpc/index.js +0 -18
  124. package/server/dist/trpc/router.js +0 -49
  125. package/server/dist/trpc/routers/asmr-library.js +0 -112
  126. package/server/dist/trpc/routers/asmr-progress.js +0 -121
  127. package/server/dist/trpc/routers/asmr.js +0 -87
  128. package/server/dist/trpc/routers/auth.js +0 -194
  129. package/server/dist/trpc/routers/continue-watching.js +0 -81
  130. package/server/dist/trpc/routers/data.js +0 -1026
  131. package/server/dist/trpc/routers/discord.js +0 -285
  132. package/server/dist/trpc/routers/discovery.js +0 -11
  133. package/server/dist/trpc/routers/insights.js +0 -14
  134. package/server/dist/trpc/routers/manga-library.js +0 -263
  135. package/server/dist/trpc/routers/manga-progress.js +0 -169
  136. package/server/dist/trpc/routers/manga.js +0 -201
  137. package/server/dist/trpc/routers/music.js +0 -655
  138. package/server/dist/trpc/routers/notifications.js +0 -80
  139. package/server/dist/trpc/routers/progress.js +0 -109
  140. package/server/dist/trpc/routers/queue.js +0 -93
  141. package/server/dist/trpc/routers/radio.js +0 -145
  142. package/server/dist/trpc/routers/settings.js +0 -96
  143. package/server/dist/trpc/routers/tracker.js +0 -213
  144. package/server/dist/trpc/routers/tv-library.js +0 -135
  145. package/server/dist/trpc/routers/tv-progress.js +0 -159
  146. package/server/dist/trpc/routers/tv.js +0 -551
  147. package/server/dist/trpc/routers/watchlist.js +0 -550
  148. package/server/dist/trpc/validation.js +0 -258
  149. package/server/dist/utils/cache.utils.js +0 -25
  150. package/server/dist/utils/cookie.utils.js +0 -13
  151. package/server/dist/utils/db-utils.js +0 -17
  152. package/server/dist/utils/env.utils.js +0 -77
  153. package/server/dist/utils/http.utils.js +0 -82
  154. package/server/dist/utils/request-context.js +0 -2
  155. package/server/dist/utils/security.utils.js +0 -76
  156. /package/client/dist/assets/{Asmr-D1KuQwQy.css → useAsmrPopup-D1KuQwQy.css} +0 -0
@@ -1,957 +0,0 @@
1
- import { kitsuTitlesByMalIds } from './kitsu.js';
2
- import { parseJsonBody } from '../utils/http.utils.js';
3
- const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
4
- const MIN_GAP_MS = 1500;
5
- const TIMEOUT_MS = 25000;
6
- const DEFAULT_TTL_SECONDS = 6 * 3600;
7
- let lastFetchAt = 0;
8
- async function politeWait() {
9
- const wait = MIN_GAP_MS - (Date.now() - lastFetchAt);
10
- if (wait > 0)
11
- await new Promise((r) => setTimeout(r, wait));
12
- lastFetchAt = Date.now();
13
- }
14
- async function fetchHtml(url) {
15
- await politeWait();
16
- const res = await fetch(url, {
17
- headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml' },
18
- signal: AbortSignal.timeout(TIMEOUT_MS),
19
- redirect: 'follow',
20
- });
21
- const html = await res.text();
22
- return { status: res.status, html, url: res.url };
23
- }
24
- function decodeHtml(s) {
25
- return s
26
- .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
27
- .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
28
- .replace(/&/g, '&')
29
- .replace(/"/g, '"')
30
- .replace(/&#0?39;|'/g, "'")
31
- .replace(/&lt;/g, '<')
32
- .replace(/&gt;/g, '>')
33
- .trim();
34
- }
35
- function firstImage(chunk) {
36
- const m = chunk.match(/\bdata-src=["']([^"']+)["']/i) || chunk.match(/\bsrc=["']((?!data:)[^"']+)["']/i);
37
- if (!m)
38
- return null;
39
- const raw = m[1].replace(/&amp;/g, '&');
40
- const unwrapped = raw.match(/[?&]src=([^&]+)/i);
41
- let url = raw;
42
- if (unwrapped) {
43
- try {
44
- url = decodeURIComponent(unwrapped[1]);
45
- }
46
- catch {
47
- url = unwrapped[1];
48
- }
49
- }
50
- if (/^https:\/\/cdn\.myanimelist\.net\/r\/\d+x\d+\//i.test(url)) {
51
- url = url.replace(/\/r\/\d+x\d+\//i, '/').split('?')[0];
52
- }
53
- return url;
54
- }
55
- function numeric(raw) {
56
- if (!raw)
57
- return null;
58
- const cleaned = raw.replace(/,/g, '').trim();
59
- if (/^(unknown|n\/a|-)$/i.test(cleaned))
60
- return null;
61
- const n = Number(cleaned);
62
- return Number.isFinite(n) ? n : null;
63
- }
64
- function parseSearchCard(chunk) {
65
- const idMatch = chunk.match(/id="sarea(\d+)"/);
66
- if (!idMatch)
67
- return null;
68
- const malId = Number(idMatch[1]);
69
- const href = chunk.match(new RegExp(`href="(https://myanimelist\\.net/anime/${malId}/[^"]*)"`, 'i'))?.[1];
70
- const title = decodeHtml(chunk.match(/<strong>([^<]+)<\/strong>/i)?.[1] ?? '');
71
- if (!malId || !title)
72
- return null;
73
- const synopsisRaw = chunk.match(/class="pt4">([\s\S]*?)<a\s/i)?.[1] ?? null;
74
- const synopsis = synopsisRaw
75
- ? decodeHtml(synopsisRaw)
76
- .replace(/\s*read more\.?$/i, '')
77
- .replace(/\.{3}$/, '')
78
- .trim() || null
79
- : null;
80
- const cells = [
81
- ...chunk.matchAll(/<td class="borderClass ac bgColor\d"[^>]*>\s*([\s\S]*?)\s*<\/td>/gi),
82
- ].map((m) => decodeHtml(m[1]));
83
- return {
84
- id: -malId,
85
- idMal: malId,
86
- url: href ?? `https://myanimelist.net/anime/${malId}`,
87
- title,
88
- imageUrl: firstImage(chunk),
89
- synopsis,
90
- type: cells[0] || null,
91
- episodes: numeric(cells[1]),
92
- score: numeric(cells[2]),
93
- };
94
- }
95
- export function parseSearchResults(html) {
96
- if (!html.includes('Anime - MyAnimeList.net'))
97
- return [];
98
- const out = [];
99
- for (const chunk of html.split('<tr>').slice(1)) {
100
- const entry = parseSearchCard(chunk);
101
- if (entry && !out.some((e) => e.idMal === entry.idMal))
102
- out.push(entry);
103
- }
104
- return out;
105
- }
106
- function metaContent(html, property) {
107
- const m = html.match(new RegExp(`<meta\\s+property=["']${property}["']\\s+content=["']([^"']*)["']`, 'i'));
108
- return m ? decodeHtml(m[1]) : null;
109
- }
110
- function infoField(html, label) {
111
- const m = html.match(new RegExp(`<span class="dark_text">${label}:</span>\\s*([^<]*?)(?:<|$)`, 'i'));
112
- return m ? decodeHtml(m[1]).trim() || null : null;
113
- }
114
- export function parseDetail(html, malId) {
115
- const title = metaContent(html, 'og:title') || decodeHtml(html.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ?? '');
116
- if (!title)
117
- return null;
118
- if (/^(404\b|not found\b)/i.test(title.trim()))
119
- return null;
120
- const rawImage = metaContent(html, 'og:image');
121
- const imageUrl = rawImage && !/apple-touch-icon|404|error/i.test(rawImage) ? rawImage : null;
122
- const genres = [...html.matchAll(/\/anime\/genre\/\d+\/[^"']*"[^>]*>([^<]+)</gi)]
123
- .map((m) => decodeHtml(m[1]).trim())
124
- .filter((g, i, a) => g && a.indexOf(g) === i)
125
- .slice(0, 10);
126
- return {
127
- id: -malId,
128
- idMal: malId,
129
- url: metaContent(html, 'og:url') || `https://myanimelist.net/anime/${malId}`,
130
- title,
131
- titleEnglish: infoField(html, 'English'),
132
- titleJapanese: infoField(html, 'Japanese'),
133
- imageUrl,
134
- synopsis: metaContent(html, 'og:description'),
135
- type: infoField(html, 'Type'),
136
- episodes: numeric(infoField(html, 'Episodes')),
137
- status: infoField(html, 'Status'),
138
- score: numeric(infoField(html, 'Score')),
139
- rating: infoField(html, 'Rating'),
140
- genres,
141
- };
142
- }
143
- function detailToSearchEntry(d) {
144
- return {
145
- id: d.id,
146
- idMal: d.idMal,
147
- url: d.url,
148
- title: d.title,
149
- imageUrl: d.imageUrl,
150
- synopsis: d.synopsis,
151
- type: d.type,
152
- episodes: d.episodes,
153
- score: d.score,
154
- };
155
- }
156
- function mapMalStatus(status) {
157
- if (!status)
158
- return undefined;
159
- const s = status.toUpperCase().replace(/\s+/g, '_');
160
- if (s.includes('FINISHED'))
161
- return 'FINISHED';
162
- if (s.includes('CURRENTLY_AIRING') || s === 'AIRING')
163
- return 'RELEASING';
164
- if (s.includes('NOT_YET_AIRED') || s === 'UPCOMING')
165
- return 'NOT_YET_RELEASED';
166
- return s;
167
- }
168
- export function toAnilistSearchMedia(e) {
169
- return {
170
- id: e.id,
171
- idMal: e.idMal,
172
- title: { romaji: e.title, english: e.title, native: e.title },
173
- coverImage: e.imageUrl
174
- ? { extraLarge: e.imageUrl, large: e.imageUrl, medium: e.imageUrl }
175
- : undefined,
176
- description: e.synopsis,
177
- averageScore: e.score != null ? Math.round(e.score * 10) : null,
178
- format: e.type?.toUpperCase(),
179
- episodes: e.episodes,
180
- siteUrl: e.url,
181
- };
182
- }
183
- export function toAnilistDetailMedia(e) {
184
- const rating = e.rating || '';
185
- return {
186
- id: e.id,
187
- idMal: e.idMal,
188
- title: {
189
- romaji: e.title,
190
- english: e.titleEnglish || e.title,
191
- native: e.titleJapanese || e.title,
192
- },
193
- coverImage: e.imageUrl
194
- ? { extraLarge: e.imageUrl, large: e.imageUrl, medium: e.imageUrl }
195
- : undefined,
196
- description: e.synopsis,
197
- genres: e.genres.length > 0 ? e.genres : undefined,
198
- averageScore: e.score != null ? Math.round(e.score * 10) : null,
199
- format: e.type?.toUpperCase(),
200
- status: mapMalStatus(e.status),
201
- episodes: e.episodes,
202
- isAdult: rating.includes('Rx') || rating.includes('R+'),
203
- siteUrl: e.url,
204
- };
205
- }
206
- export async function malSearchAnime(store, query, page = 1) {
207
- const key = `mal:search:anime:${query.toLowerCase()}:page:${page}`;
208
- const hit = store.get(key);
209
- if (hit && hit.fresh) {
210
- return { entries: JSON.parse(hit.payload), cached: true, ms: 0 };
211
- }
212
- const t0 = Date.now();
213
- const show = (page - 1) * 50;
214
- const params = new URLSearchParams({ q: query, cat: 'anime' });
215
- if (show > 0)
216
- params.set('show', String(show));
217
- const res = await fetchHtml(`https://myanimelist.net/anime.php?${params.toString()}`);
218
- const ms = Date.now() - t0;
219
- if (res.status !== 200) {
220
- if (hit)
221
- return { entries: JSON.parse(hit.payload), cached: true, ms };
222
- throw new Error(`MAL search failed with status ${res.status}`);
223
- }
224
- const detailMatch = res.url.match(/myanimelist\.net\/anime\/(\d+)/i);
225
- if (detailMatch && !res.html.includes('Search Anime - MyAnimeList.net')) {
226
- const detail = parseDetail(res.html, Number(detailMatch[1]));
227
- const entries = detail ? [detailToSearchEntry(detail)] : [];
228
- if (entries.length > 0)
229
- store.put(key, JSON.stringify(entries), DEFAULT_TTL_SECONDS);
230
- return { entries, cached: false, ms };
231
- }
232
- const entries = parseSearchResults(res.html);
233
- store.put(key, JSON.stringify(entries), DEFAULT_TTL_SECONDS);
234
- return { entries, cached: false, ms };
235
- }
236
- export async function malAnimeDetail(store, malId) {
237
- const key = `mal:detail:anime:${malId}`;
238
- const hit = store.get(key);
239
- if (hit && hit.fresh) {
240
- return { detail: JSON.parse(hit.payload), cached: true, ms: 0 };
241
- }
242
- const t0 = Date.now();
243
- const { status, html } = await fetchHtml(`https://myanimelist.net/anime/${malId}`);
244
- const ms = Date.now() - t0;
245
- if (status !== 200) {
246
- if (hit)
247
- return { detail: JSON.parse(hit.payload), cached: true, ms };
248
- throw new Error(`MAL detail failed with status ${status}`);
249
- }
250
- const detail = parseDetail(html, malId);
251
- if (detail)
252
- store.put(key, JSON.stringify(detail), DEFAULT_TTL_SECONDS);
253
- return { detail, cached: false, ms };
254
- }
255
- const MAL_TYPE_IDS = {
256
- tv: '1',
257
- ova: '2',
258
- movie: '3',
259
- special: '4',
260
- ona: '5',
261
- music: '6',
262
- };
263
- const MAL_STATUS_IDS = {
264
- airing: '1',
265
- complete: '2',
266
- finished: '2',
267
- upcoming: '3',
268
- hiatus: '3',
269
- cancelled: undefined,
270
- not_yet_released: '3',
271
- };
272
- const MAL_ORDER_IDS = {
273
- start_date: '2',
274
- score: '3',
275
- episodes: '4',
276
- end_date: '5',
277
- type: '6',
278
- members: '7',
279
- rating: '8',
280
- mal_id: '9',
281
- };
282
- const ORDER_BY_MAP = {
283
- POPULARITY_DESC: 'members',
284
- SCORE_DESC: 'score',
285
- TRENDING_DESC: 'members',
286
- START_DATE_DESC: 'start_date',
287
- END_DATE_DESC: 'end_date',
288
- EPISODES_DESC: 'episodes',
289
- FAVOURITES_DESC: 'members',
290
- UPDATED_AT_DESC: 'start_date',
291
- };
292
- const SEASON_MONTHS = {
293
- WINTER: [1, 2, 3],
294
- SPRING: [4, 5, 6],
295
- SUMMER: [7, 8, 9],
296
- FALL: [10, 11, 12],
297
- };
298
- const SEASON_START_MONTH = {
299
- WINTER: '01',
300
- SPRING: '04',
301
- SUMMER: '07',
302
- FALL: '10',
303
- };
304
- const MONTH_INDEX = {
305
- jan: 1,
306
- january: 1,
307
- feb: 2,
308
- february: 2,
309
- mar: 3,
310
- march: 3,
311
- apr: 4,
312
- april: 4,
313
- may: 5,
314
- jun: 6,
315
- june: 6,
316
- jul: 7,
317
- july: 7,
318
- aug: 8,
319
- august: 8,
320
- sep: 9,
321
- sept: 9,
322
- september: 9,
323
- oct: 10,
324
- october: 10,
325
- nov: 11,
326
- november: 11,
327
- dec: 12,
328
- december: 12,
329
- };
330
- const SCHEDULE_DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
331
- const CARD_TYPE_BY_ID = {
332
- '0': 'Unknown',
333
- '1': 'TV',
334
- '2': 'OVA',
335
- '3': 'Movie',
336
- '4': 'Special',
337
- '5': 'ONA',
338
- '9': 'TV Special',
339
- };
340
- async function resolveGenreIds(store, genre) {
341
- const cached = store.get('taxonomy:anime:genres');
342
- let map;
343
- if (cached && cached.fresh) {
344
- map = JSON.parse(cached.payload);
345
- }
346
- else {
347
- const { status, html } = await fetchHtml('https://myanimelist.net/anime.php?cat=genre');
348
- if (status !== 200) {
349
- if (cached) {
350
- map = JSON.parse(cached.payload);
351
- }
352
- else {
353
- return null;
354
- }
355
- }
356
- else {
357
- map = {};
358
- for (const m of html.matchAll(/\/anime\/genre\/(\d+)\/[^"']*"[^>]*>([^<]+)</gi)) {
359
- map[decodeHtml(m[2]).trim().toLowerCase()] = Number(m[1]);
360
- }
361
- store.put('taxonomy:anime:genres', JSON.stringify(map), 24 * 3600);
362
- }
363
- }
364
- const ids = [];
365
- for (const part of genre.split(',')) {
366
- const name = part.trim().toLowerCase();
367
- if (!name)
368
- continue;
369
- if (/^\d+$/.test(name)) {
370
- ids.push(parseInt(name, 10));
371
- continue;
372
- }
373
- const id = map[name];
374
- if (id == null)
375
- return null;
376
- ids.push(id);
377
- }
378
- return ids.length > 0 ? [...new Set(ids)].join(',') : null;
379
- }
380
- function parseSeasonalCard(chunk) {
381
- const link = chunk.match(/<a href="https:\/\/myanimelist\.net\/anime\/(\d+)\/[^"]*" class="link-title">([^<]+)<\/a>/i);
382
- if (!link)
383
- return null;
384
- const typeId = chunk.slice(0, 200).match(/js-anime-type-(\d+)/)?.[1];
385
- return {
386
- malId: Number(link[1]),
387
- title: decodeHtml(link[2]),
388
- imageUrl: firstImage(chunk),
389
- score: numeric(chunk.match(/class="js-score">([\d.]+)<\/span>/i)?.[1] ?? null),
390
- type: typeId === undefined ? null : (CARD_TYPE_BY_ID[typeId] ?? null),
391
- episodes: numeric(chunk.match(/<span>(\d+)\s*eps?<\/span>/i)?.[1] ?? null),
392
- startDate: chunk.match(/class="js-start_date">(\d+)<\/span>/i)?.[1] ?? null,
393
- members: numeric(chunk.match(/class="js-members">(\d+)<\/span>/i)?.[1] ?? null),
394
- genreIds: (chunk.match(/data-genre="([\d,]+)"/i)?.[1] ?? '')
395
- .split(',')
396
- .map((n) => Number(n))
397
- .filter((n) => Number.isFinite(n) && n > 0),
398
- };
399
- }
400
- function parseSeasonalCards(html) {
401
- const out = new Map();
402
- for (const chunk of html.split('js-seasonal-anime').slice(1)) {
403
- const card = parseSeasonalCard(chunk);
404
- if (card)
405
- out.set(card.malId, card);
406
- }
407
- return [...out.values()];
408
- }
409
- function startDateParts(value) {
410
- const m = value?.match(/^(\d{4})(\d{2})(\d{2})/);
411
- if (m)
412
- return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
413
- const t = value?.match(/([A-Za-z]+)\s+(\d{4})/);
414
- if (t) {
415
- const month = MONTH_INDEX[t[1].toLowerCase()];
416
- if (month)
417
- return { year: Number(t[2]), month };
418
- }
419
- return {};
420
- }
421
- function cardToMedia(c) {
422
- const startDate = startDateParts(c.startDate);
423
- const cover = c.imageUrl || undefined;
424
- return {
425
- id: -c.malId,
426
- idMal: c.malId,
427
- title: { romaji: c.title, english: c.title, native: c.title },
428
- coverImage: cover ? { extraLarge: cover, large: cover, medium: cover } : undefined,
429
- description: null,
430
- averageScore: c.score != null ? Math.round(c.score * 10) : null,
431
- format: c.type?.toUpperCase(),
432
- episodes: c.episodes,
433
- seasonYear: startDate.year,
434
- startDate,
435
- popularity: c.members ?? undefined,
436
- isAdult: c.genreIds.includes(12),
437
- };
438
- }
439
- function cachedFetch(store, key, url) {
440
- const hit = store.get(key);
441
- if (hit && hit.fresh)
442
- return Promise.resolve({ html: hit.payload, cached: true });
443
- return fetchHtml(url).then(({ status, html }) => {
444
- if (status !== 200) {
445
- if (hit)
446
- return { html: hit.payload, cached: true };
447
- throw new Error(`MAL fetch failed with status ${status} for ${url}`);
448
- }
449
- store.put(key, html, DEFAULT_TTL_SECONDS);
450
- return { html, cached: false };
451
- });
452
- }
453
- const TITLE_TTL_SECONDS = 7 * 24 * 3600;
454
- const EN_TITLE_TTL_SECONDS = 7 * 24 * 3600;
455
- const EN_TITLE_FETCH_TIMEOUT_MS = 10000;
456
- const EN_TITLE_TOP_N = 10;
457
- export const EN_TITLE_CACHE_PREFIX = 'mal:en-title:';
458
- function needsEnglishTitle(m) {
459
- const romaji = m.title?.romaji?.trim() ?? '';
460
- const english = m.title?.english?.trim() ?? '';
461
- return !!m.idMal && (!english || (!!romaji && english === romaji));
462
- }
463
- function cleanEnTitle(raw, romaji) {
464
- const t = (raw ?? '').trim();
465
- if (!t || /^(unknown|n\/a|-)$/i.test(t))
466
- return null;
467
- if (romaji && t.toLowerCase() === romaji.trim().toLowerCase())
468
- return null;
469
- return t;
470
- }
471
- export async function enrichEnglishFromMalDetail(store, items, limit = EN_TITLE_TOP_N) {
472
- const targets = items.filter(needsEnglishTitle).slice(0, Math.max(0, limit));
473
- if (targets.length === 0)
474
- return items;
475
- await Promise.all(targets.map(async (m) => {
476
- const malId = m.idMal;
477
- const key = `${EN_TITLE_CACHE_PREFIX}${malId}`;
478
- try {
479
- const hit = store.get(key);
480
- if (hit && typeof hit.payload === 'string') {
481
- const titleEnglish = cleanEnTitle(hit.payload, m.title?.romaji);
482
- if (titleEnglish && m.title)
483
- m.title = { ...m.title, english: titleEnglish };
484
- return;
485
- }
486
- const res = await fetch(`https://myanimelist.net/anime/${malId}`, {
487
- headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml' },
488
- signal: AbortSignal.timeout(EN_TITLE_FETCH_TIMEOUT_MS),
489
- redirect: 'follow',
490
- });
491
- if (res.status !== 200)
492
- return;
493
- const html = await res.text();
494
- const raw = infoField(html, 'English');
495
- try {
496
- store.put(key, (raw ?? '').trim(), EN_TITLE_TTL_SECONDS);
497
- }
498
- catch {
499
- // ignore
500
- }
501
- const titleEnglish = cleanEnTitle(raw, m.title?.romaji);
502
- if (!titleEnglish)
503
- return;
504
- if (m.title)
505
- m.title = { ...m.title, english: titleEnglish };
506
- }
507
- catch {
508
- // ignore
509
- }
510
- }));
511
- return items;
512
- }
513
- export async function enrichCardTitles(store, items) {
514
- const cached = new Map();
515
- const missing = [];
516
- for (const m of items) {
517
- if (!m.idMal)
518
- continue;
519
- const hit = store.get(`mal:title:${m.idMal}`);
520
- if (hit) {
521
- try {
522
- cached.set(m.idMal, JSON.parse(hit.payload));
523
- continue;
524
- }
525
- catch {
526
- // ignore
527
- }
528
- }
529
- missing.push(m.idMal);
530
- }
531
- let fetched = new Map();
532
- if (missing.length > 0) {
533
- try {
534
- fetched = await kitsuTitlesByMalIds(missing);
535
- }
536
- catch {
537
- // ignore
538
- }
539
- for (const [id, t] of fetched) {
540
- store.put(`mal:title:${id}`, JSON.stringify(t), TITLE_TTL_SECONDS);
541
- }
542
- }
543
- for (const m of items) {
544
- if (!m.idMal)
545
- continue;
546
- const t = cached.get(m.idMal) ?? fetched.get(m.idMal);
547
- if (t?.english || t?.native) {
548
- m.title = {
549
- romaji: m.title?.romaji,
550
- english: t.english ?? m.title?.english,
551
- native: t.native ?? m.title?.native,
552
- };
553
- }
554
- }
555
- return items;
556
- }
557
- export async function malSearchMedia(store, options = {}, enrichTitles = true) {
558
- const { query, page = 1, format, status, season, seasonYear, genre, genre_in, genre_not_in, averageScore_greater, episodes_greater, isAdult, sort, } = options;
559
- const params = new URLSearchParams();
560
- const trimmedQuery = query?.trim();
561
- const hasQuery = !!trimmedQuery;
562
- if (trimmedQuery)
563
- params.set('q', trimmedQuery);
564
- params.set('cat', 'anime');
565
- if (page > 1)
566
- params.set('show', String((page - 1) * 50));
567
- const clientFormat = isAdult ? format : undefined;
568
- if (format && format !== 'ALL' && format !== 'ADULT' && !isAdult) {
569
- const typeId = MAL_TYPE_IDS[format.toLowerCase()];
570
- if (typeId)
571
- params.set('type', typeId);
572
- }
573
- if (status) {
574
- const statusId = MAL_STATUS_IDS[status.toLowerCase()];
575
- if (statusId)
576
- params.set('status', statusId);
577
- }
578
- const genreCombined = [
579
- ...(Array.isArray(genre) ? genre : typeof genre === 'string' ? genre.split(',') : []),
580
- ...(genre_in ?? []),
581
- ]
582
- .map((g) => g.trim())
583
- .filter(Boolean)
584
- .join(',');
585
- if (genreCombined) {
586
- const ids = await resolveGenreIds(store, genreCombined);
587
- if (!ids)
588
- return [];
589
- for (const id of ids.split(','))
590
- params.append('genre[]', id);
591
- }
592
- if (seasonYear) {
593
- const month = season && season !== 'ALL' ? (SEASON_START_MONTH[season.toUpperCase()] ?? '01') : '01';
594
- params.set('sm', String(Number(month)));
595
- params.set('sd', '1');
596
- params.set('sy', String(seasonYear));
597
- }
598
- if (averageScore_greater)
599
- params.set('score', String(averageScore_greater));
600
- if (isAdult) {
601
- params.set('r', '6');
602
- params.append('genre[]', '12');
603
- }
604
- const wantOrder = sort ? ORDER_BY_MAP[sort] || 'members' : undefined;
605
- const wantDir = sort && sort.includes('ASC') ? '2' : '1';
606
- if (wantOrder) {
607
- params.set('o', MAL_ORDER_IDS[wantOrder]);
608
- params.set('w', wantDir);
609
- }
610
- const url = `https://myanimelist.net/anime.php?${params.toString()}`;
611
- const key = `mal:search:${params.toString()}:page:${page}`;
612
- const finish = (list) => enrichTitles ? enrichCardTitles(store, list) : Promise.resolve(list);
613
- let serverSorted = false;
614
- let html;
615
- try {
616
- const hit = store.get(key);
617
- if (hit && hit.fresh) {
618
- return finish(filterMalSearch(parseSearchResults(hit.payload).map(toAnilistSearchMedia), {
619
- genre_not_in,
620
- episodes_greater,
621
- format: clientFormat,
622
- seasonYear,
623
- season,
624
- sort: serverSorted ? undefined : sort,
625
- hasQuery,
626
- }));
627
- }
628
- const res = await fetchHtml(url);
629
- if (res.status !== 200) {
630
- if (hit) {
631
- return finish(filterMalSearch(parseSearchResults(hit.payload).map(toAnilistSearchMedia), {
632
- genre_not_in,
633
- episodes_greater,
634
- format: clientFormat,
635
- seasonYear,
636
- season: undefined,
637
- sort: undefined,
638
- hasQuery,
639
- }));
640
- }
641
- throw new Error(`MAL search failed with status ${res.status}`);
642
- }
643
- const detailMatch = res.url.match(/myanimelist\.net\/anime\/(\d+)/i);
644
- if (detailMatch && !res.html.includes('Search Anime - MyAnimeList.net')) {
645
- const detail = parseDetail(res.html, Number(detailMatch[1]));
646
- const list = detail ? [toAnilistDetailMedia(detail)] : [];
647
- store.put(key, JSON.stringify(list.map((m) => m.idMal)));
648
- return list;
649
- }
650
- serverSorted = !!wantOrder;
651
- store.put(key, res.html, DEFAULT_TTL_SECONDS);
652
- html = res.html;
653
- }
654
- catch {
655
- const hit = store.get(key);
656
- if (hit) {
657
- try {
658
- const cached = JSON.parse(hit.payload);
659
- if (Array.isArray(cached) && cached.every((e) => typeof e === 'number')) {
660
- return [];
661
- }
662
- }
663
- catch {
664
- // ignore
665
- }
666
- return finish(filterMalSearch(parseSearchResults(hit.payload).map(toAnilistSearchMedia), {
667
- genre_not_in,
668
- episodes_greater,
669
- format: clientFormat,
670
- seasonYear,
671
- season: undefined,
672
- sort: undefined,
673
- hasQuery,
674
- }));
675
- }
676
- return [];
677
- }
678
- return finish(filterMalSearch(parseSearchResults(html).map(toAnilistSearchMedia), {
679
- genre_not_in,
680
- episodes_greater,
681
- format: clientFormat,
682
- seasonYear,
683
- season,
684
- sort: serverSorted ? undefined : sort,
685
- hasQuery,
686
- }));
687
- }
688
- function filterMalSearch(results, opts) {
689
- let out = results;
690
- if (opts.format && opts.format !== 'ALL' && opts.format !== 'ADULT') {
691
- const f = opts.format.toUpperCase();
692
- out = out.filter((m) => (m.format ?? '').toUpperCase() === f);
693
- }
694
- if (opts.episodes_greater != null) {
695
- out = out.filter((m) => (m.episodes ?? 0) > opts.episodes_greater);
696
- }
697
- if (opts.seasonYear && !opts.hasQuery) {
698
- out = out.filter((m) => m.startDate?.year === opts.seasonYear);
699
- }
700
- if (opts.season && opts.season !== 'ALL' && !opts.hasQuery) {
701
- const months = SEASON_MONTHS[opts.season.toUpperCase()] ?? [];
702
- out = out.filter((m) => m.startDate?.month != null && months.includes(m.startDate.month));
703
- }
704
- if (opts.sort &&
705
- !opts.sort.startsWith('POPULARITY_') &&
706
- !opts.sort.startsWith('TRENDING_') &&
707
- !opts.sort.startsWith('FAVOURITES_') &&
708
- !opts.sort.startsWith('START_DATE_') &&
709
- !opts.sort.startsWith('END_DATE_')) {
710
- const desc = !opts.sort.includes('ASC');
711
- const dir = desc ? -1 : 1;
712
- if (opts.sort.startsWith('SCORE_')) {
713
- const scoreOf = (m) => m.averageScore ?? m.meanScore ?? -1;
714
- out = [...out].sort((a, b) => (scoreOf(a) - scoreOf(b)) * dir);
715
- }
716
- else if (opts.sort.startsWith('EPISODES_')) {
717
- out = [...out].sort((a, b) => ((a.episodes ?? -1) - (b.episodes ?? -1)) * dir);
718
- }
719
- }
720
- return out;
721
- }
722
- export async function malSearchTitle(store, title) {
723
- const results = await malSearchMedia(store, { query: title, page: 1, perPage: 5 }, false);
724
- const first = results[0];
725
- if (!first?.idMal)
726
- return null;
727
- return `mal-${first.idMal}`;
728
- }
729
- export async function malTop(store, filter, page = 1) {
730
- const limit = (page - 1) * 50;
731
- const params = new URLSearchParams();
732
- if (filter)
733
- params.set('type', filter);
734
- if (limit > 0)
735
- params.set('limit', String(limit));
736
- const query = params.toString();
737
- const url = query
738
- ? `https://myanimelist.net/topanime.php?${query}`
739
- : 'https://myanimelist.net/topanime.php';
740
- const key = `mal:top:${filter ?? 'all'}:page:${page}`;
741
- const { html } = await cachedFetch(store, key, url);
742
- const out = [];
743
- for (const match of html.matchAll(/<tr class="ranking-list">([\s\S]*?)<\/tr>/gi)) {
744
- const row = match[1];
745
- const malId = Number(row.match(/href="https:\/\/myanimelist\.net\/anime\/(\d+)\//i)?.[1]);
746
- if (!malId)
747
- continue;
748
- const imgTag = row.match(/<img[^>]*alt="Anime: ([^"]+)"[^>]*>/i);
749
- const title = imgTag ? decodeHtml(imgTag[1]) : '';
750
- if (!title)
751
- continue;
752
- const info = row.match(/<div class="information[^"]*">([\s\S]*?)<\/div>/i)?.[1] ?? '';
753
- const segments = info.split(/<br\s*\/?>/i).map((s) => decodeHtml(s));
754
- const typeEpisodes = segments[0] ?? '';
755
- const dateText = segments[1] ?? '';
756
- const membersText = segments[2] ?? '';
757
- const dateMatch = dateText.match(/([A-Za-z]+)\s+(\d{4})/);
758
- const month = dateMatch ? MONTH_INDEX[dateMatch[1].toLowerCase()] : undefined;
759
- const score = numeric(row.match(/class="text on score-label[^"]*">([\d.]+)<\/span>/i)?.[1] ?? null);
760
- out.push({
761
- id: -malId,
762
- idMal: malId,
763
- title: { romaji: title, english: title, native: title },
764
- coverImage: undefined,
765
- description: null,
766
- averageScore: score != null ? Math.round(score * 10) : null,
767
- format: typeEpisodes.match(/^(\S+)/)?.[1]?.toUpperCase(),
768
- episodes: (() => {
769
- const m = typeEpisodes.match(/\((\d+)/);
770
- return m ? Number(m[1]) : null;
771
- })(),
772
- startDate: month != null && dateMatch ? { year: Number(dateMatch[2]), month } : undefined,
773
- popularity: numeric(membersText.match(/([\d,]+)/)?.[1] ?? null) ?? undefined,
774
- });
775
- const img = row.match(/<img[^>]*>/i)?.[0] ?? '';
776
- const imageUrl = firstImage(img);
777
- if (imageUrl) {
778
- const last = out[out.length - 1];
779
- last.coverImage = { extraLarge: imageUrl, large: imageUrl, medium: imageUrl };
780
- }
781
- }
782
- return enrichCardTitles(store, out);
783
- }
784
- export async function malSeasonal(store, season, year, format, page = 1, size = 14) {
785
- const key = `mal:season:${year}:${season.toLowerCase()}`;
786
- const { html } = await cachedFetch(store, key, `https://myanimelist.net/anime/season/${year}/${season.toLowerCase()}`);
787
- let all = parseSeasonalCards(html).map(cardToMedia);
788
- if (format && format !== 'ALL' && format !== 'ADULT') {
789
- const f = format.toUpperCase();
790
- all = all.filter((m) => (m.format ?? '').toUpperCase() === f);
791
- }
792
- all = [...all].sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0));
793
- const offset = (page - 1) * size;
794
- return enrichCardTitles(store, all.slice(offset, offset + size));
795
- }
796
- export async function malScheduleWeek(store) {
797
- const key = 'mal:schedule:week';
798
- const { html } = await cachedFetch(store, key, 'https://myanimelist.net/anime/season/schedule');
799
- const out = {};
800
- for (const section of html.split('class="anime-header">').slice(1)) {
801
- const day = section
802
- .slice(0, 40)
803
- .match(/^([A-Za-z]+)</)?.[1]
804
- ?.toLowerCase();
805
- if (!day || !SCHEDULE_DAYS.includes(day))
806
- continue;
807
- out[day] = parseSeasonalCards(section).map(cardToMedia);
808
- }
809
- return out;
810
- }
811
- const MAL_USER_LIST_PAGE_SIZE = 300;
812
- const MAL_USER_LIST_MAX_PAGES = 100;
813
- export async function fetchMalUserList(username) {
814
- const user = username.trim();
815
- if (!user || /[^A-Za-z0-9_-]/.test(user))
816
- throw new Error('Invalid MAL username');
817
- const entries = [];
818
- let offset = 0;
819
- for (let page = 0; page < MAL_USER_LIST_MAX_PAGES; page++) {
820
- const url = `https://myanimelist.net/animelist/${encodeURIComponent(user)}/load.json?status=7&offset=${offset}`;
821
- await politeWait();
822
- const res = await fetch(url, {
823
- headers: { 'User-Agent': UA, Accept: 'application/json' },
824
- signal: AbortSignal.timeout(TIMEOUT_MS),
825
- redirect: 'manual',
826
- });
827
- if (res.status === 400 || res.status === 404)
828
- throw new Error(`MAL user "${user}" not found`);
829
- if (res.status === 403 || res.status === 429) {
830
- throw new Error(`MAL blocked the request (HTTP ${res.status}) — try again later`);
831
- }
832
- if (res.status >= 300 && res.status < 400) {
833
- throw new Error(`MAL list for "${user}" is private or unavailable`);
834
- }
835
- if (!res.ok)
836
- throw new Error(`MAL list fetch failed (HTTP ${res.status})`);
837
- const data = await parseJsonBody(res);
838
- if (!Array.isArray(data) || data.length === 0)
839
- break;
840
- for (const item of data) {
841
- const rec = item;
842
- const malId = Number(rec.anime_id);
843
- if (!malId)
844
- continue;
845
- const titleRaw = typeof rec.anime_title === 'string' && rec.anime_title.trim() !== ''
846
- ? rec.anime_title
847
- : typeof rec.anime_title_eng === 'string'
848
- ? rec.anime_title_eng
849
- : '';
850
- entries.push({
851
- malId,
852
- title: decodeHtml(titleRaw),
853
- titleEnglish: typeof rec.anime_title_eng === 'string' && rec.anime_title_eng.trim() !== ''
854
- ? decodeHtml(rec.anime_title_eng)
855
- : null,
856
- status: Number(rec.status) || 0,
857
- score: Number(rec.score) || 0,
858
- watchedEpisodes: Number(rec.num_watched_episodes) || 0,
859
- totalEpisodes: Number(rec.anime_num_episodes) || 0,
860
- type: typeof rec.anime_media_type_string === 'string' ? rec.anime_media_type_string : null,
861
- imageUrl: typeof rec.anime_image_path === 'string' ? rec.anime_image_path : null,
862
- });
863
- }
864
- if (data.length < MAL_USER_LIST_PAGE_SIZE)
865
- break;
866
- offset += MAL_USER_LIST_PAGE_SIZE;
867
- }
868
- return entries;
869
- }
870
- export function mapMalMangaStatusCode(status) {
871
- switch (status) {
872
- case 1:
873
- return 'Reading';
874
- case 2:
875
- return 'Completed';
876
- case 3:
877
- return 'On-Hold';
878
- case 4:
879
- return 'Dropped';
880
- case 6:
881
- return 'Planned';
882
- default:
883
- return null;
884
- }
885
- }
886
- export async function fetchMalMangaList(username) {
887
- const user = username.trim();
888
- if (!user || /[^A-Za-z0-9_-]/.test(user))
889
- throw new Error('Invalid MAL username');
890
- const entries = [];
891
- let offset = 0;
892
- for (let page = 0; page < MAL_USER_LIST_MAX_PAGES; page++) {
893
- const url = `https://myanimelist.net/mangalist/${encodeURIComponent(user)}/load.json?status=7&offset=${offset}`;
894
- await politeWait();
895
- const res = await fetch(url, {
896
- headers: { 'User-Agent': UA, Accept: 'application/json' },
897
- signal: AbortSignal.timeout(TIMEOUT_MS),
898
- redirect: 'manual',
899
- });
900
- if (res.status === 400 || res.status === 404)
901
- throw new Error(`MAL user "${user}" not found`);
902
- if (res.status === 403 || res.status === 429) {
903
- throw new Error(`MAL blocked the request (HTTP ${res.status}) — try again later`);
904
- }
905
- if (res.status >= 300 && res.status < 400) {
906
- throw new Error(`MAL list for "${user}" is private or unavailable`);
907
- }
908
- if (!res.ok)
909
- throw new Error(`MAL list fetch failed (HTTP ${res.status})`);
910
- const data = await parseJsonBody(res);
911
- if (!Array.isArray(data) || data.length === 0)
912
- break;
913
- for (const item of data) {
914
- const rec = item;
915
- const malId = Number(rec.manga_id);
916
- if (!malId)
917
- continue;
918
- const titleRaw = typeof rec.manga_title === 'string' && rec.manga_title.trim() !== ''
919
- ? rec.manga_title
920
- : typeof rec.manga_title_eng === 'string'
921
- ? rec.manga_title_eng
922
- : '';
923
- entries.push({
924
- malId,
925
- title: decodeHtml(titleRaw),
926
- titleEnglish: typeof rec.manga_title_eng === 'string' && rec.manga_title_eng.trim() !== ''
927
- ? decodeHtml(rec.manga_title_eng)
928
- : null,
929
- status: Number(rec.status) || 0,
930
- chaptersRead: Number(rec.num_read_chapters ?? rec.num_chapters_read) || 0,
931
- });
932
- }
933
- if (data.length < MAL_USER_LIST_PAGE_SIZE)
934
- break;
935
- offset += MAL_USER_LIST_PAGE_SIZE;
936
- }
937
- return entries;
938
- }
939
- export async function malLatestReleases(store, format = 'TV', page = 1, size = 12) {
940
- const week = await malScheduleWeek(store);
941
- const seen = new Map();
942
- for (const day of SCHEDULE_DAYS) {
943
- for (const m of week[day] ?? []) {
944
- if (!seen.has(m.id))
945
- seen.set(m.id, m);
946
- }
947
- }
948
- let all = [...seen.values()];
949
- if (format && format !== 'ALL') {
950
- const f = format.toUpperCase();
951
- all = all.filter((m) => (m.format ?? '').toUpperCase() === f);
952
- }
953
- const rank = (m) => (m.startDate?.year ?? 0) * 10000 + (m.startDate?.month ?? 0) * 100 + (m.startDate?.day ?? 0);
954
- all.sort((a, b) => rank(b) - rank(a) || (b.popularity ?? 0) - (a.popularity ?? 0));
955
- const offset = (page - 1) * size;
956
- return enrichCardTitles(store, all.slice(offset, offset + size));
957
- }