Array of AniList media entries to sync.
The user's authentication token.
Optional
onProgress: (progress: SyncProgress) => voidOptional callback for progress updates.
Optional
abortSignal: AbortSignalOptional abort signal to cancel the sync.
Optional
displayOrderMediaIds: number[]Optional array of media IDs to control sync order.
A promise resolving to a SyncReport object.
export async function syncMangaBatch(
entries: AniListMediaEntry[],
token: string,
onProgress?: (progress: SyncProgress) => void,
abortSignal?: AbortSignal,
displayOrderMediaIds?: number[],
): Promise<SyncReport> {
const results: SyncResult[] = [];
const errors: { mediaId: number; error: string }[] = [];
// Group entries by mediaId for handling incremental sync properly
const entriesByMediaId: Record<number, AniListMediaEntry[]> = {};
// Organize entries by mediaId
entries.forEach((entry) => {
if (entry.syncMetadata?.useIncrementalSync) {
const steps = getIncrementalSteps(entry);
for (const step of steps) {
const stepEntry = { ...entry };
stepEntry.syncMetadata = {
...entry.syncMetadata,
step: step,
};
if (!entriesByMediaId[entry.mediaId]) {
entriesByMediaId[entry.mediaId] = [];
}
entriesByMediaId[entry.mediaId].push(stepEntry);
}
} else {
if (!entriesByMediaId[entry.mediaId]) {
entriesByMediaId[entry.mediaId] = [];
}
entriesByMediaId[entry.mediaId].push(entry);
}
});
// Use displayOrderMediaIds if provided, else fallback to Object.keys
const userOrderMediaIds: number[] =
displayOrderMediaIds && displayOrderMediaIds.length > 0
? displayOrderMediaIds
: Object.keys(entriesByMediaId).map(Number);
const progress: SyncProgress = {
total: userOrderMediaIds.length,
completed: 0,
successful: 0,
failed: 0,
skipped: 0,
currentEntry: null,
currentStep: null,
totalSteps: null,
rateLimited: false,
retryAfter: null,
};
if (onProgress) {
onProgress({ ...progress });
}
let apiCallsCompleted = 0;
// Track which manga have been completed in user order
const completedMediaIds = new Set<number>();
let completedCount = 0; // NEW: Track completed manga count
for (const mediaIdNum of userOrderMediaIds) {
const entriesForMediaId = entriesByMediaId[mediaIdNum];
const mediaIdStr = String(mediaIdNum);
if (!entriesForMediaId) continue; // skip if not present
// DEBUG: Log start of manga sync
console.log(
`[SYNC] Starting manga ${mediaIdNum} (${completedCount + 1} of ${progress.total})`,
);
console.log(`[SYNC] userOrderMediaIds:`, userOrderMediaIds);
console.log(`[SYNC] completedMediaIds:`, Array.from(completedMediaIds));
if (abortSignal?.aborted) {
console.log("Sync operation aborted by user");
break;
}
entriesForMediaId.sort((a: AniListMediaEntry, b: AniListMediaEntry) => {
const stepA = a.syncMetadata?.step || 0;
const stepB = b.syncMetadata?.step || 0;
return stepA - stepB;
});
const firstEntry = entriesForMediaId[0];
const isIncremental =
entriesForMediaId.length > 1 &&
firstEntry.syncMetadata?.useIncrementalSync;
progress.currentEntry = {
mediaId: Number(mediaIdStr),
title: firstEntry.title || `Manga #${mediaIdStr}`,
coverImage: firstEntry.coverImage || "",
};
if (isIncremental) {
progress.totalSteps = entriesForMediaId.length;
} else {
progress.currentStep = null;
progress.totalSteps = null;
}
let entrySuccess = true;
let entryError: string | undefined;
for (let i = 0; i < entriesForMediaId.length; i++) {
if (abortSignal?.aborted) {
console.log("Sync operation aborted by user");
break;
}
const entry = entriesForMediaId[i];
if (isIncremental) {
progress.currentStep = entry.syncMetadata?.step || i + 1;
}
if (onProgress) {
// Do not update completed here; only after manga is finished
// DEBUG: Log progress update
console.log(
`[PROGRESS] Updating progress: completed=${progress.completed}, total=${progress.total}, currentMediaId=${mediaIdNum}, currentStep=${progress.currentStep}, isIncremental=${isIncremental}`,
);
onProgress({ ...progress });
}
try {
if (apiCallsCompleted > 0) {
await new Promise((resolve) => setTimeout(resolve, REQUEST_INTERVAL));
}
const result = await updateMangaEntry(entry, token);
results.push(result);
apiCallsCompleted++;
if (result.rateLimited && result.retryAfter) {
progress.rateLimited = true;
progress.retryAfter = result.retryAfter;
if (onProgress) {
console.log(
`[PROGRESS] Rate limited: retryAfter=${result.retryAfter}`,
);
onProgress({ ...progress });
}
const retryAfterMs = result.retryAfter;
const startTime = Date.now();
const endTime = startTime + retryAfterMs;
const countdownInterval = setInterval(() => {
const currentTime = Date.now();
const remainingMs = Math.max(0, endTime - currentTime);
progress.retryAfter = remainingMs;
if (onProgress) {
onProgress({ ...progress });
}
if (remainingMs <= 0 || abortSignal?.aborted) {
clearInterval(countdownInterval);
}
}, 1000);
await new Promise((resolve) => {
const timeoutId = setTimeout(() => {
clearInterval(countdownInterval);
progress.rateLimited = false;
progress.retryAfter = null;
if (onProgress) {
onProgress({ ...progress });
}
resolve(null);
}, retryAfterMs);
if (abortSignal) {
abortSignal.addEventListener("abort", () => {
clearTimeout(timeoutId);
clearInterval(countdownInterval);
resolve(null);
});
}
});
i--;
continue;
}
if (!result.success) {
entrySuccess = false;
entryError = result.error;
if (isIncremental) break;
}
} catch (error) {
apiCallsCompleted++;
entrySuccess = false;
entryError = error instanceof Error ? error.message : String(error);
const errorOpId = `err-${mediaIdStr}-${entriesForMediaId[i].syncMetadata?.step || 0}-${Date.now().toString(36).substring(4, 10)}`;
console.error(
`❌ [${errorOpId}] Error updating entry ${mediaIdStr}:`,
error,
);
console.error(` [${errorOpId}] Entry details:`, {
mediaId: entriesForMediaId[i].mediaId,
title: entriesForMediaId[i].title,
status: entriesForMediaId[i].status,
progress: entriesForMediaId[i].progress,
score: entriesForMediaId[i].score,
incremental: isIncremental,
step: entriesForMediaId[i].syncMetadata?.step || "N/A",
});
if (isIncremental) break;
}
}
// Mark this manga as completed in user order
completedMediaIds.add(Number(mediaIdStr));
completedCount++;
progress.completed = completedCount;
// DEBUG: Log after completing manga
console.log(
`[COMPLETE] Finished manga ${mediaIdNum}: completed=${progress.completed}, total=${progress.total}`,
);
if (entrySuccess) {
progress.successful++;
} else {
progress.failed++;
errors.push({
mediaId: Number(mediaIdStr),
error: entryError || "Unknown error",
});
}
progress.currentEntry = null;
progress.currentStep = null;
if (onProgress) {
onProgress({ ...progress });
}
}
const report: SyncReport = {
totalEntries: entries.length,
successfulUpdates: progress.successful,
failedUpdates: progress.failed,
skippedEntries: progress.skipped,
errors,
timestamp: new Date(),
};
try {
const prevStats = JSON.parse(
storage.getItem(STORAGE_KEYS.SYNC_STATS) || "{}",
);
const totalSyncs = (prevStats.totalSyncs || 0) + 1;
const syncStats = {
lastSyncTime: report.timestamp,
entriesSynced: report.successfulUpdates,
failedSyncs: report.failedUpdates,
totalSyncs,
};
storage.setItem(STORAGE_KEYS.SYNC_STATS, JSON.stringify(syncStats));
} catch (e) {
console.error("Failed to save sync stats:", e);
}
console.log("Sync completed:", report);
return report;
}
Process a batch of manga updates with rate limiting and progress tracking.