@serifpersia/dango 3.3.9 → 3.4.0

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 (180) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-URCiiy5W.js → AnimeCard-CJy1SXH3.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-e3z3er9t.js → AnimeInfo-BoXlVvZ-.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-B7Djlmy_.js → AnimeInfoPage-BxbA_7AV.js} +2 -2
  5. package/client/dist/assets/Asmr--Zc8G9zv.js +2 -0
  6. package/client/dist/assets/AsmrResetProgressModal-CKYm-8cp.js +1 -0
  7. package/client/dist/assets/AvSyncCalibrator-3yjZS7s6.js +10 -0
  8. package/client/dist/assets/Home-rrhOGQcg.js +1 -0
  9. package/client/dist/assets/Insights-Bay90Yeg.js +1 -0
  10. package/client/dist/assets/{LibraryListPage-B0qWqhb_.js → LibraryListPage-HzuDavbb.js} +1 -1
  11. package/client/dist/assets/{ListeningList-CSqD8j1C.js → ListeningList-DYcQnnsG.js} +1 -1
  12. package/client/dist/assets/{Manga-Cg2YuObF.js → Manga-D8CT9aO0.js} +1 -1
  13. package/client/dist/assets/{MangaInfoPage-DcArhEsy.js → MangaInfoPage-DGK6CYp1.js} +1 -1
  14. package/client/dist/assets/MangaReadPage-B9XoDKF9.js +1 -0
  15. package/client/dist/assets/{MangaResetProgressModal-D_yT3d1v.js → MangaResetProgressModal-QjZyIAjl.js} +1 -1
  16. package/client/dist/assets/Mature-CHt_-Hz5.js +1 -0
  17. package/client/dist/assets/MediaCard-B5584Cq9.js +1 -0
  18. package/client/dist/assets/Music-HeLrEZ2d.js +1 -0
  19. package/client/dist/assets/OptionListMenu-CJ90CdwI.js +1 -0
  20. package/client/dist/assets/Player-BYtEax_z.js +56 -0
  21. package/client/dist/assets/PlayerSettings-4-cUAY4B.js +1 -0
  22. package/client/dist/assets/QueueOptionsButton-Ba0dg_82.js +1 -0
  23. package/client/dist/assets/{QueueRail-B7f1-e6i.js → QueueRail-O7f4RqjB.js} +1 -1
  24. package/client/dist/assets/Radio-DPZxQnyD.js +1 -0
  25. package/client/dist/assets/{ReadingList-BaIem8H1.js → ReadingList-n6rr8ygy.js} +1 -1
  26. package/client/dist/assets/{ResetProgressModal-DES5Y-Fl.js → ResetProgressModal-CcStlbhP.js} +1 -1
  27. package/client/dist/assets/Search-C2wrWOdy.js +1 -0
  28. package/client/dist/assets/Settings-CluUYs8U.js +21 -0
  29. package/client/dist/assets/{SkeletonGrid-BxfgjP4i.js → SkeletonGrid-CLEPY9nP.js} +1 -1
  30. package/client/dist/assets/Trackers-CTGPxs0V.js +2 -0
  31. package/client/dist/assets/Tv-BwHlDdi8.js +4 -0
  32. package/client/dist/assets/Tv-ZEA45gXK.css +1 -0
  33. package/client/dist/assets/TvInfo-CXnrLekr.js +1 -0
  34. package/client/dist/assets/TvPopup-BekfiNcX.js +1 -0
  35. package/client/dist/assets/{TvSearch-BWDwWnHe.js → TvSearch-DuqR-z3v.js} +1 -1
  36. package/client/dist/assets/{TvWatchlist-D3n5_4MI.js → TvWatchlist-CBvgFrA0.js} +1 -1
  37. package/client/dist/assets/Watchlist-mj8mTOam.js +1 -0
  38. package/client/dist/assets/{hls-BO_L1WoN.js → hls-Boje3IOE.js} +1 -1
  39. package/client/dist/assets/index-BXwhFWB_.js +178 -0
  40. package/client/dist/assets/useAnimeInfoData-DJ7A4B4T.js +1 -0
  41. package/client/dist/assets/useAsmr-DIwIsJXF.js +1 -0
  42. package/client/dist/assets/{useAsmrPopup-D_o9-QlR.js → useAsmrPopup-e4zNN9ig.js} +1 -1
  43. package/client/dist/assets/useMangaLibrary-C9Lci5N4.js +1 -0
  44. package/client/dist/assets/{useMangaPopup-TFzkC-tG.js → useMangaPopup-B8nnpvyi.js} +1 -1
  45. package/client/dist/assets/useProviders-B5Qr4I1y.js +1 -0
  46. package/client/dist/assets/useTv-Dm8MCYlL.js +1 -0
  47. package/client/dist/assets/useTvLibrary-B7jEk2Q7.js +1 -0
  48. package/client/dist/assets/{vendor-query-BXRECSI6.js → vendor-query-BUTye89G.js} +1 -1
  49. package/client/dist/index.html +2 -2
  50. package/orchestrator.js +1 -2
  51. package/package.json +2 -6
  52. package/server/dist/app-auth.js +9 -29
  53. package/server/dist/app-hono.js +621 -0
  54. package/server/dist/config.js +0 -1
  55. package/server/dist/db/drizzle.js +32 -0
  56. package/server/dist/db/schema-anime.js +64 -0
  57. package/server/dist/db/schema-asmr.js +25 -0
  58. package/server/dist/db/schema-manga.js +31 -0
  59. package/server/dist/db/schema-tv.js +33 -0
  60. package/server/dist/db.js +75 -4
  61. package/server/dist/discord-gateway.js +1 -1
  62. package/server/dist/google.js +0 -7
  63. package/server/dist/hono/asmr.js +1 -0
  64. package/server/dist/hono/auth.js +56 -0
  65. package/server/dist/hono/music.js +419 -0
  66. package/server/dist/{controllers/proxy.controller.js → hono/proxy.js} +390 -394
  67. package/server/dist/hono/tv.js +197 -0
  68. package/server/dist/hono/watchlist.js +733 -0
  69. package/server/dist/lib/anilist.js +3 -3
  70. package/server/dist/lib/insights.js +185 -0
  71. package/server/dist/lib/ipc.js +1 -2
  72. package/server/dist/lib/kitsu.js +1 -28
  73. package/server/dist/lib/mal-import.js +291 -0
  74. package/server/dist/lib/migration.js +20 -34
  75. package/server/dist/lib/offline-db.js +36 -36
  76. package/server/dist/lib/tracker/manga-sync.service.js +21 -21
  77. package/server/dist/lib/tracker/sync.service.js +20 -20
  78. package/server/dist/lib/translate.js +79 -0
  79. package/server/dist/lib/ytmusic.js +0 -2
  80. package/server/dist/providers/title-matching.js +0 -1
  81. package/server/dist/repositories/asmr.repository.js +90 -87
  82. package/server/dist/repositories/insights.repository.js +24 -13
  83. package/server/dist/repositories/library.repository.js +33 -93
  84. package/server/dist/repositories/manga.repository.js +108 -116
  85. package/server/dist/repositories/notifications.repository.js +50 -31
  86. package/server/dist/repositories/queue.repository.js +36 -25
  87. package/server/dist/repositories/settings.repository.js +18 -8
  88. package/server/dist/repositories/shows-meta.repository.js +54 -41
  89. package/server/dist/repositories/temp-show-ids.repository.js +43 -31
  90. package/server/dist/repositories/tv.repository.js +98 -115
  91. package/server/dist/repositories/watched-episodes.repository.js +52 -20
  92. package/server/dist/repositories/watchlist.repository.js +36 -48
  93. package/server/dist/server.js +59 -151
  94. package/server/dist/sync.js +41 -4
  95. package/server/dist/trpc/context.js +7 -0
  96. package/server/dist/trpc/errors.js +21 -0
  97. package/server/dist/trpc/index.js +19 -0
  98. package/server/dist/trpc/router.js +49 -0
  99. package/server/dist/trpc/routers/asmr-library.js +122 -0
  100. package/server/dist/trpc/routers/asmr-progress.js +133 -0
  101. package/server/dist/trpc/routers/asmr.js +87 -0
  102. package/server/dist/trpc/routers/auth.js +247 -0
  103. package/server/dist/trpc/routers/continue-watching.js +83 -0
  104. package/server/dist/trpc/routers/data.js +1039 -0
  105. package/server/dist/trpc/routers/discord.js +288 -0
  106. package/server/dist/trpc/routers/discovery.js +15 -0
  107. package/server/dist/trpc/routers/insights.js +45 -0
  108. package/server/dist/trpc/routers/manga-library.js +280 -0
  109. package/server/dist/trpc/routers/manga-progress.js +181 -0
  110. package/server/dist/trpc/routers/manga.js +201 -0
  111. package/server/dist/{controllers/music.controller.js → trpc/routers/music.js} +155 -288
  112. package/server/dist/trpc/routers/notifications.js +80 -0
  113. package/server/dist/trpc/routers/progress.js +15 -0
  114. package/server/dist/trpc/routers/queue.js +95 -0
  115. package/server/dist/{routes/radio.routes.js → trpc/routers/radio.js} +30 -23
  116. package/server/dist/trpc/routers/settings.js +105 -0
  117. package/server/dist/trpc/routers/tracker.js +219 -0
  118. package/server/dist/trpc/routers/tv-library.js +145 -0
  119. package/server/dist/trpc/routers/tv-progress.js +171 -0
  120. package/server/dist/trpc/routers/tv.js +565 -0
  121. package/server/dist/trpc/routers/watchlist.js +564 -0
  122. package/server/dist/trpc/validation.js +159 -0
  123. package/server/dist/utils/security.utils.js +0 -18
  124. package/server/package.json +5 -10
  125. package/client/dist/assets/Asmr-C28TfSvX.js +0 -2
  126. package/client/dist/assets/AsmrResetProgressModal-6Xh71AgR.js +0 -1
  127. package/client/dist/assets/AvSyncCalibrator-BwCtRW0P.js +0 -10
  128. package/client/dist/assets/Home-BJdbHYja.js +0 -1
  129. package/client/dist/assets/Insights-CjRy42YE.js +0 -1
  130. package/client/dist/assets/MangaReadPage-YCETZRte.js +0 -1
  131. package/client/dist/assets/Mature-BGxRWkV5.js +0 -1
  132. package/client/dist/assets/MediaCard-CAzoxsRS.js +0 -1
  133. package/client/dist/assets/Music-Cgo-yQ7E.js +0 -1
  134. package/client/dist/assets/OptionListMenu--Owe9Jle.js +0 -1
  135. package/client/dist/assets/Player-yRXG2BrP.js +0 -56
  136. package/client/dist/assets/PlayerSettings-DrZLa0CC.js +0 -1
  137. package/client/dist/assets/QueueOptionsButton-BVZyPyKf.js +0 -1
  138. package/client/dist/assets/Radio-BFjxlzuF.js +0 -1
  139. package/client/dist/assets/Search-BOBo0Ogh.js +0 -1
  140. package/client/dist/assets/Settings-DOvNQ0gX.js +0 -21
  141. package/client/dist/assets/Trackers-DZ9KqOWv.js +0 -2
  142. package/client/dist/assets/Tv-5mATEU4p.js +0 -4
  143. package/client/dist/assets/Tv-C2SWcVzN.css +0 -1
  144. package/client/dist/assets/TvInfo-CF-5C5Fs.js +0 -1
  145. package/client/dist/assets/TvPopup-Dfqifj3s.js +0 -1
  146. package/client/dist/assets/Watchlist-B8MHNsAU.js +0 -1
  147. package/client/dist/assets/index-D2RkUJIN.js +0 -178
  148. package/client/dist/assets/useAnimeInfoData-BWShDHqE.js +0 -1
  149. package/client/dist/assets/useAsmr-FyfD9G7b.js +0 -1
  150. package/client/dist/assets/useMangaLibrary-BJU5x8Vz.js +0 -1
  151. package/client/dist/assets/useProviders-DDRxa8vl.js +0 -1
  152. package/client/dist/assets/useTv-Bh6WmNnZ.js +0 -1
  153. package/client/dist/assets/useTvLibrary-_Pqm8re1.js +0 -1
  154. package/server/dist/controllers/asmr-library.controller.js +0 -288
  155. package/server/dist/controllers/auth.controller.js +0 -225
  156. package/server/dist/controllers/data.controller.js +0 -767
  157. package/server/dist/controllers/insights.controller.js +0 -203
  158. package/server/dist/controllers/manga-library.controller.js +0 -350
  159. package/server/dist/controllers/settings.controller.js +0 -609
  160. package/server/dist/controllers/tv-library.controller.js +0 -314
  161. package/server/dist/controllers/watchlist.controller.js +0 -1069
  162. package/server/dist/request-logger.js +0 -16
  163. package/server/dist/routes/asmr-library.routes.js +0 -23
  164. package/server/dist/routes/asmr.routes.js +0 -81
  165. package/server/dist/routes/auth.routes.js +0 -25
  166. package/server/dist/routes/data.routes.js +0 -47
  167. package/server/dist/routes/discord-gateway.routes.js +0 -51
  168. package/server/dist/routes/insights.routes.js +0 -35
  169. package/server/dist/routes/lan-auth.routes.js +0 -46
  170. package/server/dist/routes/manga-library.routes.js +0 -25
  171. package/server/dist/routes/manga.routes.js +0 -141
  172. package/server/dist/routes/music.routes.js +0 -20
  173. package/server/dist/routes/providers.routes.js +0 -17
  174. package/server/dist/routes/proxy.routes.js +0 -12
  175. package/server/dist/routes/settings.routes.js +0 -24
  176. package/server/dist/routes/tracker.routes.js +0 -287
  177. package/server/dist/routes/translate.routes.js +0 -88
  178. package/server/dist/routes/tv-library.routes.js +0 -23
  179. package/server/dist/routes/tv.routes.js +0 -629
  180. package/server/dist/routes/watchlist.routes.js +0 -341
@@ -764,7 +764,7 @@ export async function findPosterByTitle(title, expected) {
764
764
  }
765
765
  }
766
766
  catch {
767
- // ignore, try Kitsu next
767
+ // ignore
768
768
  }
769
769
  try {
770
770
  const kitsu = await kitsuSearchAnime({ query: clean, page: 1, perPage: 5 });
@@ -1030,7 +1030,7 @@ export async function batchGetShowStatuses(ids) {
1030
1030
  }
1031
1031
  }
1032
1032
  catch {
1033
- // Kitsu fallback failed
1033
+ // ignore
1034
1034
  }
1035
1035
  }
1036
1036
  return result;
@@ -1324,7 +1324,7 @@ export async function getSchedule(date, format, adult = false) {
1324
1324
  shows.push(withScheduleFields(meta, entry.episode, entry.airingAt));
1325
1325
  }
1326
1326
  catch {
1327
- // keep going without this show
1327
+ // ignore
1328
1328
  }
1329
1329
  }
1330
1330
  }
@@ -0,0 +1,185 @@
1
+ import logger from '../logger.js';
2
+ import { InsightsRepository } from '../repositories/insights.repository.js';
3
+ export async function getWatchInsights(db) {
4
+ const [core, activityGrid, hourlyDist, seasonality, allWatches, watchedShows, droppedWarning, velocities,] = (await Promise.all([
5
+ InsightsRepository.getCoreStats(db),
6
+ InsightsRepository.getActivityGrid(db),
7
+ InsightsRepository.getHourlyDist(db),
8
+ InsightsRepository.getSeasonality(db),
9
+ InsightsRepository.getAllWatches(db),
10
+ InsightsRepository.getWatchedShowsMeta(db),
11
+ InsightsRepository.getDroppedShows(db),
12
+ InsightsRepository.getCompletionVelocities(db),
13
+ ]));
14
+ const bingeFactor = activityGrid.length > 0 ? Math.max(...activityGrid.map((a) => a.count)) : 0;
15
+ const sessions = [];
16
+ if (allWatches.length > 0) {
17
+ let currentSessionSeconds = allWatches[0].effectiveSeconds ?? allWatches[0].currentTime;
18
+ for (let i = 1; i < allWatches.length; i++) {
19
+ const prev = new Date(allWatches[i - 1].watchedAt).getTime();
20
+ const curr = new Date(allWatches[i].watchedAt).getTime();
21
+ const gap = curr - prev;
22
+ if (Number.isFinite(gap) && gap > 0 && gap < 3600000) {
23
+ currentSessionSeconds += allWatches[i].effectiveSeconds ?? allWatches[i].currentTime;
24
+ }
25
+ else {
26
+ sessions.push(currentSessionSeconds);
27
+ currentSessionSeconds = allWatches[i].effectiveSeconds ?? allWatches[i].currentTime;
28
+ }
29
+ }
30
+ sessions.push(currentSessionSeconds);
31
+ }
32
+ const avgSessionMinutes = sessions.length > 0 ? Math.round(sessions.reduce((a, b) => a + b, 0) / sessions.length / 60) : 0;
33
+ const genreCounts = {};
34
+ let totalPopScore = 0;
35
+ let popCount = 0;
36
+ for (const show of watchedShows) {
37
+ let genres = [];
38
+ if (show.genres) {
39
+ try {
40
+ if (show.genres.startsWith('[')) {
41
+ genres = JSON.parse(show.genres);
42
+ }
43
+ else {
44
+ genres = show.genres.split(',').map((g) => g.trim());
45
+ }
46
+ }
47
+ catch (e) {
48
+ logger.warn({ err: e, showId: show.id }, 'Failed to parse genres for insights');
49
+ }
50
+ }
51
+ for (const g of genres) {
52
+ genreCounts[g] = (genreCounts[g] || 0) + 1;
53
+ }
54
+ if (show.popularityScore) {
55
+ let score = show.popularityScore;
56
+ if (score > 10)
57
+ score = score / 10;
58
+ totalPopScore += score;
59
+ popCount++;
60
+ }
61
+ }
62
+ const topGenre = Object.entries(genreCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
63
+ const personaMap = {
64
+ Action: 'Shonen Warrior',
65
+ Romance: 'Hopeless Romantic',
66
+ Comedy: 'Chaos Enjoyer',
67
+ 'Slice of Life': 'Vibe Seeker',
68
+ Horror: 'Fearless Watcher',
69
+ Fantasy: 'Isekai Traveller',
70
+ 'Sci-Fi': 'Future Scientist',
71
+ Drama: 'Feels Collector',
72
+ };
73
+ const persona = personaMap[topGenre || ''] || 'Anime Enthusiast';
74
+ const avgCompletionDays = velocities.length > 0
75
+ ? Math.round(velocities.reduce((a, b) => a + b.daysToFinish, 0) / velocities.length)
76
+ : 0;
77
+ return {
78
+ totalHours: Math.round((core?.totalSeconds || 0) / 3600),
79
+ totalEpisodes: core?.totalEpisodes || 0,
80
+ totalAnime: core?.totalAnime || 0,
81
+ completedAnime: core?.completedCount || 0,
82
+ completionRate: core?.totalWatchlist > 0 ? Math.round((core.completedCount / core.totalWatchlist) * 100) : 0,
83
+ persona,
84
+ bingeFactor,
85
+ avgSessionMinutes,
86
+ avgCompletionDays,
87
+ popularityScore: popCount > 0 ? Math.round(totalPopScore / popCount) : 0,
88
+ genreSplit: Object.entries(genreCounts)
89
+ .map(([name, count]) => ({ name, count }))
90
+ .sort((a, b) => b.count - a.count)
91
+ .slice(0, 8) || [],
92
+ activityGrid: activityGrid || [],
93
+ hourlyDist: Array.from({ length: 24 }, (_, i) => {
94
+ const hour = i.toString().padStart(2, '0');
95
+ return { hour, count: hourlyDist?.find((d) => d.hour === hour)?.count || 0 };
96
+ }) || [],
97
+ seasonality: Array.from({ length: 12 }, (_, i) => {
98
+ const month = (i + 1).toString().padStart(2, '0');
99
+ return {
100
+ month,
101
+ seconds: seasonality?.find((s) => s.month === month)?.seconds || 0,
102
+ };
103
+ }) || [],
104
+ droppedShows: (droppedWarning || []).slice(0, 5),
105
+ };
106
+ }
107
+ export async function getGenreCards(db) {
108
+ const rows = await InsightsRepository.getWatchedEpisodesWithMeta(db);
109
+ const genreData = {};
110
+ for (const row of rows) {
111
+ let genres = [];
112
+ if (row.genres) {
113
+ try {
114
+ if (row.genres.startsWith('[')) {
115
+ genres = JSON.parse(row.genres);
116
+ }
117
+ else {
118
+ genres = row.genres.split(',').map((g) => g.trim());
119
+ }
120
+ }
121
+ catch (e) {
122
+ logger.warn({ err: e, showId: row.showId }, 'Failed to parse genres for genre cards');
123
+ }
124
+ }
125
+ const timeWatched = (row.effectiveSeconds ?? row.currentTime) || 0;
126
+ let score = row.popularityScore || 0;
127
+ if (score > 10)
128
+ score = score / 10;
129
+ for (const genre of genres) {
130
+ if (!genreData[genre]) {
131
+ genreData[genre] = { count: 0, totalTime: 0, scores: [], showWatches: {}, showMeta: {} };
132
+ }
133
+ genreData[genre].count++;
134
+ genreData[genre].totalTime += timeWatched;
135
+ if (score > 0)
136
+ genreData[genre].scores.push(score);
137
+ genreData[genre].showWatches[row.showId] = (genreData[genre].showWatches[row.showId] || 0) + 1;
138
+ if (row.name || row.thumbnail) {
139
+ genreData[genre].showMeta[row.showId] = {
140
+ name: row.name,
141
+ nativeName: row.nativeName,
142
+ englishName: row.englishName,
143
+ thumbnail: row.thumbnail,
144
+ };
145
+ }
146
+ }
147
+ }
148
+ const genreCards = Object.entries(genreData).map(([name, data]) => {
149
+ const topShows = Object.entries(data.showWatches)
150
+ .sort((a, b) => b[1] - a[1])
151
+ .slice(0, 4)
152
+ .map(([showId]) => ({
153
+ id: showId,
154
+ name: data.showMeta[showId]?.name || '',
155
+ nativeName: data.showMeta[showId]?.nativeName,
156
+ englishName: data.showMeta[showId]?.englishName,
157
+ thumbnail: data.showMeta[showId]?.thumbnail || '',
158
+ }));
159
+ return {
160
+ rank: 0,
161
+ name,
162
+ count: data.count,
163
+ meanScore: data.scores.length > 0 ? data.scores.reduce((a, b) => a + b, 0) / data.scores.length : 0,
164
+ timeWatched: formatTime(data.totalTime),
165
+ topShows,
166
+ };
167
+ });
168
+ genreCards.sort((a, b) => b.count - a.count);
169
+ genreCards.forEach((card, i) => {
170
+ card.rank = i + 1;
171
+ });
172
+ return genreCards;
173
+ }
174
+ function formatTime(seconds) {
175
+ const days = Math.floor(seconds / 86400);
176
+ const hours = Math.floor((seconds % 86400) / 3600);
177
+ const minutes = Math.floor((seconds % 3600) / 60);
178
+ if (days > 0) {
179
+ return `${days} days ${hours > 0 ? `${hours} hours` : ''}`;
180
+ }
181
+ if (hours > 0) {
182
+ return `${hours} hours${minutes > 0 ? ` ${minutes} mins` : ''}`;
183
+ }
184
+ return `${minutes} mins`;
185
+ }
@@ -6,8 +6,7 @@ function sendToParent(message) {
6
6
  }
7
7
  }
8
8
  catch {
9
- // No IPC channel (dev under npm, or parent already gone).
10
- // The stdout tag below remains the fallback signal.
9
+ // ignore
11
10
  }
12
11
  }
13
12
  export function notifySyncStart(message) {
@@ -44,7 +44,6 @@ function asArray(data) {
44
44
  return [];
45
45
  return Array.isArray(data) ? data : [data];
46
46
  }
47
- /** Builds a map of kitsu relationship id -> included resource for a given type. */
48
47
  function includedMap(entry, included, type) {
49
48
  if (!included || !entry.relationships)
50
49
  return [];
@@ -57,7 +56,6 @@ function includedMap(entry, included, type) {
57
56
  }
58
57
  return included.filter((i) => i.type === type && ids.has(i.id));
59
58
  }
60
- /** Kitsu status/subtype/season values are lowercase; map to AniList-style values. */
61
59
  const STATUS_MAP = {
62
60
  current: 'RELEASING',
63
61
  finished: 'FINISHED',
@@ -125,7 +123,6 @@ function anilistSortToKitsu(sort) {
125
123
  return '-userCount';
126
124
  }
127
125
  }
128
- /** Translates an AniList-style status filter to a Kitsu filter value. */
129
126
  function statusToKitsuFilter(status) {
130
127
  if (!status)
131
128
  return undefined;
@@ -143,7 +140,6 @@ function seasonToKitsu(season) {
143
140
  return undefined;
144
141
  return season.toLowerCase();
145
142
  }
146
- /** Kitsu genre-ish taxonomy: `categories`. */
147
143
  const CATEGORY_ALIASES = {
148
144
  'Sci-Fi': 'Science Fiction',
149
145
  'Sci Fi': 'Science Fiction',
@@ -154,9 +150,6 @@ function normalizeCategory(cat) {
154
150
  const alias = Object.entries(CATEGORY_ALIASES).find(([k]) => k.toLowerCase() === c.toLowerCase());
155
151
  return alias ? alias[1] : c;
156
152
  }
157
- /**
158
- * Resolves the included anime resource linked by a mapping entry's `item` relationship.
159
- */
160
153
  function mappingItem(entry, included) {
161
154
  const rel = entry.relationships?.item?.data;
162
155
  if (!rel)
@@ -165,10 +158,6 @@ function mappingItem(entry, included) {
165
158
  return undefined;
166
159
  return included.find((i) => i.type === rel.type && i.id === rel.id);
167
160
  }
168
- /**
169
- * Fetches a list of anime from Kitsu and normalizes entries to AnilistMedia.
170
- * `include=mappings,categories` gives AniList/MAL ids + genres in one request.
171
- */
172
161
  export async function kitsuSearchAnime(options = {}) {
173
162
  const { query, page = 1, perPage = 14, format, status, season, seasonYear, genre, genre_in, genre_not_in, averageScore_greater, episodes_greater, isAdult, sort, } = options;
174
163
  const filters = [];
@@ -223,7 +212,6 @@ export async function kitsuSearchAnime(options = {}) {
223
212
  }
224
213
  return results;
225
214
  }
226
- /** Deduplicates by the resolved AniList/MAL id (keeps the first occurrence). */
227
215
  export function dedupeKitsuMedia(list) {
228
216
  const seen = new Set();
229
217
  const out = [];
@@ -236,7 +224,6 @@ export function dedupeKitsuMedia(list) {
236
224
  }
237
225
  return out;
238
226
  }
239
- /** Converts a Kitsu entry + included resources into AnilistMedia (id = AniList or MAL id). */
240
227
  function normalizeKitsuEntry(entry, included, knownIds) {
241
228
  const a = entry.attributes;
242
229
  const mappings = includedMap(entry, included, 'mappings');
@@ -298,9 +285,6 @@ function normalizeKitsuEntry(entry, included, knownIds) {
298
285
  : null,
299
286
  };
300
287
  }
301
- /**
302
- * Finds the Kitsu entry id for a given AniList id via the mappings endpoint.
303
- */
304
288
  async function kitsuIdByAnilistId(anilistId) {
305
289
  const json = await kitsuFetch(`/mappings?filter[externalSite]=anilist%2Fanime&filter[externalId]=${anilistId}&include=item`);
306
290
  const entries = asArray(json?.data);
@@ -340,12 +324,11 @@ export async function kitsuBatchGetStatuses(anilistIds) {
340
324
  result.set(id, status);
341
325
  }
342
326
  catch {
343
- // skip failed lookups
327
+ // ignore
344
328
  }
345
329
  }
346
330
  return result;
347
331
  }
348
- /** Fetches full detail (genres + studios) for a Kitsu anime id. */
349
332
  async function kitsuDetail(kitsuId, knownIds) {
350
333
  const [animeJson, studiosJson] = await Promise.all([
351
334
  kitsuFetch(`/anime/${kitsuId}?include=categories,mappings`),
@@ -384,7 +367,6 @@ export async function kitsuMetaByMalId(malId) {
384
367
  return null;
385
368
  return kitsuDetail(item.id, { malId });
386
369
  }
387
- /** Batch-resolves English/Japanese titles for MAL ids via the mappings endpoint. */
388
370
  export async function kitsuTitlesByMalIds(malIds) {
389
371
  const result = new Map();
390
372
  const unique = [...new Set(malIds.filter((n) => Number.isFinite(n) && n > 0))];
@@ -424,7 +406,6 @@ export async function kitsuTitlesByMalIds(malIds) {
424
406
  }));
425
407
  return result;
426
408
  }
427
- /** Episode numbers for a show, via the episodes relationship. */
428
409
  export async function kitsuEpisodes(anilistId, idMal) {
429
410
  let kitsuId = null;
430
411
  if (anilistId > 0)
@@ -462,7 +443,6 @@ export async function kitsuEpisodes(anilistId, idMal) {
462
443
  const max = Math.max(...all);
463
444
  return Array.from({ length: max }, (_, i) => (i + 1).toString());
464
445
  }
465
- /** Trending proxy: currently-airing shows sorted by popularity (user count). */
466
446
  export async function kitsuTrending(page = 1, perPage = 20, status) {
467
447
  const statusFilter = statusToKitsuFilter(status);
468
448
  const filters = statusFilter ? `filter[status]=${statusFilter}` : '';
@@ -474,7 +454,6 @@ export async function kitsuTrending(page = 1, perPage = 20, status) {
474
454
  .map((e) => normalizeKitsuEntry(e, json.included ?? []))
475
455
  .filter((m) => m !== null));
476
456
  }
477
- /** Spotlight: top popular shows with native Kitsu banners (3360x800). */
478
457
  export async function kitsuSpotlight(page = 1, perPage = 20) {
479
458
  const offset = (page - 1) * perPage;
480
459
  const json = await kitsuFetch(`/anime?sort=-userCount&page[limit]=${perPage}&page[offset]=${offset}&include=mappings,categories`);
@@ -485,11 +464,6 @@ export async function kitsuSpotlight(page = 1, perPage = 20) {
485
464
  .filter((m) => m !== null)
486
465
  .filter((m) => !!m.bannerImage));
487
466
  }
488
- /**
489
- * Latest releases: currently-airing shows. Kitsu has no reliable episode airdates,
490
- * so we sort current shows by start date (newest first) and bump shows with a known
491
- * next episode date to the front (soonest first).
492
- */
493
467
  export async function kitsuLatestReleases(format = 'TV', page = 1, size = 12) {
494
468
  const needed = page * size;
495
469
  const formatFilter = format && format !== 'ALL' ? `filter[subtype]=${format.toLowerCase()}` : '';
@@ -528,7 +502,6 @@ export async function kitsuLatestReleases(format = 'TV', page = 1, size = 12) {
528
502
  function fallbackStartRank(m) {
529
503
  return ((m.startDate?.year ?? 0) * 10000 + (m.startDate?.month ?? 0) * 100 + (m.startDate?.day ?? 0));
530
504
  }
531
- /** Seasonal list for a given season/year, matching AniList's POPULARITY_DESC order. */
532
505
  export async function kitsuSeasonal(season, year, format, page = 1, size = 14) {
533
506
  const filters = [`filter[season]=${seasonToKitsu(season)}`, `filter[seasonYear]=${year}`];
534
507
  if (format && format !== 'ALL' && format !== 'ADULT') {
@@ -0,0 +1,291 @@
1
+ import { XMLParser } from 'fast-xml-parser';
2
+ import { searchAnilistByTitle, isAnilistRateLimited, getShowMetaById } from './anilist.js';
3
+ import { kitsuSearchAnime } from './kitsu.js';
4
+ import { offlineDb } from './offline-db.js';
5
+ import { SettingsRepository } from '../repositories/settings.repository.js';
6
+ import { ShowsMetaRepository } from '../repositories/shows-meta.repository.js';
7
+ import { performWriteTransactionAsync } from '../sync.js';
8
+ import logger from '../logger.js';
9
+ let activeImportStatus = {
10
+ running: false,
11
+ current: 0,
12
+ total: 0,
13
+ phase: 'done',
14
+ source: null,
15
+ };
16
+ let activeImportCancelled = false;
17
+ export function getMalImportStatus() {
18
+ return activeImportStatus;
19
+ }
20
+ export function requestMalImportCancel() {
21
+ if (!activeImportStatus.running) {
22
+ return { success: true, message: 'No import running' };
23
+ }
24
+ activeImportCancelled = true;
25
+ return { success: true, message: 'Import cancellation requested' };
26
+ }
27
+ export function prepareMalImport(fileBuffer, flags) {
28
+ if (activeImportStatus.running) {
29
+ return { ok: false, status: 409, body: { error: 'An import is already in progress' } };
30
+ }
31
+ if (!fileBuffer)
32
+ return { ok: false, status: 400, body: { error: 'No file' } };
33
+ const { erase, useOfflineDb, skipFallback } = flags;
34
+ const offlineEnabled = useOfflineDb !== 'false' && useOfflineDb !== false && useOfflineDb !== '0';
35
+ const shouldSkipFallback = skipFallback === 'true' || skipFallback === true || skipFallback === '1';
36
+ let result;
37
+ try {
38
+ result = malXmlParser.parse(fileBuffer.toString());
39
+ }
40
+ catch {
41
+ return { ok: false, status: 400, body: { error: 'Invalid XML' } };
42
+ }
43
+ const animeList = result?.myanimelist?.anime || [];
44
+ if (animeList.length === 0) {
45
+ return { ok: false, status: 400, body: { error: 'No anime found in XML' } };
46
+ }
47
+ activeImportCancelled = false;
48
+ activeImportStatus = {
49
+ running: true,
50
+ current: 0,
51
+ total: animeList.length,
52
+ phase: 'offline',
53
+ source: 'offline',
54
+ };
55
+ return { ok: true, import: { animeList, erase, offlineEnabled, shouldSkipFallback } };
56
+ }
57
+ export async function executeMalImport(db, prepared, sendEvent) {
58
+ const { animeList, erase, offlineEnabled, shouldSkipFallback } = prepared;
59
+ const total = animeList.length;
60
+ let skippedCount = 0;
61
+ const offlineShows = [];
62
+ const offlineMeta = [];
63
+ const fallbackQueue = [];
64
+ for (const item of animeList) {
65
+ const malTitle = xmlText(item.series_title);
66
+ const status = mapMalStatus(xmlText(item.my_status));
67
+ let matched = false;
68
+ if (offlineEnabled) {
69
+ const malId = parseInt(xmlText(item.series_animedb_id), 10);
70
+ if (malId && !Number.isNaN(malId)) {
71
+ const entry = offlineDb.getByMalId(malId);
72
+ if (entry) {
73
+ matched = true;
74
+ const showId = String(entry.anilistId);
75
+ const title = entry.title || malTitle;
76
+ offlineShows.push({ id: showId, name: title, thumbnail: entry.thumbnail, status });
77
+ if (entry.thumbnail || entry.type || entry.genres) {
78
+ offlineMeta.push({
79
+ id: showId,
80
+ thumbnail: entry.thumbnail,
81
+ type: entry.type,
82
+ genres: entry.genres,
83
+ });
84
+ }
85
+ activeImportStatus = {
86
+ running: true,
87
+ current: offlineShows.length,
88
+ total,
89
+ phase: 'offline',
90
+ source: 'offline',
91
+ title: malTitle,
92
+ matchedTitle: title,
93
+ status,
94
+ found: true,
95
+ imported: offlineShows.length,
96
+ };
97
+ sendEvent('progress', {
98
+ current: offlineShows.length,
99
+ total,
100
+ title: malTitle,
101
+ matchedTitle: title,
102
+ status,
103
+ source: 'offline',
104
+ found: true,
105
+ });
106
+ }
107
+ }
108
+ }
109
+ if (!matched)
110
+ fallbackQueue.push(item);
111
+ }
112
+ if (offlineShows.length > 0 || erase) {
113
+ try {
114
+ await performWriteTransactionAsync(db, async (tx) => {
115
+ if (erase)
116
+ await SettingsRepository.clearWatchlist(tx);
117
+ await SettingsRepository.upsertWatchlistBatch(tx, offlineShows);
118
+ for (const meta of offlineMeta) {
119
+ if (meta.thumbnail || meta.type || meta.genres) {
120
+ await ShowsMetaRepository.upsert(tx, {
121
+ id: meta.id,
122
+ thumbnail: meta.thumbnail,
123
+ type: meta.type,
124
+ genres: meta.genres,
125
+ });
126
+ }
127
+ }
128
+ });
129
+ }
130
+ catch (dbErr) {
131
+ logger.error({ err: dbErr }, 'Failed to commit offline import matches');
132
+ }
133
+ }
134
+ let fallbackImported = 0;
135
+ if (shouldSkipFallback || activeImportCancelled) {
136
+ skippedCount += fallbackQueue.length;
137
+ }
138
+ else if (fallbackQueue.length > 0) {
139
+ activeImportStatus = { ...activeImportStatus, phase: 'fallback', source: null };
140
+ const BATCH_SIZE = 5;
141
+ for (let i = 0; i < fallbackQueue.length; i += BATCH_SIZE) {
142
+ if (activeImportCancelled) {
143
+ skippedCount += fallbackQueue.length - i;
144
+ break;
145
+ }
146
+ const batch = fallbackQueue.slice(i, i + BATCH_SIZE);
147
+ const batchResults = await Promise.allSettled(batch.map((item) => searchByTitleForMal(xmlText(item.series_title))));
148
+ const batchShows = [];
149
+ const batchMeta = [];
150
+ const metaPromises = [];
151
+ batchResults.forEach((r, idx) => {
152
+ const malTitle = xmlText(batch[idx].series_title);
153
+ const status = mapMalStatus(xmlText(batch[idx].my_status));
154
+ const current = offlineShows.length + fallbackImported + idx + 1;
155
+ if (r.status === 'fulfilled' && r.value) {
156
+ const show = r.value;
157
+ const title = show.title?.english || show.title?.romaji || malTitle;
158
+ batchShows.push({ id: String(show.id), name: title, status });
159
+ activeImportStatus = {
160
+ running: true,
161
+ current,
162
+ total,
163
+ phase: 'fallback',
164
+ source: show.source,
165
+ title: malTitle,
166
+ matchedTitle: title,
167
+ status,
168
+ found: true,
169
+ imported: offlineShows.length + fallbackImported + 1,
170
+ };
171
+ sendEvent('progress', {
172
+ current,
173
+ total,
174
+ title: malTitle,
175
+ matchedTitle: title,
176
+ status,
177
+ source: show.source,
178
+ found: true,
179
+ });
180
+ metaPromises.push(getShowMetaById(String(show.id))
181
+ .then((meta) => {
182
+ if (meta) {
183
+ batchMeta.push({
184
+ id: String(show.id),
185
+ thumbnail: meta.thumbnail || undefined,
186
+ type: meta.type || undefined,
187
+ });
188
+ }
189
+ })
190
+ .catch(() => { }));
191
+ }
192
+ else {
193
+ skippedCount++;
194
+ activeImportStatus = {
195
+ running: true,
196
+ current,
197
+ total,
198
+ phase: 'fallback',
199
+ source: null,
200
+ title: malTitle,
201
+ matchedTitle: null,
202
+ status,
203
+ found: false,
204
+ imported: offlineShows.length + fallbackImported,
205
+ };
206
+ sendEvent('progress', {
207
+ current,
208
+ total,
209
+ title: malTitle,
210
+ matchedTitle: null,
211
+ status,
212
+ source: null,
213
+ found: false,
214
+ });
215
+ }
216
+ });
217
+ await Promise.allSettled(metaPromises);
218
+ if (batchShows.length > 0) {
219
+ try {
220
+ await performWriteTransactionAsync(db, async (tx) => {
221
+ await SettingsRepository.upsertWatchlistBatch(tx, batchShows);
222
+ for (const meta of batchMeta) {
223
+ if (meta.thumbnail) {
224
+ await ShowsMetaRepository.upsert(tx, {
225
+ id: meta.id,
226
+ thumbnail: meta.thumbnail,
227
+ type: meta.type,
228
+ });
229
+ }
230
+ }
231
+ });
232
+ fallbackImported += batchShows.length;
233
+ }
234
+ catch (dbErr) {
235
+ logger.error({ err: dbErr }, 'Failed to commit fallback import batch');
236
+ skippedCount += batchShows.length;
237
+ }
238
+ }
239
+ }
240
+ }
241
+ const imported = offlineShows.length + fallbackImported;
242
+ activeImportStatus = {
243
+ running: false,
244
+ current: total,
245
+ total,
246
+ phase: 'done',
247
+ source: null,
248
+ imported,
249
+ skipped: skippedCount,
250
+ };
251
+ sendEvent('complete', { imported, skipped: skippedCount });
252
+ }
253
+ function xmlText(value) {
254
+ if (Array.isArray(value))
255
+ return String(value[0] ?? '');
256
+ return String(value ?? '');
257
+ }
258
+ function mapMalStatus(malStatus) {
259
+ switch (malStatus) {
260
+ case 'Plan to Watch':
261
+ return 'Planned';
262
+ case 'On Hold':
263
+ return 'On-Hold';
264
+ default:
265
+ return malStatus;
266
+ }
267
+ }
268
+ const malXmlParser = new XMLParser({
269
+ isArray: (name) => name === 'anime',
270
+ parseTagValue: false,
271
+ trimValues: true,
272
+ });
273
+ async function searchByTitleForMal(title) {
274
+ const result = await searchAnilistByTitle(title);
275
+ if (result)
276
+ return { ...result, source: 'anilist' };
277
+ if (isAnilistRateLimited()) {
278
+ logger.debug({ title }, 'AniList rate limited, trying Kitsu fallback for MAL import');
279
+ const fb = await kitsuSearchAnime({ query: title, page: 1, perPage: 5 });
280
+ if (fb.length > 0) {
281
+ const lowerTitle = title.toLowerCase();
282
+ const exactMatch = fb.find((m) => m.title?.romaji?.toLowerCase() === lowerTitle ||
283
+ m.title?.english?.toLowerCase() === lowerTitle);
284
+ const best = exactMatch || fb[0];
285
+ if (best.id > 0) {
286
+ return { id: best.id, title: best.title ?? {}, source: 'kitsu' };
287
+ }
288
+ }
289
+ }
290
+ return null;
291
+ }