104 lines
4.1 KiB
TypeScript
104 lines
4.1 KiB
TypeScript
import { dedupeByHash, mergeByUrl, pickDefault, splitExisting, type ExistingImage } from "./covers";
|
|
import { waybackHistory } from "./history/wayback";
|
|
import { inspectAll } from "./images";
|
|
import { LinkError, parseLink, parseMangaBakaSeriesId } from "./links";
|
|
import { MangaBakaClient } from "./mangabaka/client";
|
|
import { toExisting, type SeriesSummary } from "./mangabaka/types";
|
|
import type { CoverCandidate, Granularity, InspectedCover, SourceSeries } from "./model";
|
|
import { SOURCES, findSource } from "./sources/registry";
|
|
import type { Source } from "./sources/source";
|
|
import type { Transport } from "./transport";
|
|
|
|
export interface RunInput {
|
|
mangabakaLink: string;
|
|
sourceLink: string;
|
|
/** Wayback granularity in months, or null for no history. */
|
|
history: Granularity | null;
|
|
}
|
|
|
|
export type Stage = "series" | "source" | "history" | "inspect" | "compare";
|
|
|
|
export interface RunEvents {
|
|
onStage?(stage: Stage): void;
|
|
onProgress?(stage: Stage, done: number, total: number): void;
|
|
onWarning?(message: string): void;
|
|
/** Cancels the whole run. */
|
|
signal?: AbortSignal;
|
|
/** Stops the history early and carries on with what it found (spec §4.3). */
|
|
stopHistory?: AbortSignal;
|
|
}
|
|
|
|
export interface RunResult {
|
|
seriesId: number;
|
|
mbSeries: SeriesSummary;
|
|
source: Source;
|
|
series: SourceSeries;
|
|
linkMatches: boolean;
|
|
fresh: InspectedCover[];
|
|
present: { cover: InspectedCover; existing: ExistingImage }[];
|
|
suggestedDefault: InspectedCover | null;
|
|
warnings: string[];
|
|
}
|
|
|
|
/** Spec §3. The only place the stages are composed. */
|
|
export async function runCovers(
|
|
input: RunInput,
|
|
deps: { transport: Transport; sources?: readonly Source[] },
|
|
events: RunEvents = {},
|
|
): Promise<RunResult> {
|
|
const warnings: string[] = [];
|
|
const warn = (message: string) => {
|
|
warnings.push(message);
|
|
events.onWarning?.(message);
|
|
};
|
|
const sources = deps.sources ?? SOURCES;
|
|
const seriesId = parseMangaBakaSeriesId(input.mangabakaLink);
|
|
const sourceUrl = parseLink(input.sourceLink);
|
|
const source = findSource(sourceUrl, sources);
|
|
if (!source) {
|
|
throw new LinkError(`No supported source for ${sourceUrl.hostname}. Supported: ${sources.map((s) => s.name).join(", ")}.`);
|
|
}
|
|
const { transport } = deps;
|
|
const ctx = { transport, signal: events.signal };
|
|
|
|
events.onStage?.("series");
|
|
const mb = new MangaBakaClient(transport);
|
|
const [mbSeries, links, images] = await Promise.all([mb.getSeries(seriesId), mb.getLinks(seriesId), mb.getImages(seriesId)]);
|
|
|
|
events.onStage?.("source");
|
|
const series = await source.resolve(sourceUrl, ctx);
|
|
const linkMatches = links.some((l) => source.isSameSeries(l.url, series));
|
|
if (!linkMatches) {
|
|
warn(`MangaBaka series ${seriesId} has no link to this ${source.name} series (${series.canonicalUrl}). Check that the two links belong together.`);
|
|
}
|
|
if (series.language === "unknown") warn(`Couldn't tell the language of ${series.canonicalUrl}; covers will be marked "unknown".`);
|
|
const current = await source.currentCovers(series, ctx);
|
|
|
|
let historical: CoverCandidate[] = [];
|
|
if (input.history !== null) {
|
|
events.onStage?.("history");
|
|
const signals = [events.signal, events.stopHistory].filter((s): s is AbortSignal => !!s);
|
|
historical = await waybackHistory(source, series, {
|
|
every: input.history,
|
|
transport,
|
|
warn,
|
|
signal: signals.length ? AbortSignal.any(signals) : undefined,
|
|
onProgress: (done, total) => events.onProgress?.("history", done, total),
|
|
});
|
|
if (events.signal?.aborted) throw events.signal.reason;
|
|
if (source.historicalCovers) historical.push(...(await source.historicalCovers(series, ctx)));
|
|
}
|
|
|
|
events.onStage?.("inspect");
|
|
const inspected = await inspectAll(mergeByUrl([...current, ...historical]), transport, {
|
|
warn,
|
|
signal: events.signal,
|
|
onProgress: (done, total) => events.onProgress?.("inspect", done, total),
|
|
});
|
|
|
|
events.onStage?.("compare");
|
|
const { fresh, present } = splitExisting(dedupeByHash(inspected), images.map(toExisting));
|
|
const suggestedDefault = pickDefault([...fresh, ...present.map((p) => p.cover)]);
|
|
return { seriesId, mbSeries, source, series, linkMatches, fresh, present, suggestedDefault, warnings };
|
|
}
|