# subocr service

<p class="callout info"><strong>Service Overview:</strong> <strong>SubOCR</strong> (<a href="https://subocr.drheck.dev/">https://subocr.drheck.dev/</a>) is an end-to-end Japanese burned-in video subtitle (<em>telop</em> / テロップ) extraction, <ruby>漢字<rt>かんじ</rt></ruby> <ruby>振<rt>ふ</rt></ruby>り<ruby>仮名<rt>がな</rt></ruby> annotation, offline <strong>JMdict + JLPT (N5–N1)</strong> morphological dictionary lookup, LLM study-note refinement, and interactive video study platform running on host <code>ul</code>, accelerated by the <strong>AMD Radeon AI PRO R9700 (<code>gfx1201</code>, RDNA 4, 32 GB VRAM)</strong> GPU.</p>

## 1. Architecture & Network Topology

SubOCR is deployed as a containerized FastAPI + ONNX Runtime (ROCm 7.2 MIGraphX) service bound to a dedicated IPv6 ULA address on `eth77`, reverse-proxied through Nginx with Authentik ForwardAuth single sign-on, and paired with the local `ollama-rocm` (`qwen2.5:32b`) LLM engine on the same GPU.

$$\text{Video Frame } I(t) \longrightarrow \text{PP-OCRv5 (MIGraphX FP32)} \longrightarrow \text{Qwen2.5-32B (ROCm)} \longrightarrow \text{UniDic + JMdict/JLPT Study View}$$

* **Public URL**: [https://subocr.drheck.dev/](https://subocr.drheck.dev/)
* **Dedicated ULA IPv6 Address**: `fd77:7777:5010:0:1337:f1ac:d00d:0c70` (`eth77`, port `8000`)
* **Host Directory**: `/data/docker/subocr/`
* **Databases**:
  * **Application Database**: `/data/docker/subocr/data/subocr.db` (SQLite 3 in WAL mode; stores jobs, timed cues, readings, translations, and custom edits)
  * **Offline Dictionary & JLPT Database**: `/data/docker/subocr/data/jmdict_jlpt.db` (SQLite 3, 63 MB; 218,856 EDRDG JMdict entries, 499,325 indexed kanji/kana forms, and 7,972 JLPT N5–N1 vocabulary entries)
* **Export & Sidecar Storage**:
  * Per-job exports: `/data/docker/subocr/data/jobs/<job_id>/`
  * Automatic sidecar writes next to local videos: `/data/language/Japanese/Video/...` (mounted read-write at `/videos` inside the container)
* **Authentication**: Authentik ForwardAuth (`subocr` ProxyProvider, 30-day token validity) via `/etc/nginx/sites-available/subocr`
* **DNS Resolution**:
  * Local Unbound (`danknasty`, `@10.0.0.1`): `A 10.0.0.100`, `AAAA fd77:7777:5010::1337:f1ac:d00d:aaaa`
  * Public Authoritative DNS (deSEC): `AAAA fd77:7777:5010::1337:f1ac:d00d:aaaa`

---

## 2. Two-Stage GPU Processing & Morphological Dictionary Pipeline

When a job is submitted from [https://subocr.drheck.dev/new](https://subocr.drheck.dev/new) (either by selecting a local video under `/data/language/Japanese/Video`, uploading a video file, or providing a YouTube URL fetched via `yt-dlp`), SubOCR executes a two-stage GPU pipeline on the **AMD Radeon AI PRO R9700 (`gfx1201`)** followed by deterministic morphological and dictionary analysis:

### Stage 1: ROCm 7.2 MIGraphX Visual Subtitle OCR (`PP-OCRv5`)

1. **Frame Sampling & Spatial Crop**:
   * OpenCV samples the video at a configurable rate $f_s$ (default $2.0\text{ fps}$, or frame interval $\Delta t = 0.5\text{ s}$).
   * To isolate burned-in subtitles and ignore top-of-screen variety-show topic banners by default, frames are cropped to the lower subtitle region $y \in [0.58H, 0.98H]$.
2. **GPU Inference (`MIGraphXExecutionProvider` FP32)**:
   * Detection (`PP-OCRv5_server_det_infer.onnx`) and recognition (`japanese_PP-OCRv5_mobile_rec_infer.onnx`) run via `onnxruntime-rocm` with `MIGraphXExecutionProvider`.
   * Compiled MIGraphX kernel plans (`.mxr`) are cached in `/data/docker/subocr/cache/migraphx` so subsequent container restarts bypass ahead-of-time compilation.
   * **Precision Note**: `migraphx_fp16_enable` is explicitly set to `0` (`FP32`) because FP16 causes bounding-box coordinate underflow in `PP-OCRv5` detection on `gfx1201`.
3. **Bilingual Line Classification & Spatial Role Separation**:
   * Detected text boxes within each frame are sorted vertically and classified into Japanese (`ja_lines`) or English (`en_lines`) based on character script ratios.
   * Within the lower subtitle band, higher-positioned smaller Japanese commentary boxes ($y < 0.66H$) are separated from the primary bottom speaker dialogue ($y \ge 0.66H$) so simultaneous on-screen editor commentary and speaker subtitles are preserved cleanly.
4. **Temporal Deduplication**:
   * Consecutive frames whose normalized Japanese string similarity satisfies:
     $$\text{sim}(A, B) = \frac{2 \cdot |M|}{|A| + |B|} \ge 0.78$$
     are merged into a single timed subtitle cue $[t_0, t_1]$, selecting the highest-confidence OCR recognition frame as the canonical raw text (`ja_raw`) and storing the initial English line (`en_raw`).

<p class="callout warning"><strong>Hardware & ROCm Note:</strong> Always keep <code>MIGRAPHX_DISABLE_MIOPEN_FUSION=1</code> and <code>migraphx_fp16_enable=0</code> enabled in <code>/home/docker-data/.config/containers/systemd/subocr.container</code>, and never set <code>UserNS=keep-id</code> on the 23.3 GB ROCm image (default rootless user namespace starts in under 2 seconds). Enabling FP16 on <code>gfx1201</code> degrades bounding-box detection recall in <code>PP-OCRv5</code>.</p>

### Stage 2: Contextual LLM Refinement, Kanji-Only Furigana & Offline Dictionary Tagging

1. **Whole-Conversation Person Name Identification (`extract_conversation_person_names`)**:
   * Before batched refinement or vocabulary page generation, `qwen2.5:32b` scans the full numbered transcript across the entire video (together with the video title) to identify recurring speakers, cast members, and mentioned individuals.
   * Extracted names are stored in `jobs.person_names_json` (mapping each canonical surface form and OCR variant to its reading, English romanization, role description, and `固有名詞` tag), automatically repairing visual OCR name typos (e.g., `たくあん` / `たくみん` -> `たくみ`) and preventing person names from being misclassified as common homophones in vocabulary chips.
2. **Batched Contextual Refinement (`qwen2.5:32b` on `ollama-rocm`)**:
   * Extracted cues are processed in batches of 15 cues against `http://169.254.1.2:11434/api/chat` (`qwen2.5:32b`, routed via `pasta` `--map-host-loopback` to `127.0.0.1:11434`).
   * The LLM receives both the raw Japanese OCR (`ja_raw`) and any burned-in English subtitle (`en_raw`), repairing single-character visual OCR confusions (e.g., katakana `ニ`/`二`, `カ`/`力`, or visually degraded glyphs) while preserving the original spoken phrasing, slang, and dialect.
   * If `en_raw` is present and accurate, it is cleaned; if missing or garbled, a natural English translation (`en_text`) is synthesized alongside contextual idiom/slang notes (`vocab`).
3. **Punctuation & On-Screen Symbol Restoration (`restore_raw_punctuation`)**:
   * Post-processing compares `ja_raw` and `ja_text` to ensure the LLM never strips on-screen quotation marks (`"..."`, `「...」`, `『...』`), wave dashes (`~`, `〜`), exclamation/question marks (`!`, `?`), ellipses (`…`), or counter katakana (`何カ国`, `6カ国`).
   * Example preserved cue: `"スペシャルゲスト~!"が<ruby>無<rt>な</rt></ruby>くなり<ruby>皆<rt>みな</rt></ruby>メンバーに`
4. **Deterministic Kanji-Only Furigana (`fugashi` + `unidic-lite`)**:
   * Morphological tokenization (`fugashi` + `unidic-lite` + `jaconv`) in `generate_kanji_furigana()` strips leading, trailing, and internal okurigana and attaches `<ruby>` annotations **strictly above Kanji characters**, leaving hiragana and katakana unannotated:
   * **Example Render**: <ruby>世界<rt>せかい</rt></ruby>の<ruby>違<rt>ちが</rt></ruby>いに6カ<ruby>国<rt>こく</rt></ruby>が<ruby>大混乱<rt>だいこんらん</rt></ruby> — それ<ruby>考<rt>かんが</rt></ruby>えたらすごい<ruby>日本<rt>にほん</rt></ruby>っぽいっていうか
5. **Exhaustive Per-Word Tokenization, Grammar Categorization & Offline JMdict / JLPT Lookup (`analyze_cue_tokens`)**:
   * Every word in every subtitle cue is tokenized by `fugashi` (`MECAB_TAGGER`), lemmatized to its dictionary base form (`orthBase`), and matched against the local read-only `/data/docker/subocr/data/jmdict_jlpt.db` SQLite database and a curated `GRAMMAR_GLOSS_MAP` for Japanese particles and colloquial auxiliaries (`てる`/`てん`, `ちゃう`, `たら`, `じゃん`, Kansai `ねん`/`やん`/`へん`, etc.).
   * Each token is assigned:
     * **JLPT Level Badge (`jlpt`)**: `N5`, `N4`, `N3`, `N2`, `N1`, or `Other`
     * **Part-of-Speech Filter & Chip Tag (`pos_cat` / `pos_label`)**: Grouped under the 6 `品詞` filter buttons (`名詞`, `動詞`, `形容詞`, `副詞`, `表現`, `助詞`) and labeled on each vocabulary chip with its specific Japanese grammatical category (see the complete reference table below)
     * **English Gloss (`meaning`)**: Offline JMdict definition or grammatical function

### Reference Table: Japanese Part-of-Speech (`品詞`) Filter Groups & Vocabulary Chip Tags

| Chip Tag<br>(`pos_label`) | Reading<br>(<ruby>振<rt>ふ</rt></ruby>り<ruby>仮名<rt>がな</rt></ruby>) | Filter Button<br>(`品詞`) | English Translation | Grammatical Role & Subtitle Examples |
| :--- | :--- | :--- | :--- | :--- |
| **`名詞`** | <ruby>名詞<rt>めいし</rt></ruby> | **`名詞`**<br>(Noun) | Common Noun | General nouns and *suru*-noun stems (e.g., `世界`, `違い`, `大混乱`) |
| **`固有名詞`** | <ruby>固有名詞<rt>こゆうめいし</rt></ruby> | **`名詞`**<br>(Noun) | Proper Noun | Places, countries, people, and brand names (e.g., `日本`, `関西`, `フランス`) |
| **`代名詞`** | <ruby>代名詞<rt>だいめいし</rt></ruby> | **`名詞`**<br>(Noun) | Pronoun | Personal and demonstrative pronouns (e.g., `私`, `僕`, `これ`, `それ`, `どこ`) |
| **`助数詞`** | <ruby>助数詞<rt>じょすうし</rt></ruby> | **`名詞`**<br>(Noun) | Counter | Numerical counter suffixes (e.g., `カ国`, `人`, `回`, `つ`, `個`) |
| **`動詞`** | <ruby>動詞<rt>どうし</rt></ruby> | **`動詞`**<br>(Verb) | Verb | All main verbs (*Godan*, *Ichidan*, *Suru*, *Kuru*); displayed as `conjugated (dictionary form)` when inflected (e.g., `言った (言う)`, `考えたら (考える)`) |
| **`形容詞`** | <ruby>形容詞<rt>けいようし</rt></ruby> | **`形容詞`**<br>(Adjective) | *i*-Adjective | Inflecting adjectives ending in `-i` (e.g., `すごい`, `面白い`, `無い`) |
| **`形容動詞`** | <ruby>形容動詞<rt>けいようどうし</rt></ruby> | **`形容詞`**<br>(Adjective) | *na*-Adjective | Adjectival nouns that take `な` before nouns (e.g., `好き`, `大変`, `複雑`) |
| **`連体詞`** | <ruby>連体詞<rt>れんたいし</rt></ruby> | **`形容詞`**<br>(Adjective) | Pre-noun Adjectival | Non-inflecting words that directly modify nouns (e.g., `この`, `その`, `あんな`, `いろんな`) |
| **`副詞`** | <ruby>副詞<rt>ふくし</rt></ruby> | **`副詞`**<br>(Adverb) | Adverb | Words modifying verbs, adjectives, or clauses (e.g., `ちょっと`, `めっちゃ`, `全然`, `やっぱり`) |
| **`表現`** | <ruby>表現<rt>ひょうげん</rt></ruby> | **`表現`**<br>(Expression) | Expression / Idiom | Multi-word idioms, set phrases, and contextual slang compounds |
| **`感動詞`** | <ruby>感動詞<rt>かんどうし</rt></ruby> | **`表現`**<br>(Expression) | Interjection | Exclamations, greetings, and conversational fillers (e.g., `えっ`, `はい`, `うわ`, `なるほど`) |
| **`接続詞`** | <ruby>接続詞<rt>せつぞくし</rt></ruby> | **`表現`**<br>(Expression) | Conjunction | Sentence/clause connectors (e.g., `でも`, `だから`, `そして`, `ただ`) |
| **`助詞`** | <ruby>助詞<rt>じょし</rt></ruby> | **`助詞`**<br>(Particle) | Particle (General) | General or adverbial/quotative particles (e.g., `って`, `とか`, `だけ`, `くらい`) |
| **`格助詞`** | <ruby>格助詞<rt>かくじょし</rt></ruby> | **`助詞`**<br>(Particle) | Case Particle | Marks grammatical case relationships (e.g., `が`, `を`, `に`, `で`, `と`, `から`) |
| **`係助詞`** | <ruby>係助詞<rt>かかりじょし</rt></ruby> | **`助詞`**<br>(Particle) | Topic / Binding Particle | Marks topic, contrast, or inclusion (e.g., `は`, `も`, `こそ`) |
| **`終助詞`** | <ruby>終助詞<rt>しゅうじょし</rt></ruby> | **`助詞`**<br>(Particle) | Sentence-Ending Particle | Adds tone, question, or emphasis at clause end (e.g., `ね`, `よ`, `な`, `か`, `じゃん`) |
| **`接続助詞`** | <ruby>接続助詞<rt>せつぞくじょし</rt></ruby> | **`助詞`**<br>(Particle) | Conjunctive Particle | Connects clauses (e.g., `て`/`で`, `けど`, `から`, `ば`, `のに`, `し`) |
| **`助動詞`** | <ruby>助動詞<rt>じょどうし</rt></ruby> | **`助詞`**<br>(Particle) | Auxiliary Verb | Inflecting verbal/copular endings and colloquial contractions (e.g., `だ`/`です`, `た`, `ない`, `ます`, `てる`/`てん`, `ちゃう`, `ねん`, `やん`, `へん`) |
| **`接頭辞`** | <ruby>接頭辞<rt>せっとうじ</rt></ruby> | **`助詞`**<br>(Particle) | Prefix | Honorific or intensifying prefixes attached to stems (e.g., `お`, `ご`, `大`, `超`) |
| **`接尾辞`** | <ruby>接尾辞<rt>せつびじ</rt></ruby> | **`助詞`**<br>(Particle) | Suffix | Meaning-modifying suffixes attached to stems (e.g., `さん`, `たち`, `っぽい`, `的`, `目`) |

---

## 3. Web Application Layout & Study Controls

SubOCR uses a sharp-cornered (`border-radius: 0`) layout organized into three focused pages:

* **Main OCR Library ([https://subocr.drheck.dev/](https://subocr.drheck.dev/))**:
  * Displays all OCR jobs with status badges, cue counts, video durations, category folders, and a `Source Video ↗` link whenever an original video URL (`youtube_url`) is recorded.
  * Provides direct export and download links (`JA .SRT`, `Bilingual .SRT`, `WebVTT .VTT`, `Anki .TSV`, `Study .MD`, `JSON`, and `Save to /data`) on each OCR job card.
  * Supports pinning (`★`), category filtering, searching, sorting (`Pinned & Newest`, `Oldest First`, `Title (A–Z)`, `Most Cues`), inline editing (`Organize` drawer for Title, Category, and `Original Video URL`), one-click video recovery (`Re-download Video` via `POST /api/jobs/{job_id}/redownload-video` if a local video file is missing on disk), and job deletion (`Delete`).
* **New Subtitle OCR ([https://subocr.drheck.dev/new](https://subocr.drheck.dev/new))**:
  * Dedicated job submission form for local videos under `/data/language/Japanese/Video`, direct file uploads, or YouTube URLs, with optional start/end timestamps (`start_sec`, `end_sec`), frame sampling FPS, category assignment, and an `Original Video URL` field across all tabs (plus automatic YouTube ID extraction from `-<11char_id>.mp4/.mkv` filenames) so source URLs are preserved for backup recovery.
* **Dedicated OCR Study View (`https://subocr.drheck.dev/ocr/{job_id}`)**:
  * **Resizable Split-Screen Workspace**: Full-viewport two-column layout with a `52vw` default left video column and an interactive `8px` vertical drag handle (`#split-resizer`) between the left video panel and right subtitle cue panel. Dragging the divider resizes the video player and subtitle list in real time, persists the chosen split width in `localStorage` (`subocr_split_width`), and resets to `52vw` on double-click.
  * Compact top bar above the HTML5 video player with `← Library`, `Source Video ↗` link, category badge, and small muted video title. Native `<video controls>` and WebVTT overlays are disabled on the video canvas so burned-in subtitles at the bottom of the frame are never obscured; instead, a dedicated external player control bar (`.player-bar`) sits directly below the video with Play/Pause, an interactive timeline scrubber, `currentTime / duration` readout, volume/mute controls, and fullscreen toggle. If the local video file is ever missing on disk, a recovery banner appears above the player with a `Re-download from Source URL` button (`yt-dlp`).
  * Clicking any subtitle card seeks the video to that cue's start timestamp, starts playback, and highlights the active card in high-contrast amber/gold (`#fbbf24` border/pill and `#fef08a` text). Each card header displays the cue index, timestamp span, and `OCR <confidence>%`.
  * **Fixed-Width Display & Vocabulary Filter Controls** (under the player controls on the left, persisted in `localStorage` with fixed-width `ON`/`OFF` labels that never shift button size):
    * **Display Toggles**: `Japanese Subs`, `Furigana`, `English Sub`, `Vocab`, and `Original OCR`
    * **JLPT Level Filters**: `N5`, `N4`, `N3`, `N2`, `N1`, `Other`
    * **`品詞` (Part-of-Speech) Filters**: `名詞` (Noun), `動詞` (Verb), `形容詞` (Adjective), `副詞` (Adverb), `表現` (Expression), `助詞` (Particle / Auxiliary)
  * **Universal Pre-Generated Vocabulary & Grammar Pages (`https://subocr.drheck.dev/{品詞}/{単語}{読み}.html`)**:
    * Clicking any vocabulary box on a subtitle cue card navigates to a universal, pre-generated reference page for that word, phrase, or grammatical particle (e.g., [https://subocr.drheck.dev/代名詞/私わたし.html](https://subocr.drheck.dev/%E4%BB%A3%E5%90%8D%E8%A9%9E/%E7%A7%81%E3%82%8F%E3%81%9F%E3%81%97.html) or [https://subocr.drheck.dev/動詞/考えるかんがえる.html](https://subocr.drheck.dev/%E5%8B%95%E8%A9%9E/%E8%80%83%E3%81%88%E3%82%8B%E3%81%8B%E3%82%93%E3%81%8C%E3%81%88%E3%82%8B.html)).
    * Pages are pre-generated automatically in `/data/docker/subocr/data/vocab_pages/{pos_label}/{slug}.html` at the end of every video analysis job (and updated whenever a subtitle cue is edited).
    * Each vocabulary page includes:
      1. **Full Dictionary Senses & JLPT Level**: All matching definitions and readings from `jmdict_jlpt.db` (`218,856` JMdict entries + `7,972` JLPT entries + colloquial/dialect grammar map).
      2. **Interactive `300x300px` Kanji Stroke Order & `kanji.drheck.dev` Integration**: For any word containing Kanji, embeds an interactive `300x300px` KanjiVG + Vivus stroke-order viewer (`160x160px` per frame in `Step-by-Step` mode, with compact `5.5px` SVG stroke numbers, grid lines, and replay controls) alongside direct links to `https://kanji.drheck.dev/?text=...&mode=animated&duration=327&showNumbers=true&showGrid=true&numberSize=6&boxSize=300&nightMode=true`.
      3. **Complete Verb & *i*-Adjective Conjugation Tables**: For all verbs (`五段動詞`, `一段動詞`, `サ変動詞`, `カ変動詞`), *suru*-nouns, and `形容詞`, renders a full 19-row conjugation table (`辞書形`, `ます形`, `ない形`, `ません`, `た形`, `ました`, `なかった`, `て形`, `なくて・ないで`, `ている形`, `可能形`, `意向形`, `受身形`, `使役形`, `使役受身形`, `仮定形`, `たら形`, `命令形`, `禁止形`) with `<ruby>` furigana and plain kana readings.
      4. **Token-Highlighted Cross-Video Subtitle Occurrences & Corpus Examples**: Lists every subtitle cue across processed videos where the word appears (clicking any occurrence jumps directly to `/ocr/{job_id}?cue={cue_index}`), paired with offline EDRDG Tanaka Corpus Japanese-English example sentences (`92,940` indexed sentences). Morphological token and `<ruby>` span matching (`fugashi.Tagger`) lightly highlights the target word or phrase—including conjugated verb and adjective inflections—using `<mark class="ex-hl">` without matching inside `<rt>` furigana tags.
  * **Keyboard Shortcuts** (active whenever focus is outside text inputs):
    * `Space` or `K`: Play / Pause video without scrolling the page
    * `A` or `←`: Jump to previous subtitle cue (preserving current play/pause state)
    * `D` or `→`: Jump to next subtitle cue (preserving current play/pause state)
    * `R`: Replay current subtitle cue from its start timestamp
    * `L`: Toggle single-cue A/B loop mode (`Loop: ON/OFF`)
  * **Self-Supporting Per-Cue & Job-Wide Local Re-Analysis (`Re-analyze`, `Edit`, and `Auto-Repair Cues`)**:
    * **One-Click `Re-analyze` Button (`POST /api/cues/{cue_id}/reanalyze`)**: Every subtitle cue card header includes a **Re-analyze** button next to **Edit** that re-runs local Radeon AI PRO R9700 GPU LLM refinement (`qwen2.5:32b` via `ollama-docker-proxy.socket`) on that single cue with surrounding dialogue context (`cue - 1` and `cue + 1`), quote normalization (`"..."` -> `「...」`), and output sanity validation (`is_valid_llm_ja`), updating the card, furigana, vocabulary chips, and pre-generated vocabulary pages in place in ~1.5 seconds.
    * **Inline Cue Editor & Targeted Recovery Modes**: Clicking **Edit** on any cue card opens an inline editor for Japanese text, Furigana reading, English meaning, and vocabulary notes, with four built-in actions:
      1. **Reset to Raw OCR** (`mode="raw"`): Instantly restores `ja_raw` and re-runs local `JMdict + JLPT` morphological token and furigana analysis.
      2. **Re-analyze Edited JA (AI)** (`mode="custom_ja"`): Preserves your manually corrected Japanese text and uses the local GPU LLM + `JMdict` to regenerate accurate Furigana, English translation, and vocabulary notes.
      3. **Re-OCR Video + AI** (`mode="reocr"`): Re-samples the video frames across `[start_sec - 0.2, end_sec + 0.2]` at a dense `4.0 fps` on the GPU (`PP-OCRv5`) and runs local LLM refinement.
      4. **Save Cue**: Instantly (`< 20 ms`) saves manual field edits and automatically re-runs morphological tokenization (`analyze_cue_tokens`), Kanji-only `<ruby>` furigana generation, symbol-compound recognition (`◯✖`, `◯×`, `×印`), and background vocabulary HTML page regeneration whenever `ja_text` is modified.
    * **Job-Wide `Auto-Repair Cues` (`POST /api/jobs/{job_id}/repair-cues`)**: Located in the left control panel, scans all cues in the current video for any truncated or invalid LLM outputs and automatically re-analyzes every affected cue on the local GPU.

---

## 4. Operations, Maintenance & Backup / Restore

### Container Management

<details>
  <summary><strong>Click to expand Rootless Podman Quadlet & Service Management Commands</strong></summary>

```shell
# Check container status and logs
$ (cd /tmp && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman ps --filter name=subocr)
$ sudo -u docker-data XDG_RUNTIME_DIR=/run/user/1100 journalctl --user -u subocr.service -n 100 -f

# Restart after modifying /data/docker/subocr/app.py or vocab_pages.py
$ sudo systemctl --user -M docker-data@ restart subocr.service

# Rebuild container image if Dockerfile dependencies change
$ (cd /data/docker/subocr && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 TMPDIR=/dev/shm podman build -t localhost/subocr:latest /data/docker/subocr)
$ sudo systemctl --user -M docker-data@ restart subocr.service
```

</details>

### Automated System Backup & Restore

<p class="callout success"><strong>Backup Integration:</strong> SubOCR is integrated into the standard host <code>ul</code> backup and restore suite under <code>/home/luke/git/backups/ul/scripts/</code> and runs automatically during <code>sudo /home/luke/git/backups/ul/scripts/run_all_backups</code>.</p>

* **Backup Script (`/home/luke/git/backups/ul/scripts/backup/subocr`)**:
  1. Rotates any existing backup in `/data/backups/services/current/subocr/` to `/data/backups/services/archive/subocr/`.
  2. Refreshes `/data/docker/subocr/data/video_sources.json` (recording every job's ID, title, category, `youtube_url`, `source_path`, and `host_path`) and executes a non-blocking SQLite WAL checkpoint (`PRAGMA wal_checkpoint(TRUNCATE);`) on `/data/docker/subocr/data/subocr.db`.
  3. Creates a compressed archive (`subocr_data.tar.xz`, ~1.4 MB) of `/data/docker/subocr/` including `app.py`, `vocab_pages.py`, `Dockerfile`, `docker-compose.yml`, `static/`, `data/subocr.db`, `data/video_sources.json`, **all pre-generated `.html` vocabulary/grammar pages (`data/vocab_pages/*/*.html`)**, and all exported job subtitle/study files (`data/jobs/*/*.srt`, `.vtt`, `.anki.tsv`, `.study.md`), while excluding transient model/kernel caches (`cache/`), the static JMdict reference database (`data/jmdict_jlpt.db`), and large raw video files (`*.mp4`, `*.mkv`, `*.webm`, `*.mov`).
  4. Generates 10% Reed-Solomon parity protection archives (`subocr_data.tar.xz.par2`) via `par2 create -r10`.
* **Restore Script (`/home/luke/git/backups/ul/scripts/restore/subocr`)**:
  1. Verifies archive integrity against the `par2` parity data (`par2 verify`).
  2. Extracts `subocr_data.tar.xz` into `/data/docker/subocr/`, restoring the SQLite database, all pre-generated vocabulary HTML pages, and all subtitle exports.
  3. Restarts the `subocr` container and checks all jobs in `subocr.db`: if any job's video file is missing on disk and has a recorded `youtube_url`, automatically re-downloads the video via `yt-dlp`.

```shell
# Run a standalone SubOCR backup
$ sudo /home/luke/git/backups/ul/scripts/backup/subocr

# Restore from the latest backup in /data/backups/services/current/subocr/
$ sudo /home/luke/git/backups/ul/scripts/restore/subocr

# Restore from a specific timestamped backup
$ sudo /home/luke/git/backups/ul/scripts/restore/subocr 2026-10-03_1133.09
```
