@serifpersia/dango 3.3.4
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 +258 -0
- package/client/dist/assets/AnimeCard-B5A20jAI.js +1 -0
- package/client/dist/assets/AnimeInfo-0huPAq0O.js +1 -0
- package/client/dist/assets/AnimeInfo-BFWtHlPC.css +1 -0
- package/client/dist/assets/AnimeInfoPage-Dqr5QH6R.js +2 -0
- package/client/dist/assets/AnimeMetaDetails-BPvv5xIl.js +1 -0
- package/client/dist/assets/AnimeMetaDetails-BwNQnRCd.css +1 -0
- package/client/dist/assets/Asmr-B6ZiqkmU.js +2 -0
- package/client/dist/assets/Asmr-Cq2ua6Zk.css +1 -0
- package/client/dist/assets/Asmr.module-ChUXDZKx.js +1 -0
- package/client/dist/assets/AsmrResetProgressModal-Cqsoo3XC.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-C1_iBSyL.css +1 -0
- package/client/dist/assets/AvSyncCalibrator-C8ZoIwMY.js +10 -0
- package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
- package/client/dist/assets/ErrorMessage-z4f4WGgP.js +1 -0
- package/client/dist/assets/Home-DtRtTuI9.css +1 -0
- package/client/dist/assets/Home-thJhPfs1.js +1 -0
- package/client/dist/assets/Insights-DKtoVe6L.css +1 -0
- package/client/dist/assets/Insights-ZlgPrzGD.js +1 -0
- package/client/dist/assets/LibraryListPage-CCiee-Ot.js +1 -0
- package/client/dist/assets/ListeningList-ctAz_6Zi.js +1 -0
- package/client/dist/assets/Manga-BI9WMTx5.js +1 -0
- package/client/dist/assets/Manga-Dp_-l1Pw.css +1 -0
- package/client/dist/assets/Manga.module-BcfOdsNP.js +1 -0
- package/client/dist/assets/MangaInfoPage-DcrEr7Sk.js +1 -0
- package/client/dist/assets/MangaReadPage-SYg63m48.js +1 -0
- package/client/dist/assets/MangaResetProgressModal-DX-1PS4h.js +1 -0
- package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
- package/client/dist/assets/Map-Dj51g5zL.js +1 -0
- package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
- package/client/dist/assets/Mature-Dqh_9SA3.js +1 -0
- package/client/dist/assets/MediaCard-Be1ST9oj.js +1 -0
- package/client/dist/assets/MediaCard-DB6Yjgky.css +1 -0
- package/client/dist/assets/Music-BGH_0voi.css +1 -0
- package/client/dist/assets/Music-zsPwT8_5.js +1 -0
- package/client/dist/assets/OptionListMenu-Drc_vybW.js +1 -0
- package/client/dist/assets/Player-B036Ic3m.css +1 -0
- package/client/dist/assets/Player-BEtAYps2.js +56 -0
- package/client/dist/assets/PlayerRedirect-D70t8VUW.js +1 -0
- package/client/dist/assets/PlayerSettings-Bj6SsmQ2.css +1 -0
- package/client/dist/assets/PlayerSettings-Cwoji-P7.js +1 -0
- package/client/dist/assets/QueueOptionsButton-B6BMq66e.js +1 -0
- package/client/dist/assets/QueueOptionsButton-CM15uQn8.css +1 -0
- package/client/dist/assets/QueueRail-BUYEjdaq.css +1 -0
- package/client/dist/assets/QueueRail-VxpUDBSm.js +1 -0
- package/client/dist/assets/Radio-B2Q_Egj7.js +1 -0
- package/client/dist/assets/Radio-CCHNFN7R.css +1 -0
- package/client/dist/assets/Radio.module-DGSZLpKM.js +1 -0
- package/client/dist/assets/ReadingList-O0QPwXu7.js +1 -0
- package/client/dist/assets/ResetProgressModal-CR_ovLAu.js +1 -0
- package/client/dist/assets/Search-C8e2nlDD.js +1 -0
- package/client/dist/assets/Search-Cs45oAwI.css +1 -0
- package/client/dist/assets/Search.module-CMEai-B-.js +1 -0
- package/client/dist/assets/Settings-AuFxQcJF.css +1 -0
- package/client/dist/assets/Settings-BgPWBl2V.js +21 -0
- package/client/dist/assets/SkeletonGrid-CtHKKMLj.js +1 -0
- package/client/dist/assets/SkeletonGrid-DhPYefXr.css +1 -0
- package/client/dist/assets/SynopsisText-BcpwT5iw.js +1 -0
- package/client/dist/assets/SynopsisText-CYv2W3yR.css +1 -0
- package/client/dist/assets/Trackers-Bfoiyuzy.js +2 -0
- package/client/dist/assets/Trackers-D5XBOCDp.css +1 -0
- package/client/dist/assets/Tv-Ca0fCdko.js +3 -0
- package/client/dist/assets/Tv-CuEl3Iol.css +1 -0
- package/client/dist/assets/TvInfo-Bu36xJyO.css +1 -0
- package/client/dist/assets/TvInfo-D_scgw_h.js +1 -0
- package/client/dist/assets/TvPopup-YTI7dEbJ.js +1 -0
- package/client/dist/assets/TvSearch-CxU33UOf.js +1 -0
- package/client/dist/assets/TvWatchlist-B4X1fWEH.js +1 -0
- package/client/dist/assets/VirtualKeyboard-6ZNTym4R.js +1 -0
- package/client/dist/assets/VirtualKeyboard-KIPc5ZGH.css +1 -0
- package/client/dist/assets/Watchlist-BiDEU0iV.css +1 -0
- package/client/dist/assets/Watchlist-P6YMZUdF.js +1 -0
- package/client/dist/assets/Watchlist.module-XGtVQr1d.js +1 -0
- package/client/dist/assets/hls-BOLXTnjL.js +42 -0
- package/client/dist/assets/hls-C4cG9wQw.js +1 -0
- package/client/dist/assets/index-BOKlZEHq.js +30354 -0
- package/client/dist/assets/index-CUm1meqr.css +1 -0
- package/client/dist/assets/index-IZgh1beL.js +178 -0
- package/client/dist/assets/useAnimeInfoData-DeuwDSnI.js +1 -0
- package/client/dist/assets/useAsmr-CbMVRjmV.js +1 -0
- package/client/dist/assets/useAsmrPopup-D91Q3CBg.js +1 -0
- package/client/dist/assets/useIsMobile-Dm_b6Ob3.js +1 -0
- package/client/dist/assets/useMangaLibrary-BPvFb1lU.js +1 -0
- package/client/dist/assets/useMangaPopup-BOL2mQJs.js +1 -0
- package/client/dist/assets/useMatureConsent-CT7-KfzG.js +1 -0
- package/client/dist/assets/useProviders-BsRGONMA.js +1 -0
- package/client/dist/assets/useTv-D37MfTHA.js +1 -0
- package/client/dist/assets/useTvLibrary-Cwxgy_Bg.js +1 -0
- package/client/dist/assets/vendor-core-8R2fFbyN.js +1 -0
- package/client/dist/assets/vendor-icons-CDMuNDeL.js +1 -0
- package/client/dist/assets/vendor-query-BXRECSI6.js +1 -0
- package/client/dist/assets/vendor-ui-CUDCvRS_.js +1 -0
- package/client/dist/av-sync-test.mp4 +0 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/index.html +46 -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 +534 -0
- package/package.json +85 -0
- package/server/dist/app-auth.js +177 -0
- package/server/dist/config.js +175 -0
- package/server/dist/controllers/asmr-library.controller.js +288 -0
- package/server/dist/controllers/auth.controller.js +225 -0
- package/server/dist/controllers/data.controller.js +766 -0
- package/server/dist/controllers/insights.controller.js +198 -0
- package/server/dist/controllers/manga-library.controller.js +296 -0
- package/server/dist/controllers/music.controller.js +698 -0
- package/server/dist/controllers/proxy.controller.js +820 -0
- package/server/dist/controllers/settings.controller.js +609 -0
- package/server/dist/controllers/tv-library.controller.js +310 -0
- package/server/dist/controllers/watchlist.controller.js +1015 -0
- package/server/dist/db.js +145 -0
- package/server/dist/discord-gateway.js +391 -0
- package/server/dist/discord-rpc.js +427 -0
- package/server/dist/github-sync.js +512 -0
- package/server/dist/google.js +748 -0
- package/server/dist/lib/anilist.js +1549 -0
- package/server/dist/lib/anischedule.js +194 -0
- package/server/dist/lib/discord-roles-sync.service.js +130 -0
- package/server/dist/lib/ipc.js +24 -0
- package/server/dist/lib/kitsu.js +544 -0
- package/server/dist/lib/mal.js +934 -0
- package/server/dist/lib/migration.js +329 -0
- package/server/dist/lib/offline-db.js +401 -0
- package/server/dist/lib/temp-ids.js +3 -0
- package/server/dist/lib/tmdb.js +72 -0
- package/server/dist/lib/tracker/anilist-tracker.js +245 -0
- package/server/dist/lib/tracker/sync.service.js +496 -0
- package/server/dist/lib/ytmusic.js +125 -0
- package/server/dist/logger.js +16 -0
- package/server/dist/providers/manga/manga.types.js +7 -0
- package/server/dist/providers/provider.interface.js +1 -0
- package/server/dist/providers/remote-loader.js +574 -0
- package/server/dist/providers/remote-types.js +117 -0
- package/server/dist/providers/title-matching.js +225 -0
- package/server/dist/providers/tv.types.js +1 -0
- package/server/dist/rclone.js +120 -0
- package/server/dist/repositories/asmr.repository.js +123 -0
- package/server/dist/repositories/insights.repository.js +85 -0
- package/server/dist/repositories/library.repository.js +108 -0
- package/server/dist/repositories/mal-cache.repository.js +55 -0
- package/server/dist/repositories/manga.repository.js +138 -0
- package/server/dist/repositories/notifications.repository.js +41 -0
- package/server/dist/repositories/queue.repository.js +61 -0
- package/server/dist/repositories/settings.repository.js +12 -0
- package/server/dist/repositories/shows-meta.repository.js +42 -0
- package/server/dist/repositories/temp-show-ids.repository.js +51 -0
- package/server/dist/repositories/tv.repository.js +151 -0
- package/server/dist/repositories/watched-episodes.repository.js +52 -0
- package/server/dist/repositories/watchlist.repository.js +71 -0
- package/server/dist/request-logger.js +16 -0
- package/server/dist/routes/asmr-library.routes.js +23 -0
- package/server/dist/routes/asmr.routes.js +81 -0
- package/server/dist/routes/auth.routes.js +25 -0
- package/server/dist/routes/data.routes.js +47 -0
- package/server/dist/routes/discord-gateway.routes.js +51 -0
- package/server/dist/routes/insights.routes.js +34 -0
- package/server/dist/routes/lan-auth.routes.js +46 -0
- package/server/dist/routes/manga-library.routes.js +23 -0
- package/server/dist/routes/manga.routes.js +141 -0
- package/server/dist/routes/music.routes.js +20 -0
- package/server/dist/routes/providers.routes.js +17 -0
- package/server/dist/routes/proxy.routes.js +12 -0
- package/server/dist/routes/radio.routes.js +138 -0
- package/server/dist/routes/settings.routes.js +24 -0
- package/server/dist/routes/tracker.routes.js +164 -0
- package/server/dist/routes/translate.routes.js +88 -0
- package/server/dist/routes/tv-library.routes.js +23 -0
- package/server/dist/routes/tv.routes.js +629 -0
- package/server/dist/routes/watchlist.routes.js +341 -0
- package/server/dist/scripts/backfill-episode-duration.js +42 -0
- package/server/dist/server.js +529 -0
- package/server/dist/shipped-defaults.js +7 -0
- package/server/dist/sync-config.js +23 -0
- package/server/dist/sync-payload.js +91 -0
- package/server/dist/sync.js +1128 -0
- package/server/dist/utils/cache.utils.js +25 -0
- package/server/dist/utils/cookie.utils.js +13 -0
- package/server/dist/utils/db-utils.js +9 -0
- package/server/dist/utils/env.utils.js +77 -0
- package/server/dist/utils/http.utils.js +70 -0
- package/server/dist/utils/machine-id.js +40 -0
- package/server/dist/utils/megaplay.utils.js +41 -0
- package/server/dist/utils/request-context.js +2 -0
- package/server/dist/utils/security.utils.js +94 -0
- package/server/package.json +44 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { performAsmrWriteTransaction, performMangaWriteTransaction, performTvWriteTransaction, performWriteTransaction, setLocalAsmrManifestVersion, setLocalMangaManifestVersion, setLocalManifestVersion, setLocalTvManifestVersion, } from '../sync.js';
|
|
2
|
+
import { ANIME_SYNC_TABLES, ASMR_SYNC_TABLES, MANGA_SYNC_TABLES, TV_SYNC_TABLES, exportTables, importTables, normalizePayload, readPayloadVersion, } from '../sync-payload.js';
|
|
3
|
+
import { searchAnilistByTitle, isAnilistRateLimited, getShowMetaById } from '../lib/anilist.js';
|
|
4
|
+
import { kitsuSearchAnime } from '../lib/kitsu.js';
|
|
5
|
+
import { offlineDb } from '../lib/offline-db.js';
|
|
6
|
+
import { LibraryRepository } from '../repositories/library.repository.js';
|
|
7
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
8
|
+
import logger from '../logger.js';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import { CONFIG } from '../config.js';
|
|
12
|
+
import { SettingsRepository } from '../repositories/settings.repository.js';
|
|
13
|
+
import { ShowsMetaRepository } from '../repositories/shows-meta.repository.js';
|
|
14
|
+
import { getMachineId } from '../utils/machine-id.js';
|
|
15
|
+
import { discordRPCService } from '../discord-rpc.js';
|
|
16
|
+
let activeImportStatus = {
|
|
17
|
+
running: false,
|
|
18
|
+
current: 0,
|
|
19
|
+
total: 0,
|
|
20
|
+
phase: 'done',
|
|
21
|
+
source: null,
|
|
22
|
+
};
|
|
23
|
+
let activeImportCancelled = false;
|
|
24
|
+
function xmlText(value) {
|
|
25
|
+
if (Array.isArray(value))
|
|
26
|
+
return String(value[0] ?? '');
|
|
27
|
+
return String(value ?? '');
|
|
28
|
+
}
|
|
29
|
+
function mapMalStatus(malStatus) {
|
|
30
|
+
switch (malStatus) {
|
|
31
|
+
case 'Plan to Watch':
|
|
32
|
+
return 'Planned';
|
|
33
|
+
case 'On Hold':
|
|
34
|
+
return 'On-Hold';
|
|
35
|
+
default:
|
|
36
|
+
return malStatus;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const malXmlParser = new XMLParser({
|
|
40
|
+
isArray: (name) => name === 'anime',
|
|
41
|
+
parseTagValue: false,
|
|
42
|
+
trimValues: true,
|
|
43
|
+
});
|
|
44
|
+
async function searchByTitleForMal(title) {
|
|
45
|
+
const result = await searchAnilistByTitle(title);
|
|
46
|
+
if (result)
|
|
47
|
+
return { ...result, source: 'anilist' };
|
|
48
|
+
if (isAnilistRateLimited()) {
|
|
49
|
+
logger.debug({ title }, 'AniList rate limited, trying Kitsu fallback for MAL import');
|
|
50
|
+
const fb = await kitsuSearchAnime({ query: title, page: 1, perPage: 5 });
|
|
51
|
+
if (fb.length > 0) {
|
|
52
|
+
const lowerTitle = title.toLowerCase();
|
|
53
|
+
const exactMatch = fb.find((m) => m.title?.romaji?.toLowerCase() === lowerTitle ||
|
|
54
|
+
m.title?.english?.toLowerCase() === lowerTitle);
|
|
55
|
+
const best = exactMatch || fb[0];
|
|
56
|
+
if (best.id > 0) {
|
|
57
|
+
return { id: best.id, title: best.title ?? {}, source: 'kitsu' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
export class SettingsController {
|
|
64
|
+
getSettings = async (req, res) => {
|
|
65
|
+
try {
|
|
66
|
+
const row = await SettingsRepository.getByKey(req.db, req.query.key);
|
|
67
|
+
let value = row ? row.value : null;
|
|
68
|
+
if (value === null && req.query.key === 'discordRPCEnabled') {
|
|
69
|
+
value = 'true';
|
|
70
|
+
}
|
|
71
|
+
if (value === null && req.query.key === 'discordRPCHideMature') {
|
|
72
|
+
value = 'true';
|
|
73
|
+
}
|
|
74
|
+
if (value === null && req.query.key === 'ignoreAdultContent') {
|
|
75
|
+
value = 'true';
|
|
76
|
+
}
|
|
77
|
+
if (value === null &&
|
|
78
|
+
(req.query.key === 'mangaIgnoreAdultContent' ||
|
|
79
|
+
req.query.key === 'tvIgnoreAdultContent' ||
|
|
80
|
+
req.query.key === 'asmrIgnoreAdultContent')) {
|
|
81
|
+
value = 'true';
|
|
82
|
+
}
|
|
83
|
+
res.json({ value: value });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
res.status(500).json({ error: 'DB error' });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
updateSettings = async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const key = String(req.body.key);
|
|
92
|
+
const value = String(req.body.value ?? '');
|
|
93
|
+
const shouldDelete = value === '' && key === 'tracker_anilist_client_id';
|
|
94
|
+
await performWriteTransaction(req.db, (tx) => {
|
|
95
|
+
if (shouldDelete)
|
|
96
|
+
SettingsRepository.deleteByKey(tx, key);
|
|
97
|
+
else
|
|
98
|
+
SettingsRepository.upsert(tx, key, value);
|
|
99
|
+
});
|
|
100
|
+
if (req.body.key === 'discordRPCEnabled') {
|
|
101
|
+
discordRPCService.setEnabled(req.body.value === 'true' || req.body.value === true);
|
|
102
|
+
}
|
|
103
|
+
if (req.body.key === 'discordRPCHideMature') {
|
|
104
|
+
discordRPCService.setHideMature(req.body.value === 'true' || req.body.value === true);
|
|
105
|
+
}
|
|
106
|
+
res.json({ success: true });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
res.status(500).json({ error: 'DB error' });
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
backupDatabase = (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const payload = {
|
|
115
|
+
version: 1,
|
|
116
|
+
exportedAt: new Date().toISOString(),
|
|
117
|
+
databases: {
|
|
118
|
+
anime: exportTables(req.db, ANIME_SYNC_TABLES),
|
|
119
|
+
manga: exportTables(req.mangaDb, MANGA_SYNC_TABLES),
|
|
120
|
+
tv: exportTables(req.tvDb, TV_SYNC_TABLES),
|
|
121
|
+
asmr: exportTables(req.asmrDb, ASMR_SYNC_TABLES),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
res.setHeader('Content-Disposition', 'attachment; filename="dango-backup.json"');
|
|
125
|
+
res.json(payload);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
logger.error({ err }, 'Multi-database backup failed');
|
|
129
|
+
return res.status(500).json({ error: 'Backup failed' });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
restoreDatabase = async (req, res, db, initializeDatabase, setDb) => {
|
|
133
|
+
if (!req.file)
|
|
134
|
+
return res.status(400).json({ error: 'No file uploaded.' });
|
|
135
|
+
const buffer = req.file.buffer;
|
|
136
|
+
const originalName = req.file.originalname || '';
|
|
137
|
+
const head = buffer ? buffer.slice(0, 16).toString('utf-8').trimStart() : '';
|
|
138
|
+
if (buffer && (originalName.endsWith('.json') || head.startsWith('{'))) {
|
|
139
|
+
return this.restoreMultiDatabaseBackup(req, res, buffer);
|
|
140
|
+
}
|
|
141
|
+
const dbName = CONFIG.IS_DEV ? CONFIG.DB_NAME_DEV : CONFIG.DB_NAME_PROD;
|
|
142
|
+
const tempPath = path.join(CONFIG.ROOT, `restore_temp.db`);
|
|
143
|
+
const dbPath = path.join(CONFIG.ROOT, dbName);
|
|
144
|
+
try {
|
|
145
|
+
if (buffer) {
|
|
146
|
+
fs.writeFileSync(tempPath, buffer);
|
|
147
|
+
}
|
|
148
|
+
else if (!fs.existsSync(tempPath)) {
|
|
149
|
+
return res.status(400).json({ error: 'No file uploaded.' });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
logger.error({ err }, 'Failed to stage legacy database restore file');
|
|
154
|
+
return res.status(500).json({ error: 'Failed to stage restore file.' });
|
|
155
|
+
}
|
|
156
|
+
db.close((closeErr) => {
|
|
157
|
+
if (closeErr)
|
|
158
|
+
return res.status(500).json({ error: 'Failed to close database.' });
|
|
159
|
+
try {
|
|
160
|
+
req.db.checkpoint();
|
|
161
|
+
}
|
|
162
|
+
catch (checkpointErr) {
|
|
163
|
+
logger.warn({ err: checkpointErr }, 'WAL checkpoint failed');
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
if (fs.existsSync(`${dbPath}-wal`))
|
|
167
|
+
fs.unlinkSync(`${dbPath}-wal`);
|
|
168
|
+
if (fs.existsSync(`${dbPath}-shm`))
|
|
169
|
+
fs.unlinkSync(`${dbPath}-shm`);
|
|
170
|
+
}
|
|
171
|
+
catch (cleanupErr) {
|
|
172
|
+
logger.warn({ err: cleanupErr }, 'Failed to clean up WAL files');
|
|
173
|
+
}
|
|
174
|
+
fs.rename(tempPath, dbPath, async (renameErr) => {
|
|
175
|
+
if (renameErr) {
|
|
176
|
+
try {
|
|
177
|
+
const reopenedDb = await initializeDatabase(dbPath);
|
|
178
|
+
setDb(reopenedDb);
|
|
179
|
+
req.db = reopenedDb;
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
logger.error({ err: e }, 'Failed to reopen DB after rename failure');
|
|
183
|
+
}
|
|
184
|
+
return res.status(500).json({ error: 'Failed to replace database file.' });
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const newDb = await initializeDatabase(dbPath);
|
|
188
|
+
setDb(newDb);
|
|
189
|
+
req.db = newDb;
|
|
190
|
+
res.json({ success: true, message: 'Database restored.' });
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
logger.error({ err: e }, 'Failed to initialize restored database');
|
|
194
|
+
res.status(500).json({ error: 'Failed to initialize restored database.' });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
restoreMultiDatabaseBackup = async (req, res, buffer) => {
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = JSON.parse(buffer.toString('utf-8'));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return res.status(400).json({ error: 'Invalid backup file.' });
|
|
206
|
+
}
|
|
207
|
+
const databases = parsed?.databases;
|
|
208
|
+
if (!databases || typeof databases !== 'object') {
|
|
209
|
+
return res.status(400).json({ error: 'Invalid backup file: missing databases.' });
|
|
210
|
+
}
|
|
211
|
+
const targets = [
|
|
212
|
+
{
|
|
213
|
+
key: 'anime',
|
|
214
|
+
db: req.db,
|
|
215
|
+
tables: ANIME_SYNC_TABLES,
|
|
216
|
+
libraryTables: ['watchlist', 'watched_episodes'],
|
|
217
|
+
backupName: 'pre-sync-backup.db',
|
|
218
|
+
setVersion: setLocalManifestVersion,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
key: 'manga',
|
|
222
|
+
db: req.mangaDb,
|
|
223
|
+
tables: MANGA_SYNC_TABLES,
|
|
224
|
+
libraryTables: ['manga_library', 'manga_progress'],
|
|
225
|
+
backupName: 'pre-sync-manga-backup.db',
|
|
226
|
+
setVersion: setLocalMangaManifestVersion,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
key: 'tv',
|
|
230
|
+
db: req.tvDb,
|
|
231
|
+
tables: TV_SYNC_TABLES,
|
|
232
|
+
libraryTables: ['tv_library', 'tv_progress'],
|
|
233
|
+
backupName: 'pre-sync-tv-backup.db',
|
|
234
|
+
setVersion: setLocalTvManifestVersion,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
key: 'asmr',
|
|
238
|
+
db: req.asmrDb,
|
|
239
|
+
tables: ASMR_SYNC_TABLES,
|
|
240
|
+
libraryTables: ['asmr_library', 'asmr_progress'],
|
|
241
|
+
backupName: 'pre-sync-asmr-backup.db',
|
|
242
|
+
setVersion: setLocalAsmrManifestVersion,
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
const present = targets.filter((t) => databases[t.key] !== undefined);
|
|
246
|
+
if (present.length === 0) {
|
|
247
|
+
return res.status(400).json({ error: 'Invalid backup file: no databases found.' });
|
|
248
|
+
}
|
|
249
|
+
// Validate every section before mutating any database.
|
|
250
|
+
const sections = new Map();
|
|
251
|
+
try {
|
|
252
|
+
for (const target of present) {
|
|
253
|
+
sections.set(target.key, normalizePayload(databases[target.key], target.tables, `backup ${target.key}`));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
logger.warn({ err }, 'Multi-database restore rejected: invalid payload');
|
|
258
|
+
return res.status(400).json({ error: 'Invalid backup file.' });
|
|
259
|
+
}
|
|
260
|
+
const restored = [];
|
|
261
|
+
try {
|
|
262
|
+
for (const target of present) {
|
|
263
|
+
const section = sections.get(target.key);
|
|
264
|
+
importTables(target.db, target.tables, section, {
|
|
265
|
+
libraryTables: target.libraryTables,
|
|
266
|
+
backupName: target.backupName,
|
|
267
|
+
});
|
|
268
|
+
await target.setVersion(readPayloadVersion(section));
|
|
269
|
+
restored.push(target.key);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
logger.error({ err, restored }, 'Multi-database restore failed partway');
|
|
274
|
+
return res.status(500).json({ error: 'Restore failed partway.', restored });
|
|
275
|
+
}
|
|
276
|
+
logger.warn({ restored }, 'Databases restored from backup file by user request');
|
|
277
|
+
res.json({ success: true, message: 'Databases restored.', restored });
|
|
278
|
+
};
|
|
279
|
+
getOfflineDbInfo = (req, res) => {
|
|
280
|
+
try {
|
|
281
|
+
res.json(offlineDb.getOfflineDbInfo(req.db));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
res.status(500).json({ error: 'DB error' });
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
updateOfflineDb = (req, res) => {
|
|
288
|
+
try {
|
|
289
|
+
const info = offlineDb.getOfflineDbInfo(req.db);
|
|
290
|
+
if (info.isRefreshing) {
|
|
291
|
+
return res.status(409).json({ error: 'Offline database refresh already in progress' });
|
|
292
|
+
}
|
|
293
|
+
offlineDb.refreshDatabase(req.db).catch((err) => {
|
|
294
|
+
logger.warn({ err: err?.message }, 'Manual offline database update failed');
|
|
295
|
+
});
|
|
296
|
+
res.status(202).json({ message: 'Offline database refresh started' });
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
res.status(500).json({ error: 'DB error' });
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
setAutoUpdateOfflineDb = (req, res) => {
|
|
303
|
+
try {
|
|
304
|
+
const enabled = req.body?.enabled;
|
|
305
|
+
if (typeof enabled !== 'boolean') {
|
|
306
|
+
return res.status(400).json({ error: 'enabled must be a boolean' });
|
|
307
|
+
}
|
|
308
|
+
SettingsRepository.upsert(req.db, 'offlineDbAutoUpdateEnabled', String(enabled));
|
|
309
|
+
res.json({ success: true, enabled });
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
res.status(500).json({ error: 'DB error' });
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
getImportStatus = (_req, res) => {
|
|
316
|
+
res.json(activeImportStatus);
|
|
317
|
+
};
|
|
318
|
+
cancelImport = (_req, res) => {
|
|
319
|
+
if (!activeImportStatus.running) {
|
|
320
|
+
return res.json({ success: true, message: 'No import running' });
|
|
321
|
+
}
|
|
322
|
+
activeImportCancelled = true;
|
|
323
|
+
res.json({ success: true, message: 'Import cancellation requested' });
|
|
324
|
+
};
|
|
325
|
+
importMalXml = async (req, res) => {
|
|
326
|
+
if (activeImportStatus.running) {
|
|
327
|
+
return res.status(409).json({ error: 'An import is already in progress' });
|
|
328
|
+
}
|
|
329
|
+
if (!req.file)
|
|
330
|
+
return res.status(400).json({ error: 'No file' });
|
|
331
|
+
const { erase, useOfflineDb, skipFallback } = req.body;
|
|
332
|
+
const offlineEnabled = useOfflineDb !== 'false' && useOfflineDb !== false && useOfflineDb !== '0';
|
|
333
|
+
const shouldSkipFallback = skipFallback === 'true' || skipFallback === true || skipFallback === '1';
|
|
334
|
+
let result;
|
|
335
|
+
try {
|
|
336
|
+
result = malXmlParser.parse(req.file.buffer.toString());
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return res.status(400).json({ error: 'Invalid XML' });
|
|
340
|
+
}
|
|
341
|
+
const animeList = result?.myanimelist?.anime || [];
|
|
342
|
+
if (animeList.length === 0) {
|
|
343
|
+
return res.status(400).json({ error: 'No anime found in XML' });
|
|
344
|
+
}
|
|
345
|
+
const total = animeList.length;
|
|
346
|
+
activeImportCancelled = false;
|
|
347
|
+
activeImportStatus = { running: true, current: 0, total, phase: 'offline', source: 'offline' };
|
|
348
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
349
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
350
|
+
res.setHeader('Connection', 'keep-alive');
|
|
351
|
+
res.flushHeaders();
|
|
352
|
+
let clientDisconnected = false;
|
|
353
|
+
req.on('close', () => {
|
|
354
|
+
if (!res.writableEnded)
|
|
355
|
+
clientDisconnected = true;
|
|
356
|
+
});
|
|
357
|
+
const sendEvent = (event, data) => {
|
|
358
|
+
if (clientDisconnected || res.writableEnded)
|
|
359
|
+
return;
|
|
360
|
+
try {
|
|
361
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
362
|
+
if (typeof res.flush === 'function')
|
|
363
|
+
res.flush();
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// ignore
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
let skippedCount = 0;
|
|
370
|
+
const offlineShows = [];
|
|
371
|
+
const offlineMeta = [];
|
|
372
|
+
const fallbackQueue = [];
|
|
373
|
+
for (const item of animeList) {
|
|
374
|
+
const malTitle = xmlText(item.series_title);
|
|
375
|
+
const status = mapMalStatus(xmlText(item.my_status));
|
|
376
|
+
let matched = false;
|
|
377
|
+
if (offlineEnabled) {
|
|
378
|
+
const malId = parseInt(xmlText(item.series_animedb_id), 10);
|
|
379
|
+
if (malId && !Number.isNaN(malId)) {
|
|
380
|
+
const entry = offlineDb.getByMalId(malId);
|
|
381
|
+
if (entry) {
|
|
382
|
+
matched = true;
|
|
383
|
+
const showId = String(entry.anilistId);
|
|
384
|
+
const title = entry.title || malTitle;
|
|
385
|
+
offlineShows.push({ id: showId, name: title, thumbnail: entry.thumbnail, status });
|
|
386
|
+
if (entry.thumbnail || entry.type || entry.genres) {
|
|
387
|
+
offlineMeta.push({
|
|
388
|
+
id: showId,
|
|
389
|
+
thumbnail: entry.thumbnail,
|
|
390
|
+
type: entry.type,
|
|
391
|
+
genres: entry.genres,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
activeImportStatus = {
|
|
395
|
+
running: true,
|
|
396
|
+
current: offlineShows.length,
|
|
397
|
+
total,
|
|
398
|
+
phase: 'offline',
|
|
399
|
+
source: 'offline',
|
|
400
|
+
title: malTitle,
|
|
401
|
+
matchedTitle: title,
|
|
402
|
+
status,
|
|
403
|
+
found: true,
|
|
404
|
+
imported: offlineShows.length,
|
|
405
|
+
};
|
|
406
|
+
sendEvent('progress', {
|
|
407
|
+
current: offlineShows.length,
|
|
408
|
+
total,
|
|
409
|
+
title: malTitle,
|
|
410
|
+
matchedTitle: title,
|
|
411
|
+
status,
|
|
412
|
+
source: 'offline',
|
|
413
|
+
found: true,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!matched)
|
|
419
|
+
fallbackQueue.push(item);
|
|
420
|
+
}
|
|
421
|
+
if (offlineShows.length > 0 || erase) {
|
|
422
|
+
try {
|
|
423
|
+
await performWriteTransaction(req.db, (tx) => {
|
|
424
|
+
if (erase)
|
|
425
|
+
SettingsRepository.clearWatchlist(tx);
|
|
426
|
+
SettingsRepository.upsertWatchlistBatch(tx, offlineShows);
|
|
427
|
+
for (const meta of offlineMeta) {
|
|
428
|
+
if (meta.thumbnail || meta.type || meta.genres) {
|
|
429
|
+
ShowsMetaRepository.upsert(tx, {
|
|
430
|
+
id: meta.id,
|
|
431
|
+
thumbnail: meta.thumbnail,
|
|
432
|
+
type: meta.type,
|
|
433
|
+
genres: meta.genres,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch (dbErr) {
|
|
440
|
+
logger.error({ err: dbErr }, 'Failed to commit offline import matches');
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
let fallbackImported = 0;
|
|
444
|
+
if (shouldSkipFallback || activeImportCancelled) {
|
|
445
|
+
skippedCount += fallbackQueue.length;
|
|
446
|
+
}
|
|
447
|
+
else if (fallbackQueue.length > 0) {
|
|
448
|
+
activeImportStatus = { ...activeImportStatus, phase: 'fallback', source: null };
|
|
449
|
+
const BATCH_SIZE = 5;
|
|
450
|
+
for (let i = 0; i < fallbackQueue.length; i += BATCH_SIZE) {
|
|
451
|
+
if (activeImportCancelled) {
|
|
452
|
+
skippedCount += fallbackQueue.length - i;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
const batch = fallbackQueue.slice(i, i + BATCH_SIZE);
|
|
456
|
+
const batchResults = await Promise.allSettled(batch.map((item) => searchByTitleForMal(xmlText(item.series_title))));
|
|
457
|
+
const batchShows = [];
|
|
458
|
+
const batchMeta = [];
|
|
459
|
+
const metaPromises = [];
|
|
460
|
+
batchResults.forEach((r, idx) => {
|
|
461
|
+
const malTitle = xmlText(batch[idx].series_title);
|
|
462
|
+
const status = mapMalStatus(xmlText(batch[idx].my_status));
|
|
463
|
+
const current = offlineShows.length + fallbackImported + idx + 1;
|
|
464
|
+
if (r.status === 'fulfilled' && r.value) {
|
|
465
|
+
const show = r.value;
|
|
466
|
+
const title = show.title?.english || show.title?.romaji || malTitle;
|
|
467
|
+
batchShows.push({ id: String(show.id), name: title, status });
|
|
468
|
+
activeImportStatus = {
|
|
469
|
+
running: true,
|
|
470
|
+
current,
|
|
471
|
+
total,
|
|
472
|
+
phase: 'fallback',
|
|
473
|
+
source: show.source,
|
|
474
|
+
title: malTitle,
|
|
475
|
+
matchedTitle: title,
|
|
476
|
+
status,
|
|
477
|
+
found: true,
|
|
478
|
+
imported: offlineShows.length + fallbackImported + 1,
|
|
479
|
+
};
|
|
480
|
+
sendEvent('progress', {
|
|
481
|
+
current,
|
|
482
|
+
total,
|
|
483
|
+
title: malTitle,
|
|
484
|
+
matchedTitle: title,
|
|
485
|
+
status,
|
|
486
|
+
source: show.source,
|
|
487
|
+
found: true,
|
|
488
|
+
});
|
|
489
|
+
metaPromises.push(getShowMetaById(String(show.id))
|
|
490
|
+
.then((meta) => {
|
|
491
|
+
if (meta) {
|
|
492
|
+
batchMeta.push({
|
|
493
|
+
id: String(show.id),
|
|
494
|
+
thumbnail: meta.thumbnail || undefined,
|
|
495
|
+
type: meta.type || undefined,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
.catch(() => { }));
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
skippedCount++;
|
|
503
|
+
activeImportStatus = {
|
|
504
|
+
running: true,
|
|
505
|
+
current,
|
|
506
|
+
total,
|
|
507
|
+
phase: 'fallback',
|
|
508
|
+
source: null,
|
|
509
|
+
title: malTitle,
|
|
510
|
+
matchedTitle: null,
|
|
511
|
+
status,
|
|
512
|
+
found: false,
|
|
513
|
+
imported: offlineShows.length + fallbackImported,
|
|
514
|
+
};
|
|
515
|
+
sendEvent('progress', {
|
|
516
|
+
current,
|
|
517
|
+
total,
|
|
518
|
+
title: malTitle,
|
|
519
|
+
matchedTitle: null,
|
|
520
|
+
status,
|
|
521
|
+
source: null,
|
|
522
|
+
found: false,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
await Promise.allSettled(metaPromises);
|
|
527
|
+
if (batchShows.length > 0) {
|
|
528
|
+
try {
|
|
529
|
+
await performWriteTransaction(req.db, (tx) => {
|
|
530
|
+
SettingsRepository.upsertWatchlistBatch(tx, batchShows);
|
|
531
|
+
for (const meta of batchMeta) {
|
|
532
|
+
if (meta.thumbnail) {
|
|
533
|
+
ShowsMetaRepository.upsert(tx, {
|
|
534
|
+
id: meta.id,
|
|
535
|
+
thumbnail: meta.thumbnail,
|
|
536
|
+
type: meta.type,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
fallbackImported += batchShows.length;
|
|
542
|
+
}
|
|
543
|
+
catch (dbErr) {
|
|
544
|
+
logger.error({ err: dbErr }, 'Failed to commit fallback import batch');
|
|
545
|
+
skippedCount += batchShows.length;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const imported = offlineShows.length + fallbackImported;
|
|
551
|
+
activeImportStatus = {
|
|
552
|
+
running: false,
|
|
553
|
+
current: total,
|
|
554
|
+
total,
|
|
555
|
+
phase: 'done',
|
|
556
|
+
source: null,
|
|
557
|
+
imported,
|
|
558
|
+
skipped: skippedCount,
|
|
559
|
+
};
|
|
560
|
+
sendEvent('complete', { imported, skipped: skippedCount });
|
|
561
|
+
res.end();
|
|
562
|
+
};
|
|
563
|
+
clearDatabase = async (req, res) => {
|
|
564
|
+
if (req.body?.confirm !== true) {
|
|
565
|
+
return res.status(400).json({ error: 'Confirmation required' });
|
|
566
|
+
}
|
|
567
|
+
if (activeImportStatus.running) {
|
|
568
|
+
return res.status(409).json({ error: 'An import is in progress' });
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const before = LibraryRepository.countAll(req.db);
|
|
572
|
+
const beforeManga = LibraryRepository.countManga(req.mangaDb);
|
|
573
|
+
const beforeTv = LibraryRepository.countTv(req.tvDb);
|
|
574
|
+
const beforeAsmr = LibraryRepository.countAsmr(req.asmrDb);
|
|
575
|
+
await performWriteTransaction(req.db, (tx) => {
|
|
576
|
+
LibraryRepository.clearAll(tx);
|
|
577
|
+
});
|
|
578
|
+
await performMangaWriteTransaction(req.mangaDb, (tx) => {
|
|
579
|
+
LibraryRepository.clearManga(tx);
|
|
580
|
+
});
|
|
581
|
+
await performTvWriteTransaction(req.tvDb, (tx) => {
|
|
582
|
+
LibraryRepository.clearTv(tx);
|
|
583
|
+
});
|
|
584
|
+
await performAsmrWriteTransaction(req.asmrDb, (tx) => {
|
|
585
|
+
LibraryRepository.clearAsmr(tx);
|
|
586
|
+
});
|
|
587
|
+
logger.warn({ before, beforeManga, beforeTv, beforeAsmr }, 'Library database cleared by user request');
|
|
588
|
+
res.json({
|
|
589
|
+
success: true,
|
|
590
|
+
deleted: before,
|
|
591
|
+
deletedManga: beforeManga,
|
|
592
|
+
deletedTv: beforeTv,
|
|
593
|
+
deletedAsmr: beforeAsmr,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
res.status(500).json({ error: 'DB error' });
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
getInstallationId = (_req, res) => {
|
|
601
|
+
try {
|
|
602
|
+
res.json({ id: getMachineId() });
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
logger.error({ err }, 'Failed to get machine ID');
|
|
606
|
+
res.status(500).json({ error: 'Failed to get machine ID' });
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|