@serifpersia/dango 3.6.7 → 3.6.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 (156) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-C3P9zWi9.js → AnimeCard-DKDJK8Mp.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-Et6687Tl.js → AnimeInfo-CTcvfjva.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-DX4eNnlH.js → AnimeInfoPage-B_UiacIN.js} +2 -2
  5. package/client/dist/assets/{Asmr-CCdEWrFi.js → Asmr-DMSN2vxl.js} +2 -2
  6. package/client/dist/assets/{AsmrResetProgressModal-DgEWBJOw.js → AsmrResetProgressModal-CRwfOatX.js} +1 -1
  7. package/client/dist/assets/{AvSyncCalibrator-CQ2fjf2E.js → AvSyncCalibrator-CRJsi-vz.js} +1 -1
  8. package/client/dist/assets/{Home-DUkUTTTy.js → Home-pwmK9_Eh.js} +1 -1
  9. package/client/dist/assets/{Insights-QCh4xW12.js → Insights-m7kFzg2A.js} +1 -1
  10. package/client/dist/assets/{LibraryListPage-BoTxRqHz.js → LibraryListPage-C1wpPvu6.js} +1 -1
  11. package/client/dist/assets/ListeningList-OzfEI1Ga.js +1 -0
  12. package/client/dist/assets/{Manga-HydR-L7o.js → Manga-ChRCExhK.js} +1 -1
  13. package/client/dist/assets/{MangaInfoPage-CBakEI9a.js → MangaInfoPage-DSHJPo2A.js} +1 -1
  14. package/client/dist/assets/{MangaReadPage-DwAR3iPy.js → MangaReadPage-CzOVY1wA.js} +1 -1
  15. package/client/dist/assets/{MangaResetProgressModal-DGzl8vsO.js → MangaResetProgressModal-CJfzdUBb.js} +1 -1
  16. package/client/dist/assets/{Mature-Pi0TxKuF.js → Mature-CifQMfhs.js} +1 -1
  17. package/client/dist/assets/{MediaCard--cuFxOCS.js → MediaCard-CM10yB9V.js} +1 -1
  18. package/client/dist/assets/{OptionListMenu-B3D39dGS.js → OptionListMenu-6Kovbsqw.js} +1 -1
  19. package/client/dist/assets/{Player-CsH4x_I_.js → Player-CK0hT-u4.js} +2 -2
  20. package/client/dist/assets/{PlayerSettings-DGxBsi3F.js → PlayerSettings-Bb66uqT9.js} +1 -1
  21. package/client/dist/assets/{QueueOptionsButton-Djq4Cpex.js → QueueOptionsButton-C_nTj8_q.js} +1 -1
  22. package/client/dist/assets/{QueueRail-DOrYgNVX.js → QueueRail-DUK7URAE.js} +1 -1
  23. package/client/dist/assets/{ReadingList-DwYoBrNk.js → ReadingList-Ck9LJThU.js} +1 -1
  24. package/client/dist/assets/{ResetProgressModal-CMF86mX6.js → ResetProgressModal-BUphIv0k.js} +1 -1
  25. package/client/dist/assets/{Search-DFkhuk-x.js → Search-zJyP15h6.js} +1 -1
  26. package/client/dist/assets/{Settings-DBB2r0VQ.js → Settings-BYdoSEUh.js} +1 -1
  27. package/client/dist/assets/{SkeletonGrid-DJelJgAv.js → SkeletonGrid-CqgbOhjX.js} +1 -1
  28. package/client/dist/assets/{Trackers-DGYXFhYZ.js → Trackers-BVjyv6CM.js} +1 -1
  29. package/client/dist/assets/{Tv-C5MDGD2Z.js → Tv-L13ylswR.js} +1 -1
  30. package/client/dist/assets/{TvInfo-DrKuK13u.js → TvInfo-Cvcm5U2D.js} +1 -1
  31. package/client/dist/assets/{TvPopup-DtHilRDT.js → TvPopup-DhxfKvYm.js} +1 -1
  32. package/client/dist/assets/{TvSearch-CzNxdu_w.js → TvSearch-P8bFE6_X.js} +1 -1
  33. package/client/dist/assets/{TvWatchlist-D7p4br6S.js → TvWatchlist-D5gVPjtb.js} +1 -1
  34. package/client/dist/assets/{Watchlist-DrOzswl2.js → Watchlist-Cjq8l3rU.js} +1 -1
  35. package/client/dist/assets/{hls-BGCX7mJy.js → hls-BkzuANAg.js} +1 -1
  36. package/client/dist/assets/{index-CnfZwsds.js → index-CLVgzKLk.js} +19 -19
  37. package/client/dist/assets/{useAnimeInfoData-DYXWBPF-.js → useAnimeInfoData-DZJZu-so.js} +1 -1
  38. package/client/dist/assets/{useAsmr-BbhQ59bk.js → useAsmr-CvrMGHuo.js} +1 -1
  39. package/client/dist/assets/useAsmrPopup-Bimy8Ouw.js +1 -0
  40. package/client/dist/assets/{useMangaLibrary-DuesWQaE.js → useMangaLibrary--MiN11uR.js} +1 -1
  41. package/client/dist/assets/{useMangaPopup-D57OVGUr.js → useMangaPopup-CR5XhKKS.js} +1 -1
  42. package/client/dist/assets/{useProviders-mpQb_dix.js → useProviders-DqdT4fA5.js} +1 -1
  43. package/client/dist/assets/{useTv-DGflCsSI.js → useTv-BL0HMDyP.js} +1 -1
  44. package/client/dist/assets/{useTvLibrary-BqeSmyc4.js → useTvLibrary-D4U4iqN8.js} +1 -1
  45. package/client/dist/index.html +1 -1
  46. package/package.json +3 -5
  47. package/server/dist/config.js +1 -174
  48. package/server/dist/server.js +1068 -289
  49. package/server/dist/shipped-defaults.js +1 -6
  50. package/server/package.json +4 -5
  51. package/client/dist/assets/Asmr.module-CdWlioTY.js +0 -1
  52. package/client/dist/assets/ListeningList-DguKjI_t.js +0 -1
  53. package/client/dist/assets/Music-BGH_0voi.css +0 -1
  54. package/client/dist/assets/Music-CQpyXTLH.js +0 -1
  55. package/client/dist/assets/Radio-CCHNFN7R.css +0 -1
  56. package/client/dist/assets/Radio-DF6g6odd.js +0 -1
  57. package/client/dist/assets/Radio.module-DGSZLpKM.js +0 -1
  58. package/client/dist/assets/useAsmrPopup-SymV1J-b.js +0 -1
  59. package/server/dist/app-auth.js +0 -157
  60. package/server/dist/app-hono.js +0 -598
  61. package/server/dist/db/drizzle.js +0 -16
  62. package/server/dist/db/schema-anime.js +0 -61
  63. package/server/dist/db/schema-asmr.js +0 -13
  64. package/server/dist/db/schema-manga.js +0 -31
  65. package/server/dist/db/schema-tv.js +0 -18
  66. package/server/dist/db.js +0 -217
  67. package/server/dist/discord-gateway.js +0 -385
  68. package/server/dist/discord-rpc.js +0 -413
  69. package/server/dist/github-sync.js +0 -508
  70. package/server/dist/google.js +0 -741
  71. package/server/dist/hono/asmr.js +0 -10
  72. package/server/dist/hono/auth.js +0 -49
  73. package/server/dist/hono/music.js +0 -209
  74. package/server/dist/hono/proxy.js +0 -827
  75. package/server/dist/hono/tv.js +0 -200
  76. package/server/dist/hono/watchlist.js +0 -341
  77. package/server/dist/lib/anilist.js +0 -1583
  78. package/server/dist/lib/anischedule.js +0 -191
  79. package/server/dist/lib/insights.js +0 -185
  80. package/server/dist/lib/ipc.js +0 -23
  81. package/server/dist/lib/kitsu.js +0 -517
  82. package/server/dist/lib/mal-import.js +0 -291
  83. package/server/dist/lib/mal.js +0 -957
  84. package/server/dist/lib/migration.js +0 -310
  85. package/server/dist/lib/offline-db.js +0 -395
  86. package/server/dist/lib/potoken.js +0 -135
  87. package/server/dist/lib/recommendations.service.js +0 -216
  88. package/server/dist/lib/temp-ids.js +0 -3
  89. package/server/dist/lib/tmdb.js +0 -72
  90. package/server/dist/lib/tracker/anilist-tracker.js +0 -308
  91. package/server/dist/lib/tracker/manga-sync.service.js +0 -773
  92. package/server/dist/lib/tracker/sync-state.js +0 -21
  93. package/server/dist/lib/tracker/sync.service.js +0 -476
  94. package/server/dist/lib/translate.js +0 -79
  95. package/server/dist/lib/ump.js +0 -106
  96. package/server/dist/lib/ytmusic.js +0 -162
  97. package/server/dist/logger.js +0 -16
  98. package/server/dist/providers/manga/manga.types.js +0 -1
  99. package/server/dist/providers/provider.interface.js +0 -1
  100. package/server/dist/providers/remote-loader.js +0 -614
  101. package/server/dist/providers/remote-types.js +0 -159
  102. package/server/dist/providers/title-matching.js +0 -226
  103. package/server/dist/providers/tv.types.js +0 -1
  104. package/server/dist/rclone.js +0 -120
  105. package/server/dist/repositories/asmr.repository.js +0 -77
  106. package/server/dist/repositories/insights.repository.js +0 -106
  107. package/server/dist/repositories/library.repository.js +0 -94
  108. package/server/dist/repositories/mal-cache.repository.js +0 -52
  109. package/server/dist/repositories/manga.repository.js +0 -95
  110. package/server/dist/repositories/notifications.repository.js +0 -60
  111. package/server/dist/repositories/queue.repository.js +0 -72
  112. package/server/dist/repositories/settings.repository.js +0 -30
  113. package/server/dist/repositories/shows-meta.repository.js +0 -58
  114. package/server/dist/repositories/temp-show-ids.repository.js +0 -63
  115. package/server/dist/repositories/tv.repository.js +0 -93
  116. package/server/dist/repositories/watched-episodes.repository.js +0 -82
  117. package/server/dist/repositories/watchlist.repository.js +0 -60
  118. package/server/dist/scripts/backfill-episode-duration.js +0 -42
  119. package/server/dist/sync-payload.js +0 -91
  120. package/server/dist/sync.js +0 -657
  121. package/server/dist/trpc/context.js +0 -7
  122. package/server/dist/trpc/errors.js +0 -15
  123. package/server/dist/trpc/index.js +0 -18
  124. package/server/dist/trpc/router.js +0 -49
  125. package/server/dist/trpc/routers/asmr-library.js +0 -112
  126. package/server/dist/trpc/routers/asmr-progress.js +0 -121
  127. package/server/dist/trpc/routers/asmr.js +0 -87
  128. package/server/dist/trpc/routers/auth.js +0 -194
  129. package/server/dist/trpc/routers/continue-watching.js +0 -81
  130. package/server/dist/trpc/routers/data.js +0 -1026
  131. package/server/dist/trpc/routers/discord.js +0 -285
  132. package/server/dist/trpc/routers/discovery.js +0 -11
  133. package/server/dist/trpc/routers/insights.js +0 -14
  134. package/server/dist/trpc/routers/manga-library.js +0 -263
  135. package/server/dist/trpc/routers/manga-progress.js +0 -169
  136. package/server/dist/trpc/routers/manga.js +0 -201
  137. package/server/dist/trpc/routers/music.js +0 -655
  138. package/server/dist/trpc/routers/notifications.js +0 -80
  139. package/server/dist/trpc/routers/progress.js +0 -109
  140. package/server/dist/trpc/routers/queue.js +0 -93
  141. package/server/dist/trpc/routers/radio.js +0 -145
  142. package/server/dist/trpc/routers/settings.js +0 -96
  143. package/server/dist/trpc/routers/tracker.js +0 -213
  144. package/server/dist/trpc/routers/tv-library.js +0 -135
  145. package/server/dist/trpc/routers/tv-progress.js +0 -159
  146. package/server/dist/trpc/routers/tv.js +0 -551
  147. package/server/dist/trpc/routers/watchlist.js +0 -550
  148. package/server/dist/trpc/validation.js +0 -258
  149. package/server/dist/utils/cache.utils.js +0 -25
  150. package/server/dist/utils/cookie.utils.js +0 -13
  151. package/server/dist/utils/db-utils.js +0 -17
  152. package/server/dist/utils/env.utils.js +0 -77
  153. package/server/dist/utils/http.utils.js +0 -82
  154. package/server/dist/utils/request-context.js +0 -2
  155. package/server/dist/utils/security.utils.js +0 -76
  156. /package/client/dist/assets/{Asmr-D1KuQwQy.css → useAsmrPopup-D1KuQwQy.css} +0 -0
