Wire the media archive into the daemon: command, service, and API
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user