@serifpersia/dango 3.3.8 → 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-D4qo4289.js → AnimeCard-CJy1SXH3.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-BdRRm4So.js → AnimeInfo-BoXlVvZ-.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-DeJNAKMR.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-D3hTqTRU.js → LibraryListPage-HzuDavbb.js} +1 -1
  11. package/client/dist/assets/{ListeningList-BgWsfu40.js → ListeningList-DYcQnnsG.js} +1 -1
  12. package/client/dist/assets/{Manga-Dn-CYins.js → Manga-D8CT9aO0.js} +1 -1
  13. package/client/dist/assets/{MangaInfoPage-EmQoK7Bv.js → MangaInfoPage-DGK6CYp1.js} +1 -1
  14. package/client/dist/assets/MangaReadPage-B9XoDKF9.js +1 -0
  15. package/client/dist/assets/{MangaResetProgressModal-Dian37PQ.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-B7DeSFoA.js → QueueRail-O7f4RqjB.js} +1 -1
  24. package/client/dist/assets/Radio-DPZxQnyD.js +1 -0
  25. package/client/dist/assets/{ReadingList-BT8kUjCB.js → ReadingList-n6rr8ygy.js} +1 -1
  26. package/client/dist/assets/{ResetProgressModal-DWeiO-hs.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-B5loz7ii.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-BYwCf5kO.js → TvSearch-DuqR-z3v.js} +1 -1
  36. package/client/dist/assets/{TvWatchlist-BiJU6pKT.js → TvWatchlist-CBvgFrA0.js} +1 -1
  37. package/client/dist/assets/Watchlist-mj8mTOam.js +1 -0
  38. package/client/dist/assets/{hls-C11FOWHj.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-cT_Uu41e.js → useAsmrPopup-e4zNN9ig.js} +1 -1
  43. package/client/dist/assets/useMangaLibrary-C9Lci5N4.js +1 -0
  44. package/client/dist/assets/{useMangaPopup-PnmSctHu.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 +15 -4
  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} +235 -295
  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-D0EGRYsv.js +0 -2
  126. package/client/dist/assets/AsmrResetProgressModal-Gs0GmVaT.js +0 -1
  127. package/client/dist/assets/AvSyncCalibrator-D8hqarVB.js +0 -10
  128. package/client/dist/assets/Home-BnBzjFkW.js +0 -1
  129. package/client/dist/assets/Insights-i9QgY3nE.js +0 -1
  130. package/client/dist/assets/MangaReadPage-xJB8K2YU.js +0 -1
  131. package/client/dist/assets/Mature-DQtXKOXc.js +0 -1
  132. package/client/dist/assets/MediaCard-DpKoY-1j.js +0 -1
  133. package/client/dist/assets/Music-BP43Zqmq.js +0 -1
  134. package/client/dist/assets/OptionListMenu-Bj9ebsW3.js +0 -1
  135. package/client/dist/assets/Player-O2Cy2GF1.js +0 -56
  136. package/client/dist/assets/PlayerSettings-DYZ272Yp.js +0 -1
  137. package/client/dist/assets/QueueOptionsButton-lv26-6jp.js +0 -1
  138. package/client/dist/assets/Radio-qQBYnVfY.js +0 -1
  139. package/client/dist/assets/Search-BkvPYN33.js +0 -1
  140. package/client/dist/assets/Settings-CyZEBF6S.js +0 -21
  141. package/client/dist/assets/Trackers-JK-aXBYC.js +0 -2
  142. package/client/dist/assets/Tv-BwXWJxMy.js +0 -4
  143. package/client/dist/assets/Tv-C2SWcVzN.css +0 -1
  144. package/client/dist/assets/TvInfo-Dzyq6ucH.js +0 -1
  145. package/client/dist/assets/TvPopup-PlCU30FO.js +0 -1
  146. package/client/dist/assets/Watchlist-BOEeGXH9.js +0 -1
  147. package/client/dist/assets/index-CtoKRNLc.js +0 -178
  148. package/client/dist/assets/useAnimeInfoData-kr3WOIWH.js +0 -1
  149. package/client/dist/assets/useAsmr-CB9THf0m.js +0 -1
  150. package/client/dist/assets/useMangaLibrary-rM6z3_0E.js +0 -1
  151. package/client/dist/assets/useProviders-B3VB7A9M.js +0 -1
  152. package/client/dist/assets/useTv-BTUKMH7Q.js +0 -1
  153. package/client/dist/assets/useTvLibrary-C0sX-ilZ.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
