Files
mangabaka-seeder/src/core/pipeline.ts
T

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 };
}