Add links-mode embed thumbnails, item metadata, and media filters

This commit is contained in:
Fisto
2026-09-16 11:44:09 +02:00
parent 1fe204b91e
commit 973b9cff92
23 changed files with 863 additions and 82 deletions
+27 -2
View File
@@ -829,12 +829,37 @@ function buildServer(): McpServer {
.describe("Where the item came from"),
scrapeId: z.string().optional().describe("Restrict to one scrape"),
channelId: z.string().optional().describe("Restrict to one source channel"),
authorId: z.string().optional().describe("Restrict to one poster"),
aspect: z
.enum(["vertical", "square", "wide43", "wide169", "ultrawide", "other"])
.optional()
.describe("Aspect-ratio bucket"),
resolution: z
.enum(["sd", "hd", "fhd", "qhd", "uhd", "unknown"])
.optional()
.describe("Resolution bucket by shortest side"),
after: z.string().optional().describe("Only items posted at or after this ISO time"),
before: z.string().optional().describe("Only items posted at or before this ISO time"),
limit: z.number().int().min(1).max(500).optional(),
offset: z.number().int().min(0).optional()
}
},
async ({ kind, source, scrapeId, channelId, limit, offset }) =>
jsonText(media.queryMedia({ kind, source, scrapeId, channelId, limit, offset }))
async ({ kind, source, scrapeId, channelId, authorId, aspect, resolution, after, before, limit, offset }) =>
jsonText(
media.queryMedia({
kind,
source,
scrapeId,
channelId,
authorId,
aspect,
resolution,
after,
before,
limit,
offset
})
)
);
server.registerTool(
+45
View File
@@ -93,6 +93,12 @@ export interface MediaItemResult {
channelId: string;
kind: MediaKind;
source: string | null;
authorId: string | null;
authorName: string | null;
postedAt: string | null;
width: number | null;
height: number | null;
aspectRatio: number | null;
url: string;
localUrl: string | null;
thumbUrl: string | null;
@@ -337,6 +343,29 @@ export class MediaService {
};
}
/** Distinct posters across every scrape, biggest first. */
listAuthors(): Array<{ id: string | null; name: string | null; count: number }> {
const totals = new Map<string, { id: string | null; name: string | null; count: number }>();
for (const meta of this.engine.list()) {
if (meta.state === "deleted") continue;
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
try {
for (const author of store.authors()) {
const key = author.id ?? author.name ?? "unknown";
const current = totals.get(key);
if (current) current.count += author.count;
else totals.set(key, { ...author });
}
} finally {
store.close();
}
}
return [...totals.values()].sort((a, b) => b.count - a.count);
}
/** One media row inside a scrape, with its scrape and channel for context. */
getMediaItem(scrapeId: string, itemId: number): (MediaRow & { scrapeId: string; channelId: string }) | null {
const meta = this.engine.get(scrapeId);
@@ -358,6 +387,11 @@ export class MediaService {
source?: string;
scrapeId?: string;
channelId?: string;
authorId?: string;
aspect?: string;
resolution?: string;
after?: string;
before?: string;
limit?: number;
offset?: number;
} = {}
@@ -377,6 +411,11 @@ export class MediaService {
const rows = store.queryMedia({
kind: options.kind,
source: options.source,
authorId: options.authorId,
aspect: options.aspect,
resolution: options.resolution,
after: options.after,
before: options.before,
limit: limit + offset
});
for (const row of rows) {
@@ -388,6 +427,12 @@ export class MediaService {
channelId: meta.channelId,
kind: row.kind,
source: row.source,
authorId: row.author_id,
authorName: row.author_name,
postedAt: row.posted_at,
width: row.width,
height: row.height,
aspectRatio: row.aspect_ratio,
url: row.url,
localUrl: row.file_path ? `/api/media/${meta.id}/${row.id}/file` : null,
thumbUrl: row.preview_hash ? `/api/media/${meta.id}/${row.id}/thumb` : null,
+27 -1
View File
@@ -799,10 +799,36 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
? String(req.query.source)
: undefined;
const channelId = req.query.channelId ? String(req.query.channelId) : undefined;
const authorId = req.query.authorId ? String(req.query.authorId) : undefined;
const aspect = req.query.aspect ? String(req.query.aspect) : undefined;
const resolution = req.query.resolution ? String(req.query.resolution) : undefined;
const after = req.query.after ? String(req.query.after) : undefined;
const before = req.query.before ? String(req.query.before) : undefined;
const limit = clampLimit(req.query.limit, 60, 500);
const offset = Math.max(0, Number(req.query.offset ?? 0) || 0);
res.json({ ok: true, result: deps.media.queryMedia({ kind, source, channelId, limit, offset }) });
res.json({
ok: true,
result: deps.media.queryMedia({
kind,
source,
channelId,
authorId,
aspect,
resolution,
after,
before,
limit,
offset
})
});
})
);
app.get(
"/api/media/authors",
wrap(async (_req, res) => {
res.json({ ok: true, result: deps.media.listAuthors() });
})
);