psyche-subtitle-toolkit 0.4.0

Extract, translate, and mux ASS/SRT/VTT/PGS subtitles in MKV files via pluggable translation providers
# AGENTS.md

Guide for AI coding agents working on psyche-subtitle-toolkit.

## Quick Reference

```sh
cargo check                                      # compilation check
cargo test                                       # run all unit, integration, binary, and doc tests
cargo clippy --all-targets --all-features -- -D warnings  # strict lint
cargo build --release                            # release binary
cargo run -- inspect <mkv>                       # inspect MKV tracks
cargo run -- translate --help                    # show all translate options
```

All commands run from the project root (`/home/enrell/projects/psyche-subtitle-toolkit/`).

## Project Overview

Rust crate + CLI that extracts ASS, SRT, WebVTT, and PGS subtitles from MKV files, translates them via pluggable providers, and muxes the translation back in-place. Designed for [Psyche](https://github.com/Gitlawb/psyche) but usable standalone.

**Key constraints:**
- ASS, SRT, WebVTT, and PGS (via OCR) subtitle formats
- In-place MKV remuxing (replaces original track, does not append)
- Sequential file processing (one MKV at a time)
- Local-first: no telemetry, no cloud required, every provider is opt-in

## Project Structure

```
src/
  lib.rs              # crate root: module declarations + public re-exports
  main.rs             # CLI binary (clap): inspect + translate subcommands
  error.rs            # thiserror enum: SubtitleToolkitError
  pipeline.rs         # translate_mkv (MKV pipeline) + translate_ass (subtitle-only pipeline)
  media/
    mod.rs
    mkv.rs            # mkvmerge/mkvextract wrappers: inspect, extract, mux
  subtitles/
    mod.rs
    model.rs          # SubtitleDocument, SubtitleCue
    ass.rs            # ASS parser + renderer (AssSubtitle)
    srt.rs            # SRT parser + renderer (SrtSubtitle)
    structured.rs     # numbered text format, tag stripping, chunking
    vtt.rs            # WebVTT parser + renderer (VttSubtitle)
  ocr.rs            # PGS OCR pipeline via PaddleOCR PP-OCRv5
    translation/
    mod.rs            # Translator trait + TranslationRequest struct
    ollama.rs         # Ollama /api/generate provider
    openai.rs         # OpenAI /v1/chat/completions provider
    opencode.rs       # OpenCode Zen/Go /v1/chat/completions providers
    deepl.rs          # DeepL /v2/translate provider
    google.rs         # Google Translate v2 provider
    gemini.rs         # Gemini generateContent provider
    openrouter.rs     # OpenRouter /api/v1/chat/completions provider
    anthropic.rs      # Anthropic /v1/messages provider
```

## Architecture

### Pipeline Flow

```
MKV file → inspect → extract ASS → parse → strip tags → chunk → translate → apply → reinject → render → mux
```

Two entry points:
- `translate_mkv(options, translator: Arc<dyn Translator>)` — full MKV pipeline (file I/O + external tools)
- `translate_ass(ass, target_language, max_concurrent, translator: Arc<dyn Translator>)` — subtitle-only pipeline (no file I/O)

### Translator Trait

```rust
#[async_trait]
pub trait Translator: Send + Sync {
    async fn translate(&self, request: TranslationRequest<'_>) -> Result<String>;
}
```

- Input: numbered subtitle text (`<1> hello\n<2> world`) + target language code
- Output: translated text in the same `<N> text` format
- Each provider owns its own prompt construction and HTTP client
- Pipeline is generic over `T: Translator + ?Sized` (supports `dyn Translator`)

### Error Handling

All errors use `SubtitleToolkitError` (thiserror). Provider errors use:
```rust
Translation { provider: &'static str, message: String }
```

## Adding a New Provider

Five steps, no pipeline changes needed:

1. **Create `src/translation/foo.rs`** — struct implementing `Translator`
2. **Add `pub mod foo;`** to `src/translation/mod.rs`
3. **Add doc comment** to the `Translator` trait listing
4. **Add re-export** `pub use translation::foo::FooTranslator;` to `src/lib.rs`
5. **Add CLI support** in `src/main.rs`:
   - Add `use` import
   - Add `"foo"` to the `--provider` help string
   - Add match arm in the provider dispatch
   - Update the unknown provider error message

### Provider Implementation Template

Each provider file follows this structure:

```rust
use serde::{Deserialize, Serialize};
use crate::error::{Result, SubtitleToolkitError};
use super::{TranslationRequest, Translator};

pub struct FooTranslator {
    client: reqwest::Client,
    base_url: String,
    api_key: String,
}

impl FooTranslator {
    pub fn new(api_key: impl Into<String>) -> Result<Self> { /* ... */ }
    pub fn with_base_url(base_url: impl Into<String>, api_key: impl Into<String>) -> Result<Self> { /* ... */ }
}

#[async_trait::async_trait]
impl Translator for FooTranslator {
    async fn translate(&self, request: TranslationRequest<'_>) -> Result<String> {
        // 1. Send HTTP request
        // 2. Check status → Err(SubtitleToolkitError::Translation { provider: "foo", ... })
        // 3. Parse response
        // 4. Return translated text (trimmed)
    }
}

// Private request/response serde types

#[cfg(test)]
mod tests {
    // wiremock mock tests: happy path, auth header, error on non-200, edge cases
}
```

### Provider Design Rules

- Use `reqwest::Client` with 120s timeout
- Constructor returns `Result<Self>` (reqwest build can fail)
- `with_base_url` for custom endpoints; `new` for defaults
- Trim `.base_url` trailing slash
- Convert non-2xx responses with `provider_http_error` so status, retryability, and `Retry-After` are retained
- Report transport failures with `provider_transport_error` so URLs containing credentials are not exposed
- Trim whitespace from response text
- Each provider owns its own prompt/request construction (no shared prompt module)

## Testing

### Test Categories (157 source tests plus doc-tests)

| Category | Count | Location | What they test |
|---|---|---|---|
| Subtitle parsing | 21 | `subtitles/ass.rs` + `subtitles/srt.rs` + `subtitles/vtt.rs` | ASS + SRT + VTT parse, CRLF, metadata blocks, and render |
| Structured text | 20 | `subtitles/structured.rs` | Numbered format, inline tag protection, byte/item chunking |
| Pipeline and options | 39 | `pipeline.rs` | Processing, retry, resume manifests, crash-window safety, format regressions, concurrency, and limits |
| Provider mocks | 46 | `translation/*.rs` | HTTP contracts, completion reasons, errors, provider limits, and OpenCode adapters |
| Doc-tests | 12 | `lib.rs` + provider files | Compile-check public examples |
| OCR | 17 | `ocr.rs` | Bounded PGS rendering, cache hashes, text grouping, repair, and merging |
| Retry | 10 | `retry.rs` | Transient/permanent classification, long-running HTTP 503 retry budget, `Retry-After`, capped backoff, invalid output, and attempts |
| Real MKV mux | 1 | `media/mkv.rs` | Synthetic `mkvmerge` round-trip preserving track metadata/order |
| CLI parsing and dispatch | 3 | `main.rs` | Preferred command name, backwards-compatible alias, and OpenCode providers |

### Writing Provider Tests

Use `wiremock` (dev-dependency) to mock HTTP:

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    #[tokio::test]
    async fn translates_numbered_text() {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/expected/path"))
            .respond_with(ResponseTemplate::new(200).set_body_json(/* ... */))
            .mount(&server)
            .await;

        let translator = FooTranslator::with_base_url(server.uri(), "test-key").unwrap();
        let result = translator.translate(TranslationRequest {
            source_text: "<1> hello",
            target_language: "pt-BR",
        }).await.unwrap();

        assert_eq!(result, "<1> expected");
    }
}
```

### Writing Pipeline Tests

Pipeline tests use `FakeTranslator` (defined in `pipeline.rs::tests`) that records calls and returns configured responses. No external tools needed — tests the subtitle processing logic only.

### What Is NOT Tested

- Real PGS inference against a downloaded model and production `.sup` fixture
- Real API calls (all providers use wiremock)

## Conventions

- **Edition:** Rust 2024
- **Safety:** `#![deny(unsafe_code)]` at the crate root; no local unsafe exceptions
- **Errors:** `thiserror` only, no `anyhow`
- **Async:** `tokio` with `fs`, `io-util`, `macros`, `process`, `rt-multi-thread`, `sync`, and `time`
- **HTTP:** `reqwest` with `json`, `rustls-tls`, and `stream` features, no default features
- **Lint:** `cargo clippy --all-targets --all-features -- -D warnings` must pass
- **Format:** `cargo fmt` (default rustfmt)
- **No unnecessary abstractions:** each provider is a standalone module, no factory/registry
- **`Arc<dyn Translator>` in library API:** the pipeline uses `Arc<dyn Translator>` for concurrent chunk translation; `Box<dyn>` is only in the CLI for provider dispatch

## External Dependencies

Runtime:
- `mkvmerge`, `mkvextract` (MKVToolNix) must be in PATH for MKV operations

Dev:
- `wiremock` for HTTP mock tests