Store embed titles and search them

This commit is contained in:
Fisto
2026-09-17 13:10:16 +02:00
parent c6caa168be
commit 557ba5ca7e
13 changed files with 200 additions and 10 deletions
+8
View File
@@ -40,6 +40,14 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C49 Searchable video titles. Embed rows now store Discord's `title` plus a normalized
`title_key` (lowercase, punctuation collapsed). The Advanced drawer has a title field with an
**Exact match (quotes)** toggle: default is order-free multi-word prefix search ("Ma Ca Ju"
finds *Man who jumps over car*), exact mode is one phrase; rows without a title drop out while
the query is set. Store, `/api/media`, `/api/media/stats`, and MCP carry `title`/`titleExact`;
the API result and card tooltip expose the title. Unit tests cover prefixes, mid-word misses,
exact phrases, and punctuation folding; smoke checks the field and toggle. Note: titles only
exist for rows scraped from now on — pre-existing rows need the backfill queued in TODO.md.
- [x] C48 Expanded providers and the Must include mirror. The list grew to 19 entries from the
user's picks: reddit, twitter, tiktok, instagram, streamable, facebook, gyazo, a merged
`giphy,tenor` (one checkbox, two substrings), 2ch.hk (stand-in for 4chan; the CDN hosts stay
+9
View File
@@ -62,6 +62,15 @@ Ordered backlog. `CURRENT.md` tracks the live session state.
optional `VOX_CLEAN`, and a help line in the TTS panel. Requested in `TTS-DESIGN.md`; the
wrapper side is ready and verified. Inline `/rate` and `/pitch` need no UI.
## Media archive follow-ups
1. **Backfill titles for existing rows.** New scrapes store embed titles, but the ~20k rows
already indexed have `title IS NULL`. Add a paced pass that resolves titles through the provider
oEmbed endpoints (YouTube first) for rows where `title IS NULL AND lower(provider) LIKE
'%youtube%'`, with the same batch/cooldown settings as previews, an attempt cap so dead videos
close out, progress in the Bot tab, and tests. Consider folding it into the backfill queue as a
second work type.
## Web UI follow-ups
Ordered after the voice backend unless the owner asks first.
+6 -2
View File
@@ -88,7 +88,7 @@ One SQLite file per scrape (`index.db`, `node:sqlite`).
| Table | Columns |
| --- | --- |
| `messages` | `id`, `author_id`, `author_name`, `timestamp`, `content` |
| `media` | `id`, `kind`, `source`, `author_id`, `author_name`, `posted_at`, `url`, `provider`, `file_hash`, `preview_hash`, `content_type`, `size`, `width`, `height`, `aspect_ratio`, `duration_secs`, `downloaded`, `file_path`, `created_at` |
| `media` | `id`, `kind`, `source`, `author_id`, `author_name`, `posted_at`, `url`, `provider`, `title`, `title_key`, `file_hash`, `preview_hash`, `content_type`, `size`, `width`, `height`, `aspect_ratio`, `duration_secs`, `downloaded`, `file_path`, `created_at` |
| `media_sources` | `media_id`, `message_id` |
| `downloads` | `media_id`, `state`, `attempts`, `error` |
@@ -96,7 +96,11 @@ One SQLite file per scrape (`index.db`, `node:sqlite`).
slopbot backend could ask for it (one request; null otherwise). Indexes: `kind`, `posted_at`, and
`media_sources(message_id)`. The database is an index; the media
folder holds bytes. Every item carries its poster (`author_id`, `author_name`), the post time
(`posted_at`), and its dimensions (`width`, `height`, `aspect_ratio`). Filter presets live in
(`posted_at`), and its dimensions (`width`, `height`, `aspect_ratio`). Embed rows also store
Discord's `title` (YouTube and friends) plus `title_key`, the lowercased, punctuation-collapsed
form used by the title search: multi-word prefix tokens by default ("Ma Ca Ju" finds *Man who jumps
over car*), one phrase with the exact-match toggle, and rows without a title drop out while a
title query is set. Filter presets live in
`media-scrape/src/filter-presets.ts`: aspect buckets (vertical, square, 4:3, 16:9, 21:9, other) and
resolution buckets by the shortest side (SD, HD, FHD, QHD, 4K+, unknown). A missing resolution is
filled with `ffprobe` after a download when `VESKSLOP_FFPROBE` is available.
+3 -1
View File
@@ -149,7 +149,9 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
and the multi-select People popover. The **Advanced search** drawer has aspect ratio, resolution,
source channel, posted-date range, file-type chips, cached-only, a default-on hide for 4chan
links (they 404 almost always), a sort select (newest, oldest, user, size, length), and a **Do not
include** and **Must include** groups with nested collapsibles for sources (the common providers
include** and **Must include** groups with nested collapsibles, a title search for embed titles
(multi-word prefix matching, with an **Exact match (quotes)** toggle) for sources (the common
providers
in the archive plus embeds, attachments, and stickers), file types, size (with B/KB/MB/GB units),
and length (seconds), with **Apply filters** / **Reset** in the bar. Size and length ranges drop
rows with no known value. The bar and drawer show only on the Media tab.
+5 -2
View File
@@ -137,7 +137,10 @@ box live in Chat, not Call, and the connection stats are not chips yet.
2ch.hk, Imgur, FunnyJunk, Internet Archive — plus embeds, attachments, stickers) and file types.
**Must include** requires them (same provider list and kinds, file-type chips, and size/length
bounds with B/KB/MB/GB units or seconds). A provider entry may carry several substrings, shown as
`Giphy/Tenor`. A sort select orders the grid by newest, oldest, user, size, or length. Size and
`Giphy/Tenor`. A title field searches embed titles (YouTube): default is multi-word prefix
matching (each token must start a word of the title, order-free), and **Exact match (quotes)**
treats the input as one phrase. A sort select orders the grid by newest, oldest, user, size, or
length. Size and
length bounds drop rows with no known value. The filter bar and the drawer are hidden on the Bot
tab. Filters do not
apply until **Apply filters** is pressed; **Reset** clears every filter and the zoom, unloads the
@@ -289,7 +292,7 @@ replaceable.
| POST | `/api/audio/stop` | none | Stop speech and clear the queue |
| POST | `/api/audio/capture` | `start` | Start or stop capture |
| GET | `/api/transcript` | `limit?`, `sessionId?` | Sessions and captured segments |
| GET | `/api/media` | `kind?`, `source?`, `channelId?`, `authorId?` (comma list), `origin?` (`guild`/`dm`), `cachedOnly?`, `ext?` (comma list), `excludeHosts?` (comma list), `excludeExt?`, `excludeProviders?`, `excludeSources?`, `includeProviders?`, `includeSources?` (comma lists), `sizeLt?`/`sizeGt?` (bytes), `lenLt?`/`lenGt?` (seconds), `sort?` (`newest` default, `oldest`, `user`, `size`, `length`), `aspect?`, `resolution?`, `after?`, `before?`, `limit?`, `offset?` | Media items across every scrape, newest first. `source` is `attachment`, `sticker`, or `embed`; `aspect` and `resolution` are the preset keys; `after`/`before` are ISO times on `posted_at`. Items carry `authorName`, `postedAt`, `width`, `height`, `aspectRatio`, `contentType`, `durationSecs`, `previewError`, `animUrl`; `localUrl`/`thumbUrl` when a file or preview exists |
| GET | `/api/media` | `kind?`, `source?`, `channelId?`, `authorId?` (comma list), `origin?` (`guild`/`dm`), `cachedOnly?`, `ext?` (comma list), `excludeHosts?` (comma list), `excludeExt?`, `excludeProviders?`, `excludeSources?`, `includeProviders?`, `includeSources?` (comma lists), `title?` (200 chars max), `titleExact?`, `sizeLt?`/`sizeGt?` (bytes), `lenLt?`/`lenGt?` (seconds), `sort?` (`newest` default, `oldest`, `user`, `size`, `length`), `aspect?`, `resolution?`, `after?`, `before?`, `limit?`, `offset?` | Media items across every scrape, newest first. `source` is `attachment`, `sticker`, or `embed`; `aspect` and `resolution` are the preset keys; `after`/`before` are ISO times on `posted_at`. Items carry `authorName`, `postedAt`, `width`, `height`, `aspectRatio`, `contentType`, `durationSecs`, `previewError`, `animUrl`; `localUrl`/`thumbUrl` when a file or preview exists |
| GET | `/api/media/:scrapeId/:itemId/file` | none | The downloaded file, content-typed from its extension |
| GET | `/api/media/:scrapeId/:itemId/thumb` | none | The cached preview (type from `preview_ext`) |
| GET | `/api/media/:scrapeId/:itemId/stream` | none | Playback/full-view source: the local file, or the remote URL refreshed from the source message. Supports `Range`. Non-media upstream responses (provider pages) return `415` |
+8 -3
View File
@@ -32,6 +32,8 @@ export interface ExtractedMedia {
/** False for every embed. Downloads are a later step, never for third-party embeds. */
downloaded: boolean;
previewUrl: string | null;
/** Embed title, when Discord provides one (YouTube and friends). Null elsewhere. */
title: string | null;
}
function extension(filename: string | null | undefined): string {
@@ -81,7 +83,8 @@ function extractEmbed(embed: CoreEmbed): ExtractedMedia | null {
size: null,
width: embed.width ?? null,
height: embed.height ?? null,
durationSecs: null
durationSecs: null,
title: embed.title ?? null
};
if (embed.videoUrl) {
@@ -135,7 +138,8 @@ function extractSticker(sticker: CoreSticker): ExtractedMedia | null {
height: null,
durationSecs: null,
downloaded: false,
previewUrl: null
previewUrl: null,
title: null
};
}
@@ -162,7 +166,8 @@ function extractFrom(message: MediaMessage): ExtractedMedia[] {
? attachment.durationSecs
: null,
downloaded: false,
previewUrl: null
previewUrl: null,
title: null
});
}
+37 -1
View File
@@ -136,6 +136,9 @@ export interface MediaFilterOptions {
limit?: number;
offset?: number;
sort?: MediaSort;
/** Title search: multi-word prefix tokens by default, a phrase when `titleExact`. */
titleQuery?: string;
titleExact?: boolean;
}
export interface MediaRow {
@@ -166,9 +169,22 @@ export interface MediaRow {
download_error: string | null;
preview_error: string | null;
preview_attempts: number;
/** Embed title from Discord (YouTube and friends). */
title: string | null;
/** Lowercased, punctuation-collapsed title for token-prefix search. */
title_key: string | null;
created_at: string;
}
/** Normalize a title for prefix-token search: lowercase, punctuation to spaces, collapsed. */
export function normalizeTitleKey(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
.replace(/\s+/g, " ");
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
@@ -206,6 +222,8 @@ CREATE TABLE IF NOT EXISTS media (
download_error TEXT,
preview_error TEXT,
preview_attempts INTEGER NOT NULL DEFAULT 0,
title TEXT,
title_key TEXT,
created_at TEXT NOT NULL
);
@@ -251,6 +269,10 @@ export class ScrapeStore {
if (!names.has("preview_attempts")) {
this.db.exec("ALTER TABLE media ADD COLUMN preview_attempts INTEGER NOT NULL DEFAULT 0");
}
if (!names.has("title")) {
this.db.exec("ALTER TABLE media ADD COLUMN title TEXT");
this.db.exec("ALTER TABLE media ADD COLUMN title_key TEXT");
}
}
close(): void {
@@ -264,7 +286,7 @@ export class ScrapeStore {
"INSERT OR REPLACE INTO messages (id, author_id, author_name, timestamp, content) VALUES (?, ?, ?, ?, ?)"
);
const insertMedia = this.db.prepare(
"INSERT OR IGNORE INTO media (kind, source, author_id, author_name, posted_at, url, play_url, provider, content_type, size, width, height, aspect_ratio, duration_secs, downloaded, preview_url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT OR IGNORE INTO media (kind, source, author_id, author_name, posted_at, url, play_url, provider, content_type, size, width, height, aspect_ratio, duration_secs, downloaded, preview_url, title, title_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
const findMedia = this.db.prepare("SELECT id FROM media WHERE url = ?");
const linkSource = this.db.prepare(
@@ -301,6 +323,8 @@ export class ScrapeStore {
item.durationSecs,
item.downloaded ? 1 : 0,
item.previewUrl,
item.title,
item.title ? normalizeTitleKey(item.title) : null,
createdAt
);
if (result.changes > 0) added[item.kind]++;
@@ -640,6 +664,18 @@ export class ScrapeStore {
conditions.push("duration_secs > ?");
values.push(options.lengthGreaterThan);
}
if (options.titleQuery?.trim()) {
const query = normalizeTitleKey(options.titleQuery);
if (options.titleExact) {
conditions.push("title_key LIKE ?");
values.push(`%${query}%`);
} else {
for (const token of query.split(" ").filter(Boolean)) {
conditions.push("(' ' || COALESCE(title_key, '')) LIKE ?");
values.push(`% ${token}%`);
}
}
}
if (options.after) {
conditions.push("posted_at >= ?");
values.push(options.after);
+56
View File
@@ -29,6 +29,7 @@ import { isoFromSnowflake, snowflakeFromIso } from "../src/snowflake.js";
import {
dirSizeBytes,
listScrapes,
normalizeTitleKey,
readScrapeMeta,
ScrapeStore,
writeScrapeMeta
@@ -1347,6 +1348,61 @@ void (async () => {
store.close();
});
await test("title search matches word prefixes and exact phrases", () => {
const dir = join(root, "title-search");
mkdirSync(dir, { recursive: true });
const store = new ScrapeStore(dir);
const embed = (url: string, title: string) => ({
type: "video",
url,
title,
provider: "YouTube",
videoUrl: url,
imageUrl: null,
thumbnailUrl: null
});
store.insertPage([
message("1000", {
attachments: [attachment({ filename: "no-title.png", contentType: "image/png" })],
embeds: [
embed("https://www.youtube.com/watch?v=a", "Man who jumps over car"),
embed("https://www.youtube.com/watch?v=b", "Human cannonball"),
embed("https://www.youtube.com/watch?v=c", "Woman's Day: 3 Things!")
]
})
]);
const ids = (options: Record<string, unknown>) =>
store
.queryMedia({ limit: 50, ...options })
.map(row => row.url.split("v=")[1])
.sort();
// Prefix tokens in any order, but only at word starts.
assert.deepEqual(ids({ titleQuery: "Ma Ca Ju" }), ["a"]);
assert.deepEqual(ids({ titleQuery: "man" }), ["a"]);
assert.deepEqual(ids({ titleQuery: "man car" }), ["a"]);
assert.deepEqual(ids({ titleQuery: "cannon" }), ["b"]);
assert.deepEqual(ids({ titleQuery: "hum" }), ["b"]);
assert.deepEqual(ids({ titleQuery: "an" }), []);
// Exact mode is one phrase.
assert.deepEqual(ids({ titleQuery: "man who jumps", titleExact: true }), ["a"]);
assert.deepEqual(ids({ titleQuery: "man car", titleExact: true }), []);
// Punctuation folds away.
assert.deepEqual(ids({ titleQuery: "woman's day" }), ["c"]);
assert.deepEqual(ids({ titleQuery: "3 things" }), ["c"]);
store.close();
});
await test("normalizeTitleKey collapses punctuation and case", () => {
assert.equal(normalizeTitleKey("Woman's Day: 3 Things!"), "woman s day 3 things");
assert.equal(normalizeTitleKey(" A---B "), "a b");
});
await test("provider filters are the curated archive list", () => {
assert.ok(PROVIDER_FILTERS.length >= 12, `provider list has ${PROVIDER_FILTERS.length} entries`);
assert.equal(new Set(PROVIDER_FILTERS).size, PROVIDER_FILTERS.length);
+37 -1
View File
@@ -291,6 +291,11 @@
if (value === "" || !Number.isFinite(number) || number < 0) return "";
return String(Math.round(number * (unit ? sizeUnit(unit) : 1)));
};
const title = state.filters.title.trim()
? `title=${encodeURIComponent(state.filters.title.trim())}&${
state.filters.titleExact ? "titleExact=1&" : ""
}`
: "";
const sizeLt = state.filters.sizeLt !== "" ? `sizeLt=${amount(state.filters.sizeLt, state.filters.sizeLtUnit)}&` : "";
const sizeGt = state.filters.sizeGt !== "" ? `sizeGt=${amount(state.filters.sizeGt, state.filters.sizeGtUnit)}&` : "";
const lenLt = state.filters.lenLt !== "" ? `lenLt=${amount(state.filters.lenLt)}&` : "";
@@ -301,7 +306,7 @@
const before = state.filters.before ? `before=${state.filters.before}T23:59:59.999Z&` : "";
return (
`${kind}${source}${channel}${authors}${origin}${cached}${exts}${excludeHosts}` +
`${sort}${excludeProviders}${excludeSources}${excludeExts}` +
`${sort}${title}${excludeProviders}${excludeSources}${excludeExts}` +
`${includeProviders}${includeSources}${sizeLt}${sizeGt}${lenLt}${lenGt}` +
`${aspect}${resolution}${after}${before}`
);
@@ -1044,6 +1049,7 @@
function renderCard(item) {
const figure = el("figure", "vk-media-figure");
if (item.title) figure.title = item.title;
const media = el("div", "vk-media-media");
const imageSource = item.localUrl || item.thumbUrl;
const fallbackSource = item.source !== "embed" ? item.url : null;
@@ -1174,6 +1180,8 @@
exts: [],
excludeHosts: ["4chan.org", "i.4cdn.org"],
sort: "newest",
title: "",
titleExact: false,
excludeProviders: [],
excludeSources: [],
includeProviders: [],
@@ -1204,6 +1212,8 @@
excludeSources: [...state.filters.excludeSources],
includeProviders: [...state.filters.includeProviders],
includeSources: [...state.filters.includeSources],
title: state.filters.title,
titleExact: state.filters.titleExact,
excludeExts: [...state.filters.excludeExts]
};
}
@@ -1224,6 +1234,8 @@
excludeSources: [...state.draft.excludeSources],
includeProviders: [...state.draft.includeProviders],
includeSources: [...state.draft.includeSources],
title: state.draft.title,
titleExact: state.draft.titleExact,
excludeExts: [...state.draft.excludeExts],
sizeLt: state.draft.sizeLt,
sizeGt: state.draft.sizeGt,
@@ -1382,6 +1394,30 @@
draft.sort = value;
}
),
(() => {
const row = el("div", "vk-media-toolbar");
const input = document.createElement("input");
input.type = "text";
input.className = "vk-media-input";
input.placeholder = "Video title (Ma Ca Ju finds Man ... car jumps)";
input.value = draft.title;
input.addEventListener("input", () => {
draft.title = input.value;
});
row.append(input);
const exact = el("label", "vk-media-check");
const box = document.createElement("input");
box.type = "checkbox";
box.checked = draft.titleExact;
box.title = "Match the whole phrase instead of per-word prefixes";
box.addEventListener("change", () => {
draft.titleExact = box.checked;
});
exact.append(box, document.createTextNode(" Exact match (quotes)"));
row.append(exact);
return row;
})(),
filterSelect(
[
["", "Any ratio"],
+9
View File
@@ -904,6 +904,11 @@ function buildServer(): McpServer {
.array(z.string())
.optional()
.describe("Keep only these row sources (attachment, sticker, embed)"),
titleQuery: z
.string()
.optional()
.describe("Search embed titles (YouTube). Multi-word prefix match by default"),
titleExact: z.boolean().optional().describe("Treat titleQuery as one exact phrase"),
sizeLessThan: z.number().optional().describe("Only items smaller than this many bytes"),
sizeGreaterThan: z.number().optional().describe("Only items larger than this many bytes"),
lengthLessThan: z.number().optional().describe("Only items shorter than this many seconds"),
@@ -941,6 +946,8 @@ function buildServer(): McpServer {
excludeSources,
includeProviders,
includeSources,
titleQuery,
titleExact,
sizeLessThan,
sizeGreaterThan,
lengthLessThan,
@@ -969,6 +976,8 @@ function buildServer(): McpServer {
excludeSources,
includeProviders,
includeSources,
titleQuery,
titleExact,
sizeLessThan,
sizeGreaterThan,
lengthLessThan,
+11
View File
@@ -135,6 +135,8 @@ export interface MediaItemResult {
thumbUrl: string | null;
provider: string | null;
downloaded: boolean;
/** Embed title (YouTube and friends), when Discord provided one. */
title: string | null;
createdAt: string;
}
@@ -719,6 +721,8 @@ export class MediaService {
excludeSources?: string[];
includeProviders?: string[];
includeSources?: string[];
titleQuery?: string;
titleExact?: boolean;
sizeLessThan?: number;
sizeGreaterThan?: number;
lengthLessThan?: number;
@@ -850,6 +854,8 @@ export class MediaService {
excludeSources?: string[];
includeProviders?: string[];
includeSources?: string[];
titleQuery?: string;
titleExact?: boolean;
sizeLessThan?: number;
sizeGreaterThan?: number;
lengthLessThan?: number;
@@ -887,6 +893,8 @@ export class MediaService {
excludeSources: options.excludeSources,
includeProviders: options.includeProviders,
includeSources: options.includeSources,
titleQuery: options.titleQuery,
titleExact: options.titleExact,
sizeLessThan: options.sizeLessThan,
sizeGreaterThan: options.sizeGreaterThan,
lengthLessThan: options.lengthLessThan,
@@ -949,6 +957,8 @@ export class MediaService {
excludeSources?: string[];
includeProviders?: string[];
includeSources?: string[];
titleQuery?: string;
titleExact?: boolean;
sizeLessThan?: number;
sizeGreaterThan?: number;
lengthLessThan?: number;
@@ -993,6 +1003,7 @@ export class MediaService {
thumbUrl: row.preview_hash ? `/api/media/${meta.id}/${row.id}/thumb` : null,
provider: row.provider,
downloaded: row.downloaded === 1,
title: row.title,
createdAt: row.created_at
}));
}
+2
View File
@@ -213,6 +213,8 @@ function parseMediaFilters(query: Record<string, unknown>) {
excludeSources: list(query.excludeSources),
includeProviders: list(query.includeProviders),
includeSources: list(query.includeSources),
titleQuery: query.title ? String(query.title).slice(0, 200) : undefined,
titleExact: query.titleExact === "1" || query.titleExact === "true",
sizeLessThan: amount(query.sizeLt),
sizeGreaterThan: amount(query.sizeGt),
lengthLessThan: amount(query.lenLt),
+9
View File
@@ -125,6 +125,15 @@ try {
await page.getByRole("button", { name: "Advanced search" }).click();
await page.waitForSelector(".vk-media-drawer:not([hidden])", { timeout: 5000 });
check(
"title search field present",
(await page.locator('.vk-media-drawer input[type="text"]').count()) >= 1
);
check(
"exact-match toggle present",
(await page.locator(".vk-media-drawer .vk-media-check", { hasText: "Exact match" }).count()) === 1
);
const sourceBoxes = await page
.locator(".vk-media-exclude-group")
.first()