@serifpersia/dango 3.6.7 → 3.6.8
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/README.md +1 -1
- package/client/dist/assets/{AnimeCard-C3P9zWi9.js → AnimeCard-DraA_TF9.js} +1 -1
- package/client/dist/assets/{AnimeInfo-Et6687Tl.js → AnimeInfo-BJ7pzs-P.js} +1 -1
- package/client/dist/assets/{AnimeInfoPage-DX4eNnlH.js → AnimeInfoPage-B_esUr5C.js} +2 -2
- package/client/dist/assets/{Asmr-CCdEWrFi.js → Asmr-Ckuh2xfW.js} +1 -1
- package/client/dist/assets/{AsmrResetProgressModal-DgEWBJOw.js → AsmrResetProgressModal-BS6UFJN4.js} +1 -1
- package/client/dist/assets/{AvSyncCalibrator-CQ2fjf2E.js → AvSyncCalibrator-DwHCd9yB.js} +1 -1
- package/client/dist/assets/{Home-DUkUTTTy.js → Home-DuM1EuvN.js} +1 -1
- package/client/dist/assets/{Insights-QCh4xW12.js → Insights-D3Pd9c9V.js} +1 -1
- package/client/dist/assets/{LibraryListPage-BoTxRqHz.js → LibraryListPage-Dm2yVn0-.js} +1 -1
- package/client/dist/assets/{ListeningList-DguKjI_t.js → ListeningList-D-zt6EXZ.js} +1 -1
- package/client/dist/assets/{Manga-HydR-L7o.js → Manga-JwprMKBk.js} +1 -1
- package/client/dist/assets/{MangaInfoPage-CBakEI9a.js → MangaInfoPage-DaeyJl2p.js} +1 -1
- package/client/dist/assets/{MangaReadPage-DwAR3iPy.js → MangaReadPage-COg0fHI9.js} +1 -1
- package/client/dist/assets/{MangaResetProgressModal-DGzl8vsO.js → MangaResetProgressModal-kguXv0_M.js} +1 -1
- package/client/dist/assets/{Mature-Pi0TxKuF.js → Mature-IHDaasrs.js} +1 -1
- package/client/dist/assets/{MediaCard--cuFxOCS.js → MediaCard-CpwsrXo5.js} +1 -1
- package/client/dist/assets/{Music-CQpyXTLH.js → Music-Dks4tPeK.js} +1 -1
- package/client/dist/assets/{OptionListMenu-B3D39dGS.js → OptionListMenu-11CcAsy8.js} +1 -1
- package/client/dist/assets/{Player-CsH4x_I_.js → Player-DQoEQkHL.js} +2 -2
- package/client/dist/assets/{PlayerSettings-DGxBsi3F.js → PlayerSettings-D4Ud4kv4.js} +1 -1
- package/client/dist/assets/{QueueOptionsButton-Djq4Cpex.js → QueueOptionsButton-B8PlPKjC.js} +1 -1
- package/client/dist/assets/{QueueRail-DOrYgNVX.js → QueueRail-B4Dok7U3.js} +1 -1
- package/client/dist/assets/{Radio-DF6g6odd.js → Radio-BHK6LSk4.js} +1 -1
- package/client/dist/assets/{ReadingList-DwYoBrNk.js → ReadingList-BCsxIz57.js} +1 -1
- package/client/dist/assets/{ResetProgressModal-CMF86mX6.js → ResetProgressModal-BXHxSfy-.js} +1 -1
- package/client/dist/assets/{Search-DFkhuk-x.js → Search-PK1ds07O.js} +1 -1
- package/client/dist/assets/{Settings-DBB2r0VQ.js → Settings-Bxk_x55l.js} +1 -1
- package/client/dist/assets/{SkeletonGrid-DJelJgAv.js → SkeletonGrid-CpJCZSRN.js} +1 -1
- package/client/dist/assets/{Trackers-DGYXFhYZ.js → Trackers-RieCh_84.js} +1 -1
- package/client/dist/assets/{Tv-C5MDGD2Z.js → Tv-DLytPJUj.js} +1 -1
- package/client/dist/assets/{TvInfo-DrKuK13u.js → TvInfo-C8djKebR.js} +1 -1
- package/client/dist/assets/{TvPopup-DtHilRDT.js → TvPopup-40JGGJDk.js} +1 -1
- package/client/dist/assets/{TvSearch-CzNxdu_w.js → TvSearch-A3hbospf.js} +1 -1
- package/client/dist/assets/{TvWatchlist-D7p4br6S.js → TvWatchlist-DuXjqmZM.js} +1 -1
- package/client/dist/assets/{Watchlist-DrOzswl2.js → Watchlist-B9wGBh0u.js} +1 -1
- package/client/dist/assets/{hls-BGCX7mJy.js → hls-CZ5X3Ya-.js} +1 -1
- package/client/dist/assets/{index-CnfZwsds.js → index-dRhc9-ol.js} +2 -2
- package/client/dist/assets/{useAnimeInfoData-DYXWBPF-.js → useAnimeInfoData-ZzDtLfvD.js} +1 -1
- package/client/dist/assets/{useAsmr-BbhQ59bk.js → useAsmr-CKOdHgol.js} +1 -1
- package/client/dist/assets/{useAsmrPopup-SymV1J-b.js → useAsmrPopup-SFHPhpcl.js} +1 -1
- package/client/dist/assets/{useMangaLibrary-DuesWQaE.js → useMangaLibrary-CKATe31P.js} +1 -1
- package/client/dist/assets/{useMangaPopup-D57OVGUr.js → useMangaPopup-C75QuYgY.js} +1 -1
- package/client/dist/assets/{useProviders-mpQb_dix.js → useProviders-CMtvZbEi.js} +1 -1
- package/client/dist/assets/{useTv-DGflCsSI.js → useTv-DUX4zYMr.js} +1 -1
- package/client/dist/assets/{useTvLibrary-BqeSmyc4.js → useTvLibrary-By4Gt-R_.js} +1 -1
- package/client/dist/index.html +1 -1
- package/package.json +2 -2
- package/server/dist/app-auth.js +1 -157
- package/server/dist/app-hono.js +7 -557
- package/server/dist/config.js +1 -174
- package/server/dist/db/drizzle.js +1 -16
- package/server/dist/db/schema-anime.js +1 -61
- package/server/dist/db/schema-asmr.js +1 -13
- package/server/dist/db/schema-manga.js +1 -31
- package/server/dist/db/schema-tv.js +1 -18
- package/server/dist/db.js +1 -217
- package/server/dist/discord-gateway.js +1 -385
- package/server/dist/discord-rpc.js +1 -413
- package/server/dist/github-sync.js +1 -508
- package/server/dist/google.js +1 -741
- package/server/dist/hono/asmr.js +1 -10
- package/server/dist/hono/auth.js +3 -26
- package/server/dist/hono/music.js +1 -209
- package/server/dist/hono/proxy.js +36 -783
- package/server/dist/hono/tv.js +5 -200
- package/server/dist/hono/watchlist.js +2 -338
- package/server/dist/lib/anilist.js +26 -1458
- package/server/dist/lib/anischedule.js +1 -191
- package/server/dist/lib/insights.js +1 -185
- package/server/dist/lib/ipc.js +1 -23
- package/server/dist/lib/kitsu.js +1 -517
- package/server/dist/lib/mal-import.js +1 -291
- package/server/dist/lib/mal.js +1 -957
- package/server/dist/lib/migration.js +1 -310
- package/server/dist/lib/offline-db.js +4 -384
- package/server/dist/lib/potoken.js +1 -135
- package/server/dist/lib/recommendations.service.js +1 -216
- package/server/dist/lib/temp-ids.js +1 -3
- package/server/dist/lib/tmdb.js +1 -72
- package/server/dist/lib/tracker/anilist-tracker.js +8 -245
- package/server/dist/lib/tracker/manga-sync.service.js +1 -773
- package/server/dist/lib/tracker/sync-state.js +1 -21
- package/server/dist/lib/tracker/sync.service.js +1 -476
- package/server/dist/lib/translate.js +3 -79
- package/server/dist/lib/ump.js +1 -106
- package/server/dist/lib/ytmusic.js +1 -162
- package/server/dist/logger.js +1 -16
- package/server/dist/providers/manga/manga.types.js +0 -1
- package/server/dist/providers/provider.interface.js +0 -1
- package/server/dist/providers/remote-loader.js +2 -614
- package/server/dist/providers/remote-types.js +1 -159
- package/server/dist/providers/title-matching.js +1 -226
- package/server/dist/providers/tv.types.js +0 -1
- package/server/dist/rclone.js +2 -120
- package/server/dist/repositories/asmr.repository.js +11 -46
- package/server/dist/repositories/insights.repository.js +11 -25
- package/server/dist/repositories/library.repository.js +3 -94
- package/server/dist/repositories/mal-cache.repository.js +1 -52
- package/server/dist/repositories/manga.repository.js +14 -58
- package/server/dist/repositories/notifications.repository.js +6 -52
- package/server/dist/repositories/queue.repository.js +5 -58
- package/server/dist/repositories/settings.repository.js +2 -29
- package/server/dist/repositories/shows-meta.repository.js +3 -56
- package/server/dist/repositories/temp-show-ids.repository.js +3 -63
- package/server/dist/repositories/tv.repository.js +11 -54
- package/server/dist/repositories/watched-episodes.repository.js +6 -55
- package/server/dist/repositories/watchlist.repository.js +3 -54
- package/server/dist/scripts/backfill-episode-duration.js +1 -42
- package/server/dist/server.js +1 -310
- package/server/dist/shipped-defaults.js +1 -6
- package/server/dist/sync-payload.js +1 -91
- package/server/dist/sync.js +3 -647
- package/server/dist/trpc/context.js +1 -7
- package/server/dist/trpc/errors.js +1 -15
- package/server/dist/trpc/index.js +1 -18
- package/server/dist/trpc/router.js +1 -49
- package/server/dist/trpc/routers/asmr-library.js +1 -112
- package/server/dist/trpc/routers/asmr-progress.js +1 -121
- package/server/dist/trpc/routers/asmr.js +1 -87
- package/server/dist/trpc/routers/auth.js +1 -194
- package/server/dist/trpc/routers/continue-watching.js +2 -58
- package/server/dist/trpc/routers/data.js +1 -1026
- package/server/dist/trpc/routers/discord.js +1 -285
- package/server/dist/trpc/routers/discovery.js +1 -11
- package/server/dist/trpc/routers/insights.js +1 -14
- package/server/dist/trpc/routers/manga-library.js +1 -263
- package/server/dist/trpc/routers/manga-progress.js +1 -169
- package/server/dist/trpc/routers/manga.js +1 -201
- package/server/dist/trpc/routers/music.js +1 -655
- package/server/dist/trpc/routers/notifications.js +1 -80
- package/server/dist/trpc/routers/progress.js +1 -109
- package/server/dist/trpc/routers/queue.js +1 -93
- package/server/dist/trpc/routers/radio.js +1 -145
- package/server/dist/trpc/routers/settings.js +1 -96
- package/server/dist/trpc/routers/tracker.js +1 -213
- package/server/dist/trpc/routers/tv-library.js +1 -135
- package/server/dist/trpc/routers/tv-progress.js +1 -159
- package/server/dist/trpc/routers/tv.js +1 -551
- package/server/dist/trpc/routers/watchlist.js +2 -549
- package/server/dist/trpc/validation.js +1 -258
- package/server/dist/utils/cache.utils.js +1 -25
- package/server/dist/utils/cookie.utils.js +1 -13
- package/server/dist/utils/db-utils.js +5 -17
- package/server/dist/utils/env.utils.js +5 -77
- package/server/dist/utils/http.utils.js +1 -82
- package/server/dist/utils/request-context.js +1 -2
- package/server/dist/utils/security.utils.js +1 -76
- package/server/package.json +3 -2
|
@@ -1,773 +1 @@
|
|
|
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
|
-
}
|
|
1
|
+
import{AniListTracker as et}from"./anilist-tracker.js";import{MangaLibraryRepository as A,MangaProgressRepository as C}from"../../repositories/manga.repository.js";import{SettingsRepository as K}from"../../repositories/settings.repository.js";import{performMangaWriteTransactionAsync as nt,performWriteTransactionAsync as at}from"../../sync.js";import B from"../../logger.js";import{searchAnilistMangaByTitle as rt,getMangaMetaById as ft,getMangaMetaByMalId as yt}from"../anilist.js";import{fetchMalMangaList as St,mapMalMangaStatusCode as It}from"../mal.js";import{readTrackerSyncState as it,resolveStatus as wt}from"./sync-state.js";const st="tracker_anilist_token",z="tracker_anilist_manga_sync_state",P="anilist",Mt=25;function ot(r){if(r==null)return 0;const i=String(r).match(/(\d+(?:\.\d+)?)/g);if(!i)return 0;let o=0;for(const g of i){const a=Number(g);Number.isFinite(a)&&(o=Math.max(o,Math.floor(a)))}return o}async function lt(r,i,o){let g=ot(o);try{const a=await C.getByManga(r,i);for(const d of a??[])g=Math.max(g,ot(d.chapterNumber))}catch{}return g}function O(r){return`${P}:${r}`}function X(r){return`${P}:ch:${r}`}function bt(r){const i=/^anilist:(\d+)$/i.exec(r.trim());if(!i)return null;const o=Number.parseInt(i[1],10);return Number.isFinite(o)?o:null}function ct(r){return["Reading","Completed","On-Hold","Dropped","Planned"].includes(r)?r:void 0}function ut(r){return String(r??"").normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase().replace(/[^\p{L}\p{N}]+/gu," ").replace(/\s+/g," ").trim()}function dt(r,i){const o=ut(r);return o.length>0&&o===ut(i)}function pt(r){let i=String(r??"").trim(),o="";for(;o!==i;)o=i,i=i.replace(/\s*\([^()]*\)$/,"").trim();return i}function vt(r,i){const o=[r.title,r.altTitle].filter(a=>!!a).map(pt).filter(a=>a.length>0),g=[i.romaji,i.english,i.native].filter(a=>!!a).map(pt).filter(a=>a.length>0);return o.some(a=>g.some(d=>dt(a,d)))}function gt(r,i){const o=[r.title,r.altTitle],g=[i.romaji,i.english,i.native];return o.some(a=>!!a&&g.some(d=>!!d&&dt(a,d)))}async function jt(r,i,o={}){const a=(o.direction==="pull-only"?"pull-only":"two-way")==="pull-only",c=(await K.getByKey(r,st))?.value;if(!c)throw new Error("AniList is not connected. Please log in first.");const l=new et(c),p=await l.getViewer(),S=await l.fetchUserMangaList(p.id),b=await A.getAll(i),f=await it(r,z),y=new Map;for(const e of S)y.set(e.mediaId,e);const v=new Map,E=[],R=new Set,V=new Map;let q=0,T=0;const G=[],h=e=>{G.push(e)},J=[];for(const e of b){let t=typeof e.anilistId=="number"?e.anilistId:null;const n=t!=null,u=n?e.anilistIdSource??null:null;let k=n;if(t==null&&(t=bt(e.id),k=t!=null),t==null&&e.provider&&e.mangaId){const s=String(e.provider).toLowerCase()===P&&/^\d+$/.test(String(e.mangaId).trim())?Number.parseInt(String(e.mangaId).trim(),10):null;s!=null&&(t=s,k=!0)}if(t==null){if(q>=Mt||!e.title){T++,h({title:e.title||e.id,action:"unresolved",note:"No AniList link yet \u2014 skipped so nothing is written to the wrong series."});continue}q++;try{const s=await rt(e.title);if(s&>(e,s.title))t=s.id,k=!1,E.push({id:e.id,anilistId:s.id}),R.add(s.id);else{T++,h({title:e.title,action:"unresolved",note:s?`Closest AniList match ("${s.title.english||s.title.romaji||s.title.native}") is not an exact title match \u2014 skipped so nothing is written to the wrong series.`:"No AniList match found \u2014 skipped."});continue}}catch(s){B.warn({err:s,mangaId:e.id},"[AniList Manga Sync] Title lookup failed"),T++,h({title:e.title||e.id,action:"unresolved",note:"Title lookup failed \u2014 skipped."});continue}}v.has(t)||(v.set(t,e),k&&n&&!R.has(t)&&V.set(t,{title:e.title,altTitle:e.altTitle,source:u}))}const Z=new Set;for(const[e,t]of V){const n=y.get(e);if(!n||t.source==="manual"||t.source==="anilist"||t.source==="search"||gt(t,n.title)||vt(t,n.title))continue;Z.add(e);const u=n.title.english||n.title.romaji||n.title.native||`Manga #${e}`;J.push(`"${t.title}" is linked to AniList #${e} ("${u}") by an older automatic match and the titles do not match \u2014 skipped in both directions to protect your AniList list.`),h({mediaId:e,title:t.title,action:"suspicious",remoteStatus:n.status,remoteProgress:n.progress,note:`Linked AniList title is "${u}".`})}const ht=new Set([...v.keys(),...y.keys(),...Object.keys(f).map(e=>Number.parseInt(e,10)).filter(e=>Number.isFinite(e))]),m={pushed:0,pulled:0,merged:0,unchanged:0,skipped:0,errors:[],warnings:J},D=[],U=[],_=[],x=[],I={},$=[],j=[],N=Math.floor(Date.now()/1e3);for(const e of ht){if(Z.has(e))continue;const t=v.get(e),n=y.get(e),u=String(e),k=t?.id??O(e);try{if(t&&!n){if(f[u]&&S.length>0){if(a){m.skipped++,h({mediaId:e,title:t.title,action:"skipped",localStatus:t.status,note:"Pull-only mode \u2014 local entry kept, nothing deleted."});continue}x.push(t.id),I[u]=void 0,m.pulled++,h({mediaId:e,title:t.title,action:"delete-local",localStatus:t.status});continue}const s=await lt(i,t.id,t.lastChapterNumber);if(a){m.skipped++,h({mediaId:e,title:t.title,action:"skipped",localStatus:t.status,localChapters:s,note:"Pull-only mode \u2014 not pushed to AniList."});continue}const L=ct(t.status);if(!L||L==="Planned"&&s===0){m.skipped++,h({mediaId:e,title:t.title,action:"skipped",localStatus:t.status,localChapters:s,note:"Bookmark with no chapters read \u2014 not pushed to AniList."});continue}$.push({mediaId:e,status:L,progress:s}),I[u]={lastSyncedAt:N,remoteUpdatedAt:N},m.pushed++,h({mediaId:e,title:t.title,action:"push",localStatus:t.status,localChapters:s});continue}if(!t&&n){if(f[u]&&!a){if(n.entryId){j.push({showId:u,entryId:n.entryId}),I[u]=void 0,m.pushed++,h({mediaId:e,title:n.title.english||n.title.romaji||`Manga #${e}`,action:"delete-remote",remoteStatus:n.status,remoteProgress:n.progress,note:"Removed locally after a previous sync \u2014 delete propagated to AniList."});continue}I[u]=void 0,m.pushed++,h({mediaId:e,title:`Manga #${e}`,action:"delete-remote"});continue}B.debug({mediaId:e,remoteStatus:n.status},"[AniList Manga Sync] Remote only -> pulling to local");const s=n.title.english||n.title.romaji||`Manga #${e}`;D.push({id:O(e),provider:P,mangaId:String(e),title:s,cover:n.coverImage??"",status:n.status,anilistId:e,anilistIdSource:"anilist"}),n.progress>0&&(U.push({id:O(e),chapterNumber:String(n.progress)}),_.push({mangaId:O(e),chapterId:X(n.progress),chapterNumber:String(n.progress),title:s,cover:n.coverImage??"",provider:P})),I[u]={lastSyncedAt:N,remoteUpdatedAt:n.updatedAt},m.pulled++,h({mediaId:e,title:s,action:"pull",remoteStatus:n.status,remoteProgress:n.progress});continue}if(!t&&!n&&f[u]){I[u]=void 0;continue}if(t&&n){const s=await lt(i,t.id,t.lastChapterNumber),L=f[u]?.lastSyncedAt??0,Y=n.updatedAt||0,w=Math.max(s,n.progress),Q=s>n.progress,W=n.progress>s,{status:M,pull:F}=wt(t.status,n.status,Y,L),tt=n.status!==t.status;a?F||W?(F&&M!==t.status&&D.push({id:t.id,provider:t.provider,mangaId:t.mangaId,title:t.title,cover:t.cover??"",status:M,anilistId:e}),W&&(U.push({id:k,chapterNumber:String(w)}),_.push({mangaId:k,chapterId:X(w),chapterNumber:String(w),title:t.title,cover:t.cover??"",provider:t.provider})),I[u]={lastSyncedAt:N,remoteUpdatedAt:Y},m.merged++,h({mediaId:e,title:t.title,action:"merge-pull",localStatus:t.status,remoteStatus:M,localChapters:s,remoteProgress:w})):Q||tt?(m.skipped++,h({mediaId:e,title:t.title,action:"skipped",localStatus:t.status,remoteStatus:n.status,localChapters:s,remoteProgress:n.progress,note:"Local is ahead \u2014 not pushed in pull-only mode."})):m.unchanged++:Q||!F&&tt?($.push({mediaId:e,status:M,progress:w}),M!==t.status&&D.push({id:t.id,provider:t.provider,mangaId:t.mangaId,title:t.title,cover:t.cover??"",status:M,anilistId:e}),I[u]={lastSyncedAt:N,remoteUpdatedAt:N},m.merged++,h({mediaId:e,title:t.title,action:"merge-push",localStatus:M,remoteStatus:n.status,localChapters:w,remoteProgress:n.progress})):F||W?(M!==t.status&&D.push({id:t.id,provider:t.provider,mangaId:t.mangaId,title:t.title,cover:t.cover??"",status:M,anilistId:e}),W&&(U.push({id:k,chapterNumber:String(w)}),_.push({mangaId:k,chapterId:X(w),chapterNumber:String(w),title:t.title,cover:t.cover??"",provider:t.provider})),I[u]={lastSyncedAt:N,remoteUpdatedAt:Y},m.merged++,h({mediaId:e,title:t.title,action:"merge-pull",localStatus:t.status,remoteStatus:M,localChapters:s,remoteProgress:w})):m.unchanged++}}catch(s){const L=s instanceof Error?s.message:String(s);B.warn({err:s,mediaId:e},"[AniList Manga Sync] Entry failed"),m.errors.push(`${t?.title??`Manga ${e}`}: ${L}`),h({mediaId:e,title:t?.title??`Manga #${e}`,action:"error",note:L})}}if($.length>0||j.length>0||x.length>0){const e=Math.max(5,Math.ceil(b.length*.1)),t=x.length+j.length;if(t>e)throw new Error(`AniList manga sync would delete ${t} titles but only ${S.length} were fetched. Aborting to protect your library \u2014 if you really removed that many manga on AniList, delete them locally first and sync again.`)}if($.length>0)for(let t=0;t<$.length;t+=10){const n=$.slice(t,t+10);await l.batchUpdateMediaEntries(n)}if(j.length>0){const e=j.map(n=>n.entryId),t=10;for(let n=0;n<e.length;n+=t){const u=e.slice(n,n+t);await l.batchDeleteMediaEntries(u)}}if((D.length>0||U.length>0||_.length>0||x.length>0||E.length>0)&&await nt(i,async e=>{for(const t of D)await A.upsert(e,t);for(const t of E)await A.setAnilistId(e,t.id,t.anilistId,"search");for(const t of U)await A.setProgressPointer(e,t.id,t.chapterNumber);for(const t of _)await C.deleteSyntheticChapters(e,t.mangaId,t.chapterId),await C.upsert(e,{mangaId:t.mangaId,chapterId:t.chapterId,chapterNumber:t.chapterNumber,page:0,pageCount:0,title:t.title,cover:t.cover,provider:t.provider});for(const t of x)await A.delete(e,t),await C.deleteByManga(e,t)}),Object.keys(I).length>0)try{const e={...f};for(const[n,u]of Object.entries(I))u===void 0?delete e[n]:e[n]=u;const t=JSON.stringify(e);await at(r,async n=>{await K.upsert(n,z,t)})}catch(e){const t=e instanceof Error?e.message:String(e);B.warn({err:e},"[AniList Manga Sync] Failed to persist sync state"),m.errors.push(`Sync state was not saved, next sync will re-compare: ${t}`)}return T>0&&m.errors.push(`${T} local title${T===1?" was":"s were"} not matched to AniList (no link yet) \u2014 they were skipped and will be retried on the next sync.`),{summary:m,details:G}}function kt(r){return ct(r)??"Planned"}async function H(r,i,o,g){if(o.length===0)return 0;const a=Math.floor(Date.now()/1e3),d=await it(r,z);await nt(i,async c=>{if(g)for(const l of await A.getAll(c))String(l.provider).toLowerCase()===P&&(await A.delete(c,l.id),await C.deleteByManga(c,l.id));for(const l of o){const p=O(l.mediaId),S=kt(l.status);if(await A.upsert(c,{id:p,provider:P,mangaId:String(l.mediaId),title:l.title,cover:l.cover??"",status:S,anilistId:l.mediaId,anilistIdSource:"anilist"}),l.progress>0){await A.setProgressPointer(c,p,String(l.progress));const b=X(l.progress);await C.deleteSyntheticChapters(c,p,b),await C.upsert(c,{mangaId:p,chapterId:b,chapterNumber:String(l.progress),page:0,pageCount:0,title:l.title,cover:l.cover??"",provider:P})}d[String(l.mediaId)]={lastSyncedAt:a,remoteUpdatedAt:l.remoteUpdatedAt??a}}});try{const c=JSON.stringify(d);await at(r,async l=>{await K.upsert(l,z,c)})}catch(c){B.warn({err:c},"[AniList Manga Sync] Failed to persist sync state after import")}return o.length}async function Bt(r,i,o,g=!1){const d=(await K.getByKey(r,st))?.value,c=new et(d);let l;if(d){const p=await c.getViewer();l=await c.fetchUserMangaList(p.id)}else l=await c.fetchUserMangaList(o);return l.length===0?0:H(r,i,l.map(p=>({mediaId:p.mediaId,title:p.title.english||p.title.romaji||`Manga #${p.mediaId}`,cover:p.coverImage,status:p.status,progress:p.progress,remoteUpdatedAt:p.updatedAt})),g)}async function mt(r,i,o){if(r)try{const a=await yt(r);if(a)return{id:a.id,title:a.title.english||a.title.romaji||i,cover:a.cover}}catch{}const g=o||i;if(!g)return null;try{const a=await rt(g);if(!a)return null;const d=await ft(a.id);return{id:a.id,title:d?.title.english||d?.title.romaji||a.title.english||a.title.romaji||g,cover:d?.cover}}catch{return null}}async function Ot(r,i,o,g=!1){const a=await St(o);if(a.length===0)return{imported:0,skipped:0};const d=[];let c=0;const l=5;for(let S=0;S<a.length;S+=l){const b=a.slice(S,S+l);(await Promise.allSettled(b.map(y=>mt(y.malId,y.title,y.titleEnglish)))).forEach((y,v)=>{const E=b[v],R=It(E.status);if(y.status!=="fulfilled"||!y.value||!R){c++;return}d.push({mediaId:y.value.id,title:y.value.title,cover:y.value.cover,status:R,progress:Math.max(E.chaptersRead,0)})})}return{imported:await H(r,i,d,g),skipped:c}}async function Wt(r,i,o,g=!1){if(o.length===0)return{imported:0,skipped:0};const a=[];let d=0;const c=5;for(let p=0;p<o.length;p+=c){const S=o.slice(p,p+c);(await Promise.allSettled(S.map(f=>mt(f.malId,f.title,null)))).forEach((f,y)=>{const v=S[y];if(f.status!=="fulfilled"||!f.value){d++;return}a.push({mediaId:f.value.id,title:f.value.title,cover:f.value.cover,status:v.status,progress:Math.max(v.chapters,0)})})}return{imported:await H(r,i,a,g),skipped:d}}export{Bt as importFromUsernameManga,Ot as importMangaFromMalUsername,Wt as importMangaFromMalXmlItems,H as insertMangaImportEntries,jt as syncAniListManga};
|