Feat: Automated Tracking + Proper Sync

This commit is contained in:
Youwes09
2026-04-26 12:11:45 -05:00
parent 361a145702
commit c0efbba4df
8 changed files with 358 additions and 64 deletions
@@ -18,6 +18,7 @@
checkAndMarkCompleted as storeCheckAndMarkCompleted,
clearMarkersForManga,
} from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import type { MangaPrefs } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { Manga, Chapter, Category } from "@types";
@@ -102,24 +103,21 @@
const continueChapter = $derived((() => {
if (!sortedChapters.length) return null;
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some(c => c.isRead);
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some(c => c.isRead);
const bookmark = store.activeManga
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
: null;
if (bookmark) {
const ch = asc.find(c => c.id === bookmark.chapterId);
if (ch) {
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
const allRead = asc.every(c => c.isRead);
if (!(isLastChapter && allRead))
return { chapter: ch, type: "continue" as const, resumePage: bookmark.pageNumber };
}
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null;
if (bookmarkedCh && !bookmarkedCh.isRead) {
return { chapter: bookmarkedCh, type: (anyRead ? "continue" : "start") as const, resumePage: bookmark!.pageNumber };
}
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find(c => !c.isRead);
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
const target = inProgress ?? firstUnread;
if (target) {
return { chapter: target, type: (anyRead ? "continue" : "start") as const, resumePage: null };
}
return { chapter: asc[0], type: "reread" as const, resumePage: null };
})());
@@ -258,7 +256,7 @@
$effect(() => {
const m = store.activeManga;
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); trackingState.loadForManga(m.id); });
});
let prevChapterId: number | null = null;
@@ -309,6 +307,8 @@
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead) {
const ch = chapters.find(c => c.id === chapterId);
if (ch) trackingState.updateFromRead(ch, chapters, { sortMode, sortDir, preferredScanlator: get("preferredScanlator") as string, scanlatorFilter: scanlatorFilter as string[], scanlatorBlacklist: scanlatorBlacklist as string[], scanlatorForce: scanlatorForce as boolean });
if (get("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId);
if (ch?.isDownloaded) {
@@ -334,17 +334,22 @@
const idSet = new Set(ids);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead && get("deleteOnRead")) {
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (toDelete.length) {
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
const doDelete = async () => {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
};
if (delayMs === 0) doDelete();
else setTimeout(doDelete, delayMs);
if (isRead) {
const ascending = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const lastInBatch = ascending.filter(c => idSet.has(c.id)).at(-1);
if (lastInBatch) trackingState.updateFromRead(lastInBatch, chapters, { sortMode, sortDir, preferredScanlator: get("preferredScanlator") as string, scanlatorFilter: scanlatorFilter as string[], scanlatorBlacklist: scanlatorBlacklist as string[], scanlatorForce: scanlatorForce as boolean });
if (get("deleteOnRead")) {
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (toDelete.length) {
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
const doDelete = async () => {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
};
if (delayMs === 0) doDelete();
else setTimeout(doDelete, delayMs);
}
}
}
}
+62 -37
View File
@@ -1,11 +1,15 @@
<script lang="ts">
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise, ArrowLineDown } from "phosphor-svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_TRACKERS, GET_MANGA_TRACK_RECORDS, SEARCH_TRACKER } from "@api/queries/tracking";
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations/tracking";
import { addToast } from "@store/state.svelte";
import { GET_TRACKERS, SEARCH_TRACKER } from "@api/queries/tracking";
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK } from "@api/mutations/tracking";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { addToast, store } from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import type { Tracker, TrackRecord, TrackSearch } from "@types";
import type { Chapter } from "@types/index";
let { mangaId, mangaTitle, onClose }: {
mangaId: number;
@@ -16,8 +20,7 @@
type TabId = "records" | number;
let trackers: Tracker[] = $state([]);
let records: TrackRecord[] = $state([]);
let loading: boolean = $state(true);
let loadingTrackers: boolean = $state(true);
let activeTab: TabId = $state("records");
let searchQuery: string = $state("");
@@ -30,26 +33,22 @@
let syncing: number | null = $state(null);
let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0);
let applyingRecord: number | null = $state(null);
const records = $derived(trackingState.records);
const loading = $derived(trackingState.loading || loadingTrackers);
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
async function load() {
loading = true;
try {
const [tRes, rRes] = await Promise.all([
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(GET_MANGA_TRACK_RECORDS, { mangaId }),
]);
trackers = tRes.trackers.nodes;
records = rRes.manga.trackRecords.nodes;
} catch (e: any) {
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
} finally {
loading = false;
}
}
$effect(() => { load(); });
$effect(() => {
loadingTrackers = true;
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
.then(r => { trackers = r.trackers.nodes; })
.catch(e => addToast({ kind: "error", title: "Failed to load trackers", body: e?.message }))
.finally(() => { loadingTrackers = false; });
trackingState.loadForManga(mangaId);
});
$effect(() => {
const tab = activeTab;
@@ -62,7 +61,6 @@
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); }
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
let searchTimer: ReturnType<typeof setTimeout>;
@@ -96,7 +94,7 @@
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
);
records = [...records.filter(r => r.trackerId !== activeTab), res.bindTrack.trackRecord];
trackingState.patchRecord(res.bindTrack.trackRecord);
activeTab = "records";
addToast({ kind: "success", title: "Now tracking", body: result.title });
} catch (e: any) {
@@ -110,7 +108,7 @@
updatingRecord = record.id;
try {
await gql(UNBIND_TRACK, { recordId: record.id });
records = records.filter(r => r.id !== record.id);
trackingState.removeRecord(record.id);
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
} catch (e: any) {
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
@@ -119,15 +117,11 @@
}
}
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
}
async function updateStatus(record: TrackRecord, status: number) {
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
patchRecord(res.updateTrack.trackRecord);
trackingState.patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
@@ -139,7 +133,7 @@
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
patchRecord(res.updateTrack.trackRecord);
trackingState.patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
@@ -151,7 +145,7 @@
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private });
patchRecord(res.updateTrack.trackRecord);
trackingState.patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
@@ -162,9 +156,8 @@
async function syncRecord(record: TrackRecord) {
syncing = record.id;
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
patchRecord(res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
const fresh = await trackingState.syncRecordFromRemote(record.id);
if (fresh) addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally {
@@ -179,6 +172,33 @@
function cancelChapterEditor() { editingChapter = null; }
async function applyToLibrary(record: TrackRecord) {
applyingRecord = record.id;
try {
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
const marked = await syncBackFromTracker(
[record],
chapRes.chapters.nodes,
{
threshold: store.settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
(query, vars) => gql(query, vars),
);
if (marked.length > 0) {
addToast({ kind: "success", title: `${marked.length} chapter${marked.length !== 1 ? "s" : ""} marked read` });
} else {
addToast({ kind: "info", title: "Already up to date" });
}
} catch (e: any) {
addToast({ kind: "error", title: "Apply failed", body: e?.message });
} finally {
applyingRecord = null;
}
}
async function submitChapter(record: TrackRecord) {
const val = Math.max(0, chapterDraft);
editingChapter = null;
@@ -186,7 +206,7 @@
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
patchRecord(res.updateTrack.trackRecord);
trackingState.patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
@@ -269,6 +289,11 @@
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button>
{#if store.settings.trackerSyncBack}
<button class="record-icon-btn" title="Apply tracker progress to library" disabled={applyingRecord === record.id} onclick={() => applyToLibrary(record)}>
<ArrowLineDown size={11} weight="light" class={applyingRecord === record.id ? "anim-spin" : ""} />
</button>
{/if}
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
<X size={11} weight="bold" />
</button>