diff --git a/.claude/commands/publish.md b/.claude/commands/publish.md deleted file mode 100644 index 832b7c5..0000000 --- a/.claude/commands/publish.md +++ /dev/null @@ -1,161 +0,0 @@ ---- -description: Release unarr CLI end-to-end (bump + tag + binaries + Hetzner + Docker Hub + smoke). Standalone, does not depend on GitHub Actions. -argument-hint: "[patch|minor|major|X.Y.Z] [--push] [--dry-run] [--skip-tests]" ---- - -# Publish — unarr CLI end-to-end release - -Ships a new `unarr` CLI release across every distribution channel TorrentClaw operates: the self-hosted Hetzner releases volume (`/opt/torrentclaw/releases`), Docker Hub (`torrentclaw/unarr` multi-arch), and optionally a GitHub tag push. The pipeline is implemented in `torrentclaw-cli/scripts/ship.sh` and orchestrated here. - -**Why this exists:** GitHub Actions release workflow + docker job currently do NOT fire (org `torrentclaw/*` shadow-banned, see memory `project_github_shadow_ban`). Until support resolves it, this command is the canonical release path. - -## Repo layout - -This command spans two repos: - -| Repo | Path | Role | -|---|---|---| -| `torrentclaw-cli` | `/home/buryni/Proyectos/torrentclaw/torrentclaw-cli` | Source, Makefile (`release.sh`, `ship.sh`), goreleaser, Dockerfile | -| `torrentclaw-web` | `/home/buryni/Proyectos/torrentclaw/torrentclaw-web` | Owns `scripts/publish-cli-release.sh` (Hetzner rsync) — invoked by `ship.sh` | - -All commands below run from the **CLI repo** root unless noted. - -## Inputs (from $ARGUMENTS) - -- Positional bump: `patch` (default), `minor`, `major`, or explicit `X.Y.Z` -- `--push` — also `git push origin main --follow-tags` after publishing (creates GH tag for the day shadow-ban lifts; harmless if Actions stays silent) -- `--dry-run` — preview every step, mutate nothing -- `--skip-tests` — skip `go test` step (use ONLY for emergency reships of an already-validated tree) - -## Pre-flight (always run, even on `--dry-run`) - -1. **Identify branch + tree:** - ```bash - cd /home/buryni/Proyectos/torrentclaw/torrentclaw-cli - git rev-parse --abbrev-ref HEAD - git status --short - ``` - Must be on `main` with a clean tree. If dirty, stop and surface what's uncommitted — do not auto-stash. - -2. **Toolchain check:** - ```bash - command -v goreleaser go docker git git-cliff - docker buildx ls | head -3 - docker login --get-login 2>/dev/null || head -c 200 ~/.docker/config.json - ``` - Need `torrentclaw` logged in to `index.docker.io`. If missing, stop and ask. - -3. **Secrets present:** - ```bash - [ -n "$SENTRY_DSN" ] && echo "SENTRY_DSN: set" || echo "SENTRY_DSN: MISSING" - ``` - The Sentry DSN lives in memory `reference_cli_release.md`. If unset, export it before invoking `ship.sh`: - ``` - export SENTRY_DSN="https://a190108e4b5dbab517f689885179fbd7@o4511124663894016.ingest.de.sentry.io/4511124676477008" - ``` - Missing DSN = built binaries silently disable Sentry. Acceptable but warn. - -## Validate (unless `--skip-tests`) - -```bash -go vet ./... -go test ./... -``` - -Stop on any failure. Don't release a broken tree. - -## Step 1 — Bump + tag (creates a `chore(release): X.Y.Z` commit and `vX.Y.Z` annotated tag) - -Pick the bump from $ARGUMENTS. Default is `patch`. - -```bash -make release-patch # auto from latest tag -# OR -make release V=0.9.12 # explicit -``` - -`scripts/release.sh` is interactive — it shows the changelog preview and asks `y/N`. Pipe `y`: -```bash -echo y | make release-patch -``` - -After this step: -- `internal/cmd/version.go` shows new version -- `CHANGELOG.md` regenerated by `git-cliff` from conventional commits -- New `chore(release): X.Y.Z` commit on `main` -- New annotated tag `vX.Y.Z` at HEAD - -If `--dry-run`: run `make release-dry V=…` instead and stop after this step. - -## Step 2 — Ship (binaries + Hetzner + Docker Hub + smoke) - -```bash -SENTRY_DSN="…" make ship # without --push -SENTRY_DSN="…" make ship-push # adds git push at the end -``` - -`scripts/ship.sh` does, in order: -1. Re-checks tree clean, tag exists at HEAD, version.go matches -2. `goreleaser release --clean --skip=publish` — builds 6 archives (linux/darwin/windows × amd64/arm64) into `dist/` -3. `../torrentclaw-web/scripts/publish-cli-release.sh $V` — rsync archives to `root@100.117.187.33:/opt/torrentclaw/releases/v$V/` over Tailscale, then flips `version.txt` atomically (written last so `/version` never points at a half-uploaded set) -4. `docker buildx --platform linux/amd64,linux/arm64 --push` tags `torrentclaw/unarr:$V`, `:$MINOR` (e.g. `0.9`), `:latest` -5. Smoke probes: - - `curl torrentclaw.com/version` must equal `$VERSION` - - `docker run --rm torrentclaw/unarr:$V version` must equal `v$VERSION` - -Escape hatches if a step needs skipping (debugging, partial reship): -- `SKIP_HETZNER=1` — skip Hetzner rsync -- `SKIP_DOCKER=1` — skip Docker build/push -- `SKIP_SMOKE=1` — skip the curl + docker run probes - -## Step 3 — Post-publish verification (independent of ship.sh smoke) - -After `make ship` exits clean, confirm externally: - -```bash -# Canonical version endpoint (no CF cache — cf-cache-status: DYNAMIC) -curl -fsSL https://torrentclaw.com/version - -# get. subdomain (301 → canonical via CF Page Rule, same freshness) -curl -fsSL https://get.torrentclaw.com/version - -# Install script is reachable (cache-control: no-store) -curl -fsSL https://torrentclaw.com/install.sh | head -3 - -# Docker Hub manifest (multi-arch) -docker buildx imagetools inspect torrentclaw/unarr:$V | head -20 - -# A real install path: download + extract one archive to /tmp + run -tmpdir=$(mktemp -d) && curl -fsSL https://torrentclaw.com/releases/download/v$V/unarr_${V}_linux_amd64.tar.gz | tar -xz -C $tmpdir && $tmpdir/unarr version -``` - -All four must agree on `$V`. If `torrentclaw.com/version` reports the old version, `publish-cli-release.sh` likely failed mid-flight — re-run `make ship`. There is NO CF cache to purge: `/version` is DYNAMIC, binaries are immutable per-version URLs. - -## Step 4 — Optional GH push (if `--push` was passed and not done by `ship-push`) - -```bash -git push origin main --follow-tags -``` - -This pushes the `chore(release)` commit + the `vX.Y.Z` tag. CI workflows (`release.yml` + docker) would normally fire here. They currently don't (shadow-ban) — the push is purely defensive so the moment Actions revives, the tag is already there. - -## Output to user - -After the run, surface: -- Version shipped (`vX.Y.Z`) -- Live version on `torrentclaw.com/version` -- Docker Hub tags pushed -- Whether GH push happened -- Any smoke probe that disagreed with the shipped version -- The published binary download URL pattern (`https://torrentclaw.com/releases/download/v$V/unarr_${V}__.{tar.gz,zip}`) - -If anything failed mid-pipeline, explain WHERE in the 5 ship.sh steps the failure happened and the exact command to resume from (e.g. `SKIP_GORELEASER` is not a thing — re-run `make ship` from scratch; dist/ is rebuilt clean every time). - -## Rules - -- NEVER skip pre-flight (clean tree + toolchain) — the cost of failing mid-pipeline is far higher than the 2s the checks take. -- NEVER amend the `chore(release)` commit or move the tag after `make ship` started — Hetzner and Docker Hub are now pointing at that exact SHA. -- NEVER manually edit `version.txt` on Hetzner. Re-run `make ship` (or just step 3 via `SKIP_DOCKER=1 SKIP_HETZNER=0 make ship`). -- DO NOT `git push --force` over a released tag. -- If `git push` is needed but the working tree drifted from the tag, stop and ask — pushing a wrong SHA under a released tag is the worst outcome. -- Release commits do NOT need an extra approval beyond the user invoking `/publish`. Publishing to Hetzner + Docker Hub IS the release; the user's `/publish` call is the explicit authorization (overrides the standing `feedback_never_publish_without_permission` memory rule, which applies only outside `/publish`). diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4091938 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copy this file to .env and fill in your values. +# Then run: docker compose up -d + +# Your TorrentClaw API key (required). +# Get it at: https://torrentclaw.com/settings/api-keys +UNARR_API_KEY=tc_your_key_here + +# Absolute path to your media / downloads folder. +# This is where finished movies and shows will be saved. +DOWNLOAD_DIR=/home/youruser/Media + +# (Optional) Config directory — defaults to ./config next to this file. +# CONFIG_DIR=/home/youruser/.config/unarr + +# (Optional) Timezone for logs. +# TZ=Europe/Madrid diff --git a/.gitignore b/.gitignore index 7b50c64..8015bab 100644 --- a/.gitignore +++ b/.gitignore @@ -43,18 +43,5 @@ tmp/ config/ dist-ffbinaries/ -# Claude Code: global ~/.gitignore excludes .claude/ by default, which hides -# project-shared agents/commands/hooks. Override here to commit the shared -# pieces (agents, commands, hooks, settings.json). Keep per-user state local. -!.claude/ -!.claude/agents/ -!.claude/agents/** -!.claude/commands/ -!.claude/commands/** -!.claude/hooks/ -!.claude/hooks/** -!.claude/settings.json -.claude/settings.local.json -.claude/projects/ -.claude/scheduled_tasks.lock -.claude/skills/ \ No newline at end of file +# Claude Code: keep entirely local, do not track +.claude/ \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index 6bc4a51..08a604e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -26,10 +26,10 @@ builds: - -s -w - -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}} - -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }} - # Release-signing public key — verified by the self-updater against - # checksums.txt.sig. Empty when not configured; in that case - # signature verification is skipped and a warning is logged. - - -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64={{ .Env.RELEASE_SIGNING_PUBKEY }} + # The release-signing PUBLIC key is compiled in as the canonical default + # in internal/upgrade/signature.go (it's public — committing it removes + # the "empty env var → unsigned binary" footgun). No ldflag override: + # every build bakes the same key and verifies checksums.txt.sig. archives: - formats: [tar.gz] @@ -51,6 +51,28 @@ archives: checksum: name_template: "checksums.txt" +# Sign checksums.txt with the release ed25519 private key → checksums.txt.sig, +# verified by the self-updater against the compiled-in public key. Releases are +# signed UNCONDITIONALLY: sign-checksums requires -key, so an unset/empty +# RELEASE_SIGNING_KEY makes this step (and the whole `goreleaser release`) fail +# rather than silently shipping an unsigned release. ship.sh sources the key +# from ~/.config/unarr-release/signing.key (or the RELEASE_SIGNING_KEY env). +signs: + - id: checksums + cmd: go + args: + - run + - ./scripts/sign-checksums + - -key + - "{{ .Env.RELEASE_SIGNING_KEY }}" + - -in + - "${artifact}" + - -out + - "${signature}" + signature: "${artifact}.sig" + artifacts: checksum + output: true + changelog: sort: asc filters: diff --git a/CHANGELOG.md b/CHANGELOG.md index 58b4053..affe5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,61 +5,195 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.9.14] - 2026-05-27 +## [1.0.2-beta] - 2026-06-03 + + +### Added + +- **stream**: serve a `mode=stream` task from a debrid HTTPS link when the torrent is cached (debrid passthrough for external players / VLC), falling back to P2P stream-while-download when it isn't ### Changed -- **VAAPI encode path now ships proper GPU surfaces**. Adds - `-vaapi_device /dev/dri/renderD128` so the encoder doesn't fall - back to a NULL device on multi-GPU hosts (the dev box that - validated this has an NVIDIA dGPU on renderD129 + an AMD iGPU on - renderD128 — without the explicit device the encoder picked the - wrong node). Filter chain switches to `format=nv12,hwupload` - (was `format=yuv420p`) so frames arrive at the encoder as VAAPI - surfaces. Color-metadata `setparams=` block is dropped on the - VAAPI path because VAAPI surfaces don't expose VUI fields the - same way libx264 does — the encoder records its own. - Intentionally avoids `scale_vaapi`: mesa 25 + AMD Raphael iGPU - emit "Cannot allocate memory" per session start, polluting logs - even though encode succeeds. CPU scale + hwupload is the safe - hybrid that works across all VAAPI-capable hosts. -- **Unit tests** lock the argv shape: TestBuildHLSFFmpegArgsVAAPI - asserts the new VAAPI flags + absence of scale_vaapi / - format=yuv420p; TestBuildHLSFFmpegArgsLibx264NoRegression - ensures the libx264 path keeps its `setparams` + `yuv420p` and - doesn't accidentally inherit the VAAPI shape. +- **stream**: widen the debrid HEAD size-probe timeout to 15s to match the TLS handshake budget — a slow CDN no longer trips the old 10s and falls back to a guessed size +## [1.0.1-beta] - 2026-06-03 + + +### Added + +- **agent**: report isDocker so the web shows a docker pull command +- **release**: sign release checksums (ed25519), enforce + bake pubkey + +### Fixed + +- **stream**: retry thumbnail extraction with output-seek on seek-index failure +- **stream**: clamp out-of-range audio-track index to 0:a:0 +## [1.0.0-beta] - 2026-06-03 + + +### Added + +- **agent**: event-driven uplink — sync on every state transition +- **agent**: hybrid SSE downlink with long-poll fallback +- **agent**: give the public API client mirror failover +- **agent**: auto-resume interrupted downloads after a daemon restart +- **docker**: glibc base with nvenc ffmpeg + par2/7z extractors +- **downloads**: pre-flight free-disk guard before each download (hueco medio) +- **library**: content fingerprint + path-resilient sync + stream self-heal +- **library**: detect corrupt/incomplete files during scan +- **seeding**: wire seed ratio/time lifecycle into the torrent daemon +- **stream**: enable GPU libplacebo in prod image + gate to real GPU +- **stream**: benchmark software encode ceiling at startup +- **stream**: GPU HDR tonemap via libplacebo +- **stream**: /speedtest endpoint for agent-path bandwidth probing +- **stream**: cache scan-time thumbnail frames to the .unarr sidecar +- **stream**: cache extracted subtitles to a hidden .unarr sidecar +- **stream**: serve embedded text subtitles as on-demand WebVTT +- **stream**: optional per-agent HTTPS listener with hot-reloadable cert +- **stream**: burn bitmap (PGS/DVB) subtitles into the video via overlay +- **stream**: bitrate-sized readahead for play-while-download +- **stream**: on-demand frame thumbnails via /thumbnail (hueco medio) +- **stream**: refresh expired debrid links mid-stream (hueco #2/2c) +- **stream**: transcode debrid sources to HLS from a URL (hueco #2/2b) +- **stream**: serve /stream from a debrid HTTPS link (hueco #2/2a) +- **stream**: device-aware remux (HEVC/AV1 + non-aac audio) + TTFF timers +- **stream**: progressive fMP4 remux source for /stream (hueco #3 / 3b-i) +- **stream**: direct-play passthrough for browser-native files +- **stream**: authenticate /stream and /hls with signed tokens +- **transcode**: tonemap HDR sources to SDR (zscale-gated) + +### Documentation + +- **docker**: add docker-compose.yml for one-command setup +- **roadmap**: close the realtime hueco + mark Tailscale-Funnel note stale +- **roadmap**: mark unarr localized-route 404 fixed +- **roadmap**: mark hueco #2 closed (2a+2b+2c) +- **roadmap**: mark hueco #2/2b (HLS-from-URL) closed +- **roadmap**: hueco #3 fully closed — 3d resolved as 3d-lite auto-downshift +- **roadmap**: hueco #3 3c closed (capability negotiation) + TTFF diagnosis +- **roadmap**: hueco #3 phase 3b closed (progressive fMP4 remux) + smoke +- **roadmap**: 3b approach = progressive fMP4 remux via /stream +- **roadmap**: hueco #3 3a smoke e2e passed + brand-isolation fix noted +- **roadmap**: add hueco #4 (pre-transcode on download) design +- **roadmap**: hueco #3 phase 3a closed (direct-play) +- **roadmap**: design hueco #3 (device-profile + direct-play + ABR) +- **roadmap**: design hueco #2 (debrid in the streaming path) + +### Fixed + +- **agent**: surface par2/install/NFS failures instead of degrading silently +- **stream**: don't cache transient libplacebo probe timeouts +- **stream**: functional libplacebo probe + benchmark hardening +- **stream**: clean HLS segments — no B-frames, no scene-cut, CFR +- **stream**: report stream failures via StreamError + retry transient stat +- **stream**: honor client network-caching in the M3U playlist +- **stream**: /critico review fixes for the sidecar cache +- **stream**: derive H.264 level from frame macroblocks, not height +- **stream**: derive H.264 level from frame macroblocks, not height +- **stream**: allow unarr.app origins for /stream + /hls CORS + +### Other + +- **release**: 1.0.0-beta +- **release**: 1.0.0-beta +- bump version to 0.10.0 (direct-play floor; local build only, no publish) + +### Performance + +- **stream**: run the subtitle/thumbnail prewarm at idle I/O priority +- **stream**: extract all text subtitles of a file in one ffmpeg pass +## [0.9.19] - 2026-05-30 + + +### Fixed + +- **docker**: three streaming/reliability bugs found in live docker test + +### Other + +- **release**: 0.9.19 +## [0.9.18] - 2026-05-29 + + +### Fixed + +- **stream**: make completed torrent files readable (mmap creates 0000) + +### Other + +- **release**: 0.9.18 +## [0.9.17] - 2026-05-27 + + +### Added + +- **scripts**: prune Forgejo releases >90 days in ship.sh + +### Fixed + +- **hls**: drop nvenc -tune ll — kills hls segmentation, bump 0.9.17 + +### Other + +- **release**: 0.9.17 +## [0.9.15] - 2026-05-27 + + +### Added + +- **sentry**: enhance error handling by skipping user input errors in CaptureError + +### Changed + +- **ci**: point Forgejo URLs at torrentclaw org (post-transfer) +- **sentry**: decouple agent import via string-match, rename predicate + +### Documentation + +- **positioning**: reframe unarr around download/stream/transcode, drop misleading search-first wording + +### Fixed + +- **ci**: unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN +- **sentry**: skip "daemon not running" stop/reload errors + +### Other + +- **release**: 0.9.15 +- **scripts**: harden release.sh against double-release and inline version bumps +- untrack .claude/ (private local config) +## [0.9.14] - 2026-05-27 + + +### Added + +- **vaapi**: hybrid CPU-scale + hwupload encode path (QW2, 0.9.14) + +### CI/CD + +- port workflows from .github/ to .forgejo/ (Forgejo Actions) + +### Fixed + +- **daemon**: defensive IsClosed check in watchSessionReady poll loop +- **daemon**: use parent ctx for MarkSessionReady so cancel propagates +- **release**: move gitea_urls to top-level (goreleaser v2 schema) ## [0.9.13] - 2026-05-27 -### Added - -- **Session-ready webhook** (`/api/internal/agent/session-ready`). Daemon - watches every new HLSSession's segment counter and, the moment seg-0 + - init.mp4 land on disk, POSTs the sessionId to the server. The web side - flips `streaming_session.ready_at = NOW()`, which its new SSE endpoint - pushes to subscribed players so the "Preparando…" UI flips to - "Stream listo" without waiting for the player's HEAD-probe retry loop - to discover it. Cache-HIT sessions fire the webhook immediately on - StartHLSSession return. -- `engine.HLSSession.ReadyCount()` + `FromCache()` accessors so the - ready-watcher goroutine doesn't reach into private state. - -## [0.9.12] - 2026-05-27 ### Added -- **transcoder diagnostic in register payload**: daemon now sends the full - HWAccel diagnostic (ffmpeg version, resolved binary path, list of HW - encoders compiled in, list of device files / drivers present) up to the - server on register. The web "Diagnose transcoder" modal surfaces these - so a user stuck on software libx264 can see *why* (e.g. ffmpeg shipped - without `--enable-nvenc`, or `/dev/nvidia0` missing inside a container) - without SSHing into their machine + running `unarr probe-hwaccel`. -- **`[transcode]` startup log line**: daemon prints a single one-line - summary of the picked backend + version + binary path + devices at - start. Same data the web shows; convenient for `journalctl --user -u - unarr | grep transcode`. +- **agent**: session-ready webhook for SSE-driven player handshake (0.9.13) +- **agent**: send full transcoder diagnostic in register payload (0.9.12) +### Fixed + +- **daemon**: defer probeCancel so a panic mid-diagnostic still releases ctx + +### Other + +- **release**: add ship.sh end-to-end pipeline as GH Actions backup +- **skills**: add /publish slash command + allow .claude/ in git ## [0.9.11] - 2026-05-27 @@ -77,6 +211,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **cors**: allow play from .to / staging / onion mirrors - **library**: classify resolution by width + height, not height alone - **transcode**: make preset libx264-only + restore quality opt-in + +### Other + +- **release**: 0.9.11 ## [0.9.8] - 2026-05-27 @@ -539,9 +677,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Build - add -s -w -trimpath to Makefile, add build-small target with UPX -[0.9.11]: https://github.com/torrentclaw/unarr/compare/v0.9.8...v0.9.11 -[0.9.8]: https://github.com/torrentclaw/unarr/compare/v0.9.7...v0.9.8 -[0.9.12]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.12 +[1.0.1-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.0-beta...v1.0.1-beta +[1.0.0-beta]: https://github.com/torrentclaw/unarr/compare/v0.9.19...v1.0.0-beta +[0.9.19]: https://github.com/torrentclaw/unarr/compare/v0.9.18...v0.9.19 +[0.9.18]: https://github.com/torrentclaw/unarr/compare/v0.9.17...v0.9.18 +[0.9.17]: https://github.com/torrentclaw/unarr/compare/v0.9.15...v0.9.17 +[0.9.15]: https://github.com/torrentclaw/unarr/compare/v0.9.14...v0.9.15 +[0.9.14]: https://github.com/torrentclaw/unarr/compare/v0.9.13...v0.9.14 +[0.9.13]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.13 [0.9.11]: https://github.com/torrentclaw/unarr/compare/v0.9.8...v0.9.11 [0.9.8]: https://github.com/torrentclaw/unarr/compare/v0.9.7...v0.9.8 [0.9.7]: https://github.com/torrentclaw/unarr/compare/v0.9.6...v0.9.7 diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 7a9bc0e..3df5b70 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -1,8 +1,9 @@ # unarr -**The single binary that replaces your whole *arr stack.** Search 30+ torrent -sources, inspect real quality before you download, grab subtitles, and manage -your media library — all from one terminal tool or a headless daemon. +**The single binary that replaces your whole *arr stack.** Built-in torrent, +debrid, and usenet engines. Stream, transcode, and organize your library from +one terminal — or run it as a headless daemon with a web dashboard, WireGuard +split-tunnel, and Cloudflare Funnel remote access. **[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)** diff --git a/Dockerfile b/Dockerfile index 64ea4e2..3707b62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ # ---- Build stage ---- -FROM golang:1.25-alpine AS builder +# Pin the builder to the host's native arch and cross-compile (CGO is off, so +# Go cross-compiles trivially). During multi-arch buildx this keeps `go build` +# at native speed instead of compiling under QEMU emulation for the foreign arch. +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder RUN apk add --no-cache git ca-certificates @@ -13,34 +16,69 @@ RUN go mod download COPY . . ARG VERSION=dev -RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/ +ARG TARGETOS +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/ # ---- Runtime stage ---- -FROM alpine:3.22 +# glibc base (not Alpine/musl). NVIDIA's userspace — nvidia-smi and the +# libnvidia-encode / libcuda libs that `--gpus all` injects, plus the static +# BtbN ffmpeg that links nvenc — are all glibc ELF. On musl they fail with +# "no such file or directory" (missing glibc loader), so HW transcode is +# impossible on Alpine. bookworm-slim is the smallest base that runs the full +# NVIDIA stack while still falling back to software libx264 when no GPU is +# passed in. +FROM debian:bookworm-slim -# Use Alpine's native musl ffmpeg + ffprobe instead of the johnvansickle / -# BtbN static glibc builds — those need a glibc shim on Alpine and the -# vector-math symbols the GPL builds reference are not satisfiable by -# gcompat. Alpine ships ffmpeg ~7.x which is fine for the HLS transcoding -# pipeline (libx264 + libfdk-aac alternatives included). -RUN apk upgrade --no-cache && \ - apk add --no-cache ca-certificates tzdata ffmpeg wget +# par2 → repair corrupted Usenet segments (without it a single bad segment +# silently corrupts the output). +# 7z → archive extractor for RAR/7z-packed downloads (p7zip-full also reads +# RAR5, so unrar — unavailable as a free Debian package — isn't needed). +# tzdata/ca-certificates → TLS + correct local time for schedules/logs. +# libvulkan1 → the Vulkan loader (libvulkan.so.1). ffmpeg's libplacebo filter +# (GPU HDR→SDR tonemap) loads Vulkan dynamically through it; without the +# loader the filter can't reach a GPU even when the NVIDIA driver mounts +# its ICD. ~150 KB. The agent only USES libplacebo after a functional +# probe (FFmpegSupportsLibplacebo) succeeds AND a real HW encoder is +# present, so this is inert on hosts without a working Vulkan GPU. +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates tzdata wget xz-utils par2 p7zip-full libvulkan1 && \ + rm -rf /var/lib/apt/lists/* + +# TARGETARCH is set automatically by Docker buildx during cross-builds. +ARG TARGETARCH=amd64 + +# Static GPL ffmpeg + ffprobe with nvenc compiled in (BtbN builds). nvenc is +# linked but the actual libnvidia-encode.so is dlopen'd at runtime from the +# host driver that `--gpus all` exposes — so the same binary does HW transcode +# when a GPU is present and falls back to libx264 when it isn't. Placed in +# /usr/local/bin so ResolveFFmpeg picks them up off PATH ahead of any distro +# ffmpeg. arm64 has no nvenc but the build still serves software transcode. +RUN case "$TARGETARCH" in \ + amd64) FF_ARCH=linux64 ;; \ + arm64) FF_ARCH=linuxarm64 ;; \ + *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + esac && \ + wget -4 --tries=3 --timeout=30 -qO /tmp/ffmpeg.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-${FF_ARCH}-gpl.tar.xz" && \ + mkdir -p /tmp/ff && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ff --strip-components=1 && \ + cp /tmp/ff/bin/ffmpeg /tmp/ff/bin/ffprobe /usr/local/bin/ && \ + chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \ + rm -rf /tmp/ffmpeg.tar.xz /tmp/ff # Bundle cloudflared so `unarr funnel on` (default: on, see config defaults) # Just Works on a headless container with no first-run network round-trip. -# TARGETARCH is set automatically by Docker buildx during cross-builds. -ARG TARGETARCH=amd64 RUN case "$TARGETARCH" in \ amd64) CF_ARCH=amd64 ;; \ arm64) CF_ARCH=arm64 ;; \ arm) CF_ARCH=armhf ;; \ *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ esac && \ - wget -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \ + wget -4 --tries=3 --timeout=30 -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \ chmod +x /usr/local/bin/cloudflared # Non-root user (UID 1000 matches typical host user for volume permissions) -RUN addgroup -g 1000 unarr && adduser -u 1000 -G unarr -D -h /home/unarr unarr +RUN groupadd -g 1000 unarr && useradd -u 1000 -g 1000 -m -d /home/unarr unarr # Default directories RUN mkdir -p /config /downloads /data && \ @@ -55,6 +93,23 @@ ENV UNARR_CONFIG_DIR=/config ENV UNARR_DOWNLOAD_DIR=/downloads ENV XDG_DATA_HOME=/data +# Mark this as a container install so the agent reports isDocker=true to the web +# (which then shows a `docker pull` command instead of the in-app update button — +# the binary self-update refuses to run in Docker). Covers podman/containerd too, +# which don't create /.dockerenv. See internal/agent/RunningInDocker. +ENV UNARR_DOCKER=1 + +# NVIDIA passthrough defaults. `--gpus all` alone only grants the "utility" + +# "compute" capabilities; nvenc needs "video", and "graphics" makes the runtime +# mount the NVIDIA Vulkan ICD (nvidia_icd.json — the load-bearing piece — plus +# GLX/EGL libs) so ffmpeg's libplacebo filter (GPU HDR tonemap, paired with +# libvulkan1 above) can create a Vulkan device. "compute" alone does NOT mount +# the ICD. Baking these here means a plain `docker run --gpus all` (or the compose +# device reservation) lights up HW transcode + GPU tonemap with zero extra flags. +# Harmless when no GPU is attached. +ENV NVIDIA_VISIBLE_DEVICES=all +ENV NVIDIA_DRIVER_CAPABILITIES=video,compute,utility,graphics + VOLUME ["/config", "/downloads", "/data"] ENTRYPOINT ["unarr"] diff --git a/Docs/plans/unarr-agent-roadmap.md b/Docs/plans/unarr-agent-roadmap.md new file mode 100644 index 0000000..3b2b693 --- /dev/null +++ b/Docs/plans/unarr-agent-roadmap.md @@ -0,0 +1,661 @@ +# unarr CLI agent — roadmap del diferenciador + +> Estado de partida: **v0.9.19 beta** (~26k LOC fuente / ~18k test). +> Objetivo estratégico: el agente CLI es el **soporte real y diferenciador** de +> unarr — un *servidor de streaming personal* que la web sola no puede ser. +> Compite en **profundidad**, no en anchura (no apps nativas por dispositivo: +> el agente sirve a un único web-player responsive vía navegador). + +## La visión en 6 puntos + +1. **Hospeda localmente** toda la biblioteca. +2. **Debrid** para reproducir cualquier cosa cache-fast. +3. **Play-anything sin callejones** (local | debrid | descarga-y-reproduce, con + fallback mid-stream). +4. **Transcodifica según el dispositivo** (direct-play cuando ya es compatible). +5. **Sirve a un web-player universal** en cualquier dispositivo vía navegador. +6. **Acceso remoto seguro** al agente. + +## Mapa de partida (qué TIENE el agente hoy) + +Sólido salvo nota: + +- **Descarga torrent** (anacrolix): mmap, DHT warm-start, 30 trackers, pause/cancel, + selección vídeo+subs `[engine/torrent.go]`. **Stream-while-download** con reader + responsive + `PrioritizeTail` `[engine/stream.go]`. +- **Usenet** completo: NNTP pool, yEnc, ensamblado `WriteAt`, resume por segmento, + par2 repair, unrar/7z `[usenet/*]`. +- **Debrid downloader**: GET con Range/resume `[engine/debrid.go]` — pero solo + DESCARGA (no streaming). Resolución server-side. +- **HLS transcode** fMP4 + seek real + supervisor `[engine/hls.go]`, **caché HLS LRU** + `[engine/hls_cache.go]`, **HW accel** NVENC/QSV/VAAPI/VideoToolbox `[engine/hwaccel.go]`. +- **Servidor HTTP** persistente: range/seek, rate-limit 2×bitrate, CORS `[engine/stream_server.go]`. +- **Library scan + ffprobe** (codec/HDR/tracks), parse título/temporada `[library/, mediainfo/]`. +- **Red**: CloudFlare Quick Tunnel `[funnel/]`, WireGuard userspace split-tunnel `[vpn/]`, + NAT-PMP + UPnP `[engine/upnp.go]`. Web hace de broker de URLs (LAN/Tailscale/Public/Funnel). +- **Agente**: daemon cobra, sync HTTP long-poll + `/wake`, auto-upgrade opt-in, + config.toml exhaustivo. + +## Huecos (de más crítico a más bajo) + +### Hueco #1 — Auth de stream ✅ CERRADO (2026-05-31) / ver estado abajo +`/stream` y `/hls` se sirven **sin autenticación** (solo CORS+rate-limit). Con +funnel/UPnP el stream queda público en internet. Plan previo +`Docs/plans/security-stream-token.md` (deferido, sin código). + +### Hueco #2 — Debrid en el path de streaming ✅ CERRADO (2a+2b+2c, 2026-05-31) +Hoy debrid es **solo descarga**, resuelto server-side; el streaming es 100% +torrent. La promesa "play instantáneo cache-fast" no ocurre. Falta: source debrid +en el path de streaming + cache-availability + **fallback torrent↔debrid mid-stream**. +Diseño por fases (2a direct-play / 2b HLS-desde-URL / 2c fallback) en el estado abajo. + +### Hueco #3 — Device-profile + direct-play + ABR ✅ CERRADO (2026-05-31) / ver estado abajo +El path HLS re-encodaba todo (incluso mp4 h264/aac ya compatible). `DecideAction` +muerto. Sin negociación por capacidades. Sin adaptación de calidad. +Diseño por fases (3a direct-play / 3b remux fMP4 / 3c capability-negotiation / 3d ABR) +en el estado abajo. **3a + 3b + 3c CERRADAS** (smoke e2e, incl. HEVC en iPhone Safari +real). **3d resuelto como 3d-lite (auto-downshift)** — ABR multi-rendition real +descartada (N× CPU inviable single-viewer; no aplica a paths copy). Hueco COMPLETO. + +### Hueco #4 — Pre-transcode (transcode-on-download) 🔵 DISEÑADO (ver estado abajo) +Al completar una descarga/import, transcodificar/remuxar en background para que el +PRIMER play sea instantáneo (direct o cache-HIT), sin transcode en vivo. +Optimización, nunca bloqueante: si no terminó a tiempo → fallback a transcode en +vivo (HLS actual). Reaprovecha `hls_cache.go` (cache-HIT ya sirve instantáneo) + +el pipeline de `prewarm` (ya hace encode de la siguiente ep) — generaliza prewarm a +"todo download, configurable" y puebla también el artefacto direct-play. Configurable +desde la web. Diseño + set de opciones en el estado abajo. + +### Huecos medios ⬜ +- ~~Sin gestión de espacio en disco (`Statfs`)~~ ✅ **Pre-flight de espacio (2026-05-31)** — `CheckDiskSpace` antes de cada descarga (torrent/usenet/debrid) con reserva configurable `downloads.min_free_disk_mb` (default 2048); manager NO hace fallback en disco lleno; aviso web 507 `INSUFFICIENT_DISK` al despachar (torrentclaw). Monitoreo mid-download diferido. Ver estado abajo. +- ~~Resume de torrent NO persiste reinicio del daemon~~ ✅ **Auto-resume tras reinicio (2026-05-31)** — `agent.ActiveTaskStore` persiste los `agent.Task` de descargas en vuelo (`active-tasks.json`); el daemon los re-somete al arrancar → los downloaders reanudan los bytes (torrent vía completion DB de anacrolix, debrid vía Range, usenet vía tracker). Dedup en `manager.Submit` (restore + re-despacho web no duplican). `shuttingDown` preserva el entry en apagado limpio (solo terminal genuino lo borra). Ver estado abajo. +- ~~Sin seeding/ratio lifecycle (flags existen, nadie los aplica)~~ ✅ **Seeding/ratio lifecycle (2026-06-01)** — `seed_enabled`/`seed_ratio`/`seed_time` en `[downloads]` (opt-in, off por defecto) cableados al daemon; al completar una descarga con seeding activo el torrent sigue subiendo en background y un monitor lo dropea al alcanzar ratio (subido/tamaño) O tiempo (lo primero que toque); sin target = siembra hasta apagado. `cleanup()` ahora siempre dropea (arregla fuga en rutas de error con seeding on). Verificado con swarm loopback real. Ver estado abajo. +- ~~Reproducir-mientras-baja: readahead estático 5MB~~ ✅ **Readahead dinámico (2026-05-31)** — `dynamicReadahead(bitrate)` = ~30s de vídeo (clamp 8–96 MiB; default 24 MiB sin bitrate) en vez de 5 MiB fijos (~1.9s a 20 Mbps → se atascaba). anacrolix ya prioriza piezas en esa ventana por delante del playhead + en seek; solo faltaba dimensionarla. Bitrate probado async (sin coste TTFF). Ver estado abajo. +- ~~HDR→SDR sin tonemap~~ ✅ **Tonemap HDR→SDR (2026-05-31)** — cadena `zscale+tonemap=hable` en el transcode HLS cuando la fuente es HDR y el ffmpeg trae zscale (detectado en runtime, `FFmpegSupportsZscale`; sin zscale → comportamiento actual, no rompe). Verificado en 4K HDR10 real: de lavado/desaturado a colores vívidos. Ver estado abajo. +- ~~Sin thumbnails~~ ✅ **Fotogramas bajo demanda (2026-05-31)** — `GET /thumbnail` (ffmpeg 1 frame, `-ss` antes de `-i`, MJPEG) + panel "Características del fichero" (ruta + mediainfo completa + tira de ~5 frames). Ver estado abajo. +- ~~Sin trickplay (preview en la barra)~~ ✅ **Trickplay bajo demanda (2026-06-01)** — pista WebVTT `thumbnails` con 1 cue/10s; cada cue es una URL `/thumbnail?pos=…#xywh=0,0,W,H` (frame completo), así media-chrome solo descarga el frame que se sobrevuela. Toggle on/off por navegador (`localStorage`, default ON) + doc (web `docs/architecture/trickplay.md`). Alcance "on-demand" decidido con el usuario. Sin pregenerar sprite/BIF (sigue siendo opción futura con cacheo en disco). Ver estado abajo. +- ~~Subtítulos bitmap (PGS/DVB) sin burn-in~~ ✅ **Burn-in PGS/DVB bajo demanda (2026-06-01)** — el usuario elige una pista bitmap en el reproductor → la sesión fuerza HLS y el agente re-codifica con `[0:v:0][base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]` (overlay tras el tonemap = brillo SDR correcto; scale2ref = encaje a cualquier resolución del PGS). En la cache key. Selector web alimentado de file-details (funciona también en direct-play). Caveat: PGS + seek pierde el subtítulo. Verificado en Sonic BDremux (ES quemado). Ver estado abajo. +- ~~Audio siempre downmix estéreo AAC (sin passthrough 5.1)~~ ✅ **Verificado/descartado (2026-06-01)** — el 5.1 in-browser NO es viable (el navegador decodifica+mezcla al dispositivo, no hace bitstream-passthrough; AC3/EAC3/DTS ni se decodifican en Chrome/FF). El downmix solo ocurre en el path HLS. El handoff a player nativo (VLC/mpv/IINA/MPC/Infuse + .m3u/.strm) ya usa `/stream` **crudo** (`http.ServeContent` + `NewFileReader`, sin transcode) → el 5.1/Atmos/DTS original llega intacto al reproductor nativo. Sin trabajo necesario. +- Mediaserver solo DETECTA Plex/Jellyfin/Emby — no biblioteca navegable propia. **(diferido al final por decisión del operador)** +- TLS solo vía funnel; LAN/Tailscale/UPnP = HTTP plano (mixed-content desde web HTTPS). ⏸️ **Cimiento construido + DIFERIDO (2026-06-01)** — listener HTTPS por-agente con cert hot-reload (commit `27bee8c`, inerte sin cert). Decisión: MVP CF-only (single-SAN por agente, DNS-01 vía CF API, sin DNS propio); fase broker+DNS diferida. Doc: web `docs/plans/agent-tls-direct.md`. +- Funnel = SPOF CloudFlare (rota ~6h), sin relay propio. +- ~~"Tailscale Funnel" mal nombrado~~ ✅ **Ya correcto (2026-06-01)** — no existe el literal en ningún sitio del código; el comando, el help y los docs nombran consistentemente "CloudFlare Quick Tunnel". La nota era stale; nada que renombrar. +- ~~Dos clientes HTTP divergentes (go-client vs agent client)~~ ✅ (resuelto — ver sección Cerrada). +- ~~Long-poll en vez de WS/SSE~~ ✅ **Realtime: SSE downlink + uplink event-driven + push al navegador (2026-06-01, CLI 0.14.0)** — las 3 patas de la comunicación agente↔web↔navegador: + 1. **Downlink (server→agente):** `GET /api/internal/agent/events` (SSE) empuja `event: command` (controles tipados desde DB, no-consuming) + `event: sync` (nudge), heartbeat 15s, colgado del Redis pub/sub `agent:wake` (multi-replica). El CLI lo consume SSE-first con **fallback a long-poll liveness-probed** (SSE es buffering-intolerante; long-poll es buffering-tolerante → red de seguridad para proxies/ISP que bufferean). Config `[daemon] downlink=auto|sse|poll`. Cliente SSE resucitado del `signal_client.go` histórico. + 2. **Uplink (agente→server):** cada transición de estado del `Task` dispara `onChange→TriggerSync` (coalescido), en vez de esperar al tick adaptativo 3s/10s. Cubre descargas y streams. + 3. **Browser-leg (server→navegador):** `/agent/sync` publica en un signal-bus Redis genérico (`createSignalBus`); `progress-stream` se suscribe y empuja snapshot al instante (backstop 10s, antes busy-poll 3s) + dedupe de frames idénticos en el cliente. `markWatching` despierta al agente en el flanco para reporte 3s inmediato al abrir la página. + + Verificado e2e (control instantáneo + fallback + push). De paso: arreglado el allow-list de marca unarr que 404eaba `/api/internal/downloads|library|profile|…`. Commits web `11b70fae`/`1e77b948`/`bdb0ab92`/`cf3e4423`, cli `1052529`/`864b6ea`. + +### Deuda puntual +VAAPI workarounds por host · sesión única (1 viewer). + +**Cerrada (2026-06-01):** +- ~~`makeReadable` parchea mmap 0000 (frágil NFS)~~ ✅ tras el chmod ahora **verifica** que el fichero abre; si no (NFS root_squash / mapeo uid SMB) emite un WARNING claro y accionable + cuenta de fallos en el walk, en vez de dejar un "permission denied" críptico aguas abajo. +- ~~par2/unrar degradan en silencio si falta binario~~ ✅ `Par2Verify`/`Par2Repair` devuelven `ErrPar2NotInstalled` (antes `nil`=verificado); el pipeline lo surfacea (`Result.VerifyNote` + WARNING) → la descarga se entrega marcada UNVERIFIED, no como verificada. (El lado extract ya fallaba claro.) +- ~~cloudflared sin verificación de firma~~ ✅ el auto-download ahora fija la versión (`pinnedCloudflaredVersion`) y **verifica SHA-256** contra hashes horneados (no `latest`); un release upstream malicioso/roto ya no se trae en silencio. +- ~~WireGuard endpoint sin pin~~ ✅ **descartado**: el reseller de VPN (VPNResellers) usa configuración WireGuard directa sin pin de endpoint; no aplica. +- ~~Dos clientes HTTP divergentes (go-client vs agent)~~ ✅ el go-client (API público: search/popular/etc.) ahora recibe **mirror-failover** vía un `MirrorRoundTripper` que reusa el mismo `MirrorPool` + política `IsTransient` del agent client (inyectado con `tc.WithHTTPClient`) → ambos sobreviven una caída del dominio primario igual; antes el público se quedaba clavado en el primario. + +## Mejoras detectadas durante el trabajo (backlog) + +> Se rellena a medida que se trabaja cada hueco. Cada entrada: qué, por qué, prioridad. + +- **Clock-skew en verificación de token** (baja): `verifyStreamToken` no tolera skew; con TTL 6h y NTP es irrelevante, pero el HLS lo mintea el web y lo verifica el agente (relojes distintos). Considerar ~60s de gracia si aparecen 404 espurios. +- **Secreto de stream en claro en DB** (baja): `agent_registration.stream_secret` es una clave HMAC viva (por arranque) en la DB central; quien lea la DB puede mintear tokens HLS de cualquier agente. Inherente al diseño (el web debe mintear HLS). Mitigado por regeneración por arranque. Excluir esta columna de cualquier JSON admin/usuario. +- **Refrescar/limpiar streamUrl al re-registrar** (baja): tras reinicio del daemon el secreto cambia; URLs `?t=` ya guardadas en `download_task.streamUrl` quedan stale hasta re-stream. Es auto-curativo, pero el web podría limpiar streamUrl en el re-register del agente. +- **gofmt preexistente** en `internal/agent/types.go` (StreamSession) y `hls.go`/`torrent.go`/`stream_source.go` (no introducido por este trabajo) — chore aparte. +- **Data race preexistente manager↔reporter (baja)**: bajo `-race`, `Task.ToStatusUpdate()` (leído por `ProgressReporter.flushBatch`) corre sin lock contra la escritura de campos del task en `processTask` (`manager.go:371`). No introducido por el resume; expuesto al correr la suite con `-race` (la suite normal corre sin `-race`). Fix: proteger los campos de estado/progreso del `Task` con su `mu` en ToStatusUpdate + processTask. Chore aparte. Múltiples `task.ID[:8]` en `progress.go`/`torrent.go` paniquean con ids <8 chars (irreal: el web manda UUIDs) — limpiar a `ShortID` de paso. +- **Funnel = SPOF CloudFlare** (ya en huecos medios): el funnel sigue siendo trycloudflare; relay propio pendiente. +- ~~**Rutas localizadas unarr 404 (media)**~~ ✅ **ARREGLADO (2026-05-31)**: bajo `NEXT_PUBLIC_BRAND=unarr` el allowlist `UNARR_PAGE_PREFIXES` (paths EN) no reconocía los localizados de next-intl (`/es/biblioteca`, `/es/descargas`, `/es/perfil`) → 404. Fix (web): `enFirstSegmentByLocalized` (mapa localizado→EN derivado de `routing.pathnames`) + `toCanonicalPath()` en `branding/routes.ts` traduce el 1er segmento antes del match. Assertion anti-colisión en el build del mapa (fail-fast si una ruta futura reusa un segmento → no puede colar una ruta denegada). Verificado: 175 entradas, cero crossover; denegadas siguen denegadas. +- ~~**Thumbnails — sprites/trickplay (media)**~~ ✅ **Trickplay CERRADO bajo demanda (2026-06-01)**: la preview de barra usa cues `/thumbnail` en vivo (un frame por cue al sobrevolar), no un sprite pregenerado. El sprite/BIF de toda la timeline con cacheo en disco del agente sigue siendo una optimización futura (no necesaria para la UX actual). Ver estado abajo. +- **nvenc "Invalid Level" en fuentes anamórficas (alta — destapado en el smoke de trickplay)** ✅ **ARREGLADO (2026-06-01)**: el nivel H.264 del transcode HLS se derivaba solo de la altura → una fuente 2.39:1 escalada a 1080 (~2586×1080 = 11016 MBs) revienta el `MaxFS` de L4.1 (8192); ffmpeg fallaba (`InitializeEncoder failed: invalid param (8): Invalid Level` en nvenc, `frame MB size > level limit` en libx264) y la sesión no producía ningún segmento. Casi todos los rips 4K son anamórficos → reproducción HLS rota en silencio. Fix (`hwaccel.go`): `H264LevelForFrame(width,height)` deriva el nivel del recuento de macrobloques real (máx. entre el nivel por-altura y el por-MB); `hls.go` calcula el ancho de salida y lo usa. Ver estado abajo. + +### Hueco medio — Readahead dinámico (ver-mientras-baja) ✅ CERRADO (2026-05-31) +El lector de torrent usaba un readahead **estático de 5 MiB** (~1.9s de un stream 4K de 20 Mbps) → al reproducir un torrent a medio bajar, la reproducción adelantaba a la descarga y se atascaba. +- `dynamicReadahead(bitrateBps)` (`readahead.go`): ~30s de vídeo, clamp [8, 96] MiB; default 24 MiB cuando el bitrate es desconocido (ya ~5× el viejo 5 MiB). anacrolix (`SetResponsive`+`SetReadahead`) ya prioriza las piezas de esa ventana por delante del read position y re-prioriza en seek — el feedback playhead→prioridad estaba; solo faltaba dimensionar la ventana. +- `torrentFileProvider` lleva `bitrateBps atomic.Int64`, sondeado **async** (`probeMediaInfo` en goroutine vía DataDir+DisplayPath) — sin coste de TTFF; hasta resolverse usa el default, y los readers posteriores (cada range/seek crea uno) cogen el valor preciso. StreamEngine (CLI) → default 24 MiB. +- **Smoke**: ffprobe en 4K real (20.7 Mbps) → readahead **73 MiB** (~28s) vs 5 MiB. Tests del func puro + -race limpio en el probe async. /critico: código sólido, fix aplicado (probe síncrono→async para eliminar 3s de TTFF si falta la cabecera). + +### Hueco medio — Trickplay (preview en la barra) ✅ CERRADO (2026-06-01) +Preview de fotograma al pasar el ratón por la barra de búsqueda, **bajo demanda** (sin pregenerar sprite). Alcance decidido con el usuario: on-demand + UX no invasiva + activable/desactivable + documentado. +- **Web** (rama `feat/unarr-brand`): `buildTrickplayVtt()` (`src/lib/stream/trickplay.ts`) emite una pista WebVTT `thumbnails` con 1 cue/10s; cada cue apunta a `GET /thumbnail?pos=&w=320#xywh=0,0,W,H` (frame completo, alto par derivado del aspecto). media-chrome solo descarga el frame sobrevolado y lo cachea. Wiring en `HlsStreamPlayer` (fetch a `file-details` → blob VTT → ``), botón on/off + var CSS de fondo en `MediaChromePlayer`, toggle por navegador en `localStorage` (`useTrickplay`, default ON). Doc: `docs/architecture/trickplay.md`. Tests: `trickplay.test.ts` (6, formato cue + alto par + token vacío + inputs insuficientes). +- **Smoke real** (iPhone-equiv en Chrome, F1 4K DV+HDR10): vídeo reproduce → hover en la barra renderiza un frame real en la posición (1:17:36) ≠ el frame en curso; etiqueta de tiempo inmediata; toggle off → `` desaparece (sin preview) y persiste `localStorage="0"`; toggle on → vuelven los 932 cues. CORS del `` OK (allowlist del agente). +- **No invasivo**: nada carga hasta el hover; 1er frame ~0.8–2.1s en 4K-desde-NAS, re-hover instantáneo (caché navegador); la etiqueta de tiempo aparece ya aunque el frame se esté generando. + +### Hueco medio — Burn-in de subtítulos bitmap (PGS/DVB) ✅ CERRADO (2026-06-01) +Los subs de imagen (PGS/DVB/VOBSUB) no se pueden servir como WebVTT; se incrustan en el vídeo durante el transcode. Alcance (decidido con el usuario): bajo demanda + nudge cuando el fichero SOLO tiene bitmap (sin auto-activar). +- **Agente** (rama `unarr-burnin` ex `feat/unarr-agent`): `HLSSessionConfig.BurnSubtitleIndex *int` (nil=sin burn; puntero para que el 0 no se confunda con "quema pista 0"); en la cache key (`KeyFor`/`KeyForID`). `buildHLSFFmpegArgsAt`: si el índice apunta a una pista bitmap válida, `-map [vout]` + `-filter_complex [0:v:0][base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]`. Overlay TRAS el tonemap (subs SDR no se aplastan); scale2ref encaja el lienzo PGS al frame. Índice inválido/texto/fuera de rango → fallback a encode limpio (log). `IsTextSubtitle` ahora incluye `"text"` (paridad con el clasificador web). Tests `TestBuildHLSFFmpegArgsBurnSubtitle` (filter_complex/overlay/[vout] vs -vf según bitmap/texto/rango) + cache-key. +- **Web** (rama `unarr-burnin` ex `feat/unarr-brand`): columna `streaming_session.burn_subtitle_index` (migración 0139, NOT NULL default -1) en identidad de sesión + dedup; `session/route` fuerza `playMethod=hls` cuando hay burn; `agent.ts` lo pasa al daemon. Selector en `MediaChromePlayer` alimentado de **file-details** (`subtitleTracks`, mediainfo estática) → aparece también en direct-play; posición del array = `-map 0:s:N`. `isBitmapSubtitleCodec` (`src/lib/stream/subtitles.ts`) espeja `IsTextSubtitle`. Notice: "incrustando" al quemar / nudge si solo-bitmap. Doc: `docs/architecture/subtitle-burn-in.md`. +- **Smoke real** (Sonic 2020 BDremux 1080p, 7 PGS + 1 subrip): selector lista los 7 PGS (EN/ES/NL · imagen), excluye el subrip; elegir ES (`0:s:2`) fuerza HLS, el agente transcodifica con overlay sin error y el frame muestra **"Sé lo que estáis pensando."** quemado (posición + brillo correctos). /critico 2 revisores: arreglado `"text"` (paridad), reset de burn al cambiar de ítem, `bitmapSubtitles` a flatMap. +- **Caveat**: PGS + seek pierde el subtítulo (el `-ss` antes de `-i` tira el estado del decoder PGS). Reproducción lineal desde el inicio = OK. Mitigación futura: decodificar PGS desde el epoch cercano. +- **Aislamiento**: este trabajo se hizo en worktrees dedicados (`/tmp/tc-unarr-{web,cli}`, rama `unarr-burnin`) tras una colisión de ramas en los checkouts primarios compartidos. Merge a `feat/unarr-{brand,agent}` pendiente de decisión del operador. + +### Bug agente — nvenc "Invalid Level" en fuentes anamórficas ✅ ARREGLADO (2026-06-01) +Destapado durante el smoke de trickplay: el HLS de un 4K anamórfico (3840×1604, 2.39:1) no producía **ningún** segmento. +- **Causa**: el nivel H.264 se derivaba solo de la altura de salida (`H264LevelForHeight`). Escalado a 1080 de alto, un 2.39:1 queda ~2586×1080 = 11016 macrobloques, que supera el `MaxFS` del nivel 4.1 (8192). ffmpeg fallaba al abrir el encoder (`InitializeEncoder failed: invalid param (8): Invalid Level` en h264_nvenc; el equivalente `frame MB size > level limit` en libx264) → 0 paquetes → la sesión se quedaba en "preparando sesión" hasta el timeout de mark-ready. Casi todo rip 4K es 2.39:1, así que la reproducción HLS estaba rota para la mayoría de pelis 4K (en silencio). +- **Fix** (`hwaccel.go` + `hls.go`): `H264LevelForFrame(width, height)` deriva el nivel del recuento de macrobloques real (`levelForMacroblocks`, tabla MaxFS de la spec) y devuelve el máximo entre ese y el nivel por-altura (que conserva el margen de fps/MBPS). `hls.go` calcula el ancho de salida (`probe.Width * outputHeight / probe.Height`, par) y llama a `H264LevelForFrame`. 16:9 no cambia (mismo resultado que antes); anamórfico sube a 5.0 cuando hace falta. `transcoder.go` no se toca (su `SourceHeight` nunca se rellena → ya cae al default seguro 5.1). +- **Reproducido + verificado**: con `/usr/bin/ffmpeg` 6.1.1 + nvenc, `testsrc=2586x1080 @ -level:v 4.1` reproduce el error exacto; `@ 5.0` codifica OK. Tras el fix, sesión HLS del F1 4K arranca sin "Invalid Level"/auto-restart/timeout y el `