@serifpersia/dango 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/client/dist/assets/AnimeInfo-CMxs0nRH.css +1 -0
- package/client/dist/assets/AnimeInfo-DVdaocCl.js +1 -0
- package/client/dist/assets/AnimeInfoPage-Bx5EvGt-.js +2 -0
- package/client/dist/assets/Asmr-C2i9GKD4.css +1 -0
- package/client/dist/assets/Asmr-CIjmFtRH.js +2 -0
- package/client/dist/assets/Asmr.module-DPG2JhDJ.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-CIYcyon5.css +1 -0
- package/client/dist/assets/AvSyncCalibrator-vqErH7Cm.js +10 -0
- package/client/dist/assets/ErrorMessage-BwSjzblw.js +1 -0
- package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
- package/client/dist/assets/Home-D7YfGBUk.js +1 -0
- package/client/dist/assets/Home-DKCfEiwl.css +1 -0
- package/client/dist/assets/Insights-BljI64Ua.css +1 -0
- package/client/dist/assets/Insights-CLQGrlpX.js +1 -0
- package/client/dist/assets/Manga-DQ9HYnpk.js +1 -0
- package/client/dist/assets/Manga-H8Wb04w3.css +1 -0
- package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
- package/client/dist/assets/Map-L_ryVBTq.js +1 -0
- package/client/dist/assets/Mature-BeWzAJ7B.js +1 -0
- package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
- package/client/dist/assets/Player-CYpd__FP.js +30 -0
- package/client/dist/assets/Player-DreAhybc.css +1 -0
- package/client/dist/assets/PlayerRedirect-fxUAp9JV.js +1 -0
- package/client/dist/assets/PlayerSettings-BcsotxP9.js +1 -0
- package/client/dist/assets/PlayerSettings-Cqua1CDD.css +1 -0
- package/client/dist/assets/QueueRail-B9Pgbqc3.js +1 -0
- package/client/dist/assets/QueueRail-BnaltOP8.css +1 -0
- package/client/dist/assets/Radio-BvdY5twD.js +1 -0
- package/client/dist/assets/Radio-CUuYy3DY.css +1 -0
- package/client/dist/assets/Search-BzO-aRP7.css +1 -0
- package/client/dist/assets/Search-R9YLAXS1.js +1 -0
- package/client/dist/assets/Search.module-D8ZbQAiK.js +1 -0
- package/client/dist/assets/Settings-9kYcXdTR.js +21 -0
- package/client/dist/assets/Settings-DF8JThUL.css +1 -0
- package/client/dist/assets/SkeletonGrid-D5vRvX5q.js +1 -0
- package/client/dist/assets/SkeletonGrid-DEbYtO4H.css +1 -0
- package/client/dist/assets/SynopsisText-BlFw0R-0.js +1 -0
- package/client/dist/assets/SynopsisText-CUgOjOxt.css +1 -0
- package/client/dist/assets/Trackers-DfZlnnVq.js +2 -0
- package/client/dist/assets/Trackers-Dt9lR_uP.css +1 -0
- package/client/dist/assets/Tv-CVKC2YBs.css +1 -0
- package/client/dist/assets/Tv-Db1Wx_b7.js +15 -0
- package/client/dist/assets/Watchlist-B2nkNZqM.css +1 -0
- package/client/dist/assets/Watchlist-a77CYtzu.js +1 -0
- package/client/dist/assets/hls-6Owgw2Aj.js +1 -0
- package/client/dist/assets/hls-BOLXTnjL.js +42 -0
- package/client/dist/assets/index-B6qdXFtl.css +1 -0
- package/client/dist/assets/index-B8TPVaIS.js +30354 -0
- package/client/dist/assets/index-CpjgU8ou.js +178 -0
- package/client/dist/assets/useAnimeInfoData-BOfyi9UJ.js +1 -0
- package/client/dist/assets/useIsMobile-CM15uQn8.css +1 -0
- package/client/dist/assets/useIsMobile-Cb8HcKRj.js +1 -0
- package/client/dist/assets/useMatureConsent-CZMYBVXd.js +1 -0
- package/client/dist/assets/vendor-rLNfZm7Y.js +1 -0
- package/client/dist/av-sync-test.mp4 +0 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/index.html +31 -0
- package/client/dist/logo-mochi.png +0 -0
- package/client/dist/logo-sakura.png +0 -0
- package/client/dist/logo.png +0 -0
- package/client/dist/placeholder.svg +4 -0
- package/client/dist/robots.txt +3 -0
- package/orchestrator.js +416 -0
- package/package.json +89 -0
- package/server/dist/app-auth.js +231 -0
- package/server/dist/config.js +133 -0
- package/server/dist/controllers/auth.controller.js +247 -0
- package/server/dist/controllers/data.controller.js +785 -0
- package/server/dist/controllers/insights.controller.js +197 -0
- package/server/dist/controllers/proxy.controller.js +665 -0
- package/server/dist/controllers/settings.controller.js +267 -0
- package/server/dist/controllers/watchlist.controller.js +967 -0
- package/server/dist/db.js +152 -0
- package/server/dist/discord-gateway.js +381 -0
- package/server/dist/discord-rpc.js +424 -0
- package/server/dist/github-sync.js +403 -0
- package/server/dist/google.js +549 -0
- package/server/dist/lib/anilist.js +1519 -0
- package/server/dist/lib/anischedule.js +202 -0
- package/server/dist/lib/ipc.js +32 -0
- package/server/dist/lib/kitsu.js +552 -0
- package/server/dist/lib/mal.js +886 -0
- package/server/dist/lib/migration.js +335 -0
- package/server/dist/lib/temp-ids.js +11 -0
- package/server/dist/lib/tmdb.js +71 -0
- package/server/dist/lib/tracker/anilist-tracker.js +250 -0
- package/server/dist/lib/tracker/sync.service.js +312 -0
- package/server/dist/logger.js +19 -0
- package/server/dist/providers/123anime.provider.js +240 -0
- package/server/dist/providers/anilight.provider.js +366 -0
- package/server/dist/providers/animepahe.provider.js +531 -0
- package/server/dist/providers/animeya.provider.js +746 -0
- package/server/dist/providers/base-provider.js +45 -0
- package/server/dist/providers/hn.provider.js +473 -0
- package/server/dist/providers/ht.provider.js +439 -0
- package/server/dist/providers/jasmr.provider.js +504 -0
- package/server/dist/providers/kaa.provider.js +388 -0
- package/server/dist/providers/manga/manga.types.js +10 -0
- package/server/dist/providers/manga/mangadex.provider.js +227 -0
- package/server/dist/providers/manga/mangapill.provider.js +232 -0
- package/server/dist/providers/megaplay.provider.js +583 -0
- package/server/dist/providers/op.provider.js +440 -0
- package/server/dist/providers/provider.interface.js +2 -0
- package/server/dist/providers/title-matching.js +234 -0
- package/server/dist/providers/wh.provider.js +600 -0
- package/server/dist/rclone.js +126 -0
- package/server/dist/repositories/insights.repository.js +43 -0
- package/server/dist/repositories/mal-cache.repository.js +66 -0
- package/server/dist/repositories/notifications.repository.js +40 -0
- package/server/dist/repositories/queue.repository.js +64 -0
- package/server/dist/repositories/settings.repository.js +15 -0
- package/server/dist/repositories/shows-meta.repository.js +43 -0
- package/server/dist/repositories/temp-show-ids.repository.js +54 -0
- package/server/dist/repositories/watched-episodes.repository.js +54 -0
- package/server/dist/repositories/watchlist.repository.js +74 -0
- package/server/dist/routes/asmr.routes.js +72 -0
- package/server/dist/routes/auth.routes.js +28 -0
- package/server/dist/routes/data.routes.js +50 -0
- package/server/dist/routes/discord-gateway.routes.js +57 -0
- package/server/dist/routes/insights.routes.js +12 -0
- package/server/dist/routes/lan-auth.routes.js +49 -0
- package/server/dist/routes/manga.routes.js +115 -0
- package/server/dist/routes/proxy.routes.js +15 -0
- package/server/dist/routes/radio.routes.js +143 -0
- package/server/dist/routes/settings.routes.js +27 -0
- package/server/dist/routes/tracker.routes.js +143 -0
- package/server/dist/routes/translate.routes.js +90 -0
- package/server/dist/routes/tv.routes.js +843 -0
- package/server/dist/routes/watchlist.routes.js +312 -0
- package/server/dist/server.js +310 -0
- package/server/dist/shipped-defaults.js +8 -0
- package/server/dist/sync-config.js +32 -0
- package/server/dist/sync.js +506 -0
- package/server/dist/utils/cookie.utils.js +17 -0
- package/server/dist/utils/db-utils.js +15 -0
- package/server/dist/utils/env.utils.js +83 -0
- package/server/dist/utils/machine-id.js +46 -0
- package/server/dist/utils/request-context.js +5 -0
- package/server/dist/utils/security.utils.js +99 -0
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.WatchlistController = void 0;
|
|
7
|
+
const logger_1 = __importDefault(require("../logger"));
|
|
8
|
+
const sync_1 = require("../sync");
|
|
9
|
+
const watchlist_repository_1 = require("../repositories/watchlist.repository");
|
|
10
|
+
const watched_episodes_repository_1 = require("../repositories/watched-episodes.repository");
|
|
11
|
+
const shows_meta_repository_1 = require("../repositories/shows-meta.repository");
|
|
12
|
+
const notifications_repository_1 = require("../repositories/notifications.repository");
|
|
13
|
+
const queue_repository_1 = require("../repositories/queue.repository");
|
|
14
|
+
const settings_repository_1 = require("../repositories/settings.repository");
|
|
15
|
+
const discord_rpc_1 = require("../discord-rpc");
|
|
16
|
+
const db_utils_1 = require("../utils/db-utils");
|
|
17
|
+
const anilist_1 = require("../lib/anilist");
|
|
18
|
+
const kitsu_1 = require("../lib/kitsu");
|
|
19
|
+
const migration_1 = require("../lib/migration");
|
|
20
|
+
const BACKGROUND_DISCOVERY_INTERVAL_MS = 5 * 60 * 1000;
|
|
21
|
+
const NUDGE_THROTTLE_MS = 120 * 1000;
|
|
22
|
+
const SLOW_MAX_RUN_MS = 5 * 60 * 1000;
|
|
23
|
+
class WatchlistController {
|
|
24
|
+
triggerDiscovery;
|
|
25
|
+
discoveryIntervalId = null;
|
|
26
|
+
lastExternalDiscoveryAt = 0;
|
|
27
|
+
discoveryBusy = false;
|
|
28
|
+
lastDiscoveryRunAt = 0;
|
|
29
|
+
discoveryState = 'idle';
|
|
30
|
+
discoveryTotal = 0;
|
|
31
|
+
discoveryDone = 0;
|
|
32
|
+
stopped = false;
|
|
33
|
+
stopNotificationDiscovery() {
|
|
34
|
+
this.stopped = true;
|
|
35
|
+
if (this.discoveryIntervalId !== null) {
|
|
36
|
+
clearInterval(this.discoveryIntervalId);
|
|
37
|
+
this.discoveryIntervalId = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
getDiscoveryStatus = () => ({
|
|
41
|
+
running: this.discoveryBusy,
|
|
42
|
+
state: this.discoveryState,
|
|
43
|
+
total: this.discoveryTotal,
|
|
44
|
+
done: Math.min(this.discoveryDone, this.discoveryTotal),
|
|
45
|
+
lastRunAt: this.lastDiscoveryRunAt,
|
|
46
|
+
});
|
|
47
|
+
startNotificationDiscovery(getDb) {
|
|
48
|
+
const anilistIdCache = new Map();
|
|
49
|
+
const getAnilistId = async (showId, showName) => {
|
|
50
|
+
if (anilistIdCache.has(showId)) {
|
|
51
|
+
return anilistIdCache.get(showId) || null;
|
|
52
|
+
}
|
|
53
|
+
const db = getDb();
|
|
54
|
+
const meta = (await shows_meta_repository_1.ShowsMetaRepository.getById(db, showId));
|
|
55
|
+
if (meta?.anilistId) {
|
|
56
|
+
anilistIdCache.set(showId, meta.anilistId);
|
|
57
|
+
return meta.anilistId;
|
|
58
|
+
}
|
|
59
|
+
if (/^\d+$/.test(showId)) {
|
|
60
|
+
const numericId = parseInt(showId);
|
|
61
|
+
anilistIdCache.set(showId, numericId);
|
|
62
|
+
return numericId;
|
|
63
|
+
}
|
|
64
|
+
anilistIdCache.set(showId, null);
|
|
65
|
+
return null;
|
|
66
|
+
};
|
|
67
|
+
const runDiscovery = async (fast = false) => {
|
|
68
|
+
if (this.discoveryBusy || this.stopped)
|
|
69
|
+
return;
|
|
70
|
+
this.discoveryBusy = true;
|
|
71
|
+
this.discoveryState = 'running';
|
|
72
|
+
this.discoveryTotal = 0;
|
|
73
|
+
this.discoveryDone = 0;
|
|
74
|
+
const db = getDb();
|
|
75
|
+
if (!db || db.isClosedCheck()) {
|
|
76
|
+
this.discoveryBusy = false;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const startedAt = Date.now();
|
|
80
|
+
const MAX_RUN_MS = fast ? 90000 : SLOW_MAX_RUN_MS;
|
|
81
|
+
try {
|
|
82
|
+
const watchingShows = await watchlist_repository_1.WatchlistRepository.getWatchingShows(db);
|
|
83
|
+
this.discoveryTotal = watchingShows.length;
|
|
84
|
+
if (watchingShows.length === 0) {
|
|
85
|
+
this.discoveryBusy = false;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const showIdMap = new Map();
|
|
89
|
+
const MAP_CONCURRENCY = fast ? 4 : 2;
|
|
90
|
+
const anilistResults = [];
|
|
91
|
+
let nextIndex = 0;
|
|
92
|
+
const worker = async () => {
|
|
93
|
+
while (nextIndex < watchingShows.length) {
|
|
94
|
+
if (Date.now() - startedAt > MAX_RUN_MS)
|
|
95
|
+
return;
|
|
96
|
+
const show = watchingShows[nextIndex];
|
|
97
|
+
nextIndex += 1;
|
|
98
|
+
const id = await getAnilistId(show.id, show.name);
|
|
99
|
+
this.discoveryDone += 1;
|
|
100
|
+
anilistResults.push({ show, id });
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
await Promise.all(Array.from({ length: Math.min(MAP_CONCURRENCY, watchingShows.length) }, () => worker()));
|
|
104
|
+
for (const { show, id } of anilistResults) {
|
|
105
|
+
if (id) {
|
|
106
|
+
showIdMap.set(show.id, id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (Date.now() - startedAt > MAX_RUN_MS || showIdMap.size === 0) {
|
|
110
|
+
this.discoveryBusy = false;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const weekStart = new Date(now);
|
|
115
|
+
weekStart.setDate(now.getDate() - 7);
|
|
116
|
+
weekStart.setHours(0, 0, 0, 0);
|
|
117
|
+
const weekEnd = new Date(now);
|
|
118
|
+
weekEnd.setHours(23, 59, 59, 999);
|
|
119
|
+
const schedules = await (0, anilist_1.getAiredEpisodesForShows)(Array.from(showIdMap.values()), weekStart, weekEnd);
|
|
120
|
+
const nowUnix = Math.floor(Date.now() / 1000);
|
|
121
|
+
const reverseMap = new Map();
|
|
122
|
+
for (const [watchlistId, anilistId] of showIdMap.entries()) {
|
|
123
|
+
reverseMap.set(anilistId, watchlistId);
|
|
124
|
+
}
|
|
125
|
+
const finishedShowIds = new Set();
|
|
126
|
+
const unresolvedStatus = [];
|
|
127
|
+
for (const [watchlistId, anilistId] of showIdMap.entries()) {
|
|
128
|
+
const localMeta = (await shows_meta_repository_1.ShowsMetaRepository.getById(db, watchlistId));
|
|
129
|
+
if (localMeta?.status === 'FINISHED') {
|
|
130
|
+
finishedShowIds.add(anilistId);
|
|
131
|
+
}
|
|
132
|
+
else if (!localMeta?.status) {
|
|
133
|
+
unresolvedStatus.push({ watchlistId, anilistId });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (unresolvedStatus.length > 0 && !(0, anilist_1.isAnilistRateLimited)()) {
|
|
137
|
+
const statuses = await (0, anilist_1.batchGetShowStatuses)(unresolvedStatus.map((s) => s.anilistId));
|
|
138
|
+
let persisted = 0;
|
|
139
|
+
for (const { watchlistId, anilistId } of unresolvedStatus) {
|
|
140
|
+
const status = statuses.get(anilistId);
|
|
141
|
+
if (status === 'FINISHED') {
|
|
142
|
+
finishedShowIds.add(anilistId);
|
|
143
|
+
await shows_meta_repository_1.ShowsMetaRepository.upsert(db, { id: watchlistId, status: 'FINISHED' });
|
|
144
|
+
persisted++;
|
|
145
|
+
}
|
|
146
|
+
else if (status) {
|
|
147
|
+
await shows_meta_repository_1.ShowsMetaRepository.upsert(db, { id: watchlistId, status });
|
|
148
|
+
persisted++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (persisted > 0)
|
|
152
|
+
db.scheduleSave();
|
|
153
|
+
}
|
|
154
|
+
if (finishedShowIds.size > 0) {
|
|
155
|
+
const monthStart = new Date(now);
|
|
156
|
+
monthStart.setDate(now.getDate() - 30);
|
|
157
|
+
monthStart.setHours(0, 0, 0, 0);
|
|
158
|
+
const monthEnd = new Date(now);
|
|
159
|
+
monthEnd.setHours(23, 59, 59, 999);
|
|
160
|
+
const finishedSchedules = await (0, anilist_1.getAiredEpisodesForShows)(Array.from(finishedShowIds), monthStart, monthEnd);
|
|
161
|
+
const latestFinishedByShow = new Map();
|
|
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 current = latestFinishedByShow.get(watchlistId);
|
|
169
|
+
if (!current || entry.airingAt > current.airingAt) {
|
|
170
|
+
latestFinishedByShow.set(watchlistId, {
|
|
171
|
+
episodeKey: String(Math.round(entry.episode)),
|
|
172
|
+
airingAt: entry.airingAt,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const [watchlistId, { episodeKey, airingAt }] of latestFinishedByShow) {
|
|
177
|
+
if (nowUnix - airingAt > 30 * 24 * 60 * 60)
|
|
178
|
+
continue;
|
|
179
|
+
const [watchedEps, dismissedEps] = await Promise.all([
|
|
180
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.getWatchedEpisodeNumbers(db, watchlistId),
|
|
181
|
+
notifications_repository_1.NotificationsRepository.getDismissedByShow(db, watchlistId),
|
|
182
|
+
]);
|
|
183
|
+
const watchedSet = new Set(watchedEps.map((e) => e.toString()));
|
|
184
|
+
const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
|
|
185
|
+
if (!watchedSet.has(episodeKey) && !dismissedSet.has(episodeKey)) {
|
|
186
|
+
await notifications_repository_1.NotificationsRepository.addDiscovered(db, watchlistId, episodeKey);
|
|
187
|
+
db.scheduleSave();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const latestByShow = new Map();
|
|
192
|
+
for (const entry of schedules) {
|
|
193
|
+
if (entry.airingAt > nowUnix)
|
|
194
|
+
continue;
|
|
195
|
+
const watchlistId = reverseMap.get(entry.mediaId);
|
|
196
|
+
if (!watchlistId)
|
|
197
|
+
continue;
|
|
198
|
+
const current = latestByShow.get(watchlistId);
|
|
199
|
+
if (!current || entry.airingAt > current.airingAt) {
|
|
200
|
+
latestByShow.set(watchlistId, {
|
|
201
|
+
episodeKey: String(Math.round(entry.episode)),
|
|
202
|
+
airingAt: entry.airingAt,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const [watchlistId, { episodeKey, airingAt }] of latestByShow) {
|
|
207
|
+
const [watchedEps, dismissedEps] = await Promise.all([
|
|
208
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.getWatchedEpisodeNumbers(db, watchlistId),
|
|
209
|
+
notifications_repository_1.NotificationsRepository.getDismissedByShow(db, watchlistId),
|
|
210
|
+
]);
|
|
211
|
+
const watchedSet = new Set(watchedEps.map((e) => e.toString()));
|
|
212
|
+
const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
|
|
213
|
+
if (!watchedSet.has(episodeKey) && !dismissedSet.has(episodeKey)) {
|
|
214
|
+
await notifications_repository_1.NotificationsRepository.addDiscovered(db, watchlistId, episodeKey);
|
|
215
|
+
db.scheduleSave();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
await notifications_repository_1.NotificationsRepository.cleanupWatchedNotifications(db);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
this.discoveryState = 'error';
|
|
222
|
+
if (e?.message === 'Database is closed') {
|
|
223
|
+
logger_1.default.info('Notification discovery stopped: database is closed');
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
logger_1.default.error({ err: e }, 'AniList notification discovery failed');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
this.discoveryBusy = false;
|
|
231
|
+
this.lastDiscoveryRunAt = Date.now();
|
|
232
|
+
if (this.discoveryState === 'running') {
|
|
233
|
+
this.discoveryState = this.discoveryTotal === 0 ? 'empty' : 'complete';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
this.triggerDiscovery = (force = false) => {
|
|
238
|
+
if (this.stopped || this.discoveryBusy)
|
|
239
|
+
return false;
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
if (!force && now - this.lastExternalDiscoveryAt < NUDGE_THROTTLE_MS)
|
|
242
|
+
return false;
|
|
243
|
+
this.lastExternalDiscoveryAt = now;
|
|
244
|
+
runDiscovery(force);
|
|
245
|
+
return true;
|
|
246
|
+
};
|
|
247
|
+
this.discoveryIntervalId = setInterval(() => {
|
|
248
|
+
if (!this.stopped)
|
|
249
|
+
runDiscovery(false);
|
|
250
|
+
}, BACKGROUND_DISCOVERY_INTERVAL_MS);
|
|
251
|
+
}
|
|
252
|
+
showsMetaChanged(db, showId, candidate) {
|
|
253
|
+
const existing = shows_meta_repository_1.ShowsMetaRepository.getById(db, showId);
|
|
254
|
+
if (!existing)
|
|
255
|
+
return true;
|
|
256
|
+
const differs = (incoming, stored) => {
|
|
257
|
+
if (incoming === undefined || incoming === null || incoming === '')
|
|
258
|
+
return false;
|
|
259
|
+
return String(incoming) !== String(stored ?? '');
|
|
260
|
+
};
|
|
261
|
+
return (differs(candidate.name, existing.name) ||
|
|
262
|
+
differs(candidate.thumbnail, existing.thumbnail) ||
|
|
263
|
+
differs(candidate.nativeName, existing.nativeName) ||
|
|
264
|
+
differs(candidate.englishName, existing.englishName) ||
|
|
265
|
+
differs(candidate.genres, existing.genres) ||
|
|
266
|
+
differs(candidate.popularityScore, existing.popularityScore) ||
|
|
267
|
+
differs(candidate.status, existing.status) ||
|
|
268
|
+
differs(candidate.episodeCount, existing.episodeCount) ||
|
|
269
|
+
differs(candidate.type, existing.type) ||
|
|
270
|
+
differs(candidate.anilistId, existing.anilistId) ||
|
|
271
|
+
differs(candidate.isAdult, existing.isAdult));
|
|
272
|
+
}
|
|
273
|
+
normalizeFilterValue(value) {
|
|
274
|
+
if (typeof value !== 'string')
|
|
275
|
+
return undefined;
|
|
276
|
+
const trimmed = value.trim();
|
|
277
|
+
return trimmed && trimmed !== 'ALL' ? trimmed : undefined;
|
|
278
|
+
}
|
|
279
|
+
getWatchlistFilters(query) {
|
|
280
|
+
return {
|
|
281
|
+
query: this.normalizeFilterValue(query.query),
|
|
282
|
+
type: this.normalizeFilterValue(query.type),
|
|
283
|
+
season: this.normalizeFilterValue(query.season),
|
|
284
|
+
year: this.normalizeFilterValue(query.year),
|
|
285
|
+
genres: this.normalizeFilterValue(query.genres),
|
|
286
|
+
excludeGenres: this.normalizeFilterValue(query.excludeGenres),
|
|
287
|
+
sortBy: this.normalizeFilterValue(query.sortBy),
|
|
288
|
+
titlePreference: ['name', 'nativeName', 'englishName'].includes(String(query.titlePreference))
|
|
289
|
+
? query.titlePreference
|
|
290
|
+
: 'name',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
matchesLocalFilters(row, filters) {
|
|
294
|
+
if (filters.query) {
|
|
295
|
+
const queryWords = new Set(filters.query
|
|
296
|
+
.toLowerCase()
|
|
297
|
+
.split(/\s+/)
|
|
298
|
+
.filter((w) => w.length >= 2));
|
|
299
|
+
const rowTitle = (row.englishName || row.name || row.nativeName || '').toLowerCase();
|
|
300
|
+
const titleWords = rowTitle.split(/\s+/);
|
|
301
|
+
const overlap = titleWords.filter((w) => queryWords.has(w)).length;
|
|
302
|
+
if (overlap < queryWords.size)
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
if (filters.type && row.type !== filters.type)
|
|
306
|
+
return false;
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
sortFilteredRows(rows, filters) {
|
|
310
|
+
const getSortTitle = (row) => {
|
|
311
|
+
const preferredTitle = filters.titlePreference ? row[filters.titlePreference] : undefined;
|
|
312
|
+
return preferredTitle || row.name || '';
|
|
313
|
+
};
|
|
314
|
+
if (filters.sortBy === 'name_asc') {
|
|
315
|
+
return [...rows].sort((a, b) => getSortTitle(a).localeCompare(getSortTitle(b)));
|
|
316
|
+
}
|
|
317
|
+
if (filters.sortBy === 'name_desc') {
|
|
318
|
+
return [...rows].sort((a, b) => getSortTitle(b).localeCompare(getSortTitle(a)));
|
|
319
|
+
}
|
|
320
|
+
return rows;
|
|
321
|
+
}
|
|
322
|
+
async getAnilistSeasonYearMatches(season, year) {
|
|
323
|
+
const matched = new Map();
|
|
324
|
+
if (!year || year === 'ALL') {
|
|
325
|
+
return matched;
|
|
326
|
+
}
|
|
327
|
+
const seasonYear = parseInt(year);
|
|
328
|
+
if (Number.isNaN(seasonYear))
|
|
329
|
+
return matched;
|
|
330
|
+
const perPage = 50;
|
|
331
|
+
let page = 1;
|
|
332
|
+
while (true) {
|
|
333
|
+
const searchVars = {
|
|
334
|
+
seasonYear,
|
|
335
|
+
page,
|
|
336
|
+
perPage,
|
|
337
|
+
};
|
|
338
|
+
if (season && season !== 'ALL') {
|
|
339
|
+
searchVars.season = season.toUpperCase();
|
|
340
|
+
}
|
|
341
|
+
const results = await (0, anilist_1.searchAnilist)(searchVars);
|
|
342
|
+
for (const show of results) {
|
|
343
|
+
if (show.anilistId) {
|
|
344
|
+
matched.set(show.anilistId, { title: show.names || {} });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (results.length < perPage)
|
|
348
|
+
break;
|
|
349
|
+
page++;
|
|
350
|
+
if (page > 10)
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
return matched;
|
|
354
|
+
}
|
|
355
|
+
rowMatchesAnilistSeasonYear(row, anilistMatches) {
|
|
356
|
+
if (anilistMatches.size === 0)
|
|
357
|
+
return true;
|
|
358
|
+
if (/^\d+$/.test(row.id) && anilistMatches.has(parseInt(row.id))) {
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
const rowTitle = (row.englishName || row.name || row.nativeName || '').toLowerCase();
|
|
362
|
+
if (!rowTitle)
|
|
363
|
+
return false;
|
|
364
|
+
const rowWords = new Set(rowTitle.split(/\s+/).filter((w) => w.length >= 2));
|
|
365
|
+
if (rowWords.size === 0)
|
|
366
|
+
return false;
|
|
367
|
+
for (const [, media] of anilistMatches) {
|
|
368
|
+
const titles = [media.title?.romaji, media.title?.english, media.title?.native].filter(Boolean);
|
|
369
|
+
for (const title of titles) {
|
|
370
|
+
const titleWords = new Set(title
|
|
371
|
+
.toLowerCase()
|
|
372
|
+
.split(/\s+/)
|
|
373
|
+
.filter((w) => w.length >= 2));
|
|
374
|
+
const overlap = [...rowWords].filter((w) => titleWords.has(w)).length;
|
|
375
|
+
const minLen = Math.min(rowWords.size, titleWords.size);
|
|
376
|
+
if (minLen >= 2 && overlap / minLen >= 0.7)
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
async filterWatchlistRows(rows, filters, db) {
|
|
383
|
+
let filtered = rows.filter((row) => this.matchesLocalFilters(row, filters));
|
|
384
|
+
if ((filters.genres || filters.excludeGenres) && db) {
|
|
385
|
+
const ids = filtered.map((r) => r.id);
|
|
386
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
387
|
+
const genreRows = await (0, db_utils_1.dbAll)(db, `SELECT id, genres FROM shows_meta WHERE id IN (${placeholders})`, ids);
|
|
388
|
+
const idToGenres = new Map(genreRows.map((r) => [r.id, r.genres ? JSON.parse(r.genres) : []]));
|
|
389
|
+
const includeList = filters.genres?.split(',') || [];
|
|
390
|
+
const excludeList = filters.excludeGenres?.split(',') || [];
|
|
391
|
+
filtered = filtered.filter((row) => {
|
|
392
|
+
const rowGenres = idToGenres.get(row.id) || [];
|
|
393
|
+
if (includeList.length && !includeList.every((g) => rowGenres.includes(g)))
|
|
394
|
+
return false;
|
|
395
|
+
if (excludeList.length && excludeList.some((g) => rowGenres.includes(g)))
|
|
396
|
+
return false;
|
|
397
|
+
return true;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (((filters.season && filters.season !== 'ALL') || (filters.year && filters.year !== 'ALL')) &&
|
|
401
|
+
filtered.length > 0) {
|
|
402
|
+
const anilistMatches = await this.getAnilistSeasonYearMatches(filters.season, filters.year);
|
|
403
|
+
if (anilistMatches.size > 0) {
|
|
404
|
+
filtered = filtered.filter((row) => this.rowMatchesAnilistSeasonYear(row, anilistMatches));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return this.sortFilteredRows(filtered, filters);
|
|
408
|
+
}
|
|
409
|
+
async backfillMissingPosters(db, rows) {
|
|
410
|
+
const missing = rows.filter((r) => !r.thumbnail || r.thumbnail.trim() === '');
|
|
411
|
+
if (missing.length === 0)
|
|
412
|
+
return;
|
|
413
|
+
const slice = missing.slice(0, 10);
|
|
414
|
+
await Promise.allSettled(slice.map(async (row) => {
|
|
415
|
+
try {
|
|
416
|
+
const meta = await (0, anilist_1.getShowMetaById)(row.id);
|
|
417
|
+
const poster = meta?.thumbnail?.trim();
|
|
418
|
+
if (!poster)
|
|
419
|
+
return;
|
|
420
|
+
row.thumbnail = poster;
|
|
421
|
+
const dual = row;
|
|
422
|
+
if (dual._id !== undefined)
|
|
423
|
+
dual._id = row.id;
|
|
424
|
+
try {
|
|
425
|
+
shows_meta_repository_1.ShowsMetaRepository.upsert(db, { id: row.id, thumbnail: poster });
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// ignore
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
await watchlist_repository_1.WatchlistRepository.updateThumbnail(db, row.id, poster);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
// ignore
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// ignore
|
|
439
|
+
}
|
|
440
|
+
}));
|
|
441
|
+
db.scheduleSave();
|
|
442
|
+
}
|
|
443
|
+
async getContinueWatchingData(req, limit) {
|
|
444
|
+
const rows = await watched_episodes_repository_1.WatchedEpisodesRepository.getContinueWatching(req.db, limit);
|
|
445
|
+
const ignoreAdultRow = await settings_repository_1.SettingsRepository.getByKey(req.db, 'ignoreAdultContent');
|
|
446
|
+
const ignoreAdult = ignoreAdultRow ? ignoreAdultRow.value !== 'false' : true;
|
|
447
|
+
const watchlistOnlyRow = await settings_repository_1.SettingsRepository.getByKey(req.db, 'cwWatchlistOnly');
|
|
448
|
+
const watchlistOnly = watchlistOnlyRow
|
|
449
|
+
? watchlistOnlyRow.value === 'true' || watchlistOnlyRow.value === '1'
|
|
450
|
+
: false;
|
|
451
|
+
const enrichedRows = rows
|
|
452
|
+
.filter((show) => {
|
|
453
|
+
if (ignoreAdult && show.isAdult)
|
|
454
|
+
return false;
|
|
455
|
+
if (watchlistOnly && show.watchlistStatus !== 'Watching')
|
|
456
|
+
return false;
|
|
457
|
+
return true;
|
|
458
|
+
})
|
|
459
|
+
.map((show) => ({
|
|
460
|
+
...show,
|
|
461
|
+
episodeCount: show.episodeCount,
|
|
462
|
+
type: show.type || show.smType,
|
|
463
|
+
thumbnail: show.thumbnail ?? '',
|
|
464
|
+
}));
|
|
465
|
+
await this.backfillMissingPosters(req.db, enrichedRows);
|
|
466
|
+
return enrichedRows;
|
|
467
|
+
}
|
|
468
|
+
getAllContinueWatching = async (req, res) => {
|
|
469
|
+
const page = parseInt(req.query.page) || 1;
|
|
470
|
+
const limit = parseInt(req.query.limit) || 10;
|
|
471
|
+
const offset = (page - 1) * limit;
|
|
472
|
+
const filters = this.getWatchlistFilters(req.query);
|
|
473
|
+
const data = await this.filterWatchlistRows(await this.getContinueWatchingData(req), filters, req.db);
|
|
474
|
+
res.json({
|
|
475
|
+
data: data.slice(offset, offset + limit),
|
|
476
|
+
total: data.length,
|
|
477
|
+
page,
|
|
478
|
+
limit,
|
|
479
|
+
});
|
|
480
|
+
};
|
|
481
|
+
updateProgress = async (req, res) => {
|
|
482
|
+
const { showId: showIdRaw, episodeNumber, currentTime, duration, showName, showThumbnail, nativeName, englishName, genres, popularityScore, type, status, episodeCount, isPlaying, sessionId, isAdult, } = req.body;
|
|
483
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, showIdRaw);
|
|
484
|
+
const titlePreferenceRow = await settings_repository_1.SettingsRepository.getByKey(req.db, 'titlePreference');
|
|
485
|
+
const titlePreference = titlePreferenceRow ? titlePreferenceRow.value : 'englishName';
|
|
486
|
+
let displayName = showName;
|
|
487
|
+
if (titlePreference === 'englishName' && englishName) {
|
|
488
|
+
displayName = englishName;
|
|
489
|
+
}
|
|
490
|
+
else if (titlePreference === 'nativeName' && nativeName) {
|
|
491
|
+
displayName = nativeName;
|
|
492
|
+
}
|
|
493
|
+
discord_rpc_1.discordRPCService.updatePresence({
|
|
494
|
+
title: displayName,
|
|
495
|
+
episode: String(episodeNumber),
|
|
496
|
+
totalEpisodes: episodeCount ? String(episodeCount) : undefined,
|
|
497
|
+
currentTime: currentTime || 0,
|
|
498
|
+
duration: duration || 0,
|
|
499
|
+
thumbnail: showThumbnail || '',
|
|
500
|
+
isPlaying: isPlaying !== false,
|
|
501
|
+
sessionId,
|
|
502
|
+
isAdult,
|
|
503
|
+
});
|
|
504
|
+
const genresStr = Array.isArray(genres) ? JSON.stringify(genres) : genres;
|
|
505
|
+
const anilistId = /^\d+$/.test(showId)
|
|
506
|
+
? ((0, db_utils_1.dbGet)(req.db, 'SELECT anilistId FROM shows_meta WHERE id = ? AND anilistId IS NOT NULL', [showId])?.anilistId ?? parseInt(showId))
|
|
507
|
+
: undefined;
|
|
508
|
+
const metaCandidate = {
|
|
509
|
+
name: showName,
|
|
510
|
+
thumbnail: showThumbnail,
|
|
511
|
+
nativeName,
|
|
512
|
+
englishName,
|
|
513
|
+
genres: genresStr,
|
|
514
|
+
popularityScore,
|
|
515
|
+
status,
|
|
516
|
+
episodeCount,
|
|
517
|
+
type,
|
|
518
|
+
anilistId,
|
|
519
|
+
isAdult: typeof isAdult === 'boolean' ? (isAdult ? 1 : 0) : null,
|
|
520
|
+
};
|
|
521
|
+
const metaChanged = this.showsMetaChanged(req.db, showId, metaCandidate);
|
|
522
|
+
if (metaChanged) {
|
|
523
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
524
|
+
shows_meta_repository_1.ShowsMetaRepository.upsert(tx, {
|
|
525
|
+
id: showId,
|
|
526
|
+
...metaCandidate,
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
531
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.upsert(tx, {
|
|
532
|
+
showId,
|
|
533
|
+
episodeNumber,
|
|
534
|
+
currentTime,
|
|
535
|
+
duration,
|
|
536
|
+
});
|
|
537
|
+
notifications_repository_1.NotificationsRepository.deleteSpecificDismissed(tx, showId, episodeNumber);
|
|
538
|
+
});
|
|
539
|
+
req.db.scheduleSave();
|
|
540
|
+
res.json({ success: true });
|
|
541
|
+
};
|
|
542
|
+
removeContinueWatching = async (req, res) => {
|
|
543
|
+
const { showId: showIdRaw } = req.body;
|
|
544
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, showIdRaw);
|
|
545
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
546
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.deleteByShow(tx, showId);
|
|
547
|
+
notifications_repository_1.NotificationsRepository.deleteByShow(tx, showId);
|
|
548
|
+
});
|
|
549
|
+
res.json({ success: true });
|
|
550
|
+
};
|
|
551
|
+
async getAdultNonWatchlistShowIds(db) {
|
|
552
|
+
const rows = await (0, db_utils_1.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
|
+
getAdultContinueWatchingCount = async (req, res) => {
|
|
560
|
+
const ids = await this.getAdultNonWatchlistShowIds(req.db);
|
|
561
|
+
res.json({ count: ids.length });
|
|
562
|
+
};
|
|
563
|
+
purgeAdultContinueWatching = async (req, res) => {
|
|
564
|
+
const ids = await this.getAdultNonWatchlistShowIds(req.db);
|
|
565
|
+
if (ids.length > 0) {
|
|
566
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
567
|
+
for (const id of ids) {
|
|
568
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.deleteByShow(tx, id);
|
|
569
|
+
notifications_repository_1.NotificationsRepository.deleteByShow(tx, id);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
req.db.scheduleSave();
|
|
573
|
+
}
|
|
574
|
+
res.json({ success: true, removed: ids.length });
|
|
575
|
+
};
|
|
576
|
+
batchRemoveContinueWatching = async (req, res) => {
|
|
577
|
+
const { ids: idsRaw } = req.body;
|
|
578
|
+
if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
|
|
579
|
+
return res.status(400).json({ error: 'ids must be a non-empty array' });
|
|
580
|
+
}
|
|
581
|
+
const ids = await Promise.all(idsRaw.map((id) => (0, migration_1.getMigratedId)(req.db, id)));
|
|
582
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
583
|
+
for (const id of ids) {
|
|
584
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.deleteByShow(tx, id);
|
|
585
|
+
notifications_repository_1.NotificationsRepository.deleteByShow(tx, id);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
req.db.scheduleSave();
|
|
589
|
+
res.json({ success: true, removed: ids.length });
|
|
590
|
+
};
|
|
591
|
+
getWatchlist = async (req, res) => {
|
|
592
|
+
const { status, page: pageStr, limit: limitStr } = req.query;
|
|
593
|
+
const page = parseInt(pageStr) || 1;
|
|
594
|
+
const limit = parseInt(limitStr) || 10;
|
|
595
|
+
const offset = (page - 1) * limit;
|
|
596
|
+
const filters = this.getWatchlistFilters(req.query);
|
|
597
|
+
const allRows = await watchlist_repository_1.WatchlistRepository.getAll(req.db, status);
|
|
598
|
+
const filteredRows = await this.filterWatchlistRows(allRows, filters, req.db);
|
|
599
|
+
const rows = filteredRows.slice(offset, offset + limit);
|
|
600
|
+
for (const row of rows) {
|
|
601
|
+
if (!row.thumbnail || row.thumbnail.trim() === '') {
|
|
602
|
+
try {
|
|
603
|
+
const meta = shows_meta_repository_1.ShowsMetaRepository.getById(req.db, row.id);
|
|
604
|
+
if (meta?.thumbnail && meta.thumbnail.trim() !== '') {
|
|
605
|
+
row.thumbnail = meta.thumbnail;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// ignore
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
await this.backfillMissingPosters(req.db, rows);
|
|
614
|
+
res.json({
|
|
615
|
+
data: rows.map((row) => ({
|
|
616
|
+
...row,
|
|
617
|
+
_id: row.id,
|
|
618
|
+
thumbnail: row.thumbnail || '',
|
|
619
|
+
})),
|
|
620
|
+
total: filteredRows.length,
|
|
621
|
+
page,
|
|
622
|
+
limit,
|
|
623
|
+
});
|
|
624
|
+
};
|
|
625
|
+
checkWatchlist = async (req, res) => {
|
|
626
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, req.params.showId);
|
|
627
|
+
const item = await watchlist_repository_1.WatchlistRepository.getById(req.db, showId);
|
|
628
|
+
res.json({ inWatchlist: !!item, status: item?.status ?? null });
|
|
629
|
+
};
|
|
630
|
+
getQueue = async (req, res) => {
|
|
631
|
+
const rows = await queue_repository_1.QueueRepository.getAll(req.db);
|
|
632
|
+
res.json(rows.map((row) => ({
|
|
633
|
+
...row,
|
|
634
|
+
_id: row.showId,
|
|
635
|
+
id: row.id,
|
|
636
|
+
thumbnail: row.thumbnail || '',
|
|
637
|
+
})));
|
|
638
|
+
};
|
|
639
|
+
addToQueue = async (req, res) => {
|
|
640
|
+
const { showId: showIdRaw, episodeNumber, showName, showThumbnail, nativeName, englishName, type, } = req.body;
|
|
641
|
+
if (!showIdRaw || !episodeNumber) {
|
|
642
|
+
return res.status(400).json({ error: 'showId and episodeNumber are required' });
|
|
643
|
+
}
|
|
644
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, showIdRaw);
|
|
645
|
+
const existing = await queue_repository_1.QueueRepository.getByEpisode(req.db, showId, String(episodeNumber));
|
|
646
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
647
|
+
if (showName || showThumbnail || nativeName || englishName || type) {
|
|
648
|
+
shows_meta_repository_1.ShowsMetaRepository.upsert(tx, {
|
|
649
|
+
id: showId,
|
|
650
|
+
name: showName || '',
|
|
651
|
+
thumbnail: showThumbnail || '',
|
|
652
|
+
nativeName,
|
|
653
|
+
englishName,
|
|
654
|
+
type,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
if (existing) {
|
|
658
|
+
queue_repository_1.QueueRepository.removeEpisode(tx, showId, String(episodeNumber));
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
queue_repository_1.QueueRepository.addToEnd(tx, showId, String(episodeNumber));
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
req.db.scheduleSave();
|
|
665
|
+
res.json({ success: true, queued: !existing });
|
|
666
|
+
};
|
|
667
|
+
removeFromQueue = async (req, res) => {
|
|
668
|
+
const { showId: showIdRaw, episodeNumber } = req.body;
|
|
669
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, showIdRaw);
|
|
670
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
671
|
+
queue_repository_1.QueueRepository.removeEpisode(tx, showId, String(episodeNumber));
|
|
672
|
+
});
|
|
673
|
+
res.json({ success: true });
|
|
674
|
+
};
|
|
675
|
+
clearQueue = async (req, res) => {
|
|
676
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
677
|
+
queue_repository_1.QueueRepository.clear(tx);
|
|
678
|
+
});
|
|
679
|
+
res.json({ success: true });
|
|
680
|
+
};
|
|
681
|
+
reorderQueue = async (req, res) => {
|
|
682
|
+
const { items } = req.body;
|
|
683
|
+
if (!Array.isArray(items)) {
|
|
684
|
+
return res.status(400).json({ error: 'items must be an array' });
|
|
685
|
+
}
|
|
686
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
687
|
+
queue_repository_1.QueueRepository.reorder(tx, items);
|
|
688
|
+
});
|
|
689
|
+
res.json({ success: true });
|
|
690
|
+
};
|
|
691
|
+
async resolveAvailableEpisodes(db, showId) {
|
|
692
|
+
const episodeData = await (0, anilist_1.getAnilistEpisodes)(showId);
|
|
693
|
+
const episodes = Array.isArray(episodeData) && episodeData.length
|
|
694
|
+
? [...episodeData].sort((a, b) => parseFloat(a) - parseFloat(b))
|
|
695
|
+
: [];
|
|
696
|
+
return episodes;
|
|
697
|
+
}
|
|
698
|
+
getQueueRemainingEpisodes = async (req, res) => {
|
|
699
|
+
const showIdRaw = req.params.showId;
|
|
700
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, showIdRaw);
|
|
701
|
+
const [watchedEpisodes, queuedEpisodes, episodes] = await Promise.all([
|
|
702
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.getByShow(req.db, showId),
|
|
703
|
+
queue_repository_1.QueueRepository.getByShow(req.db, showId),
|
|
704
|
+
this.resolveAvailableEpisodes(req.db, showId),
|
|
705
|
+
]);
|
|
706
|
+
const watchedSet = new Set(watchedEpisodes.map((ep) => ep.episodeNumber.toString()));
|
|
707
|
+
const queuedSet = new Set(queuedEpisodes.map((ep) => ep.episodeNumber.toString()));
|
|
708
|
+
const remaining = episodes.filter((ep) => !watchedSet.has(ep) && !queuedSet.has(ep));
|
|
709
|
+
res.json({ showId, episodes: remaining });
|
|
710
|
+
};
|
|
711
|
+
addToQueueBatch = async (req, res) => {
|
|
712
|
+
const { showId: showIdRaw, episodeNumbers, showName, showThumbnail, nativeName, englishName, type, } = req.body;
|
|
713
|
+
if (!showIdRaw || !Array.isArray(episodeNumbers) || episodeNumbers.length === 0) {
|
|
714
|
+
return res.status(400).json({ error: 'showId and episodeNumbers are required' });
|
|
715
|
+
}
|
|
716
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, showIdRaw);
|
|
717
|
+
const normalized = [...new Set(episodeNumbers.map((ep) => String(ep)))];
|
|
718
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
719
|
+
if (showName || showThumbnail || nativeName || englishName || type) {
|
|
720
|
+
shows_meta_repository_1.ShowsMetaRepository.upsert(tx, {
|
|
721
|
+
id: showId,
|
|
722
|
+
name: showName || '',
|
|
723
|
+
thumbnail: showThumbnail || '',
|
|
724
|
+
nativeName,
|
|
725
|
+
englishName,
|
|
726
|
+
type,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return queue_repository_1.QueueRepository.addManyToEnd(tx, normalized.map((episodeNumber) => ({ showId, episodeNumber })));
|
|
730
|
+
});
|
|
731
|
+
req.db.scheduleSave();
|
|
732
|
+
res.json({ success: true, added: normalized.length });
|
|
733
|
+
};
|
|
734
|
+
removeFromQueueBatch = async (req, res) => {
|
|
735
|
+
const { showId: showIdRaw, episodeNumbers } = req.body;
|
|
736
|
+
if (!showIdRaw) {
|
|
737
|
+
return res.status(400).json({ error: 'showId is required' });
|
|
738
|
+
}
|
|
739
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, showIdRaw);
|
|
740
|
+
let removed;
|
|
741
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
742
|
+
removed = (queue_repository_1.QueueRepository.getByShow(tx, showId) || []).map((ep) => ep.episodeNumber);
|
|
743
|
+
const toRemove = Array.isArray(episodeNumbers) && episodeNumbers.length
|
|
744
|
+
? [...new Set(episodeNumbers.map((ep) => String(ep)))]
|
|
745
|
+
: removed;
|
|
746
|
+
return queue_repository_1.QueueRepository.removeMany(tx, showId, toRemove);
|
|
747
|
+
});
|
|
748
|
+
res.json({ success: true, removed: removed.length });
|
|
749
|
+
};
|
|
750
|
+
getSuggestedQueueEpisode = async (req, res) => {
|
|
751
|
+
const showIdRaw = req.params.showId;
|
|
752
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, showIdRaw);
|
|
753
|
+
const resumeProgress = await watched_episodes_repository_1.WatchedEpisodesRepository.getLatestResumeProgress(req.db, showId);
|
|
754
|
+
if (resumeProgress) {
|
|
755
|
+
return res.json({
|
|
756
|
+
showId,
|
|
757
|
+
episodeNumber: resumeProgress.episodeNumber,
|
|
758
|
+
resumeTime: resumeProgress.currentTime || 0,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
const [watchedEpisodes, episodes] = await Promise.all([
|
|
762
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.getByShow(req.db, showId),
|
|
763
|
+
this.resolveAvailableEpisodes(req.db, showId),
|
|
764
|
+
]);
|
|
765
|
+
const watchedSet = new Set(watchedEpisodes.map((ep) => ep.episodeNumber.toString()));
|
|
766
|
+
const finishedEpisodes = watchedEpisodes
|
|
767
|
+
.filter((ep) => ep.duration > 0 && ep.currentTime >= ep.duration * 0.8)
|
|
768
|
+
.map((ep) => parseFloat(ep.episodeNumber))
|
|
769
|
+
.filter((ep) => !Number.isNaN(ep));
|
|
770
|
+
const nextAfterFinished = finishedEpisodes.length > 0 ? String(Math.max(...finishedEpisodes) + 1) : undefined;
|
|
771
|
+
const episodeNumber = (nextAfterFinished &&
|
|
772
|
+
episodes.includes(nextAfterFinished) &&
|
|
773
|
+
!watchedSet.has(nextAfterFinished)
|
|
774
|
+
? nextAfterFinished
|
|
775
|
+
: episodes.find((ep) => !watchedSet.has(ep))) ||
|
|
776
|
+
episodes[0] ||
|
|
777
|
+
'1';
|
|
778
|
+
res.json({ showId, episodeNumber, resumeTime: 0 });
|
|
779
|
+
};
|
|
780
|
+
getEpisodeProgress = async (req, res) => {
|
|
781
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, req.params.showId);
|
|
782
|
+
const progress = await watched_episodes_repository_1.WatchedEpisodesRepository.getByShowAndEpisode(req.db, showId, req.params.episodeNumber);
|
|
783
|
+
res.json(progress || { currentTime: 0, duration: 0 });
|
|
784
|
+
};
|
|
785
|
+
getWatchedEpisodes = async (req, res) => {
|
|
786
|
+
const showId = await (0, migration_1.getMigratedId)(req.db, req.params.showId);
|
|
787
|
+
const episodes = await watched_episodes_repository_1.WatchedEpisodesRepository.getWatchedEpisodeNumbers(req.db, showId);
|
|
788
|
+
res.json(episodes);
|
|
789
|
+
};
|
|
790
|
+
addToWatchlist = async (req, res) => {
|
|
791
|
+
const { id: idRaw, status, nativeName, englishName } = req.body;
|
|
792
|
+
const { name, thumbnail, type, isAdult: isAdultRaw } = req.body;
|
|
793
|
+
const id = await (0, migration_1.getMigratedId)(req.db, idRaw);
|
|
794
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
795
|
+
watchlist_repository_1.WatchlistRepository.upsert(tx, {
|
|
796
|
+
id,
|
|
797
|
+
name,
|
|
798
|
+
thumbnail: thumbnail,
|
|
799
|
+
status: status || 'Watching',
|
|
800
|
+
nativeName: nativeName || '',
|
|
801
|
+
englishName: englishName || '',
|
|
802
|
+
type: type || 'TV',
|
|
803
|
+
});
|
|
804
|
+
if (typeof isAdultRaw === 'boolean') {
|
|
805
|
+
shows_meta_repository_1.ShowsMetaRepository.upsert(tx, { id, isAdult: isAdultRaw ? 1 : 0 });
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
await req.db.saveNow();
|
|
809
|
+
if (name && !/^\d+$/.test(id)) {
|
|
810
|
+
const resolveAndSave = async () => {
|
|
811
|
+
if (!(0, anilist_1.isAnilistRateLimited)()) {
|
|
812
|
+
const result = await (0, anilist_1.searchAnilistByTitle)(name);
|
|
813
|
+
if (result?.id) {
|
|
814
|
+
shows_meta_repository_1.ShowsMetaRepository.upsert(req.db, { id, anilistId: result.id });
|
|
815
|
+
req.db.scheduleSave();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
const kitsuResults = await (0, kitsu_1.kitsuSearchAnime)({ query: name, page: 1, perPage: 3 });
|
|
821
|
+
if (kitsuResults.length > 0) {
|
|
822
|
+
const anilistId = Math.abs(kitsuResults[0].id);
|
|
823
|
+
shows_meta_repository_1.ShowsMetaRepository.upsert(req.db, { id, anilistId });
|
|
824
|
+
req.db.scheduleSave();
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
// Kitsu failed, show will be skipped in discovery
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
resolveAndSave().catch(() => { });
|
|
832
|
+
}
|
|
833
|
+
res.json({ success: true });
|
|
834
|
+
};
|
|
835
|
+
removeFromWatchlist = async (req, res) => {
|
|
836
|
+
const { id: idRaw } = req.body;
|
|
837
|
+
const id = await (0, migration_1.getMigratedId)(req.db, idRaw);
|
|
838
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
839
|
+
watchlist_repository_1.WatchlistRepository.delete(tx, id);
|
|
840
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.deleteByShow(tx, id);
|
|
841
|
+
notifications_repository_1.NotificationsRepository.deleteByShow(tx, id);
|
|
842
|
+
});
|
|
843
|
+
res.json({ success: true });
|
|
844
|
+
};
|
|
845
|
+
updateWatchlistStatus = async (req, res) => {
|
|
846
|
+
const { id: idRaw, status } = req.body;
|
|
847
|
+
const id = await (0, migration_1.getMigratedId)(req.db, idRaw);
|
|
848
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
849
|
+
watchlist_repository_1.WatchlistRepository.updateStatus(tx, id, status);
|
|
850
|
+
});
|
|
851
|
+
res.json({ success: true });
|
|
852
|
+
};
|
|
853
|
+
batchUpdateWatchlistStatus = async (req, res) => {
|
|
854
|
+
const { ids: idsRaw, status } = req.body;
|
|
855
|
+
if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
|
|
856
|
+
return res.status(400).json({ error: 'ids must be a non-empty array' });
|
|
857
|
+
}
|
|
858
|
+
if (!status) {
|
|
859
|
+
return res.status(400).json({ error: 'status is required' });
|
|
860
|
+
}
|
|
861
|
+
const ids = await Promise.all(idsRaw.map((id) => (0, migration_1.getMigratedId)(req.db, id)));
|
|
862
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
863
|
+
watchlist_repository_1.WatchlistRepository.updateStatusMany(tx, ids, status);
|
|
864
|
+
});
|
|
865
|
+
req.db.scheduleSave();
|
|
866
|
+
res.json({ success: true, updated: ids.length });
|
|
867
|
+
};
|
|
868
|
+
batchRemoveFromWatchlist = async (req, res) => {
|
|
869
|
+
const { ids: idsRaw } = req.body;
|
|
870
|
+
if (!Array.isArray(idsRaw) || idsRaw.length === 0) {
|
|
871
|
+
return res.status(400).json({ error: 'ids must be a non-empty array' });
|
|
872
|
+
}
|
|
873
|
+
const ids = await Promise.all(idsRaw.map((id) => (0, migration_1.getMigratedId)(req.db, id)));
|
|
874
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
875
|
+
watchlist_repository_1.WatchlistRepository.deleteMany(tx, ids);
|
|
876
|
+
for (const id of ids) {
|
|
877
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.deleteByShow(tx, id);
|
|
878
|
+
notifications_repository_1.NotificationsRepository.deleteByShow(tx, id);
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
req.db.scheduleSave();
|
|
882
|
+
res.json({ success: true, removed: ids.length });
|
|
883
|
+
};
|
|
884
|
+
getNotifications = async (req, res) => {
|
|
885
|
+
const db = req.db;
|
|
886
|
+
const watchingShows = await watchlist_repository_1.WatchlistRepository.getWatchingShows(db);
|
|
887
|
+
const notifications = [];
|
|
888
|
+
for (const show of watchingShows) {
|
|
889
|
+
try {
|
|
890
|
+
const [watchedEps, dismissedEps, discoveredEps] = await Promise.all([
|
|
891
|
+
watched_episodes_repository_1.WatchedEpisodesRepository.getWatchedEpisodeNumbers(db, show.id),
|
|
892
|
+
notifications_repository_1.NotificationsRepository.getDismissedByShow(db, show.id),
|
|
893
|
+
notifications_repository_1.NotificationsRepository.getDiscoveredByShow(db, show.id),
|
|
894
|
+
]);
|
|
895
|
+
const watchedSet = new Set(watchedEps.map((e) => e.toString()));
|
|
896
|
+
const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
|
|
897
|
+
for (const discovered of discoveredEps) {
|
|
898
|
+
if (!watchedSet.has(discovered.episodeNumber) &&
|
|
899
|
+
!dismissedSet.has(discovered.episodeNumber)) {
|
|
900
|
+
notifications.push({
|
|
901
|
+
showId: show.id,
|
|
902
|
+
name: show.name,
|
|
903
|
+
nativeName: show.nativeName,
|
|
904
|
+
englishName: show.englishName,
|
|
905
|
+
thumbnail: show.thumbnail,
|
|
906
|
+
episodeNumber: discovered.episodeNumber,
|
|
907
|
+
id: `${show.id}-${discovered.episodeNumber}`,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
catch (e) {
|
|
913
|
+
logger_1.default.error({ err: e, showId: show.id }, 'Failed to get notifications for show');
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
res.json(notifications.sort((a, b) => parseFloat(b.episodeNumber) - parseFloat(a.episodeNumber)));
|
|
917
|
+
};
|
|
918
|
+
dismissNotification = async (req, res) => {
|
|
919
|
+
const { showId, episodeNumber } = req.body;
|
|
920
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
921
|
+
notifications_repository_1.NotificationsRepository.addDismissed(tx, showId, episodeNumber);
|
|
922
|
+
});
|
|
923
|
+
res.json({ success: true });
|
|
924
|
+
};
|
|
925
|
+
clearAllNotifications = async (req, res) => {
|
|
926
|
+
const { showId } = req.body;
|
|
927
|
+
await (0, sync_1.performWriteTransaction)(req.db, (tx) => {
|
|
928
|
+
notifications_repository_1.NotificationsRepository.dismissFromDiscovered(tx, showId);
|
|
929
|
+
});
|
|
930
|
+
res.json({ success: true });
|
|
931
|
+
};
|
|
932
|
+
getThisWeekSchedule = async (req, res) => {
|
|
933
|
+
const rows = await (0, db_utils_1.dbAll)(req.db, `SELECT
|
|
934
|
+
w.id, w.name, w.thumbnail, w.nativeName, w.englishName, w.type,
|
|
935
|
+
dn.episodeNumber, dn.discoveredAt
|
|
936
|
+
FROM discovered_notifications dn
|
|
937
|
+
JOIN watchlist w ON dn.showId = w.id
|
|
938
|
+
WHERE w.status = 'Watching'
|
|
939
|
+
AND dn.episodeNumber = (
|
|
940
|
+
SELECT MAX(CAST(dn2.episodeNumber AS INTEGER))
|
|
941
|
+
FROM discovered_notifications dn2
|
|
942
|
+
WHERE dn2.showId = dn.showId
|
|
943
|
+
AND dn2.discoveredAt >= datetime('now', '-7 days')
|
|
944
|
+
AND NOT EXISTS (
|
|
945
|
+
SELECT 1 FROM watched_episodes we2
|
|
946
|
+
WHERE we2.showId = dn2.showId AND we2.episodeNumber = dn2.episodeNumber
|
|
947
|
+
)
|
|
948
|
+
)
|
|
949
|
+
AND dn.discoveredAt >= datetime('now', '-7 days')
|
|
950
|
+
AND NOT EXISTS (
|
|
951
|
+
SELECT 1 FROM watched_episodes we
|
|
952
|
+
WHERE we.showId = dn.showId AND we.episodeNumber = dn.episodeNumber
|
|
953
|
+
)
|
|
954
|
+
ORDER BY CAST(dn.episodeNumber AS INTEGER) DESC`);
|
|
955
|
+
res.json(rows.map((row) => ({
|
|
956
|
+
_id: row.id,
|
|
957
|
+
id: row.id,
|
|
958
|
+
name: row.name,
|
|
959
|
+
thumbnail: row.thumbnail || '',
|
|
960
|
+
nativeName: row.nativeName,
|
|
961
|
+
englishName: row.englishName,
|
|
962
|
+
type: row.type,
|
|
963
|
+
episodeNumber: parseInt(row.episodeNumber) || row.episodeNumber,
|
|
964
|
+
})));
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
exports.WatchlistController = WatchlistController;
|