@@ -1,773 +0,0 @@
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 { performMangaWriteTransactionAsync, performWriteTransactionAsync } 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
- import { readTrackerSyncState, resolveStatus, } from './sync-state.js';
9
- const TOKEN_KEY = 'tracker_anilist_token';
10
- const MANGA_SYNC_STATE_KEY = 'tracker_anilist_manga_sync_state';
11
- const MANGA_PROVIDER = 'anilist';
12
- const MAX_TITLE_LOOKUPS_PER_SYNC = 25;
13
- function parseChapterProgress(value) {
14
- if (value == null)
15
- return 0;
16
- const matches = String(value).match(/(\d+(?:\.\d+)?)/g);
17
- if (!matches)
18
- return 0;
19
- let best = 0;
20
- for (const m of matches) {
21
- const n = Number(m);
22
- if (Number.isFinite(n))
23
- best = Math.max(best, Math.floor(n));
24
- }
25
- return best;
26
- }
27
- async function getLocalChapterProgress(mangaDb, libraryId, lastChapterNumber) {
28
- let best = parseChapterProgress(lastChapterNumber);
29
- try {
30
- const rows = await MangaProgressRepository.getByManga(mangaDb, libraryId);
31
- for (const row of rows ?? []) {
32
- best = Math.max(best, parseChapterProgress(row.chapterNumber));
33
- }
34
- }
35
- catch {
36
- // ignore
37
- }
38
- return best;
39
- }
40
- function buildAnilistMangaId(mediaId) {
41
- return `${MANGA_PROVIDER}:${mediaId}`;
42
- }
43
- function buildSyntheticChapterId(progress) {
44
- return `${MANGA_PROVIDER}:ch:${progress}`;
45
- }
46
- function parseAnilistMangaId(id) {
47
- const match = /^anilist:(\d+)$/i.exec(id.trim());
48
- if (!match)
49
- return null;
50
- const n = Number.parseInt(match[1], 10);
51
- return Number.isFinite(n) ? n : null;
52
- }
53
- function mangaStatusOrFallback(status) {
54
- const known = ['Reading', 'Completed', 'On-Hold', 'Dropped', 'Planned'];
55
- return known.includes(status) ? status : undefined;
56
- }
57
- function normalizeTitle(value) {
58
- return String(value ?? '')
59
- .normalize('NFD')
60
- .replace(/[\u0300-\u036f]/g, '')
61
- .toLowerCase()
62
- .replace(/[^\p{L}\p{N}]+/gu, ' ')
63
- .replace(/\s+/g, ' ')
64
- .trim();
65
- }
66
- function titlesEqual(a, b) {
67
- const na = normalizeTitle(a);
68
- return na.length > 0 && na === normalizeTitle(b);
69
- }
70
- function stripEditionSuffix(value) {
71
- let s = String(value ?? '').trim();
72
- let prev = '';
73
- while (prev !== s) {
74
- prev = s;
75
- s = s.replace(/\s*\([^()]*\)$/, '').trim();
76
- }
77
- return s;
78
- }
79
- function matchesStrippedBaseTitle(item, match) {
80
- const candidates = [item.title, item.altTitle]
81
- .filter((c) => !!c)
82
- .map(stripEditionSuffix)
83
- .filter((c) => c.length > 0);
84
- const remotes = [match.romaji, match.english, match.native]
85
- .filter((r) => !!r)
86
- .map(stripEditionSuffix)
87
- .filter((r) => r.length > 0);
88
- return candidates.some((c) => remotes.some((r) => titlesEqual(c, r)));
89
- }
90
- function isExactTitleMatch(item, match) {
91
- const candidates = [item.title, item.altTitle];
92
- const remotes = [match.romaji, match.english, match.native];
93
- return candidates.some((c) => !!c && remotes.some((r) => !!r && titlesEqual(c, r)));
94
- }
95
- export async function syncAniListManga(db, mangaDb, opts = {}) {
96
- const direction = opts.direction === 'pull-only' ? 'pull-only' : 'two-way';
97
- const pullOnly = direction === 'pull-only';
98
- const tokenRow = await SettingsRepository.getByKey(db, TOKEN_KEY);
99
- const token = tokenRow?.value;
100
- if (!token)
101
- throw new Error('AniList is not connected. Please log in first.');
102
- const tracker = new AniListTracker(token);
103
- const viewer = await tracker.getViewer();
104
- const remoteEntries = await tracker.fetchUserMangaList(viewer.id);
105
- const localItems = await MangaLibraryRepository.getAll(mangaDb);
106
- const syncState = await readTrackerSyncState(db, MANGA_SYNC_STATE_KEY);
107
- const remoteByMediaId = new Map();
108
- for (const entry of remoteEntries)
109
- remoteByMediaId.set(entry.mediaId, entry);
110
- const localByMediaId = new Map();
111
- const newLinks = [];
112
- const searchedMediaIds = new Set();
113
- const preexistingLinks = new Map();
114
- let titleLookups = 0;
115
- let unresolved = 0;
116
- const details = [];
117
- const record = (detail) => {
118
- details.push(detail);
119
- };
120
- const pendingWarnings = [];
121
- for (const item of localItems) {
122
- let anilistId = typeof item.anilistId === 'number' ? item.anilistId : null;
123
- const fromColumn = anilistId != null;
124
- const columnSource = fromColumn ? (item.anilistIdSource ?? null) : null;
125
- let preexisting = fromColumn;
126
- if (anilistId == null) {
127
- anilistId = parseAnilistMangaId(item.id);
128
- preexisting = anilistId != null;
129
- }
130
- if (anilistId == null && item.provider && item.mangaId) {
131
- const fromParts = String(item.provider).toLowerCase() === MANGA_PROVIDER &&
132
- /^\d+$/.test(String(item.mangaId).trim())
133
- ? Number.parseInt(String(item.mangaId).trim(), 10)
134
- : null;
135
- if (fromParts != null) {
136
- anilistId = fromParts;
137
- preexisting = true;
138
- }
139
- }
140
- if (anilistId == null) {
141
- if (titleLookups >= MAX_TITLE_LOOKUPS_PER_SYNC || !item.title) {
142
- unresolved++;
143
- record({
144
- title: item.title || item.id,
145
- action: 'unresolved',
146
- note: 'No AniList link yet — skipped so nothing is written to the wrong series.',
147
- });
148
- continue;
149
- }
150
- titleLookups++;
151
- try {
152
- const match = await searchAnilistMangaByTitle(item.title);
153
- if (match && isExactTitleMatch(item, match.title)) {
154
- anilistId = match.id;
155
- preexisting = false;
156
- newLinks.push({ id: item.id, anilistId: match.id });
157
- searchedMediaIds.add(match.id);
158
- }
159
- else {
160
- unresolved++;
161
- record({
162
- title: item.title,
163
- action: 'unresolved',
164
- note: match
165
- ? `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.`
166
- : 'No AniList match found — skipped.',
167
- });
168
- continue;
169
- }
170
- }
171
- catch (err) {
172
- logger.warn({ err, mangaId: item.id }, '[AniList Manga Sync] Title lookup failed');
173
- unresolved++;
174
- record({
175
- title: item.title || item.id,
176
- action: 'unresolved',
177
- note: 'Title lookup failed — skipped.',
178
- });
179
- continue;
180
- }
181
- }
182
- if (!localByMediaId.has(anilistId)) {
183
- localByMediaId.set(anilistId, item);
184
- if (preexisting && fromColumn && !searchedMediaIds.has(anilistId)) {
185
- preexistingLinks.set(anilistId, {
186
- title: item.title,
187
- altTitle: item.altTitle,
188
- source: columnSource,
189
- });
190
- }
191
- }
192
- }
193
- const suspectMediaIds = new Set();
194
- for (const [mediaId, link] of preexistingLinks) {
195
- const remote = remoteByMediaId.get(mediaId);
196
- if (!remote)
197
- continue;
198
- if (link.source === 'manual' || link.source === 'anilist' || link.source === 'search')
199
- continue;
200
- if (isExactTitleMatch(link, remote.title))
201
- continue;
202
- if (matchesStrippedBaseTitle(link, remote.title))
203
- continue;
204
- suspectMediaIds.add(mediaId);
205
- const remoteLabel = remote.title.english || remote.title.romaji || remote.title.native || `Manga #${mediaId}`;
206
- 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.`);
207
- record({
208
- mediaId,
209
- title: link.title,
210
- action: 'suspicious',
211
- remoteStatus: remote.status,
212
- remoteProgress: remote.progress,
213
- note: `Linked AniList title is "${remoteLabel}".`,
214
- });
215
- }
216
- const allMediaIds = new Set([
217
- ...localByMediaId.keys(),
218
- ...remoteByMediaId.keys(),
219
- ...Object.keys(syncState)
220
- .map((k) => Number.parseInt(k, 10))
221
- .filter((n) => Number.isFinite(n)),
222
- ]);
223
- const summary = {
224
- pushed: 0,
225
- pulled: 0,
226
- merged: 0,
227
- unchanged: 0,
228
- skipped: 0,
229
- errors: [],
230
- warnings: pendingWarnings,
231
- };
232
- const libraryUpserts = [];
233
- const progressPointers = [];
234
- const progressRows = [];
235
- const libraryDeletes = [];
236
- const stateUpdates = {};
237
- const pushUpdates = [];
238
- const remoteDeleteEntryIds = [];
239
- const now = Math.floor(Date.now() / 1000);
240
- for (const mediaId of allMediaIds) {
241
- if (suspectMediaIds.has(mediaId))
242
- continue;
243
- const local = localByMediaId.get(mediaId);
244
- const remote = remoteByMediaId.get(mediaId);
245
- const stateKey = String(mediaId);
246
- const localId = local?.id ?? buildAnilistMangaId(mediaId);
247
- try {
248
- if (local && !remote) {
249
- if (syncState[stateKey] && remoteEntries.length > 0) {
250
- if (pullOnly) {
251
- summary.skipped++;
252
- record({
253
- mediaId,
254
- title: local.title,
255
- action: 'skipped',
256
- localStatus: local.status,
257
- note: 'Pull-only mode — local entry kept, nothing deleted.',
258
- });
259
- continue;
260
- }
261
- libraryDeletes.push(local.id);
262
- stateUpdates[stateKey] = undefined;
263
- summary.pulled++;
264
- record({ mediaId, title: local.title, action: 'delete-local', localStatus: local.status });
265
- continue;
266
- }
267
- const chapters = await getLocalChapterProgress(mangaDb, local.id, local.lastChapterNumber);
268
- if (pullOnly) {
269
- summary.skipped++;
270
- record({
271
- mediaId,
272
- title: local.title,
273
- action: 'skipped',
274
- localStatus: local.status,
275
- localChapters: chapters,
276
- note: 'Pull-only mode — not pushed to AniList.',
277
- });
278
- continue;
279
- }
280
- const status = mangaStatusOrFallback(local.status);
281
- if (!status || (status === 'Planned' && chapters === 0)) {
282
- summary.skipped++;
283
- record({
284
- mediaId,
285
- title: local.title,
286
- action: 'skipped',
287
- localStatus: local.status,
288
- localChapters: chapters,
289
- note: 'Bookmark with no chapters read — not pushed to AniList.',
290
- });
291
- continue;
292
- }
293
- pushUpdates.push({
294
- mediaId,
295
- status,
296
- progress: chapters,
297
- });
298
- stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: now };
299
- summary.pushed++;
300
- record({
301
- mediaId,
302
- title: local.title,
303
- action: 'push',
304
- localStatus: local.status,
305
- localChapters: chapters,
306
- });
307
- continue;
308
- }
309
- if (!local && remote) {
310
- if (syncState[stateKey] && !pullOnly) {
311
- if (remote.entryId) {
312
- remoteDeleteEntryIds.push({ showId: stateKey, entryId: remote.entryId });
313
- stateUpdates[stateKey] = undefined;
314
- summary.pushed++;
315
- record({
316
- mediaId,
317
- title: remote.title.english || remote.title.romaji || `Manga #${mediaId}`,
318
- action: 'delete-remote',
319
- remoteStatus: remote.status,
320
- remoteProgress: remote.progress,
321
- note: 'Removed locally after a previous sync — delete propagated to AniList.',
322
- });
323
- continue;
324
- }
325
- stateUpdates[stateKey] = undefined;
326
- summary.pushed++;
327
- record({ mediaId, title: `Manga #${mediaId}`, action: 'delete-remote' });
328
- continue;
329
- }
330
- logger.debug({ mediaId, remoteStatus: remote.status }, '[AniList Manga Sync] Remote only -> pulling to local');
331
- const title = remote.title.english || remote.title.romaji || `Manga #${mediaId}`;
332
- libraryUpserts.push({
333
- id: buildAnilistMangaId(mediaId),
334
- provider: MANGA_PROVIDER,
335
- mangaId: String(mediaId),
336
- title,
337
- cover: remote.coverImage ?? '',
338
- status: remote.status,
339
- anilistId: mediaId,
340
- anilistIdSource: 'anilist',
341
- });
342
- if (remote.progress > 0) {
343
- progressPointers.push({
344
- id: buildAnilistMangaId(mediaId),
345
- chapterNumber: String(remote.progress),
346
- });
347
- progressRows.push({
348
- mangaId: buildAnilistMangaId(mediaId),
349
- chapterId: buildSyntheticChapterId(remote.progress),
350
- chapterNumber: String(remote.progress),
351
- title,
352
- cover: remote.coverImage ?? '',
353
- provider: MANGA_PROVIDER,
354
- });
355
- }
356
- stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: remote.updatedAt };
357
- summary.pulled++;
358
- record({
359
- mediaId,
360
- title,
361
- action: 'pull',
362
- remoteStatus: remote.status,
363
- remoteProgress: remote.progress,
364
- });
365
- continue;
366
- }
367
- if (!local && !remote && syncState[stateKey]) {
368
- stateUpdates[stateKey] = undefined;
369
- continue;
370
- }
371
- if (local && remote) {
372
- const localChapters = await getLocalChapterProgress(mangaDb, local.id, local.lastChapterNumber);
373
- const localLastSync = syncState[stateKey]?.lastSyncedAt ?? 0;
374
- const remoteUpdated = remote.updatedAt || 0;
375
- const targetProgress = Math.max(localChapters, remote.progress);
376
- const shouldPushProgress = localChapters > remote.progress;
377
- const shouldPullProgress = remote.progress > localChapters;
378
- const { status: targetStatus, pull: pullStatus } = resolveStatus(local.status, remote.status, remoteUpdated, localLastSync);
379
- const statusDiffers = remote.status !== local.status;
380
- if (pullOnly) {
381
- if (pullStatus || shouldPullProgress) {
382
- if (pullStatus && targetStatus !== local.status) {
383
- libraryUpserts.push({
384
- id: local.id,
385
- provider: local.provider,
386
- mangaId: local.mangaId,
387
- title: local.title,
388
- cover: local.cover ?? '',
389
- status: targetStatus,
390
- anilistId: mediaId,
391
- });
392
- }
393
- if (shouldPullProgress) {
394
- progressPointers.push({ id: localId, chapterNumber: String(targetProgress) });
395
- progressRows.push({
396
- mangaId: localId,
397
- chapterId: buildSyntheticChapterId(targetProgress),
398
- chapterNumber: String(targetProgress),
399
- title: local.title,
400
- cover: local.cover ?? '',
401
- provider: local.provider,
402
- });
403
- }
404
- stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: remoteUpdated };
405
- summary.merged++;
406
- record({
407
- mediaId,
408
- title: local.title,
409
- action: 'merge-pull',
410
- localStatus: local.status,
411
- remoteStatus: targetStatus,
412
- localChapters: localChapters,
413
- remoteProgress: targetProgress,
414
- });
415
- }
416
- else if (shouldPushProgress || statusDiffers) {
417
- summary.skipped++;
418
- record({
419
- mediaId,
420
- title: local.title,
421
- action: 'skipped',
422
- localStatus: local.status,
423
- remoteStatus: remote.status,
424
- localChapters: localChapters,
425
- remoteProgress: remote.progress,
426
- note: 'Local is ahead — not pushed in pull-only mode.',
427
- });
428
- }
429
- else {
430
- summary.unchanged++;
431
- }
432
- }
433
- else if (shouldPushProgress || (!pullStatus && statusDiffers)) {
434
- pushUpdates.push({
435
- mediaId,
436
- status: targetStatus,
437
- progress: targetProgress,
438
- });
439
- if (targetStatus !== local.status) {
440
- libraryUpserts.push({
441
- id: local.id,
442
- provider: local.provider,
443
- mangaId: local.mangaId,
444
- title: local.title,
445
- cover: local.cover ?? '',
446
- status: targetStatus,
447
- anilistId: mediaId,
448
- });
449
- }
450
- stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: now };
451
- summary.merged++;
452
- record({
453
- mediaId,
454
- title: local.title,
455
- action: 'merge-push',
456
- localStatus: targetStatus,
457
- remoteStatus: remote.status,
458
- localChapters: targetProgress,
459
- remoteProgress: remote.progress,
460
- });
461
- }
462
- else if (pullStatus || shouldPullProgress) {
463
- if (targetStatus !== local.status) {
464
- libraryUpserts.push({
465
- id: local.id,
466
- provider: local.provider,
467
- mangaId: local.mangaId,
468
- title: local.title,
469
- cover: local.cover ?? '',
470
- status: targetStatus,
471
- anilistId: mediaId,
472
- });
473
- }
474
- if (shouldPullProgress) {
475
- progressPointers.push({ id: localId, chapterNumber: String(targetProgress) });
476
- progressRows.push({
477
- mangaId: localId,
478
- chapterId: buildSyntheticChapterId(targetProgress),
479
- chapterNumber: String(targetProgress),
480
- title: local.title,
481
- cover: local.cover ?? '',
482
- provider: local.provider,
483
- });
484
- }
485
- stateUpdates[stateKey] = { lastSyncedAt: now, remoteUpdatedAt: remoteUpdated };
486
- summary.merged++;
487
- record({
488
- mediaId,
489
- title: local.title,
490
- action: 'merge-pull',
491
- localStatus: local.status,
492
- remoteStatus: targetStatus,
493
- localChapters: localChapters,
494
- remoteProgress: targetProgress,
495
- });
496
- }
497
- else {
498
- summary.unchanged++;
499
- }
500
- }
501
- }
502
- catch (err) {
503
- const message = err instanceof Error ? err.message : String(err);
504
- logger.warn({ err, mediaId }, '[AniList Manga Sync] Entry failed');
505
- summary.errors.push(`${local?.title ?? `Manga ${mediaId}`}: ${message}`);
506
- record({
507
- mediaId,
508
- title: local?.title ?? `Manga #${mediaId}`,
509
- action: 'error',
510
- note: message,
511
- });
512
- }
513
- }
514
- if (pushUpdates.length > 0 || remoteDeleteEntryIds.length > 0 || libraryDeletes.length > 0) {
515
- const deleteCap = Math.max(5, Math.ceil(localItems.length * 0.1));
516
- const totalDeletes = libraryDeletes.length + remoteDeleteEntryIds.length;
517
- if (totalDeletes > deleteCap) {
518
- 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.`);
519
- }
520
- }
521
- if (pushUpdates.length > 0) {
522
- const batchSize = 10;
523
- for (let i = 0; i < pushUpdates.length; i += batchSize) {
524
- const batch = pushUpdates.slice(i, i + batchSize);
525
- await tracker.batchUpdateMediaEntries(batch);
526
- }
527
- }
528
- if (remoteDeleteEntryIds.length > 0) {
529
- const deleteBatch = remoteDeleteEntryIds.map((r) => r.entryId);
530
- const batchSize = 10;
531
- for (let i = 0; i < deleteBatch.length; i += batchSize) {
532
- const batch = deleteBatch.slice(i, i + batchSize);
533
- await tracker.batchDeleteMediaEntries(batch);
534
- }
535
- }
536
- if (libraryUpserts.length > 0 ||
537
- progressPointers.length > 0 ||
538
- progressRows.length > 0 ||
539
- libraryDeletes.length > 0 ||
540
- newLinks.length > 0) {
541
- await performMangaWriteTransactionAsync(mangaDb, async (tx) => {
542
- for (const item of libraryUpserts) {
543
- await MangaLibraryRepository.upsert(tx, item);
544
- }
545
- for (const link of newLinks) {
546
- await MangaLibraryRepository.setAnilistId(tx, link.id, link.anilistId, 'search');
547
- }
548
- for (const p of progressPointers) {
549
- await MangaLibraryRepository.setProgressPointer(tx, p.id, p.chapterNumber);
550
- }
551
- for (const p of progressRows) {
552
- await MangaProgressRepository.deleteSyntheticChapters(tx, p.mangaId, p.chapterId);
553
- await MangaProgressRepository.upsert(tx, {
554
- mangaId: p.mangaId,
555
- chapterId: p.chapterId,
556
- chapterNumber: p.chapterNumber,
557
- page: 0,
558
- pageCount: 0,
559
- title: p.title,
560
- cover: p.cover,
561
- provider: p.provider,
562
- });
563
- }
564
- for (const id of libraryDeletes) {
565
- await MangaLibraryRepository.delete(tx, id);
566
- await MangaProgressRepository.deleteByManga(tx, id);
567
- }
568
- });
569
- }
570
- if (Object.keys(stateUpdates).length > 0) {
571
- try {
572
- const mergedState = { ...syncState };
573
- for (const [k, v] of Object.entries(stateUpdates)) {
574
- if (v === undefined)
575
- delete mergedState[k];
576
- else
577
- mergedState[k] = v;
578
- }
579
- const snapshot = JSON.stringify(mergedState);
580
- await performWriteTransactionAsync(db, async (tx) => {
581
- await SettingsRepository.upsert(tx, MANGA_SYNC_STATE_KEY, snapshot);
582
- });
583
- }
584
- catch (err) {
585
- const message = err instanceof Error ? err.message : String(err);
586
- logger.warn({ err }, '[AniList Manga Sync] Failed to persist sync state');
587
- summary.errors.push(`Sync state was not saved, next sync will re-compare: ${message}`);
588
- }
589
- }
590
- if (unresolved > 0) {
591
- 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.`);
592
- }
593
- return { summary, details };
594
- }
595
- function mangaImportStatusOrDefault(status) {
596
- return mangaStatusOrFallback(status) ?? 'Planned';
597
- }
598
- export async function insertMangaImportEntries(db, mangaDb, items, eraseAnilistRows) {
599
- if (items.length === 0)
600
- return 0;
601
- const now = Math.floor(Date.now() / 1000);
602
- const syncState = await readTrackerSyncState(db, MANGA_SYNC_STATE_KEY);
603
- await performMangaWriteTransactionAsync(mangaDb, async (tx) => {
604
- if (eraseAnilistRows) {
605
- for (const row of await MangaLibraryRepository.getAll(tx)) {
606
- if (String(row.provider).toLowerCase() === MANGA_PROVIDER) {
607
- await MangaLibraryRepository.delete(tx, row.id);
608
- await MangaProgressRepository.deleteByManga(tx, row.id);
609
- }
610
- }
611
- }
612
- for (const item of items) {
613
- const id = buildAnilistMangaId(item.mediaId);
614
- const status = mangaImportStatusOrDefault(item.status);
615
- await MangaLibraryRepository.upsert(tx, {
616
- id,
617
- provider: MANGA_PROVIDER,
618
- mangaId: String(item.mediaId),
619
- title: item.title,
620
- cover: item.cover ?? '',
621
- status,
622
- anilistId: item.mediaId,
623
- anilistIdSource: 'anilist',
624
- });
625
- if (item.progress > 0) {
626
- await MangaLibraryRepository.setProgressPointer(tx, id, String(item.progress));
627
- const chapterId = buildSyntheticChapterId(item.progress);
628
- await MangaProgressRepository.deleteSyntheticChapters(tx, id, chapterId);
629
- await MangaProgressRepository.upsert(tx, {
630
- mangaId: id,
631
- chapterId,
632
- chapterNumber: String(item.progress),
633
- page: 0,
634
- pageCount: 0,
635
- title: item.title,
636
- cover: item.cover ?? '',
637
- provider: MANGA_PROVIDER,
638
- });
639
- }
640
- syncState[String(item.mediaId)] = {
641
- lastSyncedAt: now,
642
- remoteUpdatedAt: item.remoteUpdatedAt ?? now,
643
- };
644
- }
645
- });
646
- try {
647
- const snapshot = JSON.stringify(syncState);
648
- await performWriteTransactionAsync(db, async (tx) => {
649
- await SettingsRepository.upsert(tx, MANGA_SYNC_STATE_KEY, snapshot);
650
- });
651
- }
652
- catch (err) {
653
- logger.warn({ err }, '[AniList Manga Sync] Failed to persist sync state after import');
654
- }
655
- return items.length;
656
- }
657
- export async function importFromUsernameManga(db, mangaDb, username, erase = false) {
658
- const tokenRow = await SettingsRepository.getByKey(db, TOKEN_KEY);
659
- const token = tokenRow?.value;
660
- const tracker = new AniListTracker(token);
661
- let entries;
662
- if (token) {
663
- const viewer = await tracker.getViewer();
664
- entries = await tracker.fetchUserMangaList(viewer.id);
665
- }
666
- else {
667
- entries = await tracker.fetchUserMangaList(username);
668
- }
669
- if (entries.length === 0)
670
- return 0;
671
- return insertMangaImportEntries(db, mangaDb, entries.map((remote) => ({
672
- mediaId: remote.mediaId,
673
- title: remote.title.english || remote.title.romaji || `Manga #${remote.mediaId}`,
674
- cover: remote.coverImage,
675
- status: remote.status,
676
- progress: remote.progress,
677
- remoteUpdatedAt: remote.updatedAt,
678
- })), erase);
679
- }
680
- async function resolveMalMangaToAnilist(malId, title, titleEnglish) {
681
- if (malId) {
682
- try {
683
- const byMal = await getMangaMetaByMalId(malId);
684
- if (byMal) {
685
- return {
686
- id: byMal.id,
687
- title: byMal.title.english || byMal.title.romaji || title,
688
- cover: byMal.cover,
689
- };
690
- }
691
- }
692
- catch {
693
- // ignore
694
- }
695
- }
696
- const query = titleEnglish || title;
697
- if (!query)
698
- return null;
699
- try {
700
- const found = await searchAnilistMangaByTitle(query);
701
- if (!found)
702
- return null;
703
- const meta = await getMangaMetaById(found.id);
704
- return {
705
- id: found.id,
706
- title: meta?.title.english ||
707
- meta?.title.romaji ||
708
- found.title.english ||
709
- found.title.romaji ||
710
- query,
711
- cover: meta?.cover,
712
- };
713
- }
714
- catch {
715
- return null;
716
- }
717
- }
718
- export async function importMangaFromMalUsername(db, mangaDb, username, erase = false) {
719
- const entries = await fetchMalMangaList(username);
720
- if (entries.length === 0)
721
- return { imported: 0, skipped: 0 };
722
- const items = [];
723
- let skipped = 0;
724
- const BATCH_SIZE = 5;
725
- for (let i = 0; i < entries.length; i += BATCH_SIZE) {
726
- const batch = entries.slice(i, i + BATCH_SIZE);
727
- const results = await Promise.allSettled(batch.map((e) => resolveMalMangaToAnilist(e.malId, e.title, e.titleEnglish)));
728
- results.forEach((r, idx) => {
729
- const entry = batch[idx];
730
- const status = mapMalMangaStatusCode(entry.status);
731
- if (r.status !== 'fulfilled' || !r.value || !status) {
732
- skipped++;
733
- return;
734
- }
735
- items.push({
736
- mediaId: r.value.id,
737
- title: r.value.title,
738
- cover: r.value.cover,
739
- status,
740
- progress: Math.max(entry.chaptersRead, 0),
741
- });
742
- });
743
- }
744
- const imported = await insertMangaImportEntries(db, mangaDb, items, erase);
745
- return { imported, skipped };
746
- }
747
- export async function importMangaFromMalXmlItems(db, mangaDb, list, erase = false) {
748
- if (list.length === 0)
749
- return { imported: 0, skipped: 0 };
750
- const items = [];
751
- let skipped = 0;
752
- const BATCH_SIZE = 5;
753
- for (let i = 0; i < list.length; i += BATCH_SIZE) {
754
- const batch = list.slice(i, i + BATCH_SIZE);
755
- const results = await Promise.allSettled(batch.map((e) => resolveMalMangaToAnilist(e.malId, e.title, null)));
756
- results.forEach((r, idx) => {
757
- const entry = batch[idx];
758
- if (r.status !== 'fulfilled' || !r.value) {
759
- skipped++;
760
- return;
761
- }
762
- items.push({
763
- mediaId: r.value.id,
764
- title: r.value.title,
765
- cover: r.value.cover,
766
- status: entry.status,
767
- progress: Math.max(entry.chapters, 0),
768
- });
769
- });
770
- }
771
- const imported = await insertMangaImportEntries(db, mangaDb, items, erase);
772
- return { imported, skipped };
773
- }