318 lines
7.5 KiB
TypeScript
318 lines
7.5 KiB
TypeScript
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<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}`;
|
|
}
|
|
|
|
function forget(cache: Map<string, InlineMedia>, 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<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;
|
|
}
|