feat(backend,frontend,mcp): any voice or round video can be transcribed on request, media is found by its bytes

This commit is contained in:
hh committed 2026-10-28 01:35:56 +00:00
1 parent c2180e80ad
commit 2dd4e589c3
17 files changed
+472 -108

No files matched your search

@@ -0,0 +1,27 @@
"""media storage_key index for content lookup
Revision ID: b7d2e9f4a1c6
Revises: f1c4a8e2d6b3
Create Date: 2026-10-28 01:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "b7d2e9f4a1c6"
down_revision: str | None = "f1c4a8e2d6b3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"CREATE INDEX ix_media_storage_key ON media (storage_key) "
"WHERE storage_key IS NOT NULL"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_media_storage_key")
+23
View File
@@ -19,6 +19,7 @@ from api.mcp.download import (
share_media, share_media,
) )
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
from api.transcribe import request_transcript
from dependencies.container import container from dependencies.container import container
from utils.files import resolve_mime from utils.files import resolve_mime
from utils.read import ( from utils.read import (
@@ -87,6 +88,9 @@ Typical flows:
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus returns a public link valid 24h that needs no auth (`curl -O <link>`), plus
the content itself for small images and text files. `get_media` returns the content itself for small images and text files. `get_media` returns
metadata only. metadata only.
- Voice: transcripts of played voices and round videos are already in the
history ("STT Content"). For "not transcribed" ones call
`transcribe_message`.
- Monitoring: `set_watch` creates a local rule (kinds `keyword`, - Monitoring: `set_watch` creates a local rule (kinds `keyword`,
`new_message`, `message_deleted`, `message_edited`, `peer_online`; params in `new_message`, `message_deleted`, `message_edited`, `peer_online`; params in
its description), `list_watches` lists rules, and `list_alerts` reads what its description), `list_watches` lists rules, and `list_alerts` reads what
@@ -369,6 +373,25 @@ async def download_media(
] ]
@mcp.tool
async def transcribe_message(
account_id: int, chat_id: int, message_id: int, wait_seconds: int = 60
) -> dict[str, Any]:
"""Transcribe a voice message or round video through Telegram, any of them.
Returns the archived transcript at once when there is one; otherwise asks
Telegram (Premium transcription of the archived account) and waits up to
`wait_seconds`. `status` is `done` with `text`, `pending` (call again
later) or `failed` with `error`: `too_long`, `no_premium`, `not_voice`,
`empty`, `bot_account` or `failed`. Telegram may mark an unplayed voice as
listened for its sender.
"""
result = await request_transcript(
await _pool(), account_id, chat_id, message_id, max(wait_seconds, 0)
)
return result.model_dump(mode="json", exclude_none=True)
@mcp.tool @mcp.tool
async def get_callbacks( async def get_callbacks(
account_id: int, chat_id: int, message_id: int account_id: int, chat_id: int, message_id: int
+7 -9
View File
@@ -7,6 +7,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from api.transcribe import TranscribeResult, request_transcript
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.read.models import DEFAULT_LIMIT, Page from utils.read.models import DEFAULT_LIMIT, Page
@@ -35,6 +36,7 @@ class TranscribeRequest(BaseModel):
account_id: int account_id: int
chat_id: int chat_id: int
message_id: int message_id: int
wait: float = 0
class SyncDialogsRequest(BaseModel): class SyncDialogsRequest(BaseModel):
@@ -105,17 +107,13 @@ async def enqueue_fetch_media(
return EnqueueResponse(job_id=job_id) return EnqueueResponse(job_id=job_id)
@router.post("/media/transcribe", status_code=201) @router.post("/media/transcribe")
async def enqueue_transcribe( async def transcribe(
pool: FromDishka[asyncpg.Pool], body: TranscribeRequest pool: FromDishka[asyncpg.Pool], body: TranscribeRequest
) -> EnqueueResponse: ) -> TranscribeResult:
job_id = await enqueue( return await request_transcript(
pool, pool, body.account_id, body.chat_id, body.message_id, body.wait
body.account_id,
"transcribe",
{"chat_id": body.chat_id, "message_id": body.message_id},
) )
return EnqueueResponse(job_id=job_id)
@router.post("/dialogs/sync", status_code=201) @router.post("/dialogs/sync", status_code=201)
+12 -1
View File
@@ -8,17 +8,19 @@ from fastapi.responses import FileResponse
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS
from utils.files import content_disposition, media_file_name, resolve_mime from utils.files import content_disposition, media_file_name, resolve_mime
from utils.read.media import ( from utils.read.media import (
find_by_content,
get_media, get_media,
get_media_version, get_media_version,
get_media_versions, get_media_versions,
get_message_media, get_message_media,
) )
from utils.read.models import MediaVersionView, MediaView from utils.read.models import MediaPlaceView, MediaVersionView, MediaView
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute) router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
Download = Annotated[bool, Query()] Download = Annotated[bool, Query()]
Sha256 = Annotated[str, Query(pattern="^[0-9a-f]{64}$")]
def _attachment(headers: dict[str, str], file_name: str) -> dict[str, str]: def _attachment(headers: dict[str, str], file_name: str) -> dict[str, str]:
@@ -81,6 +83,15 @@ async def message_media(
return media return media
@router.get("/lookup")
async def lookup_media(
pool: FromDishka[asyncpg.Pool],
sha256: Sha256,
account_id: Annotated[int | None, Query()] = None,
) -> list[MediaPlaceView]:
return await find_by_content(pool, sha256, account_id)
@router.get("/{media_id}") @router.get("/{media_id}")
async def serve_media( async def serve_media(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
+64
View File
@@ -0,0 +1,64 @@
import asyncio
from typing import Literal
import asyncpg
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import accounts, media
MAX_WAIT_SECONDS = 60.0
_POLL_SECONDS = 1.0
_TRANSCRIBABLE = ("voice", "video_note")
TranscribeStatus = Literal["done", "pending", "failed"]
class TranscribeResult(BaseModel):
status: TranscribeStatus
text: str | None = None
error: str | None = None
job_id: int | None = None
async def _text(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> str | None:
item = await media.get_message_media(pool, account_id, chat_id, message_id)
return item.extracted_text if item else None
async def _job_error(pool: asyncpg.Pool, job_id: int) -> str | None:
row = await pool.fetchrow("SELECT status, error FROM jobs WHERE id = $1", job_id)
if row is None or row["status"] == "canceled":
return "failed"
if row["status"] == "failed":
return row["error"] or "failed"
return None
async def request_transcript(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int, wait: float = 0
) -> TranscribeResult:
item = await media.get_message_media(pool, account_id, chat_id, message_id)
if item is None or item.kind not in _TRANSCRIBABLE:
return TranscribeResult(status="failed", error="not_voice")
if item.extracted_text:
return TranscribeResult(status="done", text=item.extracted_text)
account = await accounts.get_account(pool, account_id)
if account is None or account.is_bot:
return TranscribeResult(status="failed", error="bot_account")
job_id = await enqueue(
pool, account_id, "transcribe", {"chat_id": chat_id, "message_id": message_id}
)
loop = asyncio.get_running_loop()
deadline = loop.time() + min(wait, MAX_WAIT_SECONDS)
while loop.time() < deadline:
await asyncio.sleep(_POLL_SECONDS)
text = await _text(pool, account_id, chat_id, message_id)
if text:
return TranscribeResult(status="done", text=text, job_id=job_id)
error = await _job_error(pool, job_id)
if error is not None:
return TranscribeResult(status="failed", error=error, job_id=job_id)
return TranscribeResult(status="pending", job_id=job_id)
+2 -2
View File
@@ -2,7 +2,7 @@ from pyrogram import Client
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.stt.service import transcribe_message from userbot.modules.stt.service import TranscribeError, transcribe_message
from utils.logging import logger from utils.logging import logger
@@ -20,5 +20,5 @@ async def safe_transcribe(
"transcribe", "transcribe",
{"chat_id": chat_id, "message_id": message_id}, {"chat_id": chat_id, "message_id": message_id},
) )
except RPCError as exc: except (TranscribeError, RPCError) as exc:
logger.warning(f"[yellow]STT failed for {chat_id}/{message_id}: {exc}[/]") logger.warning(f"[yellow]STT failed for {chat_id}/{message_id}: {exc}[/]")
+33 -12
View File
@@ -1,4 +1,5 @@
from pyrogram import Client, raw from pyrogram import Client, raw
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from pyrogram.types import Message from pyrogram.types import Message
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
@@ -6,6 +7,16 @@ from userbot.modules.media import self_destruct_ttl
from userbot.modules.stt import repository from userbot.modules.stt import repository
from utils.logging import logger from utils.logging import logger
REFUSALS = {
"MSG_VOICE_TOO_LONG": "too_long",
"MSG_VOICE_MISSING": "not_voice",
"PREMIUM_ACCOUNT_REQUIRED": "no_premium",
}
class TranscribeError(Exception):
pass
def is_transcribable(message: Message) -> bool: def is_transcribable(message: Message) -> bool:
if self_destruct_ttl(message) is not None: if self_destruct_ttl(message) is not None:
@@ -26,27 +37,37 @@ def should_transcribe_on_backfill(message: Message, self_id: int | None) -> bool
return not message.unread_media return not message.unread_media
async def _ask(
client: Client, chat_id: int, message_id: int
) -> raw.types.messages.TranscribedAudio:
try:
peer = await client.resolve_peer(chat_id)
if peer is None:
msg = "failed"
raise TranscribeError(msg)
return await client.invoke(
raw.functions.messages.TranscribeAudio(peer=peer, msg_id=message_id)
)
except (FloodWait, FloodPremiumWait):
raise
except RPCError as exc:
logger.warning(f"[yellow]STT refused {chat_id}/{message_id}: {exc}[/]")
raise TranscribeError(REFUSALS.get(exc.ID or "", "failed")) from exc
async def transcribe_message( async def transcribe_message(
client: Client, ctx: CaptureContext, chat_id: int, message_id: int client: Client, ctx: CaptureContext, chat_id: int, message_id: int
) -> None: ) -> None:
peer = await client.resolve_peer(chat_id) result = await _ask(client, chat_id, message_id)
if peer is None:
return
result = await client.invoke(
raw.functions.messages.TranscribeAudio(peer=peer, msg_id=message_id)
)
if result.pending: if result.pending:
logger.info( logger.info(
f"[yellow]STT pending {chat_id}/{message_id} " f"[yellow]STT pending {chat_id}/{message_id} "
f"(trial_remains={result.trial_remains_num})[/]" f"(trial_remains={result.trial_remains_num})[/]"
) )
return return
if result.text: if not result.text:
msg = "empty"
raise TranscribeError(msg)
await repository.set_extracted_text( await repository.set_extracted_text(
ctx.pool, ctx.account_id, chat_id, message_id, result.text ctx.pool, ctx.account_id, chat_id, message_id, result.text
) )
else:
logger.info(
f"[yellow]STT empty {chat_id}/{message_id} "
f"(trial_remains={result.trial_remains_num})[/]"
)
+14 -1
View File
@@ -3,7 +3,7 @@ import json
import asyncpg import asyncpg
from utils.files import media_file_name from utils.files import media_file_name
from utils.read.models import MediaVersionView, MediaView from utils.read.models import MediaPlaceView, MediaVersionView, MediaView
MEDIA_COLS = ( MEDIA_COLS = (
"m.id, m.account_id, m.chat_id, m.message_id, m.kind, m.storage_key, " "m.id, m.account_id, m.chat_id, m.message_id, m.kind, m.storage_key, "
@@ -23,6 +23,12 @@ LEFT JOIN LATERAL (
_VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at" _VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at"
_PLACES = """
SELECT account_id, chat_id, message_id, kind, extracted_text FROM media
WHERE storage_key = $1 AND ($2::bigint IS NULL OR account_id = $2)
ORDER BY created_at DESC LIMIT 100
"""
_WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio") _WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio")
@@ -100,6 +106,13 @@ async def get_message_media(
return await _web_page_media_stub(pool, account_id, chat_id, message_id) return await _web_page_media_stub(pool, account_id, chat_id, message_id)
async def find_by_content(
pool: asyncpg.Pool, storage_key: str, account_id: int | None = None
) -> list[MediaPlaceView]:
rows = await pool.fetch(_PLACES, storage_key, account_id)
return [MediaPlaceView(**dict(row)) for row in rows]
async def get_media_versions( async def get_media_versions(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[MediaVersionView]: ) -> list[MediaVersionView]:
+8
View File
@@ -275,6 +275,14 @@ class MediaView(BaseModel):
file_name: str | None = None file_name: str | None = None
class MediaPlaceView(BaseModel):
account_id: int
chat_id: int
message_id: int
kind: str
extracted_text: str | None
class MediaVersionView(BaseModel): class MediaVersionView(BaseModel):
id: int id: int
kind: str kind: str
+11 -3
View File
@@ -575,15 +575,23 @@ export function fetchMedia(
}); });
} }
export interface TranscribeResult {
error: string | null;
status: "done" | "failed" | "pending";
text: string | null;
}
export function transcribeMedia( export function transcribeMedia(
chatId: number, chatId: number,
messageId: number messageId: number,
): Promise<{ job_id: number }> { wait: number
return request<{ job_id: number }>("/media/transcribe", { ): Promise<TranscribeResult> {
return request<TranscribeResult>("/media/transcribe", {
body: { body: {
account_id: accounts.selectedId, account_id: accounts.selectedId,
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
wait,
}, },
method: "POST", method: "POST",
}); });
+44 -22
View File
@@ -3,8 +3,8 @@ import { getMessageMedia, transcribeMedia } from "#lib/api/endpoints.js";
import type { MediaRef } from "#lib/api/types.js"; import type { MediaRef } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
const TRANSCRIBE_TRIES = 10; const TRANSCRIBE_WAIT = 25;
const TRANSCRIBE_DELAY = 2000; const TRANSCRIBE_TRIES = 3;
export type InlineMedia = export type InlineMedia =
| { | {
@@ -15,7 +15,12 @@ export type InlineMedia =
url: string; url: string;
transcript: string | null; transcript: string | null;
} }
| { state: "not-downloaded"; mediaId: number; kind: string } | {
state: "not-downloaded";
mediaId: number;
kind: string;
transcript: string | null;
}
| { state: "missing" }; | { state: "missing" };
export interface ViewerItem { export interface ViewerItem {
@@ -173,7 +178,12 @@ async function resolve(
return { state: "missing" }; return { state: "missing" };
} }
if (!meta.downloaded) { if (!meta.downloaded) {
return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" }; return {
kind: meta.kind,
mediaId: meta.id,
state: "not-downloaded",
transcript: meta.extracted_text,
};
} }
const blob = await requestMedia(meta.id); const blob = await requestMedia(meta.id);
if (blob.state === "ready") { if (blob.state === "ready") {
@@ -187,7 +197,12 @@ async function resolve(
}; };
} }
if (blob.state === "not-downloaded") { if (blob.state === "not-downloaded") {
return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" }; return {
kind: meta.kind,
mediaId: meta.id,
state: "not-downloaded",
transcript: meta.extracted_text,
};
} }
return { state: "missing" }; return { state: "missing" };
} }
@@ -200,7 +215,12 @@ async function resolveById(media: MediaRef): Promise<InlineMedia> {
return { state: "missing" }; return { state: "missing" };
} }
if (!media.downloaded) { if (!media.downloaded) {
return { kind: media.kind, mediaId: media.id, state: "not-downloaded" }; return {
kind: media.kind,
mediaId: media.id,
state: "not-downloaded",
transcript: null,
};
} }
const blob = await requestMedia(media.id); const blob = await requestMedia(media.id);
if (blob.state === "ready") { if (blob.state === "ready") {
@@ -214,7 +234,12 @@ async function resolveById(media: MediaRef): Promise<InlineMedia> {
}; };
} }
if (blob.state === "not-downloaded") { if (blob.state === "not-downloaded") {
return { kind: media.kind, mediaId: media.id, state: "not-downloaded" }; return {
kind: media.kind,
mediaId: media.id,
state: "not-downloaded",
transcript: null,
};
} }
return { state: "missing" }; return { state: "missing" };
} }
@@ -257,32 +282,29 @@ function patchTranscript(
return; return;
} }
const cached = ready.get(cacheKey(account, chatId, messageId)); const cached = ready.get(cacheKey(account, chatId, messageId));
if (cached?.state === "ready") { if (cached && cached.state !== "missing") {
cached.transcript = text; cached.transcript = text;
} }
} }
export type TranscriptOutcome = { text: string } | { error: string };
export async function requestTranscription( export async function requestTranscription(
chatId: number, chatId: number,
messageId: number messageId: number
): Promise<string | null> { ): Promise<TranscriptOutcome> {
await transcribeMedia(chatId, messageId);
for (let i = 0; i < TRANSCRIBE_TRIES; i += 1) { for (let i = 0; i < TRANSCRIBE_TRIES; i += 1) {
// biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose // biome-ignore lint/performance/noAwaitInLoops: each try waits on the server
await new Promise((done) => { const result = await transcribeMedia(chatId, messageId, TRANSCRIBE_WAIT);
setTimeout(done, TRANSCRIBE_DELAY); if (result.status === "done" && result.text) {
}); patchTranscript(chatId, messageId, result.text);
try { return { text: result.text };
const meta = await getMessageMedia(chatId, messageId);
if (meta.extracted_text) {
patchTranscript(chatId, messageId, meta.extracted_text);
return meta.extracted_text;
} }
} catch { if (result.status === "failed") {
return null; return { error: result.error ?? "failed" };
} }
} }
return null; return { error: "pending" };
} }
export function loadInlineMedia( export function loadInlineMedia(
@@ -167,7 +167,12 @@
url={ready.url} url={ready.url}
/> />
{:else if ready && kind === "video_note"} {:else if ready && kind === "video_note"}
<VideoNote transcript={ready.transcript} url={ready.url} /> <VideoNote
chatId={message.chat_id}
messageId={message.message_id}
transcript={ready.transcript}
url={ready.url}
/>
{:else if ready && kind === "audio"} {:else if ready && kind === "audio"}
<AudioFile {own} title={fileName} url={ready.url} /> <AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage} {:else if ready && isImage}
@@ -210,6 +215,27 @@
<Icon name="document" size="1.25rem" /> <Icon name="document" size="1.25rem" />
<span>{label}</span> <span>{label}</span>
</button> </button>
{:else if media?.state === "not-downloaded" && kind === "voice"}
<VoiceMessage
chatId={message.chat_id}
length={ref?.duration}
messageId={message.message_id}
ondownload={queue}
{own}
transcript={media.transcript}
url={null}
{waiting}
/>
{:else if media?.state === "not-downloaded" && kind === "video_note"}
<VideoNote
chatId={message.chat_id}
length={ref?.duration}
messageId={message.message_id}
ondownload={queue}
transcript={media.transcript}
url={null}
{waiting}
/>
{:else if media?.state === "not-downloaded" && vk !== "other"} {:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button"> <button class="media-placeholder" onclick={queue} type="button">
<Icon name={waiting ? "timer" : "download"} size="1.5rem" /> <Icon name={waiting ? "timer" : "download"} size="1.5rem" />
@@ -1,18 +1,37 @@
<script lang="ts"> <script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { POSTER_TIME, poster } from "#lib/media/poster.js"; import { POSTER_TIME, poster } from "#lib/media/poster.js";
import { Transcript } from "#lib/media/transcript.svelte.js";
interface Props { interface Props {
chatId: number;
length?: number | null;
messageId: number;
ondownload?: () => void;
transcript?: string | null; transcript?: string | null;
url: string; url: string | null;
waiting?: boolean;
} }
let { url, transcript = null }: Props = $props(); let {
url,
transcript = null,
chatId,
messageId,
length = null,
ondownload,
waiting = false,
}: Props = $props();
let showTranscript = $state(false); const stt = new Transcript(
() => chatId,
() => messageId,
() => transcript
);
const RADIUS = 94; const RADIUS = 94;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
@@ -24,8 +43,21 @@
const progress = $derived(duration > 0 ? currentTime / duration : 0); const progress = $derived(duration > 0 ? currentTime / duration : 0);
const remaining = $derived(Math.max(0, duration - currentTime)); const remaining = $derived(Math.max(0, duration - currentTime));
const shownTime = $derived(
paused && currentTime <= POSTER_TIME ? duration || (length ?? 0) : remaining
);
const toggleLabel = $derived.by(() => {
if (!url) {
return t("media.downloadToArchive");
}
return paused ? t("media.playRound") : t("ui.pause");
});
function toggle() { function toggle() {
if (!url) {
ondownload?.();
return;
}
if (!element) { if (!element) {
return; return;
} }
@@ -39,11 +71,12 @@
<div class="RoundVideoWrap"> <div class="RoundVideoWrap">
<button <button
aria-label={paused ? t("media.playRound") : t("ui.pause")} aria-label={toggleLabel}
class="RoundVideo" class="RoundVideo"
onclick={toggle} onclick={toggle}
type="button" type="button"
> >
{#if url}
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
<video <video
@@ -71,28 +104,36 @@
{#if paused} {#if paused}
<span class="play"><Icon name="large-play" size="2.75rem" /></span> <span class="play"><Icon name="large-play" size="2.75rem" /></span>
{/if} {/if}
{:else}
<span class="blank"></span>
<span class="play">
<Icon name={waiting ? "timer" : "download"} size="2.25rem" />
</span>
{/if}
<span class="badge"> <span class="badge">
<Icon name="microphone" size="0.875rem" /> <Icon name="microphone" size="0.875rem" />
{formatDuration( {formatDuration(shownTime)}
paused && currentTime <= POSTER_TIME ? duration : remaining
)}
</span> </span>
</button> </button>
{#if transcript}
<button <button
aria-label={stt.open
? t("media.hideTranscription")
: t("media.showTranscription")}
class="transcribe" class="transcribe"
onclick={() => { disabled={stt.pending}
showTranscript = !showTranscript; onclick={() => stt.toggle()}
}}
type="button" type="button"
class:active={showTranscript} class:active={stt.open}
> >
{#if stt.pending}
<Spinner color="gray" size="1rem" />
{:else}
<Icon name="transcribe" size="1rem" /> <Icon name="transcribe" size="1rem" />
{/if}
{t("media.transcription")} {t("media.transcription")}
</button> </button>
{#if showTranscript} {#if stt.open}
<div class="transcript">{transcript}</div> <div class="transcript">{stt.text}</div>
{/if}
{/if} {/if}
</div> </div>
@@ -123,6 +164,10 @@
&.active { &.active {
color: var(--color-primary); color: var(--color-primary);
} }
&:disabled {
cursor: default;
}
} }
.transcript { .transcript {
@@ -150,6 +195,16 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.blank {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: var(--color-default-shadow);
}
video { video {
display: block; display: block;
@@ -1,52 +1,39 @@
<script lang="ts"> <script lang="ts">
import { requestTranscription } from "#lib/api/media.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { Transcript } from "#lib/media/transcript.svelte.js";
import { computeWaveform, flatWaveform } from "#lib/media/waveform.js"; import { computeWaveform, flatWaveform } from "#lib/media/waveform.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
chatId: number; chatId: number;
length?: number | null;
messageId: number; messageId: number;
ondownload?: () => void;
own: boolean; own: boolean;
transcript?: string | null; transcript?: string | null;
url: string; url: string | null;
waiting?: boolean;
} }
let { url, own, transcript = null, chatId, messageId }: Props = $props(); let {
url,
own,
transcript = null,
chatId,
messageId,
length = null,
ondownload,
waiting = false,
}: Props = $props();
let fetched = $state<string | null>(null); const stt = new Transcript(
let pending = $state(false); () => chatId,
const text = $derived(fetched ?? transcript); () => messageId,
() => transcript
let showTranscript = $state(false); );
async function onTranscribe() {
if (text) {
showTranscript = !showTranscript;
return;
}
if (pending) {
return;
}
pending = true;
try {
const result = await requestTranscription(chatId, messageId);
if (result) {
fetched = result;
showTranscript = true;
} else {
toasts.error(t("media.transcribeFailed"));
}
} catch {
toasts.error(t("media.transcribeFailed"));
} finally {
pending = false;
}
}
let element = $state<HTMLAudioElement>(); let element = $state<HTMLAudioElement>();
let peaks = $state<number[]>(flatWaveform()); let peaks = $state<number[]>(flatWaveform());
let currentTime = $state(0); let currentTime = $state(0);
@@ -54,11 +41,20 @@
let paused = $state(true); let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0); const progress = $derived(duration > 0 ? currentTime / duration : 0);
const toggleLabel = $derived.by(() => {
if (!url) {
return t("media.downloadToArchive");
}
return paused ? t("media.playVoice") : t("ui.pause");
});
const elapsed = $derived( const elapsed = $derived(
paused && currentTime === 0 ? duration : currentTime paused && currentTime === 0 ? duration || (length ?? 0) : currentTime
); );
$effect(() => { $effect(() => {
if (!url) {
return;
}
let active = true; let active = true;
computeWaveform(url) computeWaveform(url)
.then((result) => { .then((result) => {
@@ -73,6 +69,10 @@
}); });
function toggle() { function toggle() {
if (!url) {
ondownload?.();
return;
}
if (!element) { if (!element) {
return; return;
} }
@@ -97,16 +97,20 @@
<div class="VoiceWrap" class:own> <div class="VoiceWrap" class:own>
<div class="Voice"> <div class="Voice">
<button <button
aria-label={paused ? t("media.playVoice") : t("ui.pause")} aria-label={toggleLabel}
class="toggle" class="toggle"
onclick={toggle} onclick={toggle}
type="button" type="button"
> >
{#if !url}
<Icon name={waiting ? "timer" : "download"} size="1.5rem" />
{:else}
<Icon <Icon
class={paused ? "nudge" : ""} class={paused ? "nudge" : ""}
name={paused ? "play" : "pause"} name={paused ? "play" : "pause"}
size="1.5rem" size="1.5rem"
/> />
{/if}
</button> </button>
<div class="body"> <div class="body">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -122,21 +126,22 @@
<div class="time">{formatDuration(elapsed)}</div> <div class="time">{formatDuration(elapsed)}</div>
</div> </div>
<button <button
aria-label={showTranscript && text aria-label={stt.open
? t("media.hideTranscription") ? t("media.hideTranscription")
: t("media.showTranscription")} : t("media.showTranscription")}
class="transcribe" class="transcribe"
disabled={pending} disabled={stt.pending}
onclick={onTranscribe} onclick={() => stt.toggle()}
type="button" type="button"
class:active={showTranscript && Boolean(text)} class:active={stt.open}
> >
{#if pending} {#if stt.pending}
<Spinner color={own ? "white" : "gray"} size="1rem" /> <Spinner color={own ? "white" : "gray"} size="1rem" />
{:else} {:else}
<Icon name="transcribe" size="1.125rem" /> <Icon name="transcribe" size="1.125rem" />
{/if} {/if}
</button> </button>
{#if url}
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
<audio <audio
onended={() => element && releasePlayback(element)} onended={() => element && releasePlayback(element)}
@@ -148,9 +153,10 @@
bind:duration bind:duration
bind:paused bind:paused
></audio> ></audio>
{/if}
</div> </div>
{#if text && showTranscript} {#if stt.open}
<div class="transcript">{text}</div> <div class="transcript">{stt.text}</div>
{/if} {/if}
</div> </div>
@@ -131,6 +131,7 @@
kind: item.kind, kind: item.kind,
mediaId: item.id, mediaId: item.id,
state: "not-downloaded", state: "not-downloaded",
transcript: null,
}; };
loadMediaItem({ loadMediaItem({
downloaded: item.downloaded, downloaded: item.downloaded,
+16
View File
@@ -35,10 +35,26 @@ export const media = {
en: "Show transcription", en: "Show transcription",
ru: "Показать расшифровку", ru: "Показать расшифровку",
}, },
"media.transcribeEmpty": {
en: "No speech in the recording",
ru: "В записи не слышно речи",
},
"media.transcribeFailed": { "media.transcribeFailed": {
en: "Could not transcribe", en: "Could not transcribe",
ru: "Не удалось расшифровать", ru: "Не удалось расшифровать",
}, },
"media.transcribeNoPremium": {
en: "Transcription needs Telegram Premium",
ru: "Для расшифровки нужен Telegram Premium",
},
"media.transcribePending": {
en: "Telegram is still transcribing, try again shortly",
ru: "Telegram ещё расшифровывает, нажмите чуть позже",
},
"media.transcribeTooLong": {
en: "Too long for Telegram to transcribe",
ru: "Слишком длинное для расшифровки в Telegram",
},
"media.transcription": { en: "Transcription", ru: "Расшифровка" }, "media.transcription": { en: "Transcription", ru: "Расшифровка" },
"media.unit.B": { en: "B", ru: "Б" }, "media.unit.B": { en: "B", ru: "Б" },
"media.unit.GB": { en: "GB", ru: "ГБ" }, "media.unit.GB": { en: "GB", ru: "ГБ" },
@@ -0,0 +1,65 @@
import { requestTranscription } from "#lib/api/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
const REFUSALS: Record<string, MessageKey> = {
empty: "media.transcribeEmpty",
no_premium: "media.transcribeNoPremium",
pending: "media.transcribePending",
too_long: "media.transcribeTooLong",
};
export class Transcript {
readonly #chatId: () => number;
readonly #messageId: () => number;
readonly #initial: () => string | null;
fetched = $state<string | null>(null);
pending = $state(false);
shown = $state(false);
constructor(
chatId: () => number,
messageId: () => number,
initial: () => string | null
) {
this.#chatId = chatId;
this.#messageId = messageId;
this.#initial = initial;
}
get text(): string | null {
return this.fetched ?? this.#initial();
}
get open(): boolean {
return this.shown && Boolean(this.text);
}
async toggle(): Promise<void> {
if (this.text) {
this.shown = !this.shown;
return;
}
if (this.pending) {
return;
}
this.pending = true;
try {
const outcome = await requestTranscription(
this.#chatId(),
this.#messageId()
);
if ("text" in outcome) {
this.fetched = outcome.text;
this.shown = true;
} else {
toasts.error(t(REFUSALS[outcome.error] ?? "media.transcribeFailed"));
}
} catch {
toasts.error(t("media.transcribeFailed"));
} finally {
this.pending = false;
}
}
}