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:
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")
|
||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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,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}[/]")
|
||||||
@@ -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})[/]"
|
|
||||||
)
|
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user