import { requestMedia } from "#lib/api/client.js"; import { getMessageMedia, transcribeMedia } from "#lib/api/endpoints.js"; import type { MediaRef } from "#lib/api/types.js"; import { accounts } from "#lib/stores/accounts.svelte.js"; const TRANSCRIBE_TRIES = 10; const TRANSCRIBE_DELAY = 2000; export type InlineMedia = | { state: "ready"; mediaId: number; kind: string; mime: string | null; url: string; transcript: string | null; } | { state: "not-downloaded"; mediaId: number; kind: string } | { state: "missing" }; export interface ViewerItem { caption?: string; downloaded: boolean; fileName: string | null; fileSize: number | null; kind: string; mediaId: number | null; messageId: number; mime: string | null; src?: string; } export function viewerItemsFrom( messageId: number, media: MediaRef[] ): ViewerItem[] { if (media.length === 0) { return [ { downloaded: false, fileName: null, fileSize: null, kind: "", mediaId: null, messageId, mime: null, }, ]; } return media.map((item) => ({ downloaded: item.downloaded, fileName: item.file_name, fileSize: item.file_size, kind: item.kind, mediaId: item.id, messageId: item.message_id, mime: item.mime, })); } const PREVIEW_KINDS = new Set([ "photo", "video", "video_note", "animation", "gif", "sticker", "voice", "audio", ]); const PREVIEW_MIME_PREFIXES = ["image/", "video/", "audio/"]; export function isPreviewable(kind: string, mime: string | null): boolean { if (PREVIEW_KINDS.has(kind)) { return true; } return PREVIEW_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix)); } const ACTIVE_MIMES = new Set([ "text/html", "application/xhtml+xml", "image/svg+xml", "text/xml", "application/xml", ]); export function isActiveContent(mime: string | null): boolean { return ACTIVE_MIMES.has(mime?.split(";", 1)[0].trim().toLowerCase() ?? ""); } export type VisualKind = "image" | "video" | "other"; const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]); export function visualKind(kind: string): VisualKind { if (kind === "photo") { return "image"; } if (VIDEO_KINDS.has(kind)) { return "video"; } return "other"; } const STREAM_KINDS = new Set(["video", "video_note", "voice", "audio"]); const STREAM_MIME_PREFIXES = ["video/", "audio/"]; export function isStreamable(kind: string, mime: string | null): boolean { return ( STREAM_KINDS.has(kind) || STREAM_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix)) ); } const MAX_CACHED = 200; const ready = new Map(); const inflight = new Map>(); function remember( cache: Map, key: string, item: InlineMedia ) { cache.set(key, item); while (cache.size > MAX_CACHED) { const oldest = cache.keys().next(); if (oldest.done) { return; } const stale = cache.get(oldest.value); cache.delete(oldest.value); if (stale?.state === "ready") { URL.revokeObjectURL(stale.url); } } } function cacheKey(account: number, chatId: number, messageId: number): string { return `${account}:${chatId}:${messageId}`; } function forget(cache: Map, key: string) { const stale = cache.get(key); cache.delete(key); if (stale?.state === "ready") { URL.revokeObjectURL(stale.url); } } export function forgetMedia( chatId: number, messageId: number, mediaId: number ): void { const account = accounts.selectedId; if (account !== null) { forget(ready, cacheKey(account, chatId, messageId)); forget(byId, `${account}:${mediaId}`); } } async function resolve( chatId: number, messageId: number ): Promise { let meta: Awaited>; try { meta = await getMessageMedia(chatId, messageId); } catch { return { state: "missing" }; } if (!meta.downloaded) { return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" }; } const blob = await requestMedia(meta.id); if (blob.state === "ready") { return { kind: meta.kind, mediaId: meta.id, mime: blob.mime, state: "ready", transcript: meta.extracted_text, url: blob.url, }; } if (blob.state === "not-downloaded") { return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" }; } return { state: "missing" }; } const byId = new Map(); const byIdInflight = new Map>(); async function resolveById(media: MediaRef): Promise { if (media.id === null) { return { state: "missing" }; } if (!media.downloaded) { return { kind: media.kind, mediaId: media.id, state: "not-downloaded" }; } const blob = await requestMedia(media.id); if (blob.state === "ready") { return { kind: media.kind, mediaId: media.id, mime: blob.mime, state: "ready", transcript: null, url: blob.url, }; } if (blob.state === "not-downloaded") { return { kind: media.kind, mediaId: media.id, state: "not-downloaded" }; } return { state: "missing" }; } export function loadMediaItem(media: MediaRef): Promise { const account = accounts.selectedId; if (account === null || media.id === null) { return Promise.resolve({ state: "missing" }); } const key = `${account}:${media.id}`; const cached = byId.get(key); if (cached) { return Promise.resolve(cached); } const existing = byIdInflight.get(key); if (existing) { return existing; } const promise = resolveById(media) .then((result) => { if (result.state === "ready") { remember(byId, key, result); } return result; }) .finally(() => { byIdInflight.delete(key); }); byIdInflight.set(key, promise); return promise; } function patchTranscript( chatId: number, messageId: number, text: string ): void { const account = accounts.selectedId; if (account === null) { return; } const cached = ready.get(cacheKey(account, chatId, messageId)); if (cached?.state === "ready") { cached.transcript = text; } } export async function requestTranscription( chatId: number, messageId: number ): Promise { await transcribeMedia(chatId, messageId); for (let i = 0; i < TRANSCRIBE_TRIES; i += 1) { // biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose await new Promise((done) => { setTimeout(done, TRANSCRIBE_DELAY); }); try { const meta = await getMessageMedia(chatId, messageId); if (meta.extracted_text) { patchTranscript(chatId, messageId, meta.extracted_text); return meta.extracted_text; } } catch { return null; } } return null; } export function loadInlineMedia( chatId: number, messageId: number ): Promise { const account = accounts.selectedId; if (account === null) { return Promise.resolve({ state: "missing" }); } const key = cacheKey(account, chatId, messageId); const cached = ready.get(key); if (cached) { return Promise.resolve(cached); } const existing = inflight.get(key); if (existing) { return existing; } const promise = resolve(chatId, messageId) .then((result) => { if (result.state === "ready") { remember(ready, key, result); } return result; }) .finally(() => { inflight.delete(key); }); inflight.set(key, promise); return promise; }