@serifpersia/dango 3.3.7 → 3.3.9

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 (71) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-WlhU88fn.js → AnimeCard-URCiiy5W.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-BCLagpbu.js → AnimeInfo-e3z3er9t.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-o0U_OXKP.js → AnimeInfoPage-B7Djlmy_.js} +2 -2
  5. package/client/dist/assets/{Asmr-ieGJ_BcQ.js → Asmr-C28TfSvX.js} +1 -1
  6. package/client/dist/assets/{AsmrResetProgressModal-BwaoVski.js → AsmrResetProgressModal-6Xh71AgR.js} +1 -1
  7. package/client/dist/assets/{AvSyncCalibrator-CXvBuY4l.js → AvSyncCalibrator-BwCtRW0P.js} +1 -1
  8. package/client/dist/assets/{Home-DjLpvsp8.js → Home-BJdbHYja.js} +1 -1
  9. package/client/dist/assets/Insights-CjRy42YE.js +1 -0
  10. package/client/dist/assets/{LibraryListPage-B2nhFFrc.js → LibraryListPage-B0qWqhb_.js} +1 -1
  11. package/client/dist/assets/{ListeningList-okIpsKCr.js → ListeningList-CSqD8j1C.js} +1 -1
  12. package/client/dist/assets/{Manga-3u9nVNZC.js → Manga-Cg2YuObF.js} +1 -1
  13. package/client/dist/assets/MangaInfoPage-DcArhEsy.js +1 -0
  14. package/client/dist/assets/MangaReadPage-YCETZRte.js +1 -0
  15. package/client/dist/assets/{MangaResetProgressModal-Ch8RjwbO.js → MangaResetProgressModal-D_yT3d1v.js} +1 -1
  16. package/client/dist/assets/{Mature-pINwl5wj.js → Mature-BGxRWkV5.js} +1 -1
  17. package/client/dist/assets/{MediaCard-DDiYEAm8.js → MediaCard-CAzoxsRS.js} +1 -1
  18. package/client/dist/assets/Music-Cgo-yQ7E.js +1 -0
  19. package/client/dist/assets/{OptionListMenu-CzH2VSPh.js → OptionListMenu--Owe9Jle.js} +1 -1
  20. package/client/dist/assets/Player-yRXG2BrP.js +56 -0
  21. package/client/dist/assets/{PlayerSettings-jrmY95-i.js → PlayerSettings-DrZLa0CC.js} +1 -1
  22. package/client/dist/assets/{QueueOptionsButton-CcVP7Vi6.js → QueueOptionsButton-BVZyPyKf.js} +1 -1
  23. package/client/dist/assets/{QueueRail-Cj-PU9EV.js → QueueRail-B7f1-e6i.js} +1 -1
  24. package/client/dist/assets/{Radio-CRlbaxQt.js → Radio-BFjxlzuF.js} +1 -1
  25. package/client/dist/assets/{ReadingList-wRtHGNo8.js → ReadingList-BaIem8H1.js} +1 -1
  26. package/client/dist/assets/{ResetProgressModal-BokfoYFB.js → ResetProgressModal-DES5Y-Fl.js} +1 -1
  27. package/client/dist/assets/{Search-BOOBeTVA.js → Search-BOBo0Ogh.js} +1 -1
  28. package/client/dist/assets/{Settings-BgrcswJD.js → Settings-DOvNQ0gX.js} +1 -1
  29. package/client/dist/assets/{SkeletonGrid-C6S7OYHn.js → SkeletonGrid-BxfgjP4i.js} +1 -1
  30. package/client/dist/assets/Trackers-DZ9KqOWv.js +2 -0
  31. package/client/dist/assets/{Tv-ByVckozq.js → Tv-5mATEU4p.js} +1 -1
  32. package/client/dist/assets/{TvInfo-fJ9SFMK8.js → TvInfo-CF-5C5Fs.js} +1 -1
  33. package/client/dist/assets/{TvPopup-yfH7p0y0.js → TvPopup-Dfqifj3s.js} +1 -1
  34. package/client/dist/assets/{TvSearch-CBNWDByW.js → TvSearch-BWDwWnHe.js} +1 -1
  35. package/client/dist/assets/{TvWatchlist-BqjZ2-4A.js → TvWatchlist-D3n5_4MI.js} +1 -1
  36. package/client/dist/assets/{Watchlist-B_Q2hEiJ.js → Watchlist-B8MHNsAU.js} +1 -1
  37. package/client/dist/assets/{hls-kIF7bMpF.js → hls-BO_L1WoN.js} +1 -1
  38. package/client/dist/assets/index-D2RkUJIN.js +178 -0
  39. package/client/dist/assets/{useAnimeInfoData-BKSrS8eN.js → useAnimeInfoData-BWShDHqE.js} +1 -1
  40. package/client/dist/assets/{useAsmr-Byj8Qiwp.js → useAsmr-FyfD9G7b.js} +1 -1
  41. package/client/dist/assets/{useAsmrPopup-CLcWCCXf.js → useAsmrPopup-D_o9-QlR.js} +1 -1
  42. package/client/dist/assets/useMangaLibrary-BJU5x8Vz.js +1 -0
  43. package/client/dist/assets/{useMangaPopup-ZonOP5_c.js → useMangaPopup-TFzkC-tG.js} +1 -1
  44. package/client/dist/assets/{useProviders-CymA42ZB.js → useProviders-DDRxa8vl.js} +1 -1
  45. package/client/dist/assets/{useTv-CPj6Cf42.js → useTv-Bh6WmNnZ.js} +1 -1
  46. package/client/dist/assets/{useTvLibrary-COsyN8yu.js → useTvLibrary-_Pqm8re1.js} +1 -1
  47. package/client/dist/index.html +1 -1
  48. package/package.json +2 -2
  49. package/server/dist/controllers/data.controller.js +1 -0
  50. package/server/dist/controllers/insights.controller.js +3 -2
  51. package/server/dist/controllers/manga-library.controller.js +54 -0
  52. package/server/dist/controllers/music.controller.js +123 -50
  53. package/server/dist/controllers/watchlist.controller.js +13 -1
  54. package/server/dist/lib/anilist.js +36 -3
  55. package/server/dist/lib/mal.js +69 -0
  56. package/server/dist/lib/tracker/anilist-tracker.js +66 -0
  57. package/server/dist/lib/tracker/manga-sync.service.js +792 -0
  58. package/server/dist/lib/ytmusic.js +15 -2
  59. package/server/dist/repositories/manga.repository.js +17 -2
  60. package/server/dist/routes/manga-library.routes.js +2 -0
  61. package/server/dist/routes/tracker.routes.js +123 -0
  62. package/server/dist/sync.js +8 -1
  63. package/server/package.json +1 -1
  64. package/client/dist/assets/Insights-lQcQoeES.js +0 -1
  65. package/client/dist/assets/MangaInfoPage-Cat-B487.js +0 -1
  66. package/client/dist/assets/MangaReadPage-CyLRxzvl.js +0 -1
  67. package/client/dist/assets/Music-B1swlVLI.js +0 -1
  68. package/client/dist/assets/Player-SKPWb-o8.js +0 -56
  69. package/client/dist/assets/Trackers-DLcTEJJW.js +0 -2
  70. package/client/dist/assets/index-C65i4HWv.js +0 -178
  71. package/client/dist/assets/useMangaLibrary-U7tUo8nx.js +0 -1
@@ -0,0 +1,792 @@
1
+ import { AniListTracker } from './anilist-tracker.js';
2
+ import { MangaLibraryRepository, MangaProgressRepository, } from '../../repositories/manga.repository.js';
3
+ import { SettingsRepository } from '../../repositories/settings.repository.js';
4
+ import { performMangaWriteTransaction, performWriteTransaction } from '../../sync.js';
5
+ import logger from '../../logger.js';
6
+ import { searchAnilistMangaByTitle, getMangaMetaById, getMangaMetaByMalId } from '../anilist.js';
7
+ import { fetchMalMangaList, mapMalMangaStatusCode } from '../mal.js';
8
+ const TOKEN_KEY = 'tracker_anilist_token';
9
+ const MANGA_SYNC_STATE_KEY = 'tracker_anilist_manga_sync_state';
10
+ const MANGA_PROVIDER = 'anilist';
11
+ const MAX_TITLE_LOOKUPS_PER_SYNC = 25;
12
+ async function readMangaSyncState(db) {
13
+ const row = await SettingsRepository.getByKey(db, MANGA_SYNC_STATE_KEY);
14
+ if (!row?.value)
15
+ return {};
16
+ try {
17
+ return JSON.parse(row.value);
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ function resolveStatus(localStatus, remoteStatus, remoteUpdated, localLastSync) {
24
+ if (remoteUpdated > localLastSync && remoteStatus !== localStatus) {
25
+ return { status: remoteStatus, pull: true };
26
+ }
27
+ if (remoteStatus !== localStatus) {
28
+ return { status: localStatus, pull: false };
29
+ }
30
+ return { status: localStatus, pull: false };
31
+ }
32
+ function parseChapterProgress(value) {
33
+ if (value == null)
34
+ return 0;
35
+ const matches = String(value).match(/(\d+(?:\.\d+)?)/g);
36
+ if (!matches)
37
+ return 0;
38
+ let best = 0;
39
+ for (const m of matches) {
40
+ const n = Number(m);
41
+ if (Number.isFinite(n))
42
+ best = Math.max(best, Math.floor(n));
43
+ }
44
+ return best;
45
+ }
46
+ async function getLocalChapterProgress(mangaDb, libraryId, lastChapterNumber) {
47
+ let best = parseChapterProgress(lastChapterNumber);
48
+ try {
49
+ const rows = await MangaProgressRepository.getByManga(mangaDb, libraryId);
50
+ for (const row of rows ?? []) {
51
+ best = Math.max(best, parseChapterProgress(row.chapterNumber));
52
+ }
53
+ }
54
+ catch {
55
+ // ignore
56
+ }
57
+ return best;
58
+ }
59
+ function buildAnilistMangaId(mediaId) {
60
+ return `${MANGA_PROVIDER}:${mediaId}`;
61
+ }
62
+ function buildSyntheticChapterId(progress) {
63
+ return `${MANGA_PROVIDER}:ch:${progress}`;
64
+ }
65
+ function parseAnilistMangaId(id) {
66
+ const match = /^anilist:(\d+)$/i.exec(id.trim());
67
+ if (!match)
68
+ return null;
69
+ const n = Number.parseInt(match[1], 10);
70
+ return Number.isFinite(n) ? n : null;
71
+ }
72
+ function mangaStatusOrFallback(status) {
73
+ const known = ['Reading', 'Completed', 'On-Hold', 'Dropped', 'Planned'];
74
+ return known.includes(status) ? status : undefined;
75
+ }
76
+ function normalizeTitle(value) {
77
+ return String(value ?? '')
78
+ .normalize('NFD')
79
+ .replace(/[\u0300-\u036f]/g, '')
80
+ .toLowerCase()
81
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
82
+ .replace(/\s+/g, ' ')
83
+ .trim();
84
+ }
85
+ function titlesEqual(a, b) {
86
+ const na = normalizeTitle(a);
87
+ return na.length > 0 && na === normalizeTitle(b);
88
+ }
89
+ function stripEditionSuffix(value) {
90
+ let s = String(value ?? '').trim();
91
+ let prev = '';
92
+ while (prev !== s) {
93
+ prev = s;
94
+ s = s.replace(/\s*\([^()]*\)$/, '').trim();
95
+ }
96
+ return s;
97
+ }
98
+ function matchesStrippedBaseTitle(item, match) {
99
+ const candidates = [item.title, item.altTitle]
100
+ .filter((c) => !!c)
101
+ .map(stripEditionSuffix)
102
+ .filter((c) => c.length > 0);
103
+ const remotes = [match.romaji, match.english, match.native]
104
+ .filter((r) => !!r)
105
+ .map(stripEditionSuffix)
106
+ .filter((r) => r.length > 0);
107
+ return candidates.some((c) => remotes.some((r) => titlesEqual(c, r)));
108
+ }
109
+ function isExactTitleMatch(item, match) {
110
+ const candidates = [item.title, item.altTitle];
111
+ const remotes = [match.romaji, match.english, match.native];
112
+ return candidates.some((c) => !!c && remotes.some((r) => !!r && titlesEqual(c, r)));
113
+ }
114
+ export async function syncAniListManga(db, mangaDb, opts = {}) {
115
+ const direction = opts.direction === 'pull-only' ? 'pull-only' : 'two-way';
116
+ const pullOnly = direction === 'pull-only';
117
+ const tokenRow = await SettingsRepository.getByKey(db, TOKEN_KEY);
118
+ const token = tokenRow?.value;
119
+ if (!token)
120
+ throw new Error('AniList is not connected. Please log in first.');
121
+ const tracker = new AniListTracker(token);
122
+ const viewer = await tracker.getViewer();
123
+ const remoteEntries = await tracker.fetchUserMangaList(viewer.id);
124
+ const localItems = await MangaLibraryRepository.getAll(mangaDb);
125
+ const syncState = await readMangaSyncState(db);
126
+ const remoteByMediaId = new Map();
127
+ for (const entry of remoteEntries)
128
+ remoteByMediaId.set(entry.mediaId, entry);
129
+ const localByMediaId = new Map();
130
+ const newLinks = [];
131
+ const searchedMediaIds = new Set();
132
+ const preexistingLinks = new Map();
133
+ let titleLookups = 0;
134
+ let unresolved = 0;
135
+ const details = [];
136
+ const record = (detail) => {
137
+ details.push(detail);
138
+ };
139
+ const pendingWarnings = [];
140
+ for (const item of localItems) {
141
+ let anilistId = typeof item.anilistId === 'number' ? item.anilistId : null;
142
+ const fromColumn = anilistId != null;
143
+ const columnSource = fromColumn ? (item.anilistIdSource ?? null) : null;
144
+ let preexisting = fromColumn;
145
+ if (anilistId == null) {
146
+ anilistId = parseAnilistMangaId(item.id);
147
+ preexisting = anilistId != null;
148
+ }
149
+ if (anilistId == null && item.provider && item.mangaId) {
150
+ const fromParts = String(item.provider).toLowerCase() === MANGA_PROVIDER &&
151
+ /^\d+$/.test(String(item.mangaId).trim())
152
+ ? Number.parseInt(String(item.mangaId).trim(), 10)
153
+ : null;
154
+ if (fromParts != null) {
155
+ anilistId = fromParts;
156
+ preexisting = true;
157
+ }
158
+ }
159
+ if (anilistId == null) {
160
+ if (titleLookups >= MAX_TITLE_LOOKUPS_PER_SYNC || !item.title) {
161
+ unresolved++;
162
+ record({
163
+ title: item.title || item.id,
164
+ action: 'unresolved',
165
+ note: 'No AniList link yet — skipped so nothing is written to the wrong series.',
166
+ });
167
+ continue;
168
+ }
169
+ titleLookups++;
170
+ try {
171
+ const match = await searchAnilistMangaByTitle(item.title);
172
+ if (match && isExactTitleMatch(item, match.title)) {
173
+ anilistId = match.id;
174
+ preexisting = false;
175
+ newLinks.push({ id: item.id, anilistId: match.id });
176
+ searchedMediaIds.add(match.id);
177
+ }
178
+ else {
179
+ unresolved++;
180
+ record({
181
+ title: item.title,
182
+ action: 'unresolved',
183
+ note: match
184
+ ? `Closest AniList match ("${match.title.english || match.title.romaji || match.title.native}") is not an exact title match — skipped so nothing is written to the wrong series.`
185
+ : 'No AniList match found — skipped.',
186
+ });
187
+ continue;
188
+ }
189
+ }
190
+ catch (err) {
191
+ logger.warn({ err, mangaId: item.id }, '[AniList Manga Sync] Title lookup failed');
192
+ unresolved++;
193
+ record({
194
+ title: item.title || item.id,
195
+ action: 'unresolved',
196
+ note: 'Title lookup failed — skipped.',
197
+ });
198
+ continue;
199
+ }
200
+ }
201
+ if (!localByMediaId.has(anilistId)) {
202
+ localByMediaId.set(anilistId, item);
203
+ if (preexisting && fromColumn && !searchedMediaIds.has(anilistId)) {
204
+ preexistingLinks.set(anilistId, {
205
+ title: item.title,
206
+ altTitle: item.altTitle,
207
+ source: columnSource,
208
+ });
209
+ }
210
+ }
211
+ }
212
+ const suspectMediaIds = new Set();
213
+ for (const [mediaId, link] of preexistingLinks) {
214
+ const remote = remoteByMediaId.get(mediaId);
215
+ if (!remote)
216
+ continue;
217
+ if (link.source === 'manual' || link.source === 'anilist' || link.source === 'search')
218
+ continue;
219
+ if (isExactTitleMatch(link, remote.title))
220
+ continue;
221
+ if (matchesStrippedBaseTitle(link, remote.title))
222
+ continue;
223
+ suspectMediaIds.add(mediaId);
224
+ const remoteLabel = remote.title.english || remote.title.romaji || remote.title.native || `Manga #${mediaId}`;
225
+ pendingWarnings.push(`"${link.title}" is linked to AniList #${mediaId} ("${remoteLabel}") by an older automatic match and the titles do not match — skipped in both directions to protect your AniList list.`);
226
+ record({
227
+ mediaId,
228
+ title: link.title,
229
+ action: 'suspicious',
230
+ remoteStatus: remote.status,
231
+ remoteProgress: remote.progress,
232
+ note: `Linked AniList title is "${remoteLabel}".`,
233
+ });
234
+ }
235
+ const allMediaIds = new Set([
236
+ ...localByMediaId.keys(),
237
+ ...remoteByMediaId.keys(),
238
+ ...Object.keys(syncState)
239
+ .map((k) => Number.parseInt(k, 10))
240
+ .filter((n) => Number.isFinite(n)),
241
+ ]);
242
+ const summary = {
243
+ pushed: 0,
244
+ pulled: 0,
245
+ merged: 0,
246
+ unchanged: 0,
247
+ skipped: 0,
248
+ errors: [],
249
+ warnings: pendingWarnings,
250
+ };
251
+ const libraryUpserts = [];
252
+ const progressPointers = [];
253
+ const progressRows = [];
254
+ const libraryDeletes = [];
255
+ const stateUpdates = {};
256
+ const pushUpdates = [];
257
+ const remoteDeleteEntryIds = [];
258
+ const now = Math.floor(Date.now() / 1000);
259
+ for (const mediaId of allMediaIds) {
260
+ if (suspectMediaIds.has(mediaId))
261
+ continue;
262
+ const local = localByMediaId.get(mediaId);
263
+ const remote = remoteByMediaId.get(mediaId);
264
+ const stateKey = String(mediaId);
265
+ const localId = local?.id ?? buildAnilistMangaId(mediaId);
266
+ try {
267
+ if (local && !remote) {
268
+ if (syncState[stateKey] && remoteEntries.length > 0) {
269
+ if (pullOnly) {
270
+ summary.skipped++;
271
+ record({
272
+ mediaId,
273
+ title: local.title,
274
+ action: 'skipped',
275
+ localStatus: local.status,
276
+ note: 'Pull-only mode — local entry kept, nothing deleted.',
277
+ });
278
+ continue;
279
+ }
280
+ libraryDeletes.push(local.id);
281
+ stateUpdates[stateKey] = undefined;
282
+ summary.pulled++;
283
+ record({ mediaId, title: local.title, action: 'delete-local', localStatus: local.status });
284
+ continue;
285
+ }
286
+ const chapters = await getLocalChapterProgress(mangaDb, local.id, local.lastChapterNumber);
287
+ if (pullOnly) {
288
+ summary.skipped++;
289
+ record({
290
+ mediaId,
291
+ title: local.title,
292
+ action: 'skipped',
293
+ localStatus: local.status,
294
+ localChapters: chapters,
295
+ note: 'Pull-only mode — not pushed to AniList.',
296
+ });
297
+ continue;
298
+ }
299
+ const status = mangaStatusOrFallback(local.status);
300
+ if (!status || (status === 'Planned' && chapters === 0)) {
301
+ summary.skipped++;
302
+ record({
303
+ mediaId,
304
+ title: local.title,
305
+ action: 'skipped',
306
+ localStatus: local.status,
307
+ localChapters: chapters,
308
+ note: 'Bookmark with no chapters read — not pushed to AniList.',
309
+ });
310
+ continue;
311
+ }
312
+ pushUpdates.push({
313
+ mediaId,
314
+ status,
315
+ progress: chapters,
316
+ });
317
+ stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: now };
318
+ summary.pushed++;
319
+ record({
320
+ mediaId,
321
+ title: local.title,
322
+ action: 'push',
323
+ localStatus: local.status,
324
+ localChapters: chapters,
325
+ });
326
+ continue;
327
+ }
328
+ if (!local && remote) {
329
+ if (syncState[stateKey] && !pullOnly) {
330
+ if (remote.entryId) {
331
+ remoteDeleteEntryIds.push({ showId: stateKey, entryId: remote.entryId });
332
+ stateUpdates[stateKey] = undefined;
333
+ summary.pushed++;
334
+ record({
335
+ mediaId,
336
+ title: remote.title.english || remote.title.romaji || `Manga #${mediaId}`,
337
+ action: 'delete-remote',
338
+ remoteStatus: remote.status,
339
+ remoteProgress: remote.progress,
340
+ note: 'Removed locally after a previous sync — delete propagated to AniList.',
341
+ });
342
+ continue;
343
+ }
344
+ stateUpdates[stateKey] = undefined;
345
+ summary.pushed++;
346
+ record({ mediaId, title: `Manga #${mediaId}`, action: 'delete-remote' });
347
+ continue;
348
+ }
349
+ logger.debug({ mediaId, remoteStatus: remote.status }, '[AniList Manga Sync] Remote only -> pulling to local');
350
+ const title = remote.title.english || remote.title.romaji || `Manga #${mediaId}`;
351
+ libraryUpserts.push({
352
+ id: buildAnilistMangaId(mediaId),
353
+ provider: MANGA_PROVIDER,
354
+ mangaId: String(mediaId),
355
+ title,
356
+ cover: remote.coverImage ?? '',
357
+ status: remote.status,
358
+ anilistId: mediaId,
359
+ anilistIdSource: 'anilist',
360
+ });
361
+ if (remote.progress > 0) {
362
+ progressPointers.push({
363
+ id: buildAnilistMangaId(mediaId),
364
+ chapterNumber: String(remote.progress),
365
+ });
366
+ progressRows.push({
367
+ mangaId: buildAnilistMangaId(mediaId),
368
+ chapterId: buildSyntheticChapterId(remote.progress),
369
+ chapterNumber: String(remote.progress),
370
+ title,
371
+ cover: remote.coverImage ?? '',
372
+ provider: MANGA_PROVIDER,
373
+ });
374
+ }
375
+ stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: remote.updatedAt };
376
+ summary.pulled++;
377
+ record({
378
+ mediaId,
379
+ title,
380
+ action: 'pull',
381
+ remoteStatus: remote.status,
382
+ remoteProgress: remote.progress,
383
+ });
384
+ continue;
385
+ }
386
+ if (!local && !remote && syncState[stateKey]) {
387
+ stateUpdates[stateKey] = undefined;
388
+ continue;
389
+ }
390
+ if (local && remote) {
391
+ const localChapters = await getLocalChapterProgress(mangaDb, local.id, local.lastChapterNumber);
392
+ const localLastSync = syncState[stateKey]?.lastSyncedAt ?? 0;
393
+ const remoteUpdated = remote.updatedAt || 0;
394
+ const targetProgress = Math.max(localChapters, remote.progress);
395
+ const shouldPushProgress = localChapters > remote.progress;
396
+ const shouldPullProgress = remote.progress > localChapters;
397
+ const { status: targetStatus, pull: pullStatus } = resolveStatus(local.status, remote.status, remoteUpdated, localLastSync);
398
+ const statusDiffers = remote.status !== local.status;
399
+ if (pullOnly) {
400
+ if (pullStatus || shouldPullProgress) {
401
+ if (pullStatus && targetStatus !== local.status) {
402
+ libraryUpserts.push({
403
+ id: local.id,
404
+ provider: local.provider,
405
+ mangaId: local.mangaId,
406
+ title: local.title,
407
+ cover: local.cover ?? '',
408
+ status: targetStatus,
409
+ anilistId: mediaId,
410
+ });
411
+ }
412
+ if (shouldPullProgress) {
413
+ progressPointers.push({ id: localId, chapterNumber: String(targetProgress) });
414
+ progressRows.push({
415
+ mangaId: localId,
416
+ chapterId: buildSyntheticChapterId(targetProgress),
417
+ chapterNumber: String(targetProgress),
418
+ title: local.title,
419
+ cover: local.cover ?? '',
420
+ provider: local.provider,
421
+ });
422
+ }
423
+ stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: remoteUpdated };
424
+ summary.merged++;
425
+ record({
426
+ mediaId,
427
+ title: local.title,
428
+ action: 'merge-pull',
429
+ localStatus: local.status,
430
+ remoteStatus: targetStatus,
431
+ localChapters: localChapters,
432
+ remoteProgress: targetProgress,
433
+ });
434
+ }
435
+ else if (shouldPushProgress || statusDiffers) {
436
+ summary.skipped++;
437
+ record({
438
+ mediaId,
439
+ title: local.title,
440
+ action: 'skipped',
441
+ localStatus: local.status,
442
+ remoteStatus: remote.status,
443
+ localChapters: localChapters,
444
+ remoteProgress: remote.progress,
445
+ note: 'Local is ahead — not pushed in pull-only mode.',
446
+ });
447
+ }
448
+ else {
449
+ summary.unchanged++;
450
+ }
451
+ }
452
+ else if (shouldPushProgress || (!pullStatus && statusDiffers)) {
453
+ pushUpdates.push({
454
+ mediaId,
455
+ status: targetStatus,
456
+ progress: targetProgress,
457
+ });
458
+ if (targetStatus !== local.status) {
459
+ libraryUpserts.push({
460
+ id: local.id,
461
+ provider: local.provider,
462
+ mangaId: local.mangaId,
463
+ title: local.title,
464
+ cover: local.cover ?? '',
465
+ status: targetStatus,
466
+ anilistId: mediaId,
467
+ });
468
+ }
469
+ stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: now };
470
+ summary.merged++;
471
+ record({
472
+ mediaId,
473
+ title: local.title,
474
+ action: 'merge-push',
475
+ localStatus: targetStatus,
476
+ remoteStatus: remote.status,
477
+ localChapters: targetProgress,
478
+ remoteProgress: remote.progress,
479
+ });
480
+ }
481
+ else if (pullStatus || shouldPullProgress) {
482
+ if (targetStatus !== local.status) {
483
+ libraryUpserts.push({
484
+ id: local.id,
485
+ provider: local.provider,
486
+ mangaId: local.mangaId,
487
+ title: local.title,
488
+ cover: local.cover ?? '',
489
+ status: targetStatus,
490
+ anilistId: mediaId,
491
+ });
492
+ }
493
+ if (shouldPullProgress) {
494
+ progressPointers.push({ id: localId, chapterNumber: String(targetProgress) });
495
+ progressRows.push({
496
+ mangaId: localId,
497
+ chapterId: buildSyntheticChapterId(targetProgress),
498
+ chapterNumber: String(targetProgress),
499
+ title: local.title,
500
+ cover: local.cover ?? '',
501
+ provider: local.provider,
502
+ });
503
+ }
504
+ stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: remoteUpdated };
505
+ summary.merged++;
506
+ record({
507
+ mediaId,
508
+ title: local.title,
509
+ action: 'merge-pull',
510
+ localStatus: local.status,
511
+ remoteStatus: targetStatus,
512
+ localChapters: localChapters,
513
+ remoteProgress: targetProgress,
514
+ });
515
+ }
516
+ else {
517
+ summary.unchanged++;
518
+ }
519
+ }
520
+ }
521
+ catch (err) {
522
+ const message = err instanceof Error ? err.message : String(err);
523
+ logger.warn({ err, mediaId }, '[AniList Manga Sync] Entry failed');
524
+ summary.errors.push(`${local?.title ?? `Manga ${mediaId}`}: ${message}`);
525
+ record({
526
+ mediaId,
527
+ title: local?.title ?? `Manga #${mediaId}`,
528
+ action: 'error',
529
+ note: message,
530
+ });
531
+ }
532
+ }
533
+ if (pushUpdates.length > 0 || remoteDeleteEntryIds.length > 0 || libraryDeletes.length > 0) {
534
+ const deleteCap = Math.max(5, Math.ceil(localItems.length * 0.1));
535
+ const totalDeletes = libraryDeletes.length + remoteDeleteEntryIds.length;
536
+ if (totalDeletes > deleteCap) {
537
+ throw new Error(`AniList manga sync would delete ${totalDeletes} titles but only ${remoteEntries.length} were fetched. Aborting to protect your library — if you really removed that many manga on AniList, delete them locally first and sync again.`);
538
+ }
539
+ }
540
+ if (pushUpdates.length > 0) {
541
+ const batchSize = 10;
542
+ for (let i = 0; i < pushUpdates.length; i += batchSize) {
543
+ const batch = pushUpdates.slice(i, i + batchSize);
544
+ await tracker.batchUpdateMediaEntries(batch);
545
+ }
546
+ }
547
+ if (remoteDeleteEntryIds.length > 0) {
548
+ const deleteBatch = remoteDeleteEntryIds.map((r) => r.entryId);
549
+ const batchSize = 10;
550
+ for (let i = 0; i < deleteBatch.length; i += batchSize) {
551
+ const batch = deleteBatch.slice(i, i + batchSize);
552
+ await tracker.batchDeleteMediaEntries(batch);
553
+ }
554
+ }
555
+ if (libraryUpserts.length > 0 ||
556
+ progressPointers.length > 0 ||
557
+ progressRows.length > 0 ||
558
+ libraryDeletes.length > 0 ||
559
+ newLinks.length > 0) {
560
+ await performMangaWriteTransaction(mangaDb, (tx) => {
561
+ for (const item of libraryUpserts) {
562
+ MangaLibraryRepository.upsert(tx, item);
563
+ }
564
+ for (const link of newLinks) {
565
+ MangaLibraryRepository.setAnilistId(tx, link.id, link.anilistId, 'search');
566
+ }
567
+ for (const p of progressPointers) {
568
+ MangaLibraryRepository.setProgressPointer(tx, p.id, p.chapterNumber);
569
+ }
570
+ for (const p of progressRows) {
571
+ MangaProgressRepository.deleteSyntheticChapters(tx, p.mangaId, p.chapterId);
572
+ MangaProgressRepository.upsert(tx, {
573
+ mangaId: p.mangaId,
574
+ chapterId: p.chapterId,
575
+ chapterNumber: p.chapterNumber,
576
+ page: 0,
577
+ pageCount: 0,
578
+ title: p.title,
579
+ cover: p.cover,
580
+ provider: p.provider,
581
+ });
582
+ }
583
+ for (const id of libraryDeletes) {
584
+ MangaLibraryRepository.delete(tx, id);
585
+ MangaProgressRepository.deleteByManga(tx, id);
586
+ }
587
+ });
588
+ }
589
+ if (Object.keys(stateUpdates).length > 0) {
590
+ try {
591
+ const mergedState = { ...syncState };
592
+ for (const [k, v] of Object.entries(stateUpdates)) {
593
+ if (v === undefined)
594
+ delete mergedState[k];
595
+ else
596
+ mergedState[k] = v;
597
+ }
598
+ const snapshot = JSON.stringify(mergedState);
599
+ await performWriteTransaction(db, (tx) => {
600
+ SettingsRepository.upsert(tx, MANGA_SYNC_STATE_KEY, snapshot);
601
+ });
602
+ }
603
+ catch (err) {
604
+ const message = err instanceof Error ? err.message : String(err);
605
+ logger.warn({ err }, '[AniList Manga Sync] Failed to persist sync state');
606
+ summary.errors.push(`Sync state was not saved, next sync will re-compare: ${message}`);
607
+ }
608
+ }
609
+ if (unresolved > 0) {
610
+ summary.errors.push(`${unresolved} local title${unresolved === 1 ? ' was' : 's were'} not matched to AniList (no link yet) — they were skipped and will be retried on the next sync.`);
611
+ }
612
+ return { summary, details };
613
+ }
614
+ function mangaImportStatusOrDefault(status) {
615
+ return mangaStatusOrFallback(status) ?? 'Planned';
616
+ }
617
+ export async function insertMangaImportEntries(db, mangaDb, items, eraseAnilistRows) {
618
+ if (items.length === 0)
619
+ return 0;
620
+ const now = Math.floor(Date.now() / 1000);
621
+ const syncState = await readMangaSyncState(db);
622
+ await performMangaWriteTransaction(mangaDb, (tx) => {
623
+ if (eraseAnilistRows) {
624
+ for (const row of MangaLibraryRepository.getAll(tx)) {
625
+ if (String(row.provider).toLowerCase() === MANGA_PROVIDER) {
626
+ MangaLibraryRepository.delete(tx, row.id);
627
+ MangaProgressRepository.deleteByManga(tx, row.id);
628
+ }
629
+ }
630
+ }
631
+ for (const item of items) {
632
+ const id = buildAnilistMangaId(item.mediaId);
633
+ const status = mangaImportStatusOrDefault(item.status);
634
+ MangaLibraryRepository.upsert(tx, {
635
+ id,
636
+ provider: MANGA_PROVIDER,
637
+ mangaId: String(item.mediaId),
638
+ title: item.title,
639
+ cover: item.cover ?? '',
640
+ status,
641
+ anilistId: item.mediaId,
642
+ anilistIdSource: 'anilist',
643
+ });
644
+ if (item.progress > 0) {
645
+ MangaLibraryRepository.setProgressPointer(tx, id, String(item.progress));
646
+ const chapterId = buildSyntheticChapterId(item.progress);
647
+ MangaProgressRepository.deleteSyntheticChapters(tx, id, chapterId);
648
+ MangaProgressRepository.upsert(tx, {
649
+ mangaId: id,
650
+ chapterId,
651
+ chapterNumber: String(item.progress),
652
+ page: 0,
653
+ pageCount: 0,
654
+ title: item.title,
655
+ cover: item.cover ?? '',
656
+ provider: MANGA_PROVIDER,
657
+ });
658
+ }
659
+ syncState[String(item.mediaId)] = {
660
+ lastSyncedAt: now,
661
+ remoteUpdatedAt: item.remoteUpdatedAt ?? now,
662
+ };
663
+ }
664
+ });
665
+ try {
666
+ const snapshot = JSON.stringify(syncState);
667
+ await performWriteTransaction(db, (tx) => {
668
+ SettingsRepository.upsert(tx, MANGA_SYNC_STATE_KEY, snapshot);
669
+ });
670
+ }
671
+ catch (err) {
672
+ logger.warn({ err }, '[AniList Manga Sync] Failed to persist sync state after import');
673
+ }
674
+ return items.length;
675
+ }
676
+ export async function importFromUsernameManga(db, mangaDb, username, erase = false) {
677
+ const tokenRow = await SettingsRepository.getByKey(db, TOKEN_KEY);
678
+ const token = tokenRow?.value;
679
+ const tracker = new AniListTracker(token);
680
+ let entries;
681
+ if (token) {
682
+ const viewer = await tracker.getViewer();
683
+ entries = await tracker.fetchUserMangaList(viewer.id);
684
+ }
685
+ else {
686
+ entries = await tracker.fetchUserMangaList(username);
687
+ }
688
+ if (entries.length === 0)
689
+ return 0;
690
+ return insertMangaImportEntries(db, mangaDb, entries.map((remote) => ({
691
+ mediaId: remote.mediaId,
692
+ title: remote.title.english || remote.title.romaji || `Manga #${remote.mediaId}`,
693
+ cover: remote.coverImage,
694
+ status: remote.status,
695
+ progress: remote.progress,
696
+ remoteUpdatedAt: remote.updatedAt,
697
+ })), erase);
698
+ }
699
+ async function resolveMalMangaToAnilist(malId, title, titleEnglish) {
700
+ if (malId) {
701
+ try {
702
+ const byMal = await getMangaMetaByMalId(malId);
703
+ if (byMal) {
704
+ return {
705
+ id: byMal.id,
706
+ title: byMal.title.english || byMal.title.romaji || title,
707
+ cover: byMal.cover,
708
+ };
709
+ }
710
+ }
711
+ catch {
712
+ // ignore
713
+ }
714
+ }
715
+ const query = titleEnglish || title;
716
+ if (!query)
717
+ return null;
718
+ try {
719
+ const found = await searchAnilistMangaByTitle(query);
720
+ if (!found)
721
+ return null;
722
+ const meta = await getMangaMetaById(found.id);
723
+ return {
724
+ id: found.id,
725
+ title: meta?.title.english ||
726
+ meta?.title.romaji ||
727
+ found.title.english ||
728
+ found.title.romaji ||
729
+ query,
730
+ cover: meta?.cover,
731
+ };
732
+ }
733
+ catch {
734
+ return null;
735
+ }
736
+ }
737
+ export async function importMangaFromMalUsername(db, mangaDb, username, erase = false) {
738
+ const entries = await fetchMalMangaList(username);
739
+ if (entries.length === 0)
740
+ return { imported: 0, skipped: 0 };
741
+ const items = [];
742
+ let skipped = 0;
743
+ const BATCH_SIZE = 5;
744
+ for (let i = 0; i < entries.length; i += BATCH_SIZE) {
745
+ const batch = entries.slice(i, i + BATCH_SIZE);
746
+ const results = await Promise.allSettled(batch.map((e) => resolveMalMangaToAnilist(e.malId, e.title, e.titleEnglish)));
747
+ results.forEach((r, idx) => {
748
+ const entry = batch[idx];
749
+ const status = mapMalMangaStatusCode(entry.status);
750
+ if (r.status !== 'fulfilled' || !r.value || !status) {
751
+ skipped++;
752
+ return;
753
+ }
754
+ items.push({
755
+ mediaId: r.value.id,
756
+ title: r.value.title,
757
+ cover: r.value.cover,
758
+ status,
759
+ progress: Math.max(entry.chaptersRead, 0),
760
+ });
761
+ });
762
+ }
763
+ const imported = await insertMangaImportEntries(db, mangaDb, items, erase);
764
+ return { imported, skipped };
765
+ }
766
+ export async function importMangaFromMalXmlItems(db, mangaDb, list, erase = false) {
767
+ if (list.length === 0)
768
+ return { imported: 0, skipped: 0 };
769
+ const items = [];
770
+ let skipped = 0;
771
+ const BATCH_SIZE = 5;
772
+ for (let i = 0; i < list.length; i += BATCH_SIZE) {
773
+ const batch = list.slice(i, i + BATCH_SIZE);
774
+ const results = await Promise.allSettled(batch.map((e) => resolveMalMangaToAnilist(e.malId, e.title, null)));
775
+ results.forEach((r, idx) => {
776
+ const entry = batch[idx];
777
+ if (r.status !== 'fulfilled' || !r.value) {
778
+ skipped++;
779
+ return;
780
+ }
781
+ items.push({
782
+ mediaId: r.value.id,
783
+ title: r.value.title,
784
+ cover: r.value.cover,
785
+ status: entry.status,
786
+ progress: Math.max(entry.chapters, 0),
787
+ });
788
+ });
789
+ }
790
+ const imported = await insertMangaImportEntries(db, mangaDb, items, erase);
791
+ return { imported, skipped };
792
+ }