Wire the media archive into the daemon: command, service, and API

This commit is contained in:
Fisto
2026-09-16 08:08:12 +02:00
parent 91782f0b80
commit 8b7b5b675d
13 changed files with 421 additions and 18 deletions
+188
View File
@@ -0,0 +1,188 @@
/*
* Media archive service: the daemon side of media-scrape/. See docs/media.md.
*
* Owns the scrape engine, the slopbot client, and the read path the Web UI and
* (later) MCP tools call.
*/
import {
ScrapeEngine,
ScrapeStore,
scrapeDir,
resolveDbRoot,
type MediaKind,
type ScrapeBackend,
type ScrapeMeta
} from "../../media-scrape/src/index.js";
import { PuppetBackend } from "../../media-scrape/src/puppet-backend.js";
import {
loadSlopbotConfig,
slopbotFetcher,
slopbotStatus,
SlopbotBackend,
startSlopbot,
type SlopbotFetcher,
type SlopbotStatus
} from "../../media-scrape/slopbot/index.js";
import type { CoreClient } from "./core-client.js";
type BotClient = Awaited<ReturnType<typeof startSlopbot>>;
export interface MediaServiceDeps {
getCore: () => CoreClient | undefined;
isCoreConnected: () => boolean;
log?: (line: string) => void;
}
export interface StartScrapeInput {
channelId: string;
guildId?: string | null;
mode?: "links" | "download";
backend?: "auto" | "slopbot" | "puppet";
}
export interface MediaItemResult {
scrapeId: string;
channelId: string;
kind: MediaKind;
url: string;
provider: string | null;
downloaded: boolean;
createdAt: string;
}
export interface MediaStatus {
slopbot: SlopbotStatus;
scrapes: ScrapeMeta[];
}
export class MediaService {
private readonly rootDir = resolveDbRoot();
private readonly engine: ScrapeEngine;
private bot: BotClient | null = null;
private botFetcher: SlopbotFetcher | null = null;
private botStarting: Promise<void> | null = null;
constructor(private readonly deps: MediaServiceDeps) {
this.engine = new ScrapeEngine({
rootDir: this.rootDir,
log: line => this.deps.log?.(line)
});
}
/** Log slopbot in on first use. False when it is disabled or the login failed. */
private async ensureSlopbot(): Promise<boolean> {
if (this.bot) return true;
const config = loadSlopbotConfig();
if (!config.enabled) return false;
if (!this.botStarting) {
this.botStarting = startSlopbot(config)
.then(client => {
this.bot = client;
this.botFetcher = slopbotFetcher(client);
this.deps.log?.("slopbot connected");
})
.catch(error => {
this.deps.log?.(`slopbot login failed: ${String(error)}`);
});
}
await this.botStarting;
return this.bot !== null;
}
private puppetBackend(): ScrapeBackend {
return new PuppetBackend(async (channelId, limit, options) => {
const core = this.deps.getCore();
if (!core || !this.deps.isCoreConnected()) {
throw new Error("the core bridge is not connected");
}
return (await core.fetchMessages(channelId, limit, options)) as { messages?: unknown[] };
});
}
async startScrape(input: StartScrapeInput): Promise<ScrapeMeta> {
const mode = input.mode ?? "links";
let backend: ScrapeBackend;
if (input.backend === "puppet") {
backend = this.puppetBackend();
} else {
const available = await this.ensureSlopbot();
const inGuild = !!input.guildId && (this.bot?.guilds.cache.has(input.guildId) ?? false);
if (input.backend === "slopbot" && (!available || !inGuild)) {
throw new Error("slopbot is not connected to that guild");
}
const fetcher = this.botFetcher;
backend =
available && inGuild && fetcher ? new SlopbotBackend(fetcher) : this.puppetBackend();
}
return this.engine.start({
channelId: input.channelId,
guildId: input.guildId ?? null,
mode,
backend
});
}
stopScrape(id: string): void {
this.engine.stop(id);
}
listScrapes(): ScrapeMeta[] {
return this.engine.list();
}
getScrape(id: string): ScrapeMeta | null {
return this.engine.get(id);
}
status(): MediaStatus {
return {
slopbot: slopbotStatus(this.bot),
scrapes: this.engine.list()
};
}
/** Read media rows across every scrape, newest scrape first. */
queryMedia(options: { kind?: MediaKind; limit?: number; offset?: number } = {}): MediaItemResult[] {
const limit = Math.max(1, Math.min(options.limit ?? 60, 500));
const offset = Math.max(0, options.offset ?? 0);
const results: MediaItemResult[] = [];
let skipped = 0;
for (const meta of this.engine.list()) {
if (meta.state === "deleted") continue;
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
try {
const rows = store.queryMedia({ kind: options.kind, limit: limit + offset });
for (const row of rows) {
if (skipped++ < offset) continue;
if (results.length >= limit) break;
results.push({
scrapeId: meta.id,
channelId: meta.channelId,
kind: row.kind,
url: row.url,
provider: row.provider,
downloaded: row.downloaded === 1,
createdAt: row.created_at
});
}
} finally {
store.close();
}
if (results.length >= limit) break;
}
return results;
}
}