/* * Media extraction: classify one core message into media items. * Rules: docs/media-scraper.md. Third-party embeds are never downloaded. */ import type { CoreAttachment, CoreEmbed, CoreSticker, CoreMessage, MediaKind, MediaMessage } from "./types.js"; const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "webp", "bmp", "avif"]); const GIF_EXT = new Set(["gif"]); const VIDEO_EXT = new Set(["mp4", "webm", "mov", "mkv"]); const AUDIO_EXT = new Set(["mp3", "m4a", "ogg", "wav", "flac"]); /** Sticker formatType to file extension. 3 is Lottie JSON; not archived. */ const STICKER_EXT: Record = { 1: "png", 2: "apng", 4: "gif" }; /** Where the item came from. Embeds are never downloaded. */ export type MediaSource = "attachment" | "sticker" | "embed"; /** A media item extracted from a message. `url` is the original source link. */ export interface ExtractedMedia { kind: MediaKind; url: string; source: MediaSource; /** Direct playable file URL when the embed carries one (gifv/Tenor mp4). */ playUrl: string | null; provider: string | null; contentType: string | null; size: number | null; width: number | null; height: number | null; durationSecs: number | null; /** 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; /** Embed author (the YouTube channel Discord shows). Null elsewhere. */ titleAuthor: string | null; } /** * Shorts links break the embed player: it needs `watch?v=`. Rewrite them for storage so the * viewer, "View full", and refreshes all use the canonical form. Other URLs pass through. */ export function normalizeYouTubeUrl(url: string): string { const match = /^https?:\/\/(?:www\.|m\.)?youtube\.com\/shorts\/([A-Za-z0-9_-]{6,})/.exec(url); return match ? `https://www.youtube.com/watch?v=${match[1]}` : url; } function extension(filename: string | null | undefined): string { const match = /\.([a-z0-9]+)$/i.exec(filename ?? ""); return match ? match[1].toLowerCase() : ""; } function classifyAttachment(attachment: CoreAttachment, voiceMessage: boolean): MediaKind | null { const ext = extension(attachment.filename); const type = (attachment.contentType ?? "").toLowerCase(); // A voice-message flag wins unless the file is clearly an image or a video. if ( voiceMessage && !IMAGE_EXT.has(ext) && !GIF_EXT.has(ext) && !VIDEO_EXT.has(ext) && !type.startsWith("image/") && !type.startsWith("video/") ) { return "audio"; } if (AUDIO_EXT.has(ext) || type.startsWith("audio/")) return "audio"; if (VIDEO_EXT.has(ext) || type.startsWith("video/")) return "video"; if (GIF_EXT.has(ext) || type === "image/gif") return "gif"; if (IMAGE_EXT.has(ext) || type.startsWith("image/")) return "image"; return null; } function hostname(url: string | null): string | null { if (!url) return null; try { return new URL(url).hostname; } catch { return null; } } function extractEmbed(embed: CoreEmbed): ExtractedMedia | null { const provider = embed.provider ?? hostname(embed.url); const embedUrl = embed.url ? normalizeYouTubeUrl(embed.url) : null; const videoUrl = embed.videoUrl ? normalizeYouTubeUrl(embed.videoUrl) : null; const common = { source: "embed" as const, playUrl: videoUrl, provider, downloaded: false, size: null, width: embed.width ?? null, height: embed.height ?? null, durationSecs: null, title: embed.title ?? null, titleAuthor: embed.authorName ?? null }; if (embed.videoUrl) { return { ...common, kind: "video", url: embedUrl ?? videoUrl ?? "", contentType: null, previewUrl: embed.thumbnailUrl ?? embed.imageUrl }; } const providerKey = (provider ?? "").toLowerCase(); if (embed.type === "gifv" || providerKey.includes("tenor") || providerKey.includes("giphy")) { return { ...common, kind: "gif", url: embedUrl ?? embed.imageUrl ?? "", contentType: null, previewUrl: embed.thumbnailUrl ?? embed.imageUrl }; } if (embed.imageUrl) { return { ...common, kind: "image", url: embedUrl ?? embed.imageUrl, contentType: null, previewUrl: embed.thumbnailUrl ?? embed.imageUrl }; } return null; } function extractSticker(sticker: CoreSticker): ExtractedMedia | null { if (!sticker.id) return null; const ext = STICKER_EXT[sticker.formatType ?? 1]; if (!ext) return null; return { kind: ext === "gif" ? "gif" : "image", source: "sticker", playUrl: null, url: `https://cdn.discordapp.com/stickers/${sticker.id}.${ext}`, provider: "discord", contentType: null, size: null, width: null, height: null, durationSecs: null, downloaded: false, previewUrl: null, title: null, titleAuthor: null }; } function extractFrom(message: MediaMessage): ExtractedMedia[] { const items: ExtractedMedia[] = []; for (const attachment of message.attachments ?? []) { if (!attachment.url) continue; const kind = classifyAttachment(attachment, message.voiceMessage); if (!kind) continue; items.push({ kind, source: "attachment", playUrl: null, url: attachment.url, provider: "discord", contentType: attachment.contentType, size: attachment.size, width: attachment.width, height: attachment.height, // Discord sends 0 for non-audio attachments; treat it as unknown. durationSecs: Number.isFinite(attachment.durationSecs) && (attachment.durationSecs as number) > 0 ? attachment.durationSecs : null, downloaded: false, previewUrl: null, title: null, titleAuthor: null }); } for (const embed of message.embeds ?? []) { const item = extractEmbed(embed); if (item?.url) items.push(item); } for (const sticker of message.stickerItems ?? []) { const item = extractSticker(sticker); if (item) items.push(item); } return items; } /** Extract every media item from a message, including forwarded snapshots. Deduped by URL. */ export function extractMedia(message: CoreMessage): ExtractedMedia[] { const items = extractFrom(message); for (const snapshot of message.forwarded ?? []) { items.push(...extractFrom(snapshot)); } const seen = new Set(); return items.filter(item => { if (seen.has(item.url)) return false; seen.add(item.url); return true; }); }