Files
beavergram/frontend/src/lib/api/media.ts
T

286 lines
6.7 KiB
TypeScript

import { requestMedia } from "$lib/api/client";
import { getMessageMedia, transcribeMedia } from "$lib/api/endpoints";
import type { MediaRef } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
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 {
downloaded: boolean;
fileName: string | null;
fileSize: number | null;
kind: string;
mediaId: number | null;
messageId: number;
mime: string | null;
}
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 MAX_CACHED = 200;
const ready = new Map<string, InlineMedia>();
const inflight = new Map<string, Promise<InlineMedia>>();
function remember(
cache: Map<string, InlineMedia>,
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}`;
}
async function resolve(
chatId: number,
messageId: number
): Promise<InlineMedia> {
let meta: Awaited<ReturnType<typeof getMessageMedia>>;
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<string, InlineMedia>();
const byIdInflight = new Map<string, Promise<InlineMedia>>();
async function resolveById(media: MediaRef): Promise<InlineMedia> {
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<InlineMedia> {
const account = accounts.selectedId;
if (account === null || media.id === null) {
return Promise.resolve<InlineMedia>({ 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<string | null> {
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<InlineMedia> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve<InlineMedia>({ 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;
}