Feat: Shift from Stable to Preview (WIP)

This commit is contained in:
Youwes09
2026-04-30 01:04:56 -05:00
parent 4d3dfdbec6
commit 79cb2f7c56
43 changed files with 1140 additions and 956 deletions
@@ -135,9 +135,9 @@
const f = store.settings.libraryTabFilters?.[tab] ?? {};
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapterCount ?? 0) > (m.unreadCount ?? 0));
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 0) > (m.unreadCount ?? 0));
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
if (f.bookmarked) items = items.filter(m => !!(m as any).hasBookmark);
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
const recentlyReadMap = new Map<number, number>();
if (tabSortMode === "recentlyRead") {
@@ -247,7 +247,7 @@
cache.get(CACHE_KEYS.LIBRARY, () => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes), DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY),
reloadCategories(),
]);
const mapped = nodes.map((m: any) => ({ ...m, chapterCount: m.chapters?.totalCount ?? m.chapterCount ?? 0 }));
const mapped = nodes.map((m: any) => ({ ...m }));
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
error = null;
await migrateCategorizedToLibrary();
@@ -115,7 +115,7 @@
<div class="grid" style="--cols:{cols}">
{#each visibleManga as m (m.id)}
{@const isSelected = selectedIds.has(m.id)}
{@const isCompleted = !m.unreadCount && (m.chapterCount ?? 0) > 0}
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
<button
class="card"
class:card-selected={isSelected}
+5 -5
View File
@@ -16,11 +16,11 @@ export const librarySorter = createSorter<Manga>({
},
{
key: "totalChapters",
comparator: (a, b) => (a.chapterCount ?? 0) - (b.chapterCount ?? 0),
comparator: (a, b) => (a.chapters?.totalCount ?? 0) - (b.chapters?.totalCount ?? 0),
},
{
key: "recentlyAdded",
comparator: (a, b) => a.id - b.id,
comparator: (a, b) => Number(a.inLibraryAt ?? 0) - Number(b.inLibraryAt ?? 0),
},
{
key: "recentlyRead",
@@ -33,11 +33,11 @@ export const librarySorter = createSorter<Manga>({
},
{
key: "latestFetched",
comparator: (a, b) => a.id - b.id,
comparator: (a, b) => Number(a.latestFetchedChapter?.uploadDate ?? 0) - Number(b.latestFetchedChapter?.uploadDate ?? 0),
},
{
key: "latestUploaded",
comparator: (a, b) => a.id - b.id,
comparator: (a, b) => Number(a.latestUploadedChapter?.uploadDate ?? 0) - Number(b.latestUploadedChapter?.uploadDate ?? 0),
},
],
});
@@ -49,4 +49,4 @@ export function sortLibrary(
recentlyReadMap?: Map<number, number>,
): Manga[] {
return librarySorter.sort(items, mode, dir, recentlyReadMap ? { recentlyReadMap } : undefined);
}
}
+104 -53
View File
@@ -1,15 +1,17 @@
import { gql } from "@api/client";
import { LIBRARY_UPDATE_STATUS } from "@api/queries/manga";
import { UPDATE_LIBRARY } from "@api/mutations/manga";
import { GET_RECENTLY_UPDATED } from "@api/queries/chapters";
import { UPDATE_LIBRARY, FETCH_MANGA } from "@api/mutations/manga";
import { GET_LIBRARY } from "@api/queries/manga";
import type { LibraryUpdateEntry } from "@store/state.svelte";
const POLL_INTERVAL_MS = 3000;
const POLL_INITIAL_MS = 2000;
const POLL_INTERVAL_MS = 2000;
const POLL_INITIAL_MS = 500;
export interface UpdateProgress {
finished: number;
total: number;
finished: number;
total: number;
skippedManga: number;
skippedCategories: number;
}
export interface UpdateResult {
@@ -21,89 +23,138 @@ export interface UpdateResult {
export interface LibraryUpdaterCallbacks {
onProgress: (p: UpdateProgress) => void;
onDone: (r: UpdateResult) => void;
onError: () => void;
onError: (e?: unknown) => void;
}
export async function refreshLibraryMetadata(
onProgress?: (done: number, total: number) => void,
): Promise<void> {
const data = await gql<{ mangas: { nodes: { id: number }[] } }>(GET_LIBRARY, {});
const ids = data.mangas.nodes.map(m => m.id);
let done = 0;
for (const id of ids) {
try {
await gql(FETCH_MANGA, { id });
} catch {}
onProgress?.(++done, ids.length);
}
}
export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const startedAt = Math.floor(Date.now() / 1000);
function cancel() {
cancelled = true;
if (timer) { clearTimeout(timer); timer = null; }
}
function buildEntries(
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[]
): LibraryUpdateEntry[] {
const byManga = new Map<number, LibraryUpdateEntry>();
for (const u of mangaUpdates) {
if (u.status !== "UPDATED") continue;
const existing = byManga.get(u.manga.id);
if (existing) {
existing.newChapters++;
} else {
byManga.set(u.manga.id, {
mangaId: u.manga.id,
mangaTitle: u.manga.title,
thumbnailUrl: u.manga.thumbnailUrl,
newChapters: 1,
checkedAt: Date.now(),
});
}
}
return [...byManga.values()];
}
async function run() {
let seenWork = false;
let jobsStarted = false;
try {
const res = await gql<{
updateLibrary: { updateStatus: { jobsInfo: { isRunning: boolean; totalJobs: number } } }
updateLibrary: {
updateStatus: {
jobsInfo: {
isRunning: boolean;
totalJobs: number;
finishedJobs: number;
skippedMangasCount: number;
skippedCategoriesCount: number;
}
}
}
}>(UPDATE_LIBRARY, {});
if (cancelled) return;
seenWork = res.updateLibrary.updateStatus.jobsInfo.totalJobs > 0;
} catch {
if (!cancelled) callbacks.onError();
const { jobsInfo } = res.updateLibrary.updateStatus;
jobsStarted = jobsInfo.totalJobs > 0;
callbacks.onProgress({
finished: jobsInfo.finishedJobs,
total: jobsInfo.totalJobs,
skippedManga: jobsInfo.skippedMangasCount,
skippedCategories: jobsInfo.skippedCategoriesCount,
});
if (!jobsStarted) {
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 });
return;
}
if (jobsStarted && !jobsInfo.isRunning) {
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 });
return;
}
} catch (e) {
console.error("[libraryUpdater] failed to start update", e);
if (!cancelled) callbacks.onError(e);
return;
}
function poll() {
gql<{
libraryUpdateStatus: {
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number };
mangaUpdates: { status: string; manga: { id: number } }[];
jobsInfo: {
isRunning: boolean;
finishedJobs: number;
totalJobs: number;
skippedMangasCount: number;
skippedCategoriesCount: number;
};
mangaUpdates: {
status: string;
manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number };
}[];
}
}>(LIBRARY_UPDATE_STATUS, {})
.then(async d => {
if (cancelled) return;
const { jobsInfo } = d.libraryUpdateStatus;
const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus;
if (jobsInfo.totalJobs > 0) seenWork = true;
callbacks.onProgress({ finished: jobsInfo.finishedJobs, total: jobsInfo.totalJobs });
if (jobsInfo.totalJobs > 0) jobsStarted = true;
callbacks.onProgress({
finished: jobsInfo.finishedJobs,
total: jobsInfo.totalJobs,
skippedManga: jobsInfo.skippedMangasCount,
skippedCategories: jobsInfo.skippedCategoriesCount,
});
if (!jobsInfo.isRunning && seenWork) {
const recent = await gql<{
chapters: {
nodes: {
mangaId: number;
fetchedAt: string;
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean };
}[]
}
}>(GET_RECENTLY_UPDATED, {}).catch(() => ({ chapters: { nodes: [] } }));
if (cancelled) return;
const byManga = new Map<number, LibraryUpdateEntry>();
for (const ch of recent.chapters.nodes) {
if (!ch.manga.inLibrary) continue;
if (Number(ch.fetchedAt) < startedAt) continue;
const existing = byManga.get(ch.mangaId);
if (existing) {
existing.newChapters++;
} else {
byManga.set(ch.mangaId, {
mangaId: ch.mangaId,
mangaTitle: ch.manga.title,
thumbnailUrl: ch.manga.thumbnailUrl,
newChapters: 1,
checkedAt: Date.now(),
});
}
}
const entries = [...byManga.values()];
if (!jobsInfo.isRunning && jobsStarted) {
const entries = buildEntries(mangaUpdates);
const newChapters = entries.reduce((s, e) => s + e.newChapters, 0);
callbacks.onDone({ entries, totalUpdated: entries.length, newChapters });
return;
}
timer = setTimeout(poll, POLL_INTERVAL_MS);
})
.catch(() => {
if (!cancelled) callbacks.onError();
.catch((e) => {
console.error("[libraryUpdater] poll error", e);
if (!cancelled) callbacks.onError(e);
});
}
@@ -4,11 +4,16 @@
import { getVersion } from "@tauri-apps/api/app";
import { open as openUrl } from "@tauri-apps/plugin-shell";
import { autoBackupAppData } from "@core/backup";
import { gql } from "@api/client";
import { GET_ABOUT_SERVER, GET_ABOUT_WEBUI } from "@api/queries/updater";
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; }
type UpdatePhase = "idle" | "downloading" | "launching" | "ready" | "error";
const IS_WINDOWS = navigator.userAgent.includes("Windows");
interface AboutServer { name: string; version: string; buildType: string; buildTime: number; github: string; discord: string; }
interface AboutWebUI { channel: string; tag: string; updateTimestamp: number; }
let appVersion = $state("…");
let releases = $state<ReleaseInfo[]>([]);
let releasesLoading = $state(false);
@@ -21,9 +26,13 @@
let targetTag = $state<string | null>(null);
let releasesLoaded = false;
let serverInfo = $state<AboutServer | null>(null);
let webuiInfo = $state<AboutWebUI | null>(null);
$effect(() => {
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
loadServerInfo();
});
$effect(() => {
@@ -52,6 +61,17 @@
} finally { releasesLoading = false; }
}
async function loadServerInfo() {
try {
const [s, w] = await Promise.all([
gql<{ aboutServer: AboutServer }>(GET_ABOUT_SERVER),
gql<{ aboutWebUI: AboutWebUI }>(GET_ABOUT_WEBUI),
]);
serverInfo = s.aboutServer;
webuiInfo = w.aboutWebUI;
} catch {}
}
function stripV(v: string) { return v.replace(/^v/, ""); }
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion; }
function parseSemver(v: string) { return stripV(v).split(".").map(Number); }
@@ -72,6 +92,11 @@
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function fmtBuildTime(unix: number) {
if (!unix) return "";
return new Date(unix).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function fmtBytes(bytes: number) {
if (bytes === 0) return "0 B";
const units = ["B","KB","MB","GB"];
@@ -164,6 +189,41 @@
</div>
</div>
{#if serverInfo}
<div class="s-section">
<p class="s-section-title">Server</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Version</span>
<span class="s-desc">
{serverInfo.version}
{#if serverInfo.buildType}
<span class="s-release-badge">{serverInfo.buildType}</span>
{/if}
</span>
</div>
</div>
{#if serverInfo.buildTime}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Built</span>
<span class="s-desc">{fmtBuildTime(serverInfo.buildTime)}</span>
</div>
</div>
{/if}
{#if webuiInfo?.channel}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Channel</span>
<span class="s-desc">{webuiInfo.channel}</span>
</div>
</div>
{/if}
</div>
</div>
{/if}
<div class="s-section">
<p class="s-section-title">Releases</p>
<div class="s-section-body">
@@ -223,6 +283,12 @@
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
<a href="https://github.com/moku-project/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
{#if serverInfo?.github && serverInfo.github !== "https://github.com/moku-project/Moku"}
<a href={serverInfo.github} target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Suwayomi GitHub →</a>
{/if}
{#if serverInfo?.discord && serverInfo.discord !== "https://discord.gg/Jq3pwuNqPp"}
<a href={serverInfo.discord} target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Suwayomi Discord →</a>
{/if}
</div>
</div>
</div>
@@ -38,7 +38,7 @@
let flareTimeout = $state(store.settings.flareSolverrTimeout ?? 60);
let flareSession = $state(store.settings.flareSolverrSessionName ?? "moku");
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
let flareFallback = $state(store.settings.flareSolverrFallback ?? false);
let flareFallback = $state(store.settings.flareSolverrAsResponseFallback ?? false);
function showSaved(key: string) {
secSaved = key; secError = null;
@@ -74,7 +74,7 @@
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
});
} catch {}
}
@@ -144,7 +144,7 @@
updateSettings({
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
});
showSaved("flare");
} catch (e: any) {
+17 -22
View File
@@ -114,6 +114,7 @@ export function removeRecord(
}
export interface SyncBackOptions {
threshold: number | null;
respectScanlatorFilter: boolean;
chapterPrefs: ChapterDisplayPrefs;
}
@@ -123,36 +124,30 @@ export async function syncBackFromTracker(
chapters: Chapter[],
opts: SyncBackOptions,
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
): Promise<{ markedRead: number[]; markedUnread: number[] }> {
const eligible = buildChapterList(
opts.respectScanlatorFilter ? buildChapterList(chapters, opts.chapterPrefs) : chapters,
{ ...opts.chapterPrefs, sortDir: "asc" },
);
): Promise<number[]> {
const base = opts.respectScanlatorFilter
? buildChapterList(chapters, opts.chapterPrefs)
: chapters;
const eligible = buildChapterList(base, { ...opts.chapterPrefs, sortDir: "asc" });
const toMarkRead: number[] = [];
const toMarkUnread: number[] = [];
const toMarkRead: number[] = [];
for (const record of records) {
const remote = record.lastChapterRead;
if (!remote || remote <= 0) continue;
const position = Math.round(remote);
const below = eligible.slice(0, position);
const above = eligible.slice(position);
toMarkRead.push(...below.filter(c => !c.isRead).map(c => c.id));
toMarkUnread.push(...above.filter(c => c.isRead).map(c => c.id));
for (const chapter of eligible) {
if (chapter.isRead) continue;
const diff = Math.abs(chapter.chapterNumber - remote);
if (opts.threshold !== null && diff > opts.threshold) continue;
if (chapter.chapterNumber <= remote) toMarkRead.push(chapter.id);
}
}
const readIds = [...new Set(toMarkRead)];
const unreadIds = [...new Set(toMarkUnread)];
if (readIds.length > 0) {
await gqlFn(MARK_CHAPTERS_READ, { ids: readIds, isRead: true });
}
if (unreadIds.length > 0) {
await gqlFn(MARK_CHAPTERS_READ, { ids: unreadIds, isRead: false });
const ids = [...new Set(toMarkRead)];
if (ids.length > 0) {
await gqlFn(MARK_CHAPTERS_READ, { ids, isRead: true });
}
return { markedRead: readIds, markedUnread: unreadIds };
return ids;
}
@@ -2,7 +2,6 @@ import { gql } from "@api/client";
import { GET_MANGA_TRACK_RECORDS, GET_ALL_TRACKER_RECORDS } from "@api/queries/tracking";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { UPDATE_TRACK, FETCH_TRACK, UNBIND_TRACK } from "@api/mutations/tracking";
import { MARK_CHAPTERS_READ } from "@api/mutations/chapters";
import { buildChapterList, type ChapterDisplayPrefs } from "@features/series/lib/chapterList";
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import { store } from "@store/state.svelte";
@@ -82,7 +81,7 @@ class TrackingState {
for (const tracker of res.trackers.nodes.filter(t => t.isLoggedIn)) {
for (const record of tracker.trackRecords.nodes) {
if (!record.manga?.id) continue;
const mangaId = record.manga.id;
const mangaId = record.manga.id;
const existing = this.byManga.get(mangaId) ?? [];
const merged = [...existing.filter(r => r.id !== record.id), record];
this.setFor(mangaId, merged);
@@ -140,21 +139,22 @@ class TrackingState {
const fresh = res.fetchTrack.trackRecord;
this.patchFor(mangaId, fresh);
const { markedRead } = await this._applyRemoteProgress(fresh, chapters, prefs);
return { fresh, markedIds: markedRead };
const markedIds = await this._applyRemoteProgress(fresh, chapters, prefs);
return { fresh, markedIds };
}
private async _applyRemoteProgress(
record: TrackRecord,
chapters: Chapter[],
prefs: ChapterDisplayPrefs,
): Promise<{ markedRead: number[]; markedUnread: number[] }> {
if (!store.settings.trackerSyncBack) return { markedRead: [], markedUnread: [] };
): Promise<number[]> {
if (!store.settings.trackerSyncBack) return [];
return syncBackFromTracker(
[record],
chapters,
{
threshold: store.settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
@@ -290,6 +290,7 @@ class TrackingState {
freshRecords,
chapters,
{
threshold: store.settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},