213 lines
6.8 KiB
TypeScript
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;
|
|
});
|
|
}
|