/* * 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" }; /** A media item extracted from a message. `url` is the original source link. */ export interface ExtractedMedia { kind: MediaKind; url: string; 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; } 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 common = { provider, downloaded: false, size: null, width: null, height: null, durationSecs: null }; if (embed.videoUrl) { return { ...common, kind: "video", url: embed.url ?? embed.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: embed.url ?? embed.imageUrl ?? "", contentType: null, previewUrl: embed.thumbnailUrl ?? embed.imageUrl }; } if (embed.imageUrl) { return { ...common, kind: "image", url: embed.url ?? 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", 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 }; } 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, url: attachment.url, provider: "discord", contentType: attachment.contentType, size: attachment.size, width: attachment.width, height: attachment.height, durationSecs: attachment.durationSecs, downloaded: false, previewUrl: 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; }); }