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
+523 -159

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,
)
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 utils.files import resolve_mime
from utils.read import (
@@ -87,6 +88,9 @@ Typical flows:
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
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`,
`new_message`, `message_deleted`, `message_edited`, `peer_online`; params in
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
async def get_callbacks(
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 pydantic import BaseModel
from api.transcribe import TranscribeResult, request_transcript
from utils.jobs import enqueue
from utils.read.models import DEFAULT_LIMIT, Page
@@ -35,6 +36,7 @@ class TranscribeRequest(BaseModel):
account_id: int
chat_id: int
message_id: int
wait: float = 0
class SyncDialogsRequest(BaseModel):
@@ -105,17 +107,13 @@ async def enqueue_fetch_media(
return EnqueueResponse(job_id=job_id)
@router.post("/media/transcribe", status_code=201)
async def enqueue_transcribe(
@router.post("/media/transcribe")
async def transcribe(
pool: FromDishka[asyncpg.Pool], body: TranscribeRequest
) -> EnqueueResponse:
job_id = await enqueue(
pool,
body.account_id,
"transcribe",
{"chat_id": body.chat_id, "message_id": body.message_id},
) -> TranscribeResult:
return await request_transcript(
pool, body.account_id, body.chat_id, body.message_id, body.wait
)
return EnqueueResponse(job_id=job_id)
@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.files import content_disposition, media_file_name, resolve_mime
from utils.read.media import (
find_by_content,
get_media,
get_media_version,
get_media_versions,
get_message_media,
)
from utils.read.models import MediaVersionView, MediaView
from utils.read.models import MediaPlaceView, MediaVersionView, MediaView
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
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]:
@@ -81,6 +83,15 @@ async def message_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}")
async def serve_media(
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 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
@@ -20,5 +20,5 @@ async def safe_transcribe(
"transcribe",
{"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}[/]")
+36 -15
View File
@@ -1,4 +1,5 @@
from pyrogram import Client, raw
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from pyrogram.types import Message
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 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:
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
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(
client: Client, ctx: CaptureContext, chat_id: int, message_id: int
) -> None:
peer = await client.resolve_peer(chat_id)
if peer is None:
return
result = await client.invoke(
raw.functions.messages.TranscribeAudio(peer=peer, msg_id=message_id)
)
result = await _ask(client, chat_id, message_id)
if result.pending:
logger.info(
f"[yellow]STT pending {chat_id}/{message_id} "
f"(trial_remains={result.trial_remains_num})[/]"
)
return
if result.text:
await repository.set_extracted_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})[/]"
)
if not result.text:
msg = "empty"
raise TranscribeError(msg)
await repository.set_extracted_text(
ctx.pool, ctx.account_id, chat_id, message_id, result.text
)
+14 -1
View File
@@ -3,7 +3,7 @@ import json
import asyncpg
from utils.files import media_file_name
from utils.read.models import MediaVersionView, MediaView
from utils.read.models import MediaPlaceView, MediaVersionView, MediaView
MEDIA_COLS = (
"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"
_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")
@@ -100,6 +106,13 @@ async def get_message_media(
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(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[MediaVersionView]:
+8
View File
@@ -275,6 +275,14 @@ class MediaView(BaseModel):
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):
id: int
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(
chatId: number,
messageId: number
): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/media/transcribe", {
messageId: number,
wait: number
): Promise<TranscribeResult> {
return request<TranscribeResult>("/media/transcribe", {
body: {
account_id: accounts.selectedId,
chat_id: chatId,
message_id: messageId,
wait,
},
method: "POST",
});
+45 -23
View File
@@ -3,8 +3,8 @@ 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;
const TRANSCRIBE_WAIT = 25;
const TRANSCRIBE_TRIES = 3;
export type InlineMedia =
| {
@@ -15,7 +15,12 @@ export type InlineMedia =
url: string;
transcript: string | null;
}
| { state: "not-downloaded"; mediaId: number; kind: string }
| {
state: "not-downloaded";
mediaId: number;
kind: string;
transcript: string | null;
}
| { state: "missing" };
export interface ViewerItem {
@@ -173,7 +178,12 @@ async function resolve(
return { state: "missing" };
}
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);
if (blob.state === "ready") {
@@ -187,7 +197,12 @@ async function resolve(
};
}
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" };
}
@@ -200,7 +215,12 @@ async function resolveById(media: MediaRef): Promise<InlineMedia> {
return { state: "missing" };
}
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);
if (blob.state === "ready") {
@@ -214,7 +234,12 @@ async function resolveById(media: MediaRef): Promise<InlineMedia> {
};
}
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" };
}
@@ -257,32 +282,29 @@ function patchTranscript(
return;
}
const cached = ready.get(cacheKey(account, chatId, messageId));
if (cached?.state === "ready") {
if (cached && cached.state !== "missing") {
cached.transcript = text;
}
}
export type TranscriptOutcome = { text: string } | { error: string };
export async function requestTranscription(
chatId: number,
messageId: number
): Promise<string | null> {
await transcribeMedia(chatId, messageId);
): Promise<TranscriptOutcome> {
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;
// biome-ignore lint/performance/noAwaitInLoops: each try waits on the server
const result = await transcribeMedia(chatId, messageId, TRANSCRIBE_WAIT);
if (result.status === "done" && result.text) {
patchTranscript(chatId, messageId, result.text);
return { text: result.text };
}
if (result.status === "failed") {
return { error: result.error ?? "failed" };
}
}
return null;
return { error: "pending" };
}
export function loadInlineMedia(
@@ -167,7 +167,12 @@
url={ready.url}
/>
{: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"}
<AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage}
@@ -210,6 +215,27 @@
<Icon name="document" size="1.25rem" />
<span>{label}</span>
</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"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={waiting ? "timer" : "download"} size="1.5rem" />
@@ -1,18 +1,37 @@
<script lang="ts">
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { POSTER_TIME, poster } from "#lib/media/poster.js";
import { Transcript } from "#lib/media/transcript.svelte.js";
interface Props {
chatId: number;
length?: number | null;
messageId: number;
ondownload?: () => void;
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 CIRCUMFERENCE = 2 * Math.PI * RADIUS;
@@ -24,8 +43,21 @@
const progress = $derived(duration > 0 ? currentTime / duration : 0);
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() {
if (!url) {
ondownload?.();
return;
}
if (!element) {
return;
}
@@ -39,60 +71,69 @@
<div class="RoundVideoWrap">
<button
aria-label={paused ? t("media.playRound") : t("ui.pause")}
aria-label={toggleLabel}
class="RoundVideo"
onclick={toggle}
type="button"
>
<!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
<video
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
playsinline
preload="auto"
src={url}
bind:this={element}
bind:currentTime
bind:duration
bind:paused
use:poster
></video>
<svg aria-hidden="true" class="RoundVideoRing" viewBox="0 0 200 200">
<circle
class="RoundVideoProgress"
cx="100"
cy="100"
r={RADIUS}
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={CIRCUMFERENCE * (1 - progress)}
/>
</svg>
{#if paused}
<span class="play"><Icon name="large-play" size="2.75rem" /></span>
{#if url}
<!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
<video
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
playsinline
preload="auto"
src={url}
bind:this={element}
bind:currentTime
bind:duration
bind:paused
use:poster
></video>
<svg aria-hidden="true" class="RoundVideoRing" viewBox="0 0 200 200">
<circle
class="RoundVideoProgress"
cx="100"
cy="100"
r={RADIUS}
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={CIRCUMFERENCE * (1 - progress)}
/>
</svg>
{#if paused}
<span class="play"><Icon name="large-play" size="2.75rem" /></span>
{/if}
{:else}
<span class="blank"></span>
<span class="play">
<Icon name={waiting ? "timer" : "download"} size="2.25rem" />
</span>
{/if}
<span class="badge">
<Icon name="microphone" size="0.875rem" />
{formatDuration(
paused && currentTime <= POSTER_TIME ? duration : remaining
)}
{formatDuration(shownTime)}
</span>
</button>
{#if transcript}
<button
class="transcribe"
onclick={() => {
showTranscript = !showTranscript;
}}
type="button"
class:active={showTranscript}
>
<button
aria-label={stt.open
? t("media.hideTranscription")
: t("media.showTranscription")}
class="transcribe"
disabled={stt.pending}
onclick={() => stt.toggle()}
type="button"
class:active={stt.open}
>
{#if stt.pending}
<Spinner color="gray" size="1rem" />
{:else}
<Icon name="transcribe" size="1rem" />
{t("media.transcription")}
</button>
{#if showTranscript}
<div class="transcript">{transcript}</div>
{/if}
{t("media.transcription")}
</button>
{#if stt.open}
<div class="transcript">{stt.text}</div>
{/if}
</div>
@@ -123,6 +164,10 @@
&.active {
color: var(--color-primary);
}
&:disabled {
cursor: default;
}
}
.transcript {
@@ -150,6 +195,16 @@
-webkit-tap-highlight-color: transparent;
}
.blank {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: var(--color-default-shadow);
}
video {
display: block;
@@ -1,52 +1,39 @@
<script lang="ts">
import { requestTranscription } from "#lib/api/media.js";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.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 { toasts } from "#lib/stores/toasts.svelte.js";
interface Props {
chatId: number;
length?: number | null;
messageId: number;
ondownload?: () => void;
own: boolean;
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);
let pending = $state(false);
const text = $derived(fetched ?? 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;
}
}
const stt = new Transcript(
() => chatId,
() => messageId,
() => transcript
);
let element = $state<HTMLAudioElement>();
let peaks = $state<number[]>(flatWaveform());
let currentTime = $state(0);
@@ -54,11 +41,20 @@
let paused = $state(true);
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(
paused && currentTime === 0 ? duration : currentTime
paused && currentTime === 0 ? duration || (length ?? 0) : currentTime
);
$effect(() => {
if (!url) {
return;
}
let active = true;
computeWaveform(url)
.then((result) => {
@@ -73,6 +69,10 @@
});
function toggle() {
if (!url) {
ondownload?.();
return;
}
if (!element) {
return;
}
@@ -97,16 +97,20 @@
<div class="VoiceWrap" class:own>
<div class="Voice">
<button
aria-label={paused ? t("media.playVoice") : t("ui.pause")}
aria-label={toggleLabel}
class="toggle"
onclick={toggle}
type="button"
>
<Icon
class={paused ? "nudge" : ""}
name={paused ? "play" : "pause"}
size="1.5rem"
/>
{#if !url}
<Icon name={waiting ? "timer" : "download"} size="1.5rem" />
{:else}
<Icon
class={paused ? "nudge" : ""}
name={paused ? "play" : "pause"}
size="1.5rem"
/>
{/if}
</button>
<div class="body">
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -122,35 +126,37 @@
<div class="time">{formatDuration(elapsed)}</div>
</div>
<button
aria-label={showTranscript && text
aria-label={stt.open
? t("media.hideTranscription")
: t("media.showTranscription")}
class="transcribe"
disabled={pending}
onclick={onTranscribe}
disabled={stt.pending}
onclick={() => stt.toggle()}
type="button"
class:active={showTranscript && Boolean(text)}
class:active={stt.open}
>
{#if pending}
{#if stt.pending}
<Spinner color={own ? "white" : "gray"} size="1rem" />
{:else}
<Icon name="transcribe" size="1.125rem" />
{/if}
</button>
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
<audio
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
preload="metadata"
src={url}
bind:this={element}
bind:currentTime
bind:duration
bind:paused
></audio>
{#if url}
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
<audio
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
preload="metadata"
src={url}
bind:this={element}
bind:currentTime
bind:duration
bind:paused
></audio>
{/if}
</div>
{#if text && showTranscript}
<div class="transcript">{text}</div>
{#if stt.open}
<div class="transcript">{stt.text}</div>
{/if}
</div>
@@ -131,6 +131,7 @@
kind: item.kind,
mediaId: item.id,
state: "not-downloaded",
transcript: null,
};
loadMediaItem({
downloaded: item.downloaded,
+16
View File
@@ -35,10 +35,26 @@ export const media = {
en: "Show transcription",
ru: "Показать расшифровку",
},
"media.transcribeEmpty": {
en: "No speech in the recording",
ru: "В записи не слышно речи",
},
"media.transcribeFailed": {
en: "Could not transcribe",
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.unit.B": { en: "B", 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;
}
}
}