Skip to content

feat: Indonesian locale, Telegram Scan all, YouTube browser, and offline Transcript - #291

Open
harezadmm wants to merge 5 commits into
tonhowtf:mainfrom
harezadmm:feat/id-locale-scan-youtube-transcript
Open

feat: Indonesian locale, Telegram Scan all, YouTube browser, and offline Transcript#291
harezadmm wants to merge 5 commits into
tonhowtf:mainfrom
harezadmm:feat/id-locale-scan-youtube-transcript

Conversation

@harezadmm

@harezadmm harezadmm commented Aug 16, 2026

Copy link
Copy Markdown

Summary

Four related additions bundled together because the i18n changes underneath are shared. Split into four Conventional Commits for reviewability:

  • feat(i18n) — Adds Indonesian (id.json) as a first-class locale, exposes it in the Settings + Onboarding language selectors alongside the previously missing zh-TW and ru options, and adds the shared translation keys used by the follow-up features. Also fixes the raw ytdlp_missing_banner.text banner (the keys were never in any locale).
  • feat(telegram) — Adds a Scan all action next to Download all that exhaustively lists every media type (photo/video/document/audio) in parallel via Promise.all, caches the result per chat, and serves subsequent tab switches from the cache instead of re-querying. Media list is not touched during the scan so large channels no longer jank the UI. Also replaces the last hardcoded Portuguese strings on the Telegram surface (Gerenciar, Sync pendente, Sincronizando, "há Nh"/"há Nd" ago labels) with $t() calls.
  • feat(youtube) — New /youtube route accepting a channel handle (/@x, /channel/, /c/, /user/), playlist or single-video URL. For channels it shows the familiar tab layout (Home / Videos / Shorts / Live / Playlists), fetches on demand, caches per (url, limit). Each entry renders with its YouTube thumbnail (i.ytimg.com/vi/<id>/mqdefault.jpg, loading=\"lazy\"). Initial fetch is capped at 200 entries with a Load full channel button; this required a small backend change — playlist_entries now accepts an optional limit: Option<u32> that is forwarded as --playlist-end N to yt-dlp. When unset the behaviour is identical to before.
  • feat(transcript) — New /transcript route that transcribes local video/audio files to plain text via faster-whisper (Python) with an in-page converter that turns the transcript into Markdown. Backend spawns Python running an embedded script and streams line-delimited JSON stages back over the Tauri event bus (transcript:{jobId}) so the UI can render loading_model / transcribing / progress / done without polling. Frontend gates the page with a warning banner + install command when faster-whisper isn't importable, so first-run users know exactly what to install.

Files changed

  • 4 new files: src/lib/i18n/id.json, src/routes/youtube/+page.svelte, src/routes/transcript/+page.svelte, src-tauri/src/commands/transcript.rs
  • Modified: telegram plugin surface, 11 locale JSONs, keys.ts, nav-config.ts, language selectors, playlist_entries command
  • No new Rust dependencies; which was already in Cargo.toml

External runtime deps

  • faster-whisper (Python) for the transcript feature — user installs with pip install --user faster-whisper. The Transcript page detects the absence and shows install instructions, so this is opt-in per user.
  • Everything else uses existing OmniGet dependencies (yt-dlp, ffmpeg).

Test plan

  • pnpm check — passes with 0 errors (baseline warnings unchanged, 107)
  • pnpm generate:i18n-keys — reports all 10 non-en locales in sync with en.json
  • cargo fmt --all — clean
  • cargo clippy --workspace --all-targets — expected clean; CI will confirm
  • Manual: change language to Bahasa Indonesia in Settings, verify sidebar + Telegram + YouTube + Transcript labels render in ID
  • Manual: open a large Telegram group, click Scan all, verify parallel type counts + cache on tab switch
  • Manual: paste a YouTube channel URL, verify thumbnails render and tab switch is instant on revisit
  • Manual: with faster-whisper installed, transcribe a 30s clip; without it, verify the warning banner shows

Known limitations / follow-ups

  • Transcript feature runs on CPU only (int8 quantized). A future PR could add optional CUDA support for users with GPUs.
  • YouTube page shows entry title from playlist_entries (yt-dlp) — video duration and view count are not returned by --flat-playlist so they're not shown yet.
  • The scan-all cache is in-memory only; leaving the page and coming back re-scans. Persisting to SQLite could be a follow-up.

🤖 Generated with Claude Code

…ure keys

Adds a full Indonesian (id.json) translation baseline sourced from en.json
with UI-critical strings translated to Bahasa Indonesia. Registers the
locale in the loaders list and adds it to the language selectors on
Settings and Onboarding. The onboarding selector was also missing zh-TW
and ru; both are added here for parity with Settings.

Also adds shared translation keys used by follow-up commits:
- ytdlp_missing_banner (fixes raw-key banner shown at app top)
- telegram: manage / manage_accounts / manage_channel / sync_* / scan_*
- youtube: full section for the new YouTube page (all + tabs + actions)
- transcript: full section for the new Transcript page

Broadcasts keys across all 11 locales so pnpm generate:i18n-keys reports
"all locales in sync with en.json".
Adds a "Scan all" action next to "Download all" that exhaustively lists
every media type (photo, video, document, audio) in the currently open
chat. The four types are paginated in parallel via Promise.all, and
mediaItems is left untouched during the scan (results are collected in
a Map by message_id and assigned once at the end) so the media-list
does not re-render on every page — avoiding UI jank on large channels.

After the scan completes the result is cached per chat. Subsequent tab
switches (All/Photos/Videos/Files/Audio) filter locally against the
cache instead of re-querying the plugin. The cache is invalidated when
the user picks a different chat or returns to the chat list.

Progress is shown as an indeterminate bar plus per-type chips
(Photos · N · Videos · N · Files · N · Audio · N) and a total counter,
with a Cancel button that stops the in-flight pagination cleanly.

Also replaces the remaining hardcoded Portuguese strings on the
Telegram surface (Gerenciar buttons/aria-labels, Sync pendente,
Sincronizando, "há Nh"/"há Nd" ago labels, tooltips) with $t() calls
so an English user actually sees English throughout the plugin.
New /youtube route that accepts a channel handle (/@x, /channel/, /c/,
/user/), playlist or single-video URL. For channels the page shows the
familiar YouTube tab layout (Home / Videos / Shorts / Live / Playlists)
and constructs the corresponding sub-URL when a tab is opened. Results
are cached in memory keyed by (url, limit) so switching between tabs is
instant on the second visit.

Each entry renders with its YouTube thumbnail derived from the video ID
(https://i.ytimg.com/vi/<id>/mqdefault.jpg) with loading="lazy" so a
1000-item list does not fire a thousand simultaneous requests.

To keep the initial fetch fast on large channels the frontend requests
only the first 200 entries by default. A "Load full channel" button
appears when the partial cap is hit and re-issues the same call with no
limit. This required extending the playlist_entries Tauri command with
an optional `limit: Option<u32>` parameter that passes `--playlist-end N`
through to yt-dlp; when unset the behaviour is identical to before.

Multi-select is available with Select all / Clear / Invert. Downloads
are queued through the existing download_from_url command one URL at a
time so users can watch progress in the Downloads page.

Also registers the YouTube entry in nav-config so it shows up under
the Plugins group in the sidebar.
New /transcript route that transcribes local video/audio files to plain
text via faster-whisper (Python), with an in-page converter that turns
the resulting transcript into a Markdown document.

Backend (Tauri commands, all in src/commands/transcript.rs):
- transcribe_video(video_path, output_path, model_size, language,
  job_id): spawns Python running an embedded faster-whisper script,
  streams line-delimited JSON stages back over the Tauri event bus
  ("transcript:{jobId}") so the UI can render loading_model /
  transcribing / progress {percent, current, total} / done / error
  without polling. Emits the final transcript path and detected
  language once complete.
- check_whisper_installed(): probes whether Python can import
  faster_whisper so the UI can show an install hint instead of a
  cryptic runtime error.
- write_text_file(path, content): small helper used by the Markdown
  side of the page to persist the generated .md.

The Python script is embedded as a Rust constant and dropped into the
OS temp dir on first use to avoid a separate resource file.

Frontend:
- File picker for the source media (mp4/mkv/mov/webm/mp3/wav/…).
- Model size selector: tiny/base/small (recommended)/medium/large-v3
  with rough download-size hints.
- Language selector including Bahasa Indonesia and Auto-detect.
- Live progress bar and per-segment percentage/time counter driven
  by the event stream.
- Result textarea plus Copy and "Send to Markdown converter" action.
- Markdown converter section that adds an H1 title and groups lines
  into evenly-sized paragraphs, with Copy and Save as .md actions.

Registers three new commands in lib.rs, adds a Transcript entry to
nav-config under the Plugins group, and gates the whole page with a
warning banner + install command when faster-whisper is missing so a
first-run user knows exactly what to install.

@tonhowtf tonhowtf left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this — the Indonesian locale in particular is real work and I want it. But I can't take the branch as one unit, and the reason isn't the size: one of the four features ships a command that lets any code in the webview write to any path on disk. Details below, ordered by how much they matter.

I did merge and build it before writing this, so none of the following is guessed: cargo check --workspace finishes clean, pnpm check reports 0 errors / 107 warnings — the same baseline as main. It compiles. That isn't the problem.


1. write_text_file is an unrestricted arbitrary file write — blocking

#[tauri::command]
pub async fn write_text_file(path: String, content: String) -> Result<(), String> {
    tokio::fs::write(&path, content).await
}

It's registered globally in generate_handler! (lib.rs:895), so it isn't scoped to the Transcript page — it's reachable from every surface in the app, including the plugin frontends, which run in the same webview. There is no path validation, no directory confinement, no extension check. invoke("write_text_file", { path: "/Users/me/.zshrc", content: "..." }) succeeds.

OmniGet renders remote-derived content (titles, descriptions, thumbnails, plugin-supplied strings), so "only our own code calls it" isn't a boundary I want to lean on for a primitive this strong. The transcript is already written to disk by the Python side, and Tauri's dialog plugin — already a dependency — gives you a save-path the user picked. Please drop the command and route the Markdown export through that.

2. The transcription duplicates whisper_generate, and adds a Python runtime to do it

commands/ai.rs:213 already exposes whisper_generate, backed by omniget_core::core::ai::transcribe. The new command is a second, unrelated transcription engine, so the app would ship two answers to one question with no shared config, history, or error handling — note the existing one calls ai::history_add("transcript", ...), which this path doesn't participate in.

The bigger cost is the dependency. OmniGet manages its binaries (yt-dlp, ffmpeg, deno, aria2c) — it downloads them, versions them, repairs them, and reports their status in Settings. faster-whisper fits none of that: it needs a Python 3.10+ interpreter on PATH, a pip install --user, and a model download on first run. "The page shows a banner when it's missing" makes the failure legible; it doesn't make it opt-in, because the nav item is always visible and there's no way to act on the banner from inside the app.

If local CPU transcription is worth having — and I think it is, it's the honest answer for users without an API key — the shape should be a whisper.cpp/whisper-rs binary in the managed-dependency table, feeding the existing transcription command as a local provider. That's a design conversation worth having in an issue first, and it's a different PR.

3. The Python subprocess deadlocks on first run

.stdout(Stdio::piped())
.stderr(Stdio::piped())

stderr is piped and then never read. The loop drains stdout to EOF and only then calls child.wait(). Once the child writes more than the pipe buffer (64 KB on Linux) to stderr, it blocks in write() forever: it can't progress, so stdout never reaches EOF, so nothing ever drains stderr. Classic deadlock, and no timeout to break it.

This isn't a corner case — it's the first-run path. WhisperModel(model_size, ...) downloads the model through huggingface_hub, whose progress bars go to stderr, and ctranslate2 emits its warnings there too. A model download blows past 64 KB comfortably. Fix is either Stdio::null() for stderr, or draining it concurrently with tokio::join! (worth keeping — those warnings are exactly what you'd want in a bug report).

Two smaller things in the same function:

  • The script goes to a fixed, predictable path: std::env::temp_dir().join("omniget_transcribe.py"). On Linux that's a shared, world-writable /tmp, so another local user can pre-create that name as a symlink and have us write through it — or win the race between our write and the spawn and get their Python executed by our process. macOS is fine here (per-user TMPDIR), Linux is not. Use a randomized directory, or include_str! the script and pass it via stdin.
  • There's no cancellation. Navigating away leaves the child running, and a long transcription can't be stopped.

4. /youtube and /transcript are added to CORE_NAV_ITEMS as group: "plugins"

{ href: "/youtube", labelKey: "nav.youtube", icon: "plugin", group: "plugins", order: 60 },
{ href: "/transcript", labelKey: "nav.transcript", icon: "plugin", group: "plugins", order: 65 },

The plugins group means "appears when the plugin is installed", and entries in it carry a pluginId. These have none, so they render in the plugins section unconditionally, with the generic plugin icon, while not being plugins. That takes the core sidebar from 5 items to 7 — in the cycle where the whole point has been to cut, not add.

The /youtube page itself is decent work and I don't want to lose it, but a channel browser is arguably the omnibox's job when a channel URL is pasted, rather than a permanent seventh nav entry. Worth its own PR and its own conversation.


What I'd merge today

Split this and I'll take these two immediately:

  1. feat(i18n)id.json, the selector exposure for id/zh-TW/ru (those last two being missing is a straightforward bug), and the ytdlp_missing_banner.text keys. Clean, self-contained, obviously good.
  2. feat(telegram) — Scan all with the parallel per-type listing and per-chat cache, plus killing the last hardcoded Portuguese (Gerenciar, Sync pendente, Sincronizando, the há Nh/há Nd labels). Also good.

The playlist_entries(url, limit: Option<u32>) change is fine and backward-compatible — Option deserializes from a missing argument, and src/routes/+page.svelte:463 is the only other caller. Send it along with whichever half needs it.

These two need a different shape before I can take them: transcript (as #1#3 above) and the /youtube nav placement (#4).

To be clear about why I'm splitting rather than asking for fixes in place: with four features in one branch, the i18n work — which is ready — can't ship until the transcript security question is settled. Separating them is what gets your Indonesian locale into the next release instead of blocking it behind a design discussion.

@tonhowtf

Copy link
Copy Markdown
Owner

Thank you for this — four features, clean commit separation, and a PR description that actually explains the tradeoffs is more care than most contributions get. I want to get most of this in. But it needs to go in as separate PRs, and a couple of things need fixing first.

My review from 2026-08-17 still stands in full (the write_text_file scope, the whisper_generate duplication, the stderr deadlock, the /tmp script path, and the nav placement). The only commit since then is chore(release): 0.8.6, so I'll assume those weren't seen rather than dismissed. Below is what I found on a second pass, per feature.


1. Indonesian locale — needs the actual translation

The mechanics are right: id.json has all 3989 keys with none missing and none extra, it's registered in src/lib/i18n/index.ts, and it's in both selectors. I regenerated keys.ts from your en.json and it matches your file exactly — thank you for running the generator instead of hand-editing.

The problem is the values. 122 of 3989 strings (3.1%) are actually Indonesian. The other 3867 are byte-identical to en.json. And the 122 aren't spread across the app — they're the Telegram, Transcript and YouTube surfaces, i.e. the pages this PR adds. Everything else is English:

nav.home          → "Home"
downloads.title   → "Downloads"
league.live_title → "Live game"

For comparison, pt.json has 478 strings still matching English and ru.json has 223. A user who picks "Bahasa Indonesia" today gets a sidebar, a downloads page and a settings screen in English, which reads as a broken app rather than an untranslated one. I'd rather ship Indonesian late than ship it looking broken.

To make this mergeable: translate the core surfaces at minimum — nav.*, common.*, home.*, downloads.*, settings.*, omnibox.*, onboarding.*, errors.*. Plugin namespaces (league.*, study.*, courses.*) can lag; several existing locales do. Matching es.json's coverage (622 untranslated of 3989) would be comfortably enough.

Two pieces of this commit are ready today, and I'm taking them now rather than making them wait behind the rest — credited to you in the changelog:

  • zh-TW and ru were missing from the Settings language <select> even though both locales ship (SettingsAppearance.svelte:171-175, OnboardingWizard.svelte:153-157).
  • The ytdlp_missing_banner.* keys, which existed in no locale while src/routes/+layout.svelte:322-332 renders them. I hit this myself while testing an unrelated change — the banner literally displays ytdlp_missing_banner.text with a button reading ytdlp_missing_banner.open_settings. Good catch; users have been seeing that.

2. Telegram "Scan all" — closest to ready

I checked the thing I was most worried about: this needs no change in omniget-plugin-telegram. You call pluginInvoke("telegram", "telegram_list_media", …) with the same argument shape main already uses at src/routes/telegram/+page.svelte:607-613, and your offset = Math.min(...items.map(m => m.message_id)) matches the existing cursor convention in loadMoreMedia at :627-629. No "Unknown command" risk. The Portuguese cleanup is genuinely appreciated.

Four things before I take it:

  • hasMore is never restored on failure. scanAllMedia sets hasMore = false at the top, but finally only resets isScanning and scanCancelled. If a pluginInvoke throws mid-scan, "Load more" disappears until the user leaves the chat and comes back.
  • Four unbounded pagination loops in one Promise.all, no delay, no ceiling. On a channel with tens of thousands of items that is a lot of concurrent MTProto calls, and Telegram answers that with FLOOD_WAIT. Existing code deliberately fetches one page at a time. Either serialize the four types or cap with a "scanned N, continue?" prompt — I mostly want the failure mode to not be "account rate-limited".
  • filterFromScanCache compares chatId but never chatType, though the cache stores it. Harmless today; either compare it or drop the field.
  • .scan-chip uses font-size: 11px — use a token. The rest of your CSS correctly uses custom properties.

One next door that you didn't touch: TelegramAccountPanel.svelte:183 still has a hardcoded <h2>Contas Telegram</h2>, one line below an aria-label you did translate.

3. YouTube browser — good code, wrong place in the nav, broken CSS tokens

The fetching approach is exactly right and worth saying so: no new dependency, no embedded webview, no scraping. It goes through the existing playlist_entries command, and detectLinkKind hostname-allowlists youtube.com / m.youtube.com / youtu.be first. No cookie or token handling. playlist_entries(url, limit: Option<u32>)--playlist-end N is backward-compatible and the only other caller (src/routes/+page.svelte:463) omits the argument. That backend change I'd take as-is.

Blocking:

  • pnpm test fails. src/lib/nav-config.test.ts:5 asserts expect(CORE_NAV_ITEMS).toHaveLength(5); you add two entries.
  • Nav placement. group: "plugins" means "appears when that plugin is installed", and every other entry there carries a pluginId. These have none, so they render permanently in the Plugins section with the generic plugin icon while not being plugins. The home page already lists playlist entries with select-all/none (src/routes/+page.svelte:457-489), so the playlist and single-video paths largely duplicate it. The channel-tab browsing is genuinely new and worth having — let's settle where it lives in its own PR.
  • Three CSS variables that do not exist. var(--card) and var(--radius) are defined in zero of the 14 theme blocks in src/app.css (the project uses --border-radius; card surfaces are --surface / --button). With no fallback, .url-input-section (:498-499) and .entry-row (:673-674) render with no background and square corners in every theme, and color-mix(in oklab, var(--blue) 12%, var(--card)) at :684 is invalid, so the selected-row highlight drops entirely. var(--gold, #f59e0b) at :762 always resolves to that literal amber, which is wrong in the three eink-* themes.

Non-blocking: for a single-video link the entry title is the raw URL (:139) — a prefetch_media_info call would give the real one. And the i.ytimg.com thumbnails are the first outbound remote images the app UI loads; they work, but they bypass the user's configured proxy.

4. Offline Transcript — I can't take this one

This is where the answer is no rather than not-yet, and I want to explain rather than just decline. My 2026-08-17 points on write_text_file (an unrestricted arbitrary-file-write command registered globally in lib.rs:894), the duplication of commands/ai.rs:213 whisper_generate, the piped-and-never-read stderr deadlock (transcript.rs:134-135, which fires on the first-run model download), and the fixed /tmp/omniget_transcribe.py path (transcript.rs:117) all still apply unchanged.

Adding to it:

  • Wrong process helper. transcript.rs:127 uses tokio::process::Command directly. omniget_core::core::process::command() exists and sets CREATE_NO_WINDOW on Windows (without it a console window appears every run), extends PATH, and — the ironic part — sets PYTHONIOENCODING=utf-8 and PYTHONUTF8=1, which is exactly what a Python subprocess emitting JSON needs so non-ASCII transcripts survive on Windows.
  • transcript.save_markdown does not exist. Used at src/routes/transcript/+page.svelte:203, present in none of the 11 locales. The "Save as .md" dialog title shows the literal key. svelte-check won't flag it because t isn't typed against the key list.
  • transcript.whisper_missing_desc reads "Install it in PowerShell, then reopen this page:" — OmniGet ships on macOS and Linux too.
  • No cancellation. job_id only names the event channel; there is no handle to kill the child, so navigating away leaves a large-v3 transcription running.
  • Code comments at :30, :86, :169 — the project asks for none except ones documenting why.
  • Same var(--card) / var(--radius) / var(--gold) problem as the YouTube page (:442-445, :472-473), plus hardcoded font sizes.

On licensing, for the record: nothing is vendored, and faster-whisper, CTranslate2 and the Whisper weights are all MIT, so there is no conflict with GPL-3. The objection is that this makes a Python 3.10+ interpreter, a pip install --user, and a 40 MB–1.5 GB Hugging Face download at first run into de-facto app dependencies, none of which the managed-dependency system in Settings can see, install or repair — and the app deliberately removed its AI/transcription surface earlier this year.

If you want to pursue local transcription — and I think it is the right answer for users without an API key — the shape I'd say yes to is a whisper.cpp / whisper-rs binary in the managed dependency table alongside yt-dlp and ffmpeg, feeding the existing transcription command as a local provider. Please open an issue and let's agree the shape first.


Housekeeping

Please drop ac6efb41 chore(release): 0.8.6. Version bumps are done by scripts/bump-version.js as part of the release and the tag has to sit on that commit. Also, CI has never run on this branch — gh pr checks 291 reports no checks at all — so the pnpm check / cargo clippy boxes in the description are unverified; pnpm test I can tell you fails today on nav-config.test.ts.

What I'd like

Four PRs instead of one:

  1. fix(i18n) — already handled: I'm landing the zh-TW/ru selector options and the ytdlp_missing_banner keys in the release going out today, credited to you. Nothing for you to do.
  2. feat(telegram): Scan all + the Portuguese cleanup, with the four fixes above. Ready right after.
  3. feat(i18n): Indonesian locale, once the core surfaces are actually translated.
  4. feat(youtube), after we agree where it lives, with the CSS tokens fixed and the nav test updated.

I'm splitting rather than asking for fixes in place because right now a bug users are actively hitting can't ship until a security question about a different feature is resolved. Separating them is what gets your work in sooner, not later. Happy to review each as it lands.

tonhowtf added a commit that referenced this pull request Aug 28, 2026
The yt-dlp missing banner rendered its own key names — ytdlp_missing_banner.text
with a button reading ytdlp_missing_banner.open_settings — because the keys were
in no locale at all. zh-TW shipped as a locale but appeared in neither language
selector, and ru was missing from the onboarding one.

Reported by @harezadmm in #291.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants