Files
Veskslop-t/media-scrape/src/extract.ts
T

213 lines
6.8 KiB
TypeScript

/*
* 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<number, string | undefined> = { 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;
}
/**
* 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
};
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
};
}
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
});
}
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<string>();
return items.filter(item => {
if (seen.has(item.url)) return false;
seen.add(item.url);
return true;
});
}