Capture YouTube titles from Discord's embed cache
This commit is contained in:
@@ -885,3 +885,9 @@
|
||||
font: 400 11px/1.5 ui-monospace, Menlo, monospace;
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.vk-media-viewer-title {
|
||||
font: 600 13px/1.5 ui-monospace, Menlo, monospace;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -2669,6 +2669,18 @@
|
||||
}
|
||||
|
||||
meta.replaceChildren();
|
||||
|
||||
// The clip title matters most for finding footage; show it big when we have one.
|
||||
const heading =
|
||||
item.title || (item.titleError && (item.provider ?? "").toLowerCase().includes("youtube"))
|
||||
? item.title
|
||||
? `${item.title}${item.titleAuthor ? ` — ${item.titleAuthor}` : ""}`
|
||||
: "Video unavailable (removed or private)"
|
||||
: "";
|
||||
if (heading) {
|
||||
meta.append(el("div", "vk-media-viewer-title", heading));
|
||||
}
|
||||
|
||||
const values = metaValues(item);
|
||||
for (const [key, label] of META_FIELDS) {
|
||||
if (!values[key]) continue;
|
||||
|
||||
@@ -136,6 +136,8 @@ export interface MediaItemResult {
|
||||
remotePreviewUrl: string | null;
|
||||
/** Why no preview exists, when a fetch failed (for example "http 404"). */
|
||||
previewError: string | null;
|
||||
/** Why no title was resolved (provider oEmbed and Discord's embed both failed). */
|
||||
titleError: string | null;
|
||||
/** Local animated GIF for hover playback, when the original was cached. */
|
||||
animUrl: string | null;
|
||||
localUrl: string | null;
|
||||
@@ -217,6 +219,7 @@ export class MediaService {
|
||||
maxRetries: tunables.scrape.maxRetries,
|
||||
previewMaxAttempts: tunables.backfill.maxAttempts,
|
||||
suspectRetryMs: 50_000,
|
||||
embedMeta: async (scrapeId, media) => this.embedMetaFor(scrapeId, media),
|
||||
refreshUrl: async (scrapeId, media) => {
|
||||
const meta = this.engine.get(scrapeId);
|
||||
if (!meta) return null;
|
||||
@@ -683,6 +686,67 @@ export class MediaService {
|
||||
this.engine.stop(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Title and channel from the source message's Discord embed. Discord keeps the embed data
|
||||
* after a video is deleted, so this is the only title source for gone YouTube videos.
|
||||
*/
|
||||
private async embedMetaFor(
|
||||
scrapeId: string,
|
||||
media: { id: number; url: string; playUrl: string | null }
|
||||
): Promise<{ title: string; author: string | null } | null> {
|
||||
const meta = this.engine.get(scrapeId);
|
||||
if (!meta) return null;
|
||||
|
||||
try {
|
||||
const store = new ScrapeStore(scrapeDir(this.rootDir, scrapeId));
|
||||
let messageId: string | null = null;
|
||||
try {
|
||||
messageId = store.sourceMessageId(media.id);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
if (!messageId) return null;
|
||||
|
||||
const message = await this.fetchSourceMessage(meta, messageId);
|
||||
if (!message) return null;
|
||||
|
||||
const path = (url: string) => url.split("?")[0];
|
||||
const embeds: any[] = message.embeds ?? [];
|
||||
const embed = embeds.find(
|
||||
candidate =>
|
||||
(candidate?.url && path(candidate.url) === path(media.url)) ||
|
||||
(media.playUrl && candidate?.videoUrl && path(candidate.videoUrl) === path(media.playUrl)) ||
|
||||
(candidate?.video?.url && media.playUrl && path(candidate.video.url) === path(media.playUrl))
|
||||
);
|
||||
|
||||
const title = embed?.title ?? null;
|
||||
if (!title) return null;
|
||||
const author = embed?.authorName ?? embed?.author?.name ?? null;
|
||||
return { title, author };
|
||||
} catch (error) {
|
||||
this.deps.log?.(`embed meta failed for ${scrapeId}:${media.id}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The source message through slopbot REST (guild scrapes) or the puppet bridge. */
|
||||
private async fetchSourceMessage(meta: ScrapeMeta, messageId: string): Promise<any | null> {
|
||||
if (this.bot && meta.guildId && meta.backend === "slopbot") {
|
||||
const channel = await this.bot.channels.fetch(meta.channelId);
|
||||
if (channel?.isTextBased()) return await channel.messages.fetch(messageId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const core = this.deps.getCore();
|
||||
if (!core || !this.deps.isCoreConnected()) return null;
|
||||
|
||||
const result = (await core.fetchMessages(meta.channelId, 1, {
|
||||
before: (BigInt(messageId) + 1n).toString()
|
||||
})) as { messages?: Array<Record<string, any>> };
|
||||
|
||||
return result?.messages?.find(candidate => candidate?.id === messageId) ?? result?.messages?.[0] ?? null;
|
||||
}
|
||||
|
||||
/** Continue a paused scrape from its cursor with the same backend family. */
|
||||
async resumeScrape(scrapeId: string): Promise<ScrapeMeta | null> {
|
||||
const meta = this.engine.get(scrapeId);
|
||||
@@ -1224,6 +1288,7 @@ export class MediaService {
|
||||
playUrl: row.play_url,
|
||||
remotePreviewUrl: row.preview_url,
|
||||
previewError: row.preview_error,
|
||||
titleError: row.title_error,
|
||||
animUrl: row.anim_hash ? `/api/media/${meta.id}/${row.id}/anim` : null,
|
||||
localUrl:
|
||||
row.file_path && !meta.downloadDir
|
||||
|
||||
Reference in New Issue
Block a user