@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
@@ -0,0 +1,733 @@
1
+ import logger from '../logger.js';
2
+ import { performWriteTransactionAsync } 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 { SettingsRepository } from '../repositories/settings.repository.js';
8
+ import { discordRPCService } from '../discord-rpc.js';
9
+ import { dbAll, dbGet } from '../utils/db-utils.js';
10
+ import { searchAnilist, getAiredEpisodesForShows, getAnilistEpisodes, getShowMetaById, isAnilistRateLimited, batchGetShowStatuses, } from '../lib/anilist.js';
11
+ import { getMigratedId } from '../lib/migration.js';
12
+ import { offlineDb } from '../lib/offline-db.js';
13
+ import { parseJsonBody } from '../utils/http.utils.js';
14
+ import { pickBestMatch } from '../providers/title-matching.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
+ let discoveryIntervalId = null;
19
+ let lastExternalDiscoveryAt = 0;
20
+ let discoveryBusy = false;
21
+ let lastDiscoveryRunAt = 0;
22
+ let discoveryState = 'idle';
23
+ let discoveryTotal = 0;
24
+ let discoveryDone = 0;
25
+ let discoveryStopped = false;
26
+ let triggerImpl = null;
27
+ export function triggerWatchlistDiscovery(force = false) {
28
+ return triggerImpl?.(force) ?? false;
29
+ }
30
+ export function stopWatchlistDiscovery() {
31
+ discoveryStopped = true;
32
+ if (discoveryIntervalId !== null) {
33
+ clearInterval(discoveryIntervalId);
34
+ discoveryIntervalId = null;
35
+ }
36
+ }
37
+ export function getWatchlistDiscoveryStatus() {
38
+ return {
39
+ running: discoveryBusy,
40
+ state: discoveryState,
41
+ total: discoveryTotal,
42
+ done: Math.min(discoveryDone, discoveryTotal),
43
+ lastRunAt: lastDiscoveryRunAt,
44
+ };
45
+ }
46
+ export function startWatchlistDiscovery(getDb) {
47
+ const anilistIdCache = new Map();
48
+ const getAnilistId = async (showId, showName) => {
49
+ if (anilistIdCache.has(showId)) {
50
+ return anilistIdCache.get(showId) || null;
51
+ }
52
+ const db = getDb();
53
+ const meta = (await ShowsMetaRepository.getById(db, showId));
54
+ if (meta?.anilistId) {
55
+ anilistIdCache.set(showId, meta.anilistId);
56
+ return meta.anilistId;
57
+ }
58
+ if (/^\d+$/.test(showId)) {
59
+ const numericId = parseInt(showId);
60
+ anilistIdCache.set(showId, numericId);
61
+ return numericId;
62
+ }
63
+ anilistIdCache.set(showId, null);
64
+ return null;
65
+ };
66
+ const runDiscovery = async (fast = false) => {
67
+ if (discoveryBusy || discoveryStopped)
68
+ return;
69
+ discoveryBusy = true;
70
+ discoveryState = 'running';
71
+ discoveryTotal = 0;
72
+ discoveryDone = 0;
73
+ const db = getDb();
74
+ if (!db || db.isClosedCheck()) {
75
+ discoveryBusy = false;
76
+ return;
77
+ }
78
+ const startedAt = Date.now();
79
+ const MAX_RUN_MS = fast ? 90000 : SLOW_MAX_RUN_MS;
80
+ try {
81
+ const watchingShows = await WatchlistRepository.getWatchingShows(db);
82
+ discoveryTotal = watchingShows.length;
83
+ if (watchingShows.length === 0) {
84
+ discoveryBusy = false;
85
+ return;
86
+ }
87
+ const showIdMap = new Map();
88
+ const MAP_CONCURRENCY = fast ? 4 : 2;
89
+ const anilistResults = [];
90
+ let nextIndex = 0;
91
+ const worker = async () => {
92
+ while (nextIndex < watchingShows.length) {
93
+ if (Date.now() - startedAt > MAX_RUN_MS)
94
+ return;
95
+ const show = watchingShows[nextIndex];
96
+ nextIndex += 1;
97
+ const id = await getAnilistId(show.id, show.name);
98
+ discoveryDone += 1;
99
+ anilistResults.push({ show, id });
100
+ }
101
+ };
102
+ await Promise.all(Array.from({ length: Math.min(MAP_CONCURRENCY, watchingShows.length) }, () => worker()));
103
+ for (const { show, id } of anilistResults) {
104
+ if (id) {
105
+ showIdMap.set(show.id, id);
106
+ }
107
+ }
108
+ if (Date.now() - startedAt > MAX_RUN_MS || showIdMap.size === 0) {
109
+ discoveryBusy = false;
110
+ return;
111
+ }
112
+ const now = new Date();
113
+ const weekStart = new Date(now);
114
+ weekStart.setDate(now.getDate() - 7);
115
+ weekStart.setHours(0, 0, 0, 0);
116
+ const weekEnd = new Date(now);
117
+ weekEnd.setHours(23, 59, 59, 999);
118
+ const schedules = await getAiredEpisodesForShows(Array.from(showIdMap.values()), weekStart, weekEnd);
119
+ const nowUnix = Math.floor(Date.now() / 1000);
120
+ const reverseMap = new Map();
121
+ for (const [watchlistId, anilistId] of showIdMap.entries()) {
122
+ reverseMap.set(anilistId, watchlistId);
123
+ }
124
+ const finishedShowIds = new Set();
125
+ const unresolvedStatus = [];
126
+ for (const [watchlistId, anilistId] of showIdMap.entries()) {
127
+ const localMeta = (await ShowsMetaRepository.getById(db, watchlistId));
128
+ if (localMeta?.status === 'FINISHED') {
129
+ finishedShowIds.add(anilistId);
130
+ }
131
+ else if (!localMeta?.status) {
132
+ unresolvedStatus.push({ watchlistId, anilistId });
133
+ }
134
+ }
135
+ if (unresolvedStatus.length > 0 && !isAnilistRateLimited()) {
136
+ const statuses = await batchGetShowStatuses(unresolvedStatus.map((s) => s.anilistId));
137
+ let persisted = 0;
138
+ for (const { watchlistId, anilistId } of unresolvedStatus) {
139
+ const status = statuses.get(anilistId);
140
+ if (status === 'FINISHED') {
141
+ finishedShowIds.add(anilistId);
142
+ await ShowsMetaRepository.upsert(db, { id: watchlistId, status: 'FINISHED' });
143
+ persisted++;
144
+ }
145
+ else if (status) {
146
+ await ShowsMetaRepository.upsert(db, { id: watchlistId, status });
147
+ persisted++;
148
+ }
149
+ }
150
+ if (persisted > 0)
151
+ db.scheduleSave();
152
+ }
153
+ if (finishedShowIds.size > 0) {
154
+ const monthStart = new Date(now);
155
+ monthStart.setDate(now.getDate() - 30);
156
+ monthStart.setHours(0, 0, 0, 0);
157
+ const monthEnd = new Date(now);
158
+ monthEnd.setHours(23, 59, 59, 999);
159
+ const finishedSchedules = await getAiredEpisodesForShows(Array.from(finishedShowIds), monthStart, monthEnd);
160
+ const finishedEpisodesByShow = new Map();
161
+ const seenFinishedKeys = new Set();
162
+ for (const entry of finishedSchedules) {
163
+ if (entry.airingAt > nowUnix)
164
+ continue;
165
+ const watchlistId = reverseMap.get(entry.mediaId);
166
+ if (!watchlistId)
167
+ continue;
168
+ const episodeKey = String(Math.round(entry.episode));
169
+ const dedupeKey = `${watchlistId}:${episodeKey}`;
170
+ if (seenFinishedKeys.has(dedupeKey))
171
+ continue;
172
+ seenFinishedKeys.add(dedupeKey);
173
+ const list = finishedEpisodesByShow.get(watchlistId);
174
+ if (list)
175
+ list.push({ episodeKey, airingAt: entry.airingAt });
176
+ else
177
+ finishedEpisodesByShow.set(watchlistId, [{ episodeKey, airingAt: entry.airingAt }]);
178
+ }
179
+ for (const [watchlistId, episodes] of finishedEpisodesByShow) {
180
+ const [watchedEps, dismissedEps] = await Promise.all([
181
+ WatchedEpisodesRepository.getWatchedEpisodeNumbers(db, watchlistId),
182
+ NotificationsRepository.getDismissedByShow(db, watchlistId),
183
+ ]);
184
+ const watchedSet = new Set(watchedEps.map((e) => e.toString()));
185
+ const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
186
+ let inserted = false;
187
+ for (const { episodeKey, airingAt } of episodes) {
188
+ if (nowUnix - airingAt > 30 * 24 * 60 * 60)
189
+ continue;
190
+ if (!watchedSet.has(episodeKey) && !dismissedSet.has(episodeKey)) {
191
+ await NotificationsRepository.addDiscovered(db, watchlistId, episodeKey);
192
+ inserted = true;
193
+ }
194
+ }
195
+ if (inserted)
196
+ db.scheduleSave();
197
+ }
198
+ }
199
+ const episodesByShow = new Map();
200
+ const seenEpisodeKeys = new Set();
201
+ for (const entry of schedules) {
202
+ if (entry.airingAt > nowUnix)
203
+ continue;
204
+ const watchlistId = reverseMap.get(entry.mediaId);
205
+ if (!watchlistId)
206
+ continue;
207
+ const episodeKey = String(Math.round(entry.episode));
208
+ const dedupeKey = `${watchlistId}:${episodeKey}`;
209
+ if (seenEpisodeKeys.has(dedupeKey))
210
+ continue;
211
+ seenEpisodeKeys.add(dedupeKey);
212
+ const list = episodesByShow.get(watchlistId);
213
+ if (list)
214
+ list.push({ episodeKey, airingAt: entry.airingAt });
215
+ else
216
+ episodesByShow.set(watchlistId, [{ episodeKey, airingAt: entry.airingAt }]);
217
+ }
218
+ for (const [watchlistId, episodes] of episodesByShow) {
219
+ const [watchedEps, dismissedEps] = await Promise.all([
220
+ WatchedEpisodesRepository.getWatchedEpisodeNumbers(db, watchlistId),
221
+ NotificationsRepository.getDismissedByShow(db, watchlistId),
222
+ ]);
223
+ const watchedSet = new Set(watchedEps.map((e) => e.toString()));
224
+ const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
225
+ let inserted = false;
226
+ for (const { episodeKey } of episodes) {
227
+ if (!watchedSet.has(episodeKey) && !dismissedSet.has(episodeKey)) {
228
+ await NotificationsRepository.addDiscovered(db, watchlistId, episodeKey);
229
+ inserted = true;
230
+ }
231
+ }
232
+ if (inserted)
233
+ db.scheduleSave();
234
+ }
235
+ await NotificationsRepository.cleanupWatchedNotifications(db);
236
+ }
237
+ catch (e) {
238
+ discoveryState = 'error';
239
+ if (e?.message === 'Database is closed') {
240
+ logger.info('Notification discovery stopped: database is closed');
241
+ }
242
+ else {
243
+ logger.error({ err: e }, 'AniList notification discovery failed');
244
+ }
245
+ }
246
+ finally {
247
+ discoveryBusy = false;
248
+ lastDiscoveryRunAt = Date.now();
249
+ if (discoveryState === 'running') {
250
+ discoveryState = discoveryTotal === 0 ? 'empty' : 'complete';
251
+ }
252
+ }
253
+ };
254
+ triggerImpl = (force = false) => {
255
+ if (discoveryStopped || discoveryBusy)
256
+ return false;
257
+ const now = Date.now();
258
+ if (!force && now - lastExternalDiscoveryAt < NUDGE_THROTTLE_MS)
259
+ return false;
260
+ lastExternalDiscoveryAt = now;
261
+ runDiscovery(force);
262
+ return true;
263
+ };
264
+ discoveryIntervalId = setInterval(() => {
265
+ if (!discoveryStopped)
266
+ runDiscovery(false);
267
+ }, BACKGROUND_DISCOVERY_INTERVAL_MS);
268
+ }
269
+ async function showsMetaChanged(db, showId, candidate) {
270
+ const existing = (await ShowsMetaRepository.getById(db, showId));
271
+ if (!existing)
272
+ return true;
273
+ const differs = (incoming, stored) => {
274
+ if (incoming === undefined || incoming === null || incoming === '')
275
+ return false;
276
+ return String(incoming) !== String(stored ?? '');
277
+ };
278
+ return (differs(candidate.name, existing.name) ||
279
+ differs(candidate.thumbnail, existing.thumbnail) ||
280
+ differs(candidate.nativeName, existing.nativeName) ||
281
+ differs(candidate.englishName, existing.englishName) ||
282
+ differs(candidate.genres, existing.genres) ||
283
+ differs(candidate.popularityScore, existing.popularityScore) ||
284
+ differs(candidate.status, existing.status) ||
285
+ differs(candidate.episodeCount, existing.episodeCount) ||
286
+ differs(candidate.type, existing.type) ||
287
+ differs(candidate.anilistId, existing.anilistId) ||
288
+ differs(candidate.isAdult, existing.isAdult) ||
289
+ differs(candidate.episodeDuration, existing.episodeDuration));
290
+ }
291
+ function normalizeFilterValue(value) {
292
+ if (typeof value !== 'string')
293
+ return undefined;
294
+ const trimmed = value.trim();
295
+ return trimmed && trimmed !== 'ALL' ? trimmed : undefined;
296
+ }
297
+ function getWatchlistFilters(query) {
298
+ return {
299
+ query: normalizeFilterValue(query['query']),
300
+ type: normalizeFilterValue(query['type']),
301
+ season: normalizeFilterValue(query['season']),
302
+ year: normalizeFilterValue(query['year']),
303
+ genres: normalizeFilterValue(query['genres']),
304
+ excludeGenres: normalizeFilterValue(query['excludeGenres']),
305
+ sortBy: normalizeFilterValue(query['sortBy']),
306
+ titlePreference: ['name', 'nativeName', 'englishName'].includes(String(query['titlePreference']))
307
+ ? query['titlePreference']
308
+ : 'name',
309
+ };
310
+ }
311
+ function matchesLocalFilters(row, filters) {
312
+ if (filters.query) {
313
+ const queryWords = new Set(filters.query
314
+ .toLowerCase()
315
+ .split(/\s+/)
316
+ .filter((w) => w.length >= 2));
317
+ const rowTitle = (row.englishName || row.name || row.nativeName || '').toLowerCase();
318
+ const titleWords = rowTitle.split(/\s+/);
319
+ const overlap = titleWords.filter((w) => queryWords.has(w)).length;
320
+ if (overlap < queryWords.size)
321
+ return false;
322
+ }
323
+ return true;
324
+ }
325
+ function sortFilteredRows(rows, filters) {
326
+ const getSortTitle = (row) => {
327
+ const preferredTitle = filters.titlePreference ? row[filters.titlePreference] : undefined;
328
+ return preferredTitle || row.name || '';
329
+ };
330
+ if (filters.sortBy === 'name_asc') {
331
+ return [...rows].sort((a, b) => getSortTitle(a).localeCompare(getSortTitle(b)));
332
+ }
333
+ if (filters.sortBy === 'name_desc') {
334
+ return [...rows].sort((a, b) => getSortTitle(b).localeCompare(getSortTitle(a)));
335
+ }
336
+ return rows;
337
+ }
338
+ async function getAnilistSeasonYearMatches(season, year) {
339
+ const matched = new Map();
340
+ if (!year || year === 'ALL') {
341
+ return matched;
342
+ }
343
+ const seasonYear = parseInt(year);
344
+ if (Number.isNaN(seasonYear))
345
+ return matched;
346
+ const perPage = 50;
347
+ let page = 1;
348
+ while (true) {
349
+ const searchVars = {
350
+ seasonYear,
351
+ page,
352
+ perPage,
353
+ };
354
+ if (season && season !== 'ALL') {
355
+ searchVars.season = season.toUpperCase();
356
+ }
357
+ const results = await searchAnilist(searchVars);
358
+ for (const show of results) {
359
+ if (show.anilistId) {
360
+ matched.set(show.anilistId, { title: show.names || {} });
361
+ }
362
+ }
363
+ if (results.length < perPage)
364
+ break;
365
+ page++;
366
+ if (page > 10)
367
+ break;
368
+ }
369
+ return matched;
370
+ }
371
+ function rowMatchesAnilistSeasonYear(row, anilistMatches) {
372
+ if (anilistMatches.size === 0)
373
+ return true;
374
+ if (/^\d+$/.test(row.id) && anilistMatches.has(parseInt(row.id))) {
375
+ return true;
376
+ }
377
+ const rowTitle = (row.englishName || row.name || row.nativeName || '').toLowerCase();
378
+ if (!rowTitle)
379
+ return false;
380
+ const rowWords = new Set(rowTitle.split(/\s+/).filter((w) => w.length >= 2));
381
+ if (rowWords.size === 0)
382
+ return false;
383
+ for (const [, media] of anilistMatches) {
384
+ const titles = [media.title?.romaji, media.title?.english, media.title?.native].filter(Boolean);
385
+ for (const title of titles) {
386
+ const titleWords = new Set(title
387
+ .toLowerCase()
388
+ .split(/\s+/)
389
+ .filter((w) => w.length >= 2));
390
+ const overlap = [...rowWords].filter((w) => titleWords.has(w)).length;
391
+ const minLen = Math.min(rowWords.size, titleWords.size);
392
+ if (minLen >= 2 && overlap / minLen >= 0.7)
393
+ return true;
394
+ }
395
+ }
396
+ return false;
397
+ }
398
+ async function filterWatchlistRows(rows, filters, db) {
399
+ let filtered = rows.filter((row) => matchesLocalFilters(row, filters));
400
+ if (filters.type === 'ADULT' && db && filtered.length > 0) {
401
+ const ids = filtered.map((r) => r.id);
402
+ const placeholders = ids.map(() => '?').join(',');
403
+ const adultRows = await dbAll(db, `SELECT id FROM shows_meta WHERE id IN (${placeholders}) AND isAdult = 1`, ids);
404
+ const adultSet = new Set(adultRows.map((r) => r.id));
405
+ filtered = filtered.filter((row) => adultSet.has(row.id));
406
+ }
407
+ else if (filters.type && filters.type !== 'ALL' && filters.type !== 'ADULT') {
408
+ filtered = filtered.filter((row) => row.type === filters.type);
409
+ }
410
+ if ((filters.genres || filters.excludeGenres) && db) {
411
+ const ids = filtered.map((r) => r.id);
412
+ const placeholders = ids.map(() => '?').join(',');
413
+ const genreRows = await dbAll(db, `SELECT id, genres FROM shows_meta WHERE id IN (${placeholders})`, ids);
414
+ const idToGenres = new Map(genreRows.map((r) => [r.id, r.genres ? JSON.parse(r.genres) : []]));
415
+ const includeList = filters.genres
416
+ ?.split(',')
417
+ .map((g) => g.trim())
418
+ .filter(Boolean) || [];
419
+ const excludeList = filters.excludeGenres
420
+ ?.split(',')
421
+ .map((g) => g.trim())
422
+ .filter(Boolean) || [];
423
+ filtered = filtered.filter((row) => {
424
+ const rowGenres = idToGenres.get(row.id) || [];
425
+ if (includeList.length && !includeList.every((g) => rowGenres.includes(g)))
426
+ return false;
427
+ if (excludeList.length && excludeList.some((g) => rowGenres.includes(g)))
428
+ return false;
429
+ return true;
430
+ });
431
+ }
432
+ if (((filters.season && filters.season !== 'ALL') || (filters.year && filters.year !== 'ALL')) &&
433
+ filtered.length > 0) {
434
+ const anilistMatches = await getAnilistSeasonYearMatches(filters.season, filters.year);
435
+ if (anilistMatches.size > 0) {
436
+ filtered = filtered.filter((row) => rowMatchesAnilistSeasonYear(row, anilistMatches));
437
+ }
438
+ }
439
+ return sortFilteredRows(filtered, filters);
440
+ }
441
+ async function backfillMissingPosters(db, rows) {
442
+ const missing = rows.filter((r) => !r.thumbnail || r.thumbnail.trim() === '');
443
+ if (missing.length === 0)
444
+ return;
445
+ const slice = missing.slice(0, 10);
446
+ await Promise.allSettled(slice.map(async (row) => {
447
+ try {
448
+ const meta = await getShowMetaById(row.id);
449
+ const poster = meta?.thumbnail?.trim();
450
+ if (!poster)
451
+ return;
452
+ row.thumbnail = poster;
453
+ const dual = row;
454
+ if (dual._id !== undefined)
455
+ dual._id = row.id;
456
+ try {
457
+ await ShowsMetaRepository.upsert(db, {
458
+ id: row.id,
459
+ thumbnail: poster,
460
+ popularityScore: meta?.score ?? (meta?.averageScore != null ? meta.averageScore / 10 : undefined),
461
+ });
462
+ }
463
+ catch {
464
+ // ignore
465
+ }
466
+ try {
467
+ await WatchlistRepository.updateThumbnail(db, row.id, poster);
468
+ }
469
+ catch {
470
+ // ignore
471
+ }
472
+ }
473
+ catch {
474
+ // ignore
475
+ }
476
+ }));
477
+ db.scheduleSave();
478
+ await sweepOfflinePosters(db);
479
+ }
480
+ async function sweepOfflinePosters(db) {
481
+ try {
482
+ db.run(`UPDATE shows_meta SET name = (SELECT w.name FROM watchlist w WHERE w.id = shows_meta.id)
483
+ WHERE (name IS NULL OR TRIM(name) = '')
484
+ AND EXISTS (SELECT 1 FROM watchlist w WHERE w.id = shows_meta.id AND w.name IS NOT NULL AND TRIM(w.name) != '')`);
485
+ }
486
+ catch {
487
+ // ignore
488
+ }
489
+ const [wlMissing, metaMissing] = await Promise.all([
490
+ WatchlistRepository.getMissingThumbnails(db),
491
+ ShowsMetaRepository.getShowIdsMissingMeta(db),
492
+ ]);
493
+ const ids = [...new Set([...wlMissing.map((r) => r.id), ...metaMissing.map((r) => r.id)])].slice(0, 500);
494
+ if (ids.length === 0)
495
+ return;
496
+ for (const id of ids) {
497
+ const poster = resolveOfflinePoster(id);
498
+ if (poster)
499
+ await applyPoster(db, id, poster);
500
+ }
501
+ db.scheduleSave();
502
+ }
503
+ function resolveOfflinePoster(id) {
504
+ if (/^\d+$/.test(id)) {
505
+ return offlineDb.getByAnilistId(Number(id))?.thumbnail?.trim() || null;
506
+ }
507
+ const mal = /^mal-(\d+)$/i.exec(id)?.[1];
508
+ if (mal)
509
+ return offlineDb.getByMalId(Number(mal))?.thumbnail?.trim() || null;
510
+ return null;
511
+ }
512
+ async function applyPoster(db, id, poster) {
513
+ try {
514
+ await ShowsMetaRepository.upsert(db, { id, thumbnail: poster });
515
+ }
516
+ catch {
517
+ // ignore
518
+ }
519
+ try {
520
+ await WatchlistRepository.updateThumbnail(db, id, poster);
521
+ }
522
+ catch {
523
+ // ignore
524
+ }
525
+ }
526
+ async function getContinueWatchingData(db, limit) {
527
+ const rows = await WatchedEpisodesRepository.getContinueWatching(db, limit);
528
+ const ignoreAdultRow = await SettingsRepository.getByKey(db, 'ignoreAdultContent');
529
+ const ignoreAdult = ignoreAdultRow ? ignoreAdultRow.value !== 'false' : true;
530
+ const watchlistOnlyRow = await SettingsRepository.getByKey(db, 'cwWatchlistOnly');
531
+ const watchlistOnly = watchlistOnlyRow
532
+ ? watchlistOnlyRow.value === 'true' || watchlistOnlyRow.value === '1'
533
+ : false;
534
+ const enrichedRows = rows
535
+ .filter((show) => {
536
+ if (ignoreAdult && show.isAdult)
537
+ return false;
538
+ if (watchlistOnly && show.watchlistStatus !== 'Watching')
539
+ return false;
540
+ return true;
541
+ })
542
+ .map((show) => ({
543
+ ...show,
544
+ episodeCount: show.episodeCount,
545
+ type: show.type || show.smType,
546
+ thumbnail: show.thumbnail ?? '',
547
+ }));
548
+ await backfillMissingPosters(db, enrichedRows);
549
+ return enrichedRows;
550
+ }
551
+ export async function getAdultNonWatchlistShowIds(db) {
552
+ const rows = await dbAll(db, `SELECT DISTINCT we.showId as showId
553
+ FROM watched_episodes we
554
+ JOIN shows_meta sm ON sm.id = we.showId AND sm.isAdult = 1
555
+ LEFT JOIN watchlist w ON w.id = we.showId
556
+ WHERE w.id IS NULL`);
557
+ return rows.map((r) => r.showId);
558
+ }
559
+ async function resolveAvailableEpisodes(db, showId) {
560
+ const episodeData = await getAnilistEpisodes(showId);
561
+ const episodes = Array.isArray(episodeData) && episodeData.length
562
+ ? [...episodeData].sort((a, b) => parseFloat(a) - parseFloat(b))
563
+ : [];
564
+ return episodes;
565
+ }
566
+ const dlsitePosterCache = new Map();
567
+ const mangadexCoverCache = new Map();
568
+ export async function getMangadexCover(title) {
569
+ const key = title.trim().toLowerCase();
570
+ if (!key)
571
+ return null;
572
+ const cached = mangadexCoverCache.get(key);
573
+ if (cached && Date.now() - cached.ts < 3600_000)
574
+ return cached.url;
575
+ try {
576
+ const params = new URLSearchParams({
577
+ title: title.trim(),
578
+ limit: '5',
579
+ 'includes[]': 'cover_art',
580
+ 'order[relevance]': 'desc',
581
+ });
582
+ for (const r of ['safe', 'suggestive', 'erotica', 'pornographic']) {
583
+ params.append('contentRating[]', r);
584
+ }
585
+ const res = await fetch(`https://api.mangadex.org/manga?${params.toString()}`, {
586
+ headers: { 'User-Agent': 'Dango/3.0', Accept: 'application/json' },
587
+ signal: AbortSignal.timeout(10000),
588
+ });
589
+ if (!res.ok)
590
+ return null;
591
+ const data = (await parseJsonBody(res));
592
+ const candidates = [];
593
+ for (const m of data.data || []) {
594
+ const fileName = m.relationships?.find((r) => r.type === 'cover_art')?.attributes?.fileName;
595
+ if (!fileName)
596
+ continue;
597
+ const cover = `https://uploads.mangadex.org/covers/${m.id}/${fileName}.256.jpg`;
598
+ const main = m.attributes?.title;
599
+ const mainTitle = main?.en || main?.['ja-ro'] || Object.values(main || {})[0];
600
+ if (mainTitle)
601
+ candidates.push({ title: mainTitle, cover });
602
+ for (const alt of m.attributes?.altTitles || []) {
603
+ const t = Object.values(alt)[0];
604
+ if (t)
605
+ candidates.push({ title: t, cover });
606
+ }
607
+ }
608
+ const match = pickBestMatch(candidates, [title]);
609
+ const url = match ? match.item.cover : null;
610
+ mangadexCoverCache.set(key, { url, ts: Date.now() });
611
+ return url;
612
+ }
613
+ catch {
614
+ return null;
615
+ }
616
+ }
617
+ export async function getDlsitePoster(rjCode) {
618
+ const key = String(rjCode).trim().toUpperCase();
619
+ if (!/^RJ\d{5,}$/.test(key))
620
+ return null;
621
+ const cached = dlsitePosterCache.get(key);
622
+ if (cached && Date.now() - cached.ts < 3600_000)
623
+ return cached.url;
624
+ try {
625
+ const res = await fetch(`https://www.dlsite.com/maniax/product/info/ajax?product_id=${key}`, {
626
+ headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' },
627
+ signal: AbortSignal.timeout(5000),
628
+ });
629
+ if (!res.ok)
630
+ return null;
631
+ const raw = (await parseJsonBody(res));
632
+ const data = raw;
633
+ const entryRaw = (data[key] ?? data);
634
+ const entry = entryRaw;
635
+ const img = typeof entry?.work_image === 'string' ? entry.work_image : undefined;
636
+ if (!img)
637
+ return null;
638
+ const url = img.startsWith('//') ? `https:${img}` : img;
639
+ dlsitePosterCache.set(key, { url, ts: Date.now() });
640
+ return url;
641
+ }
642
+ catch {
643
+ return null;
644
+ }
645
+ }
646
+ export function registerWatchlist(app, getDbs) {
647
+ const db = () => getDbs().db;
648
+ app.post('/api/update-progress', async (c) => {
649
+ const { showId: showIdRaw, episodeNumber, currentTime, duration, showName, showThumbnail, nativeName, englishName, genres, popularityScore, type, status, episodeCount, isPlaying, sessionId, isAdult, } = (await c.req.json());
650
+ const showId = await getMigratedId(db(), showIdRaw);
651
+ const titlePreferenceRow = await SettingsRepository.getByKey(db(), 'titlePreference');
652
+ const titlePreference = titlePreferenceRow ? titlePreferenceRow.value : 'englishName';
653
+ let displayName = showName;
654
+ if (titlePreference === 'englishName' && englishName) {
655
+ displayName = englishName;
656
+ }
657
+ else if (titlePreference === 'nativeName' && nativeName) {
658
+ displayName = nativeName;
659
+ }
660
+ discordRPCService.updatePresence({
661
+ title: displayName,
662
+ episode: String(episodeNumber),
663
+ totalEpisodes: episodeCount ? String(episodeCount) : undefined,
664
+ currentTime: currentTime || 0,
665
+ duration: duration || 0,
666
+ thumbnail: showThumbnail || '',
667
+ isPlaying: isPlaying !== false,
668
+ sessionId: sessionId,
669
+ isAdult: isAdult,
670
+ });
671
+ const genresStr = Array.isArray(genres) ? JSON.stringify(genres) : genres;
672
+ const anilistId = /^\d+$/.test(showId)
673
+ ? (dbGet(db(), 'SELECT anilistId FROM shows_meta WHERE id = ? AND anilistId IS NOT NULL', [showId])?.anilistId ?? parseInt(showId))
674
+ : undefined;
675
+ const metaCandidate = {
676
+ name: showName,
677
+ thumbnail: showThumbnail,
678
+ nativeName: nativeName,
679
+ englishName: englishName,
680
+ genres: genresStr,
681
+ popularityScore: popularityScore,
682
+ status: status,
683
+ episodeCount: episodeCount,
684
+ type: type,
685
+ anilistId,
686
+ isAdult: typeof isAdult === 'boolean' ? (isAdult ? 1 : 0) : null,
687
+ episodeDuration: duration > 0 ? Math.round(duration / 60) : undefined,
688
+ };
689
+ const metaChanged = await showsMetaChanged(db(), showId, metaCandidate);
690
+ if (metaChanged) {
691
+ await performWriteTransactionAsync(db(), async (tx) => {
692
+ await ShowsMetaRepository.upsert(tx, {
693
+ id: showId,
694
+ ...metaCandidate,
695
+ });
696
+ });
697
+ }
698
+ await performWriteTransactionAsync(db(), async (tx) => {
699
+ await WatchedEpisodesRepository.upsert(tx, {
700
+ showId,
701
+ episodeNumber: episodeNumber,
702
+ currentTime: currentTime,
703
+ duration: duration,
704
+ });
705
+ await NotificationsRepository.deleteSpecificDismissed(tx, showId, episodeNumber);
706
+ await NotificationsRepository.deleteDiscovered(tx, showId, episodeNumber);
707
+ });
708
+ db().scheduleSave();
709
+ return c.json({ success: true });
710
+ });
711
+ app.post('/api/discord/clear', async (c) => {
712
+ const { sessionId } = (await c.req.json());
713
+ if (!discordRPCService.isServiceEnabled) {
714
+ return c.json({ success: true });
715
+ }
716
+ discordRPCService.clearPresence(sessionId);
717
+ return c.json({ success: true });
718
+ });
719
+ app.post('/api/discord/heartbeat', async (c) => {
720
+ const body = (await c.req.json().catch(() => undefined));
721
+ const { sessionId, bye } = body ?? {};
722
+ if (typeof sessionId !== 'string' || !discordRPCService.isServiceEnabled) {
723
+ return c.json({ success: true });
724
+ }
725
+ if (bye) {
726
+ discordRPCService.removeHeartbeat(sessionId);
727
+ }
728
+ else {
729
+ discordRPCService.heartbeat(sessionId);
730
+ }
731
+ return c.json({ success: true });
732
+ });
733
+ }