@@ -1,1069 +0,0 @@
1
- import logger from '../logger.js';
2
- import { performWriteTransaction } from '../sync.js';
3
- import { WatchlistRepository } from '../repositories/watchlist.repository.js';
4
- import { WatchedEpisodesRepository, } from '../repositories/watched-episodes.repository.js';
5
- import { ShowsMetaRepository } from '../repositories/shows-meta.repository.js';
6
- import { NotificationsRepository } from '../repositories/notifications.repository.js';
7
- import { QueueRepository } from '../repositories/queue.repository.js';
8
- import { SettingsRepository } from '../repositories/settings.repository.js';
9
- import { discordRPCService } from '../discord-rpc.js';
10
- import { dbAll, dbGet } from '../utils/db-utils.js';
11
- import { searchAnilist, searchAnilistByTitle, getAiredEpisodesForShows, getAnilistEpisodes, getShowMetaById, isAnilistRateLimited, batchGetShowStatuses, } from '../lib/anilist.js';
12
- import { kitsuSearchAnime } from '../lib/kitsu.js';
13
- import { getMigratedId } from '../lib/migration.js';
14
- import { offlineDb } from '../lib/offline-db.js';
15
- const BACKGROUND_DISCOVERY_INTERVAL_MS = 5 * 60 * 1000;
16
- const NUDGE_THROTTLE_MS = 120 * 1000;
17
- const SLOW_MAX_RUN_MS = 5 * 60 * 1000;
18
- export class WatchlistController {
19
- triggerDiscovery;
20
- discoveryIntervalId = null;
21
- lastExternalDiscoveryAt = 0;
22
- discoveryBusy = false;
23
- lastDiscoveryRunAt = 0;
24
- discoveryState = 'idle';
25
- discoveryTotal = 0;
26
- discoveryDone = 0;
27
- stopped = false;
28
- stopNotificationDiscovery() {
29
- this.stopped = true;
30
- if (this.discoveryIntervalId !== null) {
31
- clearInterval(this.discoveryIntervalId);
32
- this.discoveryIntervalId = null;
33
- }
34
- }
35
- getDiscoveryStatus = () => ({
36
- running: this.discoveryBusy,
37
- state: this.discoveryState,
38
- total: this.discoveryTotal,
39
- done: Math.min(this.discoveryDone, this.discoveryTotal),
40
- lastRunAt: this.lastDiscoveryRunAt,
41
- });
42
- startNotificationDiscovery(getDb) {
43
- const anilistIdCache = new Map();
44
- const getAnilistId = async (showId, showName) => {
45
- if (anilistIdCache.has(showId)) {
46
- return anilistIdCache.get(showId) || null;
47
- }
48
- const db = getDb();
49
- const meta = (await ShowsMetaRepository.getById(db, showId));
50
- if (meta?.anilistId) {
51
- anilistIdCache.set(showId, meta.anilistId);
52
- return meta.anilistId;
53
- }
54
- if (/^\d+$/.test(showId)) {
55
- const numericId = parseInt(showId);
56
- anilistIdCache.set(showId, numericId);
57
- return numericId;
58
- }
59
- anilistIdCache.set(showId, null);
60
- return null;
61
- };
62
- const runDiscovery = async (fast = false) => {
63
- if (this.discoveryBusy || this.stopped)
64
- return;
65
- this.discoveryBusy = true;
66
- this.discoveryState = 'running';
67
- this.discoveryTotal = 0;
68
- this.discoveryDone = 0;
69
- const db = getDb();
70
- if (!db || db.isClosedCheck()) {
71
- this.discoveryBusy = false;
72
- return;
73
- }
74
- const startedAt = Date.now();
75
- const MAX_RUN_MS = fast ? 90000 : SLOW_MAX_RUN_MS;
76
- try {
77
- const watchingShows = await WatchlistRepository.getWatchingShows(db);
78
- this.discoveryTotal = watchingShows.length;
79
- if (watchingShows.length === 0) {
80
- this.discoveryBusy = false;
81
- return;
82
- }
83
- const showIdMap = new Map();
84
- const MAP_CONCURRENCY = fast ? 4 : 2;
85
- const anilistResults = [];
86
- let nextIndex = 0;
87
- const worker = async () => {
88
- while (nextIndex < watchingShows.length) {
89
- if (Date.now() - startedAt > MAX_RUN_MS)
90
- return;
91
- const show = watchingShows[nextIndex];
92
- nextIndex += 1;
93
- const id = await getAnilistId(show.id, show.name);
94
- this.discoveryDone += 1;
95
- anilistResults.push({ show, id });
96
- }
97
- };
98
- await Promise.all(Array.from({ length: Math.min(MAP_CONCURRENCY, watchingShows.length) }, () => worker()));
99
- for (const { show, id } of anilistResults) {
100
- if (id) {
101
- showIdMap.set(show.id, id);
102
- }
103
- }
104
- if (Date.now() - startedAt > MAX_RUN_MS || showIdMap.size === 0) {
105
- this.discoveryBusy = false;
106
- return;
107
- }
108
- const now = new Date();
109
- const weekStart = new Date(now);
110
- weekStart.setDate(now.getDate() - 7);
111
- weekStart.setHours(0, 0, 0, 0);
112
- const weekEnd = new Date(now);
113
- weekEnd.setHours(23, 59, 59, 999);
114
- const schedules = await getAiredEpisodesForShows(Array.from(showIdMap.values()), weekStart, weekEnd);
115
- const nowUnix = Math.floor(Date.now() / 1000);
116
- const reverseMap = new Map();
117
- for (const [watchlistId, anilistId] of showIdMap.entries()) {
118
- reverseMap.set(anilistId, watchlistId);
119
- }
120
- const finishedShowIds = new Set();
121
- const unresolvedStatus = [];
122
- for (const [watchlistId, anilistId] of showIdMap.entries()) {
123
- const localMeta = (await ShowsMetaRepository.getById(db, watchlistId));
124
- if (localMeta?.status === 'FINISHED') {
125
- finishedShowIds.add(anilistId);
126
- }
127
- else if (!localMeta?.status) {
128
- unresolvedStatus.push({ watchlistId, anilistId });
129
- }
130
- }
131
- if (unresolvedStatus.length > 0 && !isAnilistRateLimited()) {
132
- const statuses = await batchGetShowStatuses(unresolvedStatus.map((s) => s.anilistId));
133
- let persisted = 0;
134
- for (const { watchlistId, anilistId } of unresolvedStatus) {
135
- const status = statuses.get(anilistId);
136
- if (status === 'FINISHED') {
137
- finishedShowIds.add(anilistId);
138
- await ShowsMetaRepository.upsert(db, { id: watchlistId, status: 'FINISHED' });
139
- persisted++;
140
- }
141
- else if (status) {
142
- await ShowsMetaRepository.upsert(db, { id: watchlistId, status });
143
- persisted++;
144
- }
145
- }
146
- if (persisted > 0)
147
- db.scheduleSave();
148
- }
149
- if (finishedShowIds.size > 0) {
150
- const monthStart = new Date(now);
151
- monthStart.setDate(now.getDate() - 30);
152
- monthStart.setHours(0, 0, 0, 0);
153
- const monthEnd = new Date(now);
154
- monthEnd.setHours(23, 59, 59, 999);
155
- const finishedSchedules = await getAiredEpisodesForShows(Array.from(finishedShowIds), monthStart, monthEnd);
156
- const finishedEpisodesByShow = new Map();
157
- const seenFinishedKeys = new Set();
158
- for (const entry of finishedSchedules) {
159
- if (entry.airingAt > nowUnix)
160
- continue;
161
- const watchlistId = reverseMap.get(entry.mediaId);
162
- if (!watchlistId)
163
- continue;
164
- const episodeKey = String(Math.round(entry.episode));
165
- const dedupeKey = `${watchlistId}:${episodeKey}`;
166
- if (seenFinishedKeys.has(dedupeKey))
167
- continue;
168
- seenFinishedKeys.add(dedupeKey);
169
- const list = finishedEpisodesByShow.get(watchlistId);
170
- if (list)
171
- list.push({ episodeKey, airingAt: entry.airingAt });
172
- else
173
- finishedEpisodesByShow.set(watchlistId, [{ episodeKey, airingAt: entry.airingAt }]);
174
- }
175
- for (const [watchlistId, episodes] of finishedEpisodesByShow) {
176
- const [watchedEps, dismissedEps] = await Promise.all([
177
- WatchedEpisodesRepository.getWatchedEpisodeNumbers(db, watchlistId),
178
- NotificationsRepository.getDismissedByShow(db, watchlistId),
179
- ]);
180
- const watchedSet = new Set(watchedEps.map((e) => e.toString()));
181
- const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
182
- let inserted = false;
183
- for (const { episodeKey, airingAt } of episodes) {
184
- if (nowUnix - airingAt > 30 * 24 * 60 * 60)
185
- continue;
186
- if (!watchedSet.has(episodeKey) && !dismissedSet.has(episodeKey)) {
187
- await NotificationsRepository.addDiscovered(db, watchlistId, episodeKey);
188
- inserted = true;
189
- }
190
- }
191
- if (inserted)
192
- db.scheduleSave();
193
- }
194
- }
195
- const episodesByShow = new Map();
196
- const seenEpisodeKeys = new Set();
197
- for (const entry of schedules) {
198
- if (entry.airingAt > nowUnix)
199
- continue;
200
- const watchlistId = reverseMap.get(entry.mediaId);
201
- if (!watchlistId)
202
- continue;
203
- const episodeKey = String(Math.round(entry.episode));
204
- const dedupeKey = `${watchlistId}:${episodeKey}`;
205
- if (seenEpisodeKeys.has(dedupeKey))
206
- continue;
207
- seenEpisodeKeys.add(dedupeKey);
208
- const list = episodesByShow.get(watchlistId);
209
- if (list)
210
- list.push({ episodeKey, airingAt: entry.airingAt });
211
- else
212
- episodesByShow.set(watchlistId, [{ episodeKey, airingAt: entry.airingAt }]);
213
- }
214
- for (const [watchlistId, episodes] of episodesByShow) {
215
- const [watchedEps, dismissedEps] = await Promise.all([
216
- WatchedEpisodesRepository.getWatchedEpisodeNumbers(db, watchlistId),
217
- NotificationsRepository.getDismissedByShow(db, watchlistId),
218
- ]);
219
- const watchedSet = new Set(watchedEps.map((e) => e.toString()));
220
- const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
221
- let inserted = false;
222
- for (const { episodeKey } of episodes) {
223
- if (!watchedSet.has(episodeKey) && !dismissedSet.has(episodeKey)) {
224
- await NotificationsRepository.addDiscovered(db, watchlistId, episodeKey);
225
- inserted = true;
226
- }
227
- }
228
- if (inserted)
229
- db.scheduleSave();
230
- }
231
- await NotificationsRepository.cleanupWatchedNotifications(db);
232
- }
233
- catch (e) {
234
- this.discoveryState = 'error';
235
- if (e?.message === 'Database is closed') {
236
- logger.info('Notification discovery stopped: database is closed');
237
- }
238
- else {
239
- logger.error({ err: e }, 'AniList notification discovery failed');
240
- }
241
- }
242
- finally {
243
- this.discoveryBusy = false;
244
- this.lastDiscoveryRunAt = Date.now();
245
- if (this.discoveryState === 'running') {
246
- this.discoveryState = this.discoveryTotal === 0 ? 'empty' : 'complete';
247
- }
248
- }
249
- };
250
- this.triggerDiscovery = (force = false) => {
251
- if (this.stopped || this.discoveryBusy)
252
- return false;
253
- const now = Date.now();
254
- if (!force && now - this.lastExternalDiscoveryAt < NUDGE_THROTTLE_MS)
255
- return false;
256
- this.lastExternalDiscoveryAt = now;
257
- runDiscovery(force);
258
- return true;
259
- };
260
- this.discoveryIntervalId = setInterval(() => {
261
- if (!this.stopped)
262
- runDiscovery(false);
263
- }, BACKGROUND_DISCOVERY_INTERVAL_MS);
264
- }
265
- showsMetaChanged(db, showId, candidate) {
266
- const existing = ShowsMetaRepository.getById(db, showId);
267
- if (!existing)
268
- return true;
269
- const differs = (incoming, stored) => {
270
- if (incoming === undefined || incoming === null || incoming === '')
271
- return false;
272
- return String(incoming) !== String(stored ?? '');
273
- };
274
- return (differs(candidate.name, existing.name) ||
275
- differs(candidate.thumbnail, existing.thumbnail) ||
276
- differs(candidate.nativeName, existing.nativeName) ||
277
- differs(candidate.englishName, existing.englishName) ||
278
- differs(candidate.genres, existing.genres) ||
279
- differs(candidate.popularityScore, existing.popularityScore) ||
280
- differs(candidate.status, existing.status) ||
281
- differs(candidate.episodeCount, existing.episodeCount) ||
282
- differs(candidate.type, existing.type) ||
283
- differs(candidate.anilistId, existing.anilistId) ||
284
- differs(candidate.isAdult, existing.isAdult) ||
285
- differs(candidate.episodeDuration, existing.episodeDuration));
286
- }
287
- normalizeFilterValue(value) {
288
- if (typeof value !== 'string')
289
- return undefined;
290
- const trimmed = value.trim();
291
- return trimmed && trimmed !== 'ALL' ? trimmed : undefined;
292
- }
293
- getWatchlistFilters(query) {
294
- return {
295
- query: this.normalizeFilterValue(query.query),
296
- type: this.normalizeFilterValue(query.type),
297
- season: this.normalizeFilterValue(query.season),
298
- year: this.normalizeFilterValue(query.year),
299
- genres: this.normalizeFilterValue(query.genres),
300
- excludeGenres: this.normalizeFilterValue(query.excludeGenres),
301
- sortBy: this.normalizeFilterValue(query.sortBy),
302
- titlePreference: ['name', 'nativeName', 'englishName'].includes(String(query.titlePreference))
303
- ? query.titlePreference
304
- : 'name',
305
- };
306
- }
307
- matchesLocalFilters(row, filters) {
308
- if (filters.query) {
309
- const queryWords = new Set(filters.query
310
- .toLowerCase()
311
- .split(/\s+/)
312
- .filter((w) => w.length >= 2));
313
- const rowTitle = (row.englishName || row.name || row.nativeName || '').toLowerCase();
314
- const titleWords = rowTitle.split(/\s+/);
315
- const overlap = titleWords.filter((w) => queryWords.has(w)).length;
316
- if (overlap < queryWords.size)
317
- return false;
318
- }
319
- return true;
320
- }
321
- sortFilteredRows(rows, filters) {
322
- const getSortTitle = (row) => {
323
- const preferredTitle = filters.titlePreference ? row[filters.titlePreference] : undefined;
324
- return preferredTitle || row.name || '';
325
- };
326
- if (filters.sortBy === 'name_asc') {
327
- return [...rows].sort((a, b) => getSortTitle(a).localeCompare(getSortTitle(b)));
328
- }
329
- if (filters.sortBy === 'name_desc') {
330
- return [...rows].sort((a, b) => getSortTitle(b).localeCompare(getSortTitle(a)));
331
- }
332
- return rows;
333
- }
334
- async getAnilistSeasonYearMatches(season, year) {
335
- const matched = new Map();
336
- if (!year || year === 'ALL') {
337
- return matched;
338
- }
339
- const seasonYear = parseInt(year);
340
- if (Number.isNaN(seasonYear))
341
- return matched;
342
- const perPage = 50;
343
- let page = 1;
344
- while (true) {
345
- const searchVars = {
346
- seasonYear,
347
- page,
348
- perPage,
349
- };
350
- if (season && season !== 'ALL') {
351
- searchVars.season = season.toUpperCase();
352
- }
353
- const results = await searchAnilist(searchVars);
354
- for (const show of results) {
355
- if (show.anilistId) {
356
- matched.set(show.anilistId, { title: show.names || {} });
357
- }
358
- }
359
- if (results.length < perPage)
360
- break;
361
- page++;
362
- if (page > 10)
363
- break;
364
- }
365
- return matched;
366
- }
367
- rowMatchesAnilistSeasonYear(row, anilistMatches) {
368
- if (anilistMatches.size === 0)
369
- return true;
370
- if (/^\d+$/.test(row.id) && anilistMatches.has(parseInt(row.id))) {
371
- return true;
372
- }
373
- const rowTitle = (row.englishName || row.name || row.nativeName || '').toLowerCase();
374
- if (!rowTitle)
375
- return false;
376
- const rowWords = new Set(rowTitle.split(/\s+/).filter((w) => w.length >= 2));
377
- if (rowWords.size === 0)
378
- return false;
379
- for (const [, media] of anilistMatches) {
380
- const titles = [media.title?.romaji, media.title?.english, media.title?.native].filter(Boolean);
381
- for (const title of titles) {
382
- const titleWords = new Set(title
383
- .toLowerCase()
384
- .split(/\s+/)
385
- .filter((w) => w.length >= 2));
386
- const overlap = [...rowWords].filter((w) => titleWords.has(w)).length;
387
- const minLen = Math.min(rowWords.size, titleWords.size);
388
- if (minLen >= 2 && overlap / minLen >= 0.7)
389
- return true;
390
- }
391
- }
392
- return false;
393
- }
394
- async filterWatchlistRows(rows, filters, db) {
395
- let filtered = rows.filter((row) => this.matchesLocalFilters(row, filters));
396
- if (filters.type === 'ADULT' && db && filtered.length > 0) {
397
- const ids = filtered.map((r) => r.id);
398
- const placeholders = ids.map(() => '?').join(',');
399
- const adultRows = await dbAll(db, `SELECT id FROM shows_meta WHERE id IN (${placeholders}) AND isAdult = 1`, ids);
400
- const adultSet = new Set(adultRows.map((r) => r.id));
401
- filtered = filtered.filter((row) => adultSet.has(row.id));
402
- }
403
- else if (filters.type && filters.type !== 'ALL' && filters.type !== 'ADULT') {
404
- filtered = filtered.filter((row) => row.type === filters.type);
405
- }
406
- if ((filters.genres || filters.excludeGenres) && db) {
407
- const ids = filtered.map((r) => r.id);
408
- const placeholders = ids.map(() => '?').join(',');
409
- const genreRows = await dbAll(db, `SELECT id, genres FROM shows_meta WHERE id IN (${placeholders})`, ids);
410
- const idToGenres = new Map(genreRows.map((r) => [r.id, r.genres ? JSON.parse(r.genres) : []]));
411
- const includeList = filters.genres
412
- ?.split(',')
413
- .map((g) => g.trim())
414
- .filter(Boolean) || [];
415
- const excludeList = filters.excludeGenres
416
- ?.split(',')
417
- .map((g) => g.trim())
418
- .filter(Boolean) || [];
419
- filtered = filtered.filter((row) => {
420
- const rowGenres = idToGenres.get(row.id) || [];
421
- if (includeList.length && !includeList.every((g) => rowGenres.includes(g)))
422
- return false;
423
- if (excludeList.length && excludeList.some((g) => rowGenres.includes(g)))
424
- return false;
425
- return true;
426
- });
427
- }
428
- if (((filters.season && filters.season !== 'ALL') || (filters.year && filters.year !== 'ALL')) &&
429
- filtered.length > 0) {
430
- const anilistMatches = await this.getAnilistSeasonYearMatches(filters.season, filters.year);
431
- if (anilistMatches.size > 0) {
432
- filtered = filtered.filter((row) => this.rowMatchesAnilistSeasonYear(row, anilistMatches));
433
- }
434
- }
435
- return this.sortFilteredRows(filtered, filters);
436
- }
437
- async backfillMissingPosters(db, rows) {
438
- const missing = rows.filter((r) => !r.thumbnail || r.thumbnail.trim() === '');
439
- if (missing.length === 0)
440
- return;
441
- const slice = missing.slice(0, 10);
442
- await Promise.allSettled(slice.map(async (row) => {
443
- try {
444
- const meta = await getShowMetaById(row.id);
445
- const poster = meta?.thumbnail?.trim();
446
- if (!poster)
447
- return;
448
- row.thumbnail = poster;
449
- const dual = row;
450
- if (dual._id !== undefined)
451
- dual._id = row.id;
452
- try {
453
- ShowsMetaRepository.upsert(db, {
454
- id: row.id,
455
- thumbnail: poster,
456
- popularityScore: meta?.score ?? (meta?.averageScore != null ? meta.averageScore / 10 : undefined),
457
- });
458
- }
459
- catch {
460
- // ignore
461
- }
462
- try {
463
- await WatchlistRepository.updateThumbnail(db, row.id, poster);
464
- }
465
- catch {
466
- // ignore
467
- }
468
- }
469
- catch {
470
- // ignore
471
- }
472
- }));
473
- db.scheduleSave();
474
- await this.sweepOfflinePosters(db);
475
- }
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
- }
485
- const [wlMissing, metaMissing] = await Promise.all([
486
- WatchlistRepository.getMissingThumbnails(db),
487
- ShowsMetaRepository.getShowIdsMissingMeta(db),
488
- ]);
489
- const ids = [
490
- ...new Set([...wlMissing.map((r) => r.id), ...metaMissing.map((r) => r.id)]),
491
- ].slice(0, 500);
492
- if (ids.length === 0)
493
- return;
494
- for (const id of ids) {
495
- const poster = this.resolveOfflinePoster(id);
496
- if (poster)
497
- await this.applyPoster(db, id, poster);
498
- }
499
- db.scheduleSave();
500
- }
501
- resolveOfflinePoster(id) {
502
- if (/^\d+$/.test(id)) {
503
- return offlineDb.getByAnilistId(Number(id))?.thumbnail?.trim() || null;
504
- }
505
- const mal = /^mal-(\d+)$/i.exec(id)?.[1];
506
- if (mal)
507
- return offlineDb.getByMalId(Number(mal))?.thumbnail?.trim() || null;
508
- return null;
509
- }
510
- async applyPoster(db, id, poster) {
511
- try {
512
- ShowsMetaRepository.upsert(db, { id, thumbnail: poster });
513
- }
514
- catch {
515
- // ignore
516
- }
517
- try {
518
- await WatchlistRepository.updateThumbnail(db, id, poster);
519
- }
520
- catch {
521
- // ignore
522
- }
523
- }
524
- async getContinueWatchingData(req, limit) {
525
- const rows = await WatchedEpisodesRepository.getContinueWatching(req.db, limit);
526
- const ignoreAdultRow = await SettingsRepository.getByKey(req.db, 'ignoreAdultContent');
527
- const ignoreAdult = ignoreAdultRow ? ignoreAdultRow.value !== 'false' : true;
528
- const watchlistOnlyRow = await SettingsRepository.getByKey(req.db, 'cwWatchlistOnly');
529
- const watchlistOnly = watchlistOnlyRow
530
- ? watchlistOnlyRow.value === 'true' || watchlistOnlyRow.value === '1'
531
- : false;
532
- const enrichedRows = rows
533
- .filter((show) => {
534
- if (ignoreAdult && show.isAdult)
535
- return false;
536
- if (watchlistOnly && show.watchlistStatus !== 'Watching')
537
- return false;
538
- return true;
539
- })
540
- .map((show) => ({
541
- ...show,
542
- episodeCount: show.episodeCount,
543
- type: show.type || show.smType,
544
- thumbnail: show.thumbnail ?? '',
545
- }));
546
- await this.backfillMissingPosters(req.db, enrichedRows);
547
- return enrichedRows;
548
- }
549
- getAllContinueWatching = async (req, res) => {
550
- const page = parseInt(req.query.page) || 1;
551
- const limit = parseInt(req.query.limit) || 10;
552
- const offset = (page - 1) * limit;
553
- const filters = this.getWatchlistFilters(req.query);
554
- const data = await this.filterWatchlistRows(await this.getContinueWatchingData(req), filters, req.db);
555
- res.json({
556
- data: data.slice(offset, offset + limit),
557
- total: data.length,
558
- page,
559
- limit,
560
- });
561
- };
562
- updateProgress = async (req, res) => {
563
- const { showId: showIdRaw, episodeNumber, currentTime, duration, showName, showThumbnail, nativeName, englishName, genres, popularityScore, type, status, episodeCount, isPlaying, sessionId, isAdult, } = req.body;
564
- const showId = await getMigratedId(req.db, showIdRaw);
565
- const titlePreferenceRow = await SettingsRepository.getByKey(req.db, 'titlePreference');
566
- const titlePreference = titlePreferenceRow ? titlePreferenceRow.value : 'englishName';
567
- let displayName = showName;
568
- if (titlePreference === 'englishName' && englishName) {
569
- displayName = englishName;
570
- }
571
- else if (titlePreference === 'nativeName' && nativeName) {
572
- displayName = nativeName;
573
- }
574
- discordRPCService.updatePresence({
575
- title: displayName,
576
- episode: String(episodeNumber),
577
- totalEpisodes: episodeCount ? String(episodeCount) : undefined,
578
- currentTime: currentTime || 0,
579
- duration: duration || 0,
580
- thumbnail: showThumbnail || '',
581
- isPlaying: isPlaying !== false,
582
- sessionId,
583
- isAdult,
584
- });
585
- const genresStr = Array.isArray(genres) ? JSON.stringify(genres) : genres;
586
- const anilistId = /^\d+$/.test(showId)
587
- ? (dbGet(req.db, 'SELECT anilistId FROM shows_meta WHERE id = ? AND anilistId IS NOT NULL', [showId])?.anilistId ?? parseInt(showId))
588
- : undefined;
589
- const metaCandidate = {
590
- name: showName,
591
- thumbnail: showThumbnail,
592
- nativeName,
593
- englishName,
594
- genres: genresStr,
595
- popularityScore,
596
- status,
597
- episodeCount,
598
- type,
599
- anilistId,
600
- isAdult: typeof isAdult === 'boolean' ? (isAdult ? 1 : 0) : null,
601
- episodeDuration: duration > 0 ? Math.round(duration / 60) : undefined,
602
- };
603
- const metaChanged = this.showsMetaChanged(req.db, showId, metaCandidate);
604
- if (metaChanged) {
605
- await performWriteTransaction(req.db, (tx) => {
606
- ShowsMetaRepository.upsert(tx, {
607
- id: showId,
608
- ...metaCandidate,
609
- });
610
- });
611
- }
612
- await performWriteTransaction(req.db, (tx) => {
613
- WatchedEpisodesRepository.upsert(tx, {
614
- showId,
615
- episodeNumber,
616
- currentTime,
617
- duration,
618
- });
619
- NotificationsRepository.deleteSpecificDismissed(tx, showId, episodeNumber);
620
- NotificationsRepository.deleteDiscovered(tx, showId, episodeNumber);
621
- });
622
- req.db.scheduleSave();
623
- res.json({ success: true });
624
- };
625
- removeContinueWatching = async (req, res) => {
626
- const { showId: showIdRaw } = req.body;
627
- const showId = await getMigratedId(req.db, showIdRaw);
628
- await performWriteTransaction(req.db, (tx) => {
629
- WatchedEpisodesRepository.deleteByShow(tx, showId);
630
- NotificationsRepository.deleteByShow(tx, showId);
631
- });
632
- res.json({ success: true });
633
- };
634
- async getAdultNonWatchlistShowIds(db) {
635
- const rows = await dbAll(db, `SELECT DISTINCT we.showId as showId
636
- FROM watched_episodes we
637
- JOIN shows_meta sm ON sm.id = we.showId AND sm.isAdult = 1
638
- LEFT JOIN watchlist w ON w.id = we.showId
639
- WHERE w.id IS NULL`);
640
- return rows.map((r) => r.showId);
641
- }
642
- getAdultContinueWatchingCount = async (req, res) => {
643
- const ids = await this.getAdultNonWatchlistShowIds(req.db);
644
- res.json({ count: ids.length });
645
- };
646
- purgeAdultContinueWatching = async (req, res) => {
647
- const ids = await this.getAdultNonWatchlistShowIds(req.db);
648
- if (ids.length > 0) {
649
- await performWriteTransaction(req.db, (tx) => {
650
- for (const id of ids) {
651
- WatchedEpisodesRepository.deleteByShow(tx, id);
652
- NotificationsRepository.deleteByShow(tx, id);
653
- }
654
- });
655
- req.db.scheduleSave();
656
- }
657
- res.json({ success: true, removed: ids.length });
658
- };
659
- batchRemoveContinueWatching = async (req, res) => {
660
- const { ids: idsRaw } = req.body;
661
- if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
662
- return res.status(400).json({ error: 'ids must be a non-empty array' });
663
- }
664
- const ids = await Promise.all(idsRaw.map((id) => getMigratedId(req.db, id)));
665
- await performWriteTransaction(req.db, (tx) => {
666
- for (const id of ids) {
667
- WatchedEpisodesRepository.deleteByShow(tx, id);
668
- NotificationsRepository.deleteByShow(tx, id);
669
- }
670
- });
671
- req.db.scheduleSave();
672
- res.json({ success: true, removed: ids.length });
673
- };
674
- getWatchlist = async (req, res) => {
675
- const { status, page: pageStr, limit: limitStr, showMature } = req.query;
676
- const page = parseInt(pageStr) || 1;
677
- const limit = parseInt(limitStr) || 10;
678
- const offset = (page - 1) * limit;
679
- const filters = this.getWatchlistFilters(req.query);
680
- const allRows = await WatchlistRepository.getAll(req.db, status);
681
- let filteredRows = await this.filterWatchlistRows(allRows, filters, req.db);
682
- if (showMature === 'false') {
683
- const ids = filteredRows.map((r) => r.id);
684
- if (ids.length > 0) {
685
- const placeholders = ids.map(() => '?').join(',');
686
- const adultIds = await dbAll(req.db, `SELECT id FROM shows_meta WHERE id IN (${placeholders}) AND isAdult = 1`, ids);
687
- const adultSet = new Set(adultIds.map((r) => r.id));
688
- filteredRows = filteredRows.filter((row) => !adultSet.has(row.id));
689
- }
690
- }
691
- const rows = filteredRows.slice(offset, offset + limit);
692
- for (const row of rows) {
693
- if (!row.thumbnail || row.thumbnail.trim() === '') {
694
- try {
695
- const meta = ShowsMetaRepository.getById(req.db, row.id);
696
- if (meta?.thumbnail && meta.thumbnail.trim() !== '') {
697
- row.thumbnail = meta.thumbnail;
698
- }
699
- }
700
- catch {
701
- // ignore
702
- }
703
- }
704
- }
705
- await this.backfillMissingPosters(req.db, rows);
706
- const rowIds = rows.map((r) => r.id);
707
- let adultIds = new Set();
708
- if (rowIds.length > 0) {
709
- const placeholders = rowIds.map(() => '?').join(',');
710
- const adultRows = await dbAll(req.db, `SELECT id FROM shows_meta WHERE id IN (${placeholders}) AND isAdult = 1`, rowIds);
711
- adultIds = new Set(adultRows.map((r) => r.id));
712
- }
713
- res.json({
714
- data: rows.map((row) => ({
715
- ...row,
716
- _id: row.id,
717
- thumbnail: row.thumbnail || '',
718
- isAdult: adultIds.has(row.id),
719
- })),
720
- total: filteredRows.length,
721
- page,
722
- limit,
723
- });
724
- };
725
- checkWatchlist = async (req, res) => {
726
- const showId = await getMigratedId(req.db, req.params.showId);
727
- const item = await WatchlistRepository.getById(req.db, showId);
728
- res.json({ inWatchlist: !!item, status: item?.status ?? null });
729
- };
730
- getQueue = async (req, res) => {
731
- const rows = await QueueRepository.getAll(req.db);
732
- res.json(rows.map((row) => ({
733
- ...row,
734
- _id: row.showId,
735
- id: row.id,
736
- thumbnail: row.thumbnail || '',
737
- })));
738
- };
739
- addToQueue = async (req, res) => {
740
- const { showId: showIdRaw, episodeNumber, showName, showThumbnail, nativeName, englishName, type, } = req.body;
741
- if (!showIdRaw || !episodeNumber) {
742
- return res.status(400).json({ error: 'showId and episodeNumber are required' });
743
- }
744
- const showId = await getMigratedId(req.db, showIdRaw);
745
- const existing = await QueueRepository.getByEpisode(req.db, showId, String(episodeNumber));
746
- await performWriteTransaction(req.db, (tx) => {
747
- if (showName || showThumbnail || nativeName || englishName || type) {
748
- ShowsMetaRepository.upsert(tx, {
749
- id: showId,
750
- name: showName || '',
751
- thumbnail: showThumbnail || '',
752
- nativeName,
753
- englishName,
754
- type,
755
- });
756
- }
757
- if (existing) {
758
- QueueRepository.removeEpisode(tx, showId, String(episodeNumber));
759
- }
760
- else {
761
- QueueRepository.addToEnd(tx, showId, String(episodeNumber));
762
- }
763
- });
764
- req.db.scheduleSave();
765
- res.json({ success: true, queued: !existing });
766
- };
767
- removeFromQueue = async (req, res) => {
768
- const { showId: showIdRaw, episodeNumber } = req.body;
769
- const showId = await getMigratedId(req.db, showIdRaw);
770
- await performWriteTransaction(req.db, (tx) => {
771
- QueueRepository.removeEpisode(tx, showId, String(episodeNumber));
772
- });
773
- res.json({ success: true });
774
- };
775
- clearQueue = async (req, res) => {
776
- await performWriteTransaction(req.db, (tx) => {
777
- QueueRepository.clear(tx);
778
- });
779
- res.json({ success: true });
780
- };
781
- reorderQueue = async (req, res) => {
782
- const { items } = req.body;
783
- if (!Array.isArray(items)) {
784
- return res.status(400).json({ error: 'items must be an array' });
785
- }
786
- await performWriteTransaction(req.db, (tx) => {
787
- QueueRepository.reorder(tx, items);
788
- });
789
- res.json({ success: true });
790
- };
791
- async resolveAvailableEpisodes(db, showId) {
792
- const episodeData = await getAnilistEpisodes(showId);
793
- const episodes = Array.isArray(episodeData) && episodeData.length
794
- ? [...episodeData].sort((a, b) => parseFloat(a) - parseFloat(b))
795
- : [];
796
- return episodes;
797
- }
798
- getQueueRemainingEpisodes = async (req, res) => {
799
- const showIdRaw = req.params.showId;
800
- const showId = await getMigratedId(req.db, showIdRaw);
801
- const [watchedEpisodes, queuedEpisodes, episodes] = await Promise.all([
802
- WatchedEpisodesRepository.getByShow(req.db, showId),
803
- QueueRepository.getByShow(req.db, showId),
804
- this.resolveAvailableEpisodes(req.db, showId),
805
- ]);
806
- const watchedSet = new Set(watchedEpisodes.map((ep) => ep.episodeNumber.toString()));
807
- const queuedSet = new Set(queuedEpisodes.map((ep) => ep.episodeNumber.toString()));
808
- const remaining = episodes.filter((ep) => !watchedSet.has(ep) && !queuedSet.has(ep));
809
- res.json({ showId, episodes: remaining });
810
- };
811
- addToQueueBatch = async (req, res) => {
812
- const { showId: showIdRaw, episodeNumbers, showName, showThumbnail, nativeName, englishName, type, } = req.body;
813
- if (!showIdRaw || !Array.isArray(episodeNumbers) || episodeNumbers.length === 0) {
814
- return res.status(400).json({ error: 'showId and episodeNumbers are required' });
815
- }
816
- const showId = await getMigratedId(req.db, showIdRaw);
817
- const normalized = [...new Set(episodeNumbers.map((ep) => String(ep)))];
818
- await performWriteTransaction(req.db, (tx) => {
819
- if (showName || showThumbnail || nativeName || englishName || type) {
820
- ShowsMetaRepository.upsert(tx, {
821
- id: showId,
822
- name: showName || '',
823
- thumbnail: showThumbnail || '',
824
- nativeName,
825
- englishName,
826
- type,
827
- });
828
- }
829
- return QueueRepository.addManyToEnd(tx, normalized.map((episodeNumber) => ({ showId, episodeNumber })));
830
- });
831
- req.db.scheduleSave();
832
- res.json({ success: true, added: normalized.length });
833
- };
834
- removeFromQueueBatch = async (req, res) => {
835
- const { showId: showIdRaw, episodeNumbers } = req.body;
836
- if (!showIdRaw) {
837
- return res.status(400).json({ error: 'showId is required' });
838
- }
839
- const showId = await getMigratedId(req.db, showIdRaw);
840
- let removed;
841
- await performWriteTransaction(req.db, (tx) => {
842
- removed = (QueueRepository.getByShow(tx, showId) || []).map((ep) => ep.episodeNumber);
843
- const toRemove = Array.isArray(episodeNumbers) && episodeNumbers.length
844
- ? [...new Set(episodeNumbers.map((ep) => String(ep)))]
845
- : removed;
846
- return QueueRepository.removeMany(tx, showId, toRemove);
847
- });
848
- res.json({ success: true, removed: removed.length });
849
- };
850
- getSuggestedQueueEpisode = async (req, res) => {
851
- const showIdRaw = req.params.showId;
852
- const showId = await getMigratedId(req.db, showIdRaw);
853
- const resumeProgress = await WatchedEpisodesRepository.getLatestResumeProgress(req.db, showId);
854
- if (resumeProgress) {
855
- return res.json({
856
- showId,
857
- episodeNumber: resumeProgress.episodeNumber,
858
- resumeTime: resumeProgress.currentTime || 0,
859
- });
860
- }
861
- const [watchedEpisodes, episodes] = await Promise.all([
862
- WatchedEpisodesRepository.getByShow(req.db, showId),
863
- this.resolveAvailableEpisodes(req.db, showId),
864
- ]);
865
- const watchedSet = new Set(watchedEpisodes.map((ep) => ep.episodeNumber.toString()));
866
- const finishedEpisodes = watchedEpisodes
867
- .filter((ep) => ep.duration > 0 && ep.currentTime >= ep.duration * 0.8)
868
- .map((ep) => parseFloat(ep.episodeNumber))
869
- .filter((ep) => !Number.isNaN(ep));
870
- const nextAfterFinished = finishedEpisodes.length > 0 ? String(Math.max(...finishedEpisodes) + 1) : undefined;
871
- const episodeNumber = (nextAfterFinished &&
872
- episodes.includes(nextAfterFinished) &&
873
- !watchedSet.has(nextAfterFinished)
874
- ? nextAfterFinished
875
- : episodes.find((ep) => !watchedSet.has(ep))) ||
876
- episodes[0] ||
877
- '1';
878
- res.json({ showId, episodeNumber, resumeTime: 0 });
879
- };
880
- getEpisodeProgress = async (req, res) => {
881
- const showId = await getMigratedId(req.db, req.params.showId);
882
- const progress = await WatchedEpisodesRepository.getByShowAndEpisode(req.db, showId, req.params.episodeNumber);
883
- res.json(progress || { currentTime: 0, duration: 0 });
884
- };
885
- getWatchedEpisodes = async (req, res) => {
886
- const showId = await getMigratedId(req.db, req.params.showId);
887
- const episodes = await WatchedEpisodesRepository.getWatchedEpisodeNumbers(req.db, showId);
888
- res.json(episodes);
889
- };
890
- addToWatchlist = async (req, res) => {
891
- const { id: idRaw, status, nativeName, englishName } = req.body;
892
- const { name, thumbnail, type, isAdult: isAdultRaw } = req.body;
893
- const id = await getMigratedId(req.db, idRaw);
894
- await performWriteTransaction(req.db, (tx) => {
895
- WatchlistRepository.upsert(tx, {
896
- id,
897
- name,
898
- thumbnail: thumbnail,
899
- status: status || 'Watching',
900
- nativeName: nativeName || '',
901
- englishName: englishName || '',
902
- type: type || 'TV',
903
- });
904
- if (typeof isAdultRaw === 'boolean') {
905
- ShowsMetaRepository.upsert(tx, { id, isAdult: isAdultRaw ? 1 : 0 });
906
- }
907
- });
908
- await req.db.saveNow();
909
- if (name && !/^\d+$/.test(id)) {
910
- const resolveAndSave = async () => {
911
- if (!isAnilistRateLimited()) {
912
- const result = await searchAnilistByTitle(name);
913
- if (result?.id) {
914
- ShowsMetaRepository.upsert(req.db, { id, anilistId: result.id });
915
- req.db.scheduleSave();
916
- return;
917
- }
918
- }
919
- try {
920
- const kitsuResults = await kitsuSearchAnime({ query: name, page: 1, perPage: 3 });
921
- if (kitsuResults.length > 0) {
922
- const anilistId = Math.abs(kitsuResults[0].id);
923
- ShowsMetaRepository.upsert(req.db, { id, anilistId });
924
- req.db.scheduleSave();
925
- }
926
- }
927
- catch {
928
- // Kitsu failed, show will be skipped in discovery
929
- }
930
- };
931
- resolveAndSave().catch(() => { });
932
- }
933
- res.json({ success: true });
934
- };
935
- removeFromWatchlist = async (req, res) => {
936
- const { id: idRaw } = req.body;
937
- const id = await getMigratedId(req.db, idRaw);
938
- await performWriteTransaction(req.db, (tx) => {
939
- WatchlistRepository.delete(tx, id);
940
- WatchedEpisodesRepository.deleteByShow(tx, id);
941
- NotificationsRepository.deleteByShow(tx, id);
942
- });
943
- res.json({ success: true });
944
- };
945
- updateWatchlistStatus = async (req, res) => {
946
- const { id: idRaw, status } = req.body;
947
- const id = await getMigratedId(req.db, idRaw);
948
- await performWriteTransaction(req.db, (tx) => {
949
- WatchlistRepository.updateStatus(tx, id, status);
950
- });
951
- res.json({ success: true });
952
- };
953
- batchUpdateWatchlistStatus = async (req, res) => {
954
- const { ids: idsRaw, status } = req.body;
955
- if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
956
- return res.status(400).json({ error: 'ids must be a non-empty array' });
957
- }
958
- if (!status) {
959
- return res.status(400).json({ error: 'status is required' });
960
- }
961
- const ids = await Promise.all(idsRaw.map((id) => getMigratedId(req.db, id)));
962
- await performWriteTransaction(req.db, (tx) => {
963
- WatchlistRepository.updateStatusMany(tx, ids, status);
964
- });
965
- req.db.scheduleSave();
966
- res.json({ success: true, updated: ids.length });
967
- };
968
- batchRemoveFromWatchlist = async (req, res) => {
969
- const { ids: idsRaw } = req.body;
970
- if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
971
- return res.status(400).json({ error: 'ids must be a non-empty array' });
972
- }
973
- const ids = await Promise.all(idsRaw.map((id) => getMigratedId(req.db, id)));
974
- await performWriteTransaction(req.db, (tx) => {
975
- WatchlistRepository.deleteMany(tx, ids);
976
- for (const id of ids) {
977
- WatchedEpisodesRepository.deleteByShow(tx, id);
978
- NotificationsRepository.deleteByShow(tx, id);
979
- }
980
- });
981
- req.db.scheduleSave();
982
- res.json({ success: true, removed: ids.length });
983
- };
984
- getNotifications = async (req, res) => {
985
- const db = req.db;
986
- const watchingShows = await WatchlistRepository.getWatchingShows(db);
987
- const notifications = [];
988
- for (const show of watchingShows) {
989
- try {
990
- const [watchedEps, dismissedEps, discoveredEps] = await Promise.all([
991
- WatchedEpisodesRepository.getWatchedEpisodeNumbers(db, show.id),
992
- NotificationsRepository.getDismissedByShow(db, show.id),
993
- NotificationsRepository.getDiscoveredByShow(db, show.id),
994
- ]);
995
- const watchedSet = new Set(watchedEps.map((e) => e.toString()));
996
- const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
997
- for (const discovered of discoveredEps) {
998
- if (!watchedSet.has(discovered.episodeNumber) &&
999
- !dismissedSet.has(discovered.episodeNumber)) {
1000
- notifications.push({
1001
- showId: show.id,
1002
- name: show.name,
1003
- nativeName: show.nativeName,
1004
- englishName: show.englishName,
1005
- thumbnail: show.thumbnail,
1006
- episodeNumber: discovered.episodeNumber,
1007
- id: `${show.id}-${discovered.episodeNumber}`,
1008
- });
1009
- }
1010
- }
1011
- }
1012
- catch (e) {
1013
- logger.error({ err: e, showId: show.id }, 'Failed to get notifications for show');
1014
- }
1015
- }
1016
- res.json(notifications.sort((a, b) => parseFloat(b.episodeNumber) - parseFloat(a.episodeNumber)));
1017
- };
1018
- dismissNotification = async (req, res) => {
1019
- const { showId, episodeNumber } = req.body;
1020
- await performWriteTransaction(req.db, (tx) => {
1021
- NotificationsRepository.addDismissed(tx, showId, episodeNumber);
1022
- });
1023
- res.json({ success: true });
1024
- };
1025
- clearAllNotifications = async (req, res) => {
1026
- const { showId } = req.body;
1027
- await performWriteTransaction(req.db, (tx) => {
1028
- NotificationsRepository.dismissFromDiscovered(tx, showId);
1029
- });
1030
- res.json({ success: true });
1031
- };
1032
- getThisWeekSchedule = async (req, res) => {
1033
- const rows = await dbAll(req.db, `SELECT
1034
- w.id, w.name, w.thumbnail, w.nativeName, w.englishName, w.type,
1035
- dn.episodeNumber, dn.discoveredAt
1036
- FROM discovered_notifications dn
1037
- JOIN watchlist w ON dn.showId = w.id
1038
- WHERE w.status = 'Watching'
1039
- AND EXISTS (
1040
- SELECT 1 FROM discovered_notifications dn_recent
1041
- WHERE dn_recent.showId = dn.showId
1042
- AND dn_recent.discoveredAt >= datetime('now', '-7 days')
1043
- )
1044
- AND CAST(dn.episodeNumber AS INTEGER) = (
1045
- SELECT MIN(CAST(dn2.episodeNumber AS INTEGER))
1046
- FROM discovered_notifications dn2
1047
- WHERE dn2.showId = dn.showId
1048
- AND NOT EXISTS (
1049
- SELECT 1 FROM watched_episodes we2
1050
- WHERE we2.showId = dn2.showId AND we2.episodeNumber = dn2.episodeNumber
1051
- )
1052
- )
1053
- AND NOT EXISTS (
1054
- SELECT 1 FROM watched_episodes we
1055
- WHERE we.showId = dn.showId AND we.episodeNumber = dn.episodeNumber
1056
- )
1057
- ORDER BY CAST(dn.episodeNumber AS INTEGER) ASC`);
1058
- res.json(rows.map((row) => ({
1059
- _id: row.id,
1060
- id: row.id,
1061
- name: row.name,
1062
- thumbnail: row.thumbnail || '',
1063
- nativeName: row.nativeName,
1064
- englishName: row.englishName,
1065
- type: row.type,
1066
- episodeNumber: parseInt(row.episodeNumber) || row.episodeNumber,
1067
- })));
1068
- };
1069
- }