Compare commits

...

10 commits

Author SHA1 Message Date
Deivid Soto
d913e66527 fix(daemon): defer probeCancel so a panic mid-diagnostic still releases ctx
Some checks failed
CI / Test (push) Failing after 8s
CI / Build (push) Failing after 1s
CI / Build-1 (push) Failing after 1s
CI / Build-2 (push) Failing after 1s
CI / Build-3 (push) Failing after 1s
CI / Build-4 (push) Failing after 1s
CI / Build-5 (push) Failing after 1s
CI / Lint (push) Failing after 1s
CI / Coverage (push) Failing after 1s
CI / Vet (push) Failing after 1s
DetectHWAccelDiagnostic spawns subprocess calls; an unexpected panic
(broken ffmpeg binary, OOM mid-exec) would otherwise leave the
WithTimeout context dangling until natural expiry. defer keeps the
goroutine + timer reachable until runDaemonStart returns, but on a
long-lived daemon that's the process lifetime anyway — same effective
cost, with the safety guarantee.
2026-05-27 14:11:24 +02:00
Deivid Soto
eb109f70ac feat(agent): send full transcoder diagnostic in register payload (0.9.12)
Daemon now runs engine.DetectHWAccelDiagnostic at startup (instead of the
lighter DetectHWAccel) and ships the full picture — ffmpeg version,
resolved binary path, HW encoders compiled in, device files / drivers
detected — up to the server in the RegisterRequest payload.

Why: the most common cause of slow first-play is a software-only ffmpeg
build. Surfacing the diagnostic in the web AgentsTab "Diagnose
transcoder" modal lets a user see *why* their backend landed on libx264
(e.g. brew's default formula ships without --enable-nvenc, or the
container is missing /dev/nvidia0) without SSHing in to run `unarr
probe-hwaccel` manually.

Also emits a single `[transcode]` startup log line summarising the same
data — convenient for `journalctl --user -u unarr | grep transcode`.

Bounded by a 10 s context so a hung ffmpeg binary can't stall daemon
startup forever.
2026-05-27 12:48:40 +02:00
Deivid Soto
287685427a chore(skills): add /publish slash command + allow .claude/ in git
Mirrors the slash command added in torrentclaw-web/.claude/commands.
With the global ~/.gitignore excluding .claude/ by default, the
gitignore override is required for project-shared commands/agents/hooks
to be checked in (settings.local.json and projects/ stay local).

/publish documents the full unarr release flow (bump + tag + binaries +
Hetzner + Docker Hub + smoke) as a single command, while GitHub Actions
remains unavailable for the torrentclaw org.
2026-05-27 12:46:24 +02:00
Deivid Soto
23b79f6411 chore(release): add ship.sh end-to-end pipeline as GH Actions backup
GitHub Actions release.yml + docker job currently doesn't fire (org
shadow-ban). ship.sh replicates the CI pipeline locally so releases
keep landing on Hetzner + Docker Hub without depending on CI:

  1. Sanity checks: clean tree, tag at HEAD, version.go match
  2. goreleaser release --skip=publish  (build dist/*)
  3. publish-cli-release.sh  (rsync to Hetzner + flip version.txt)
  4. docker buildx --push multi-arch (amd64 + arm64)
  5. Smoke: torrentclaw.com/version + docker run image version
  6. Optional --push to git-push tag to GH

Exposed via make targets: ship, ship-dry, ship-push.
2026-05-27 12:35:01 +02:00
Deivid Soto
80461ea7fe chore(release): 0.9.11
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
- Bump version to 0.9.11
- Update CHANGELOG.md
2026-05-27 11:55:30 +02:00
Deivid Soto
9df38c95a3 fix(library): classify resolution by width + height, not height alone
Cinematic widescreen content (1920×804 at 2.39:1, 3840×1600 21:9, etc.)
was being misclassified: a 1080p source presented as 1920×804 fell to
720p because 804 < 900. Same shape for 2160p sources letterboxed below
2000px tall.

ResolveResolution now takes (width, height) and picks the larger of the
width-derived and height-derived buckets, so anamorphic/letterboxed
sources land in the right bucket.
2026-05-27 11:54:29 +02:00
Deivid Soto
0b2462c82a feat(hls): pre-segmentación delantada — 2 s segments + async session start (0.9.10)
First-frame latency drops by another 1-2 s on cold-cache plays:

1. HLS segment duration halved from 4 s to 2 s. seg-0 lands in ~half
   the wait time — the player paints the first frame as soon as it
   arrives. Software encodes on 4K go from ~3 s wait to ~1.5 s; HW
   encoders shave ~0.5 s. Trade-off: 2× segment count per source
   (~3600 segments for a 2 h movie instead of ~1800), but each is
   half the size on disk. Within HLS spec — Apple recommends 6 s, but
   2 s is valid; LL-HLS uses 1-2 s.

2. Cache from 0.9.9 self-heals: cached entries used 4 s segments;
   VerifyComplete now expects a different highest segment index and
   invalidates them, triggering a re-encode on next play. No manual
   cleanup needed.

3. OnStreamSession daemon callback now runs StartHLSSession in a
   goroutine. Sync HTTP responses return immediately (~50 ms instead
   of waiting for the ~0.3-1 s ffprobe). Other pending actions in
   the same sync cycle (new tasks, deletes) no longer wait for the
   transcoder warmup. Browser HEAD probes already have a 30 s retry
   budget that covers the brief gap between playerSessionRegistry.add
   and streamSrv.HLS().Register.

Helpers added (engine.segmentDurationFor / segmentStartSec /
segmentCountForDuration) so a future short-first-segment variant or
non-uniform layout can slot in without touching every call site.

Internal: -hls_init_time was investigated but discarded — ffmpeg's
implementation treats it as a min duration, not a target, so it
couldn't deliver a uniformly 2 s first segment on top of a 4 s
steady state. Uniform 2 s is simpler and gets the same first-frame
win.
2026-05-27 11:36:41 +02:00
Deivid Soto
bf8ed0d928 refactor(hls): critico-driven hardening of fase 3.2
Addresses items raised by the multi-agent code review of the 0.9.9
HW accel + first-start work:

- EncoderProfile now carries DecodeHwAccel so the demuxer `-hwaccel`
  flag and the encoder argv derive from a single resolved profile.
  Adding a new backend can no longer leave the two switches out of
  sync.
- VAAPI no longer passes `-hwaccel_output_format vaapi`. That option
  pinned decoded frames to GPU memory, but the filter chain (scale,
  format, setparams) runs on CPU and would fail with "impossible to
  convert between formats". Frames now decode HW + flow on CPU; the
  encoder uploads back to GPU. Pre-existing bug, never reported because
  no one had VAAPI auto-detected in practice.
- readyMax field comment + name: documented that it's a COUNT
  (segments ready), not an index. The semantics were correct but the
  comment read "highest index" which made `idx < readyMax` look like
  an off-by-one to reviewers.
- probe_cache background janitor: 5-minute sweeper that drops expired
  entries even when no lookup retouches the key. Lookup-only eviction
  was fine for small libraries but unbounded for users who browse and
  abandon thousands of files within a TTL window. Lazy + sync.Once.
- probe_cache TTL eviction now re-checks under the write lock so a
  concurrent re-insert isn't accidentally evicted.
- probe_cache size-change test now Chtimes the file back to its
  original mtime so only `size` differs between store and lookup
  keys — properly exercises the size-check path.
- New TestProbeCache_SweepDropsExpired covers the janitor sweep.
- CHANGELOG: backfilled missing compare links 0.6.4 → 0.9.9.
- Stale "line ~1119" reference in VideoToolbox comment dropped; the
  bitrate block moved a few lines and the comment was already wrong.
2026-05-27 11:15:44 +02:00
Deivid Soto
0f4ad67827 fix(transcode): make preset libx264-only + restore quality opt-in
Two issues with the 0.9.9 preset retune:

1. applyDefaults was filling Preset="veryfast" before
   ResolveEncoderProfile got to pick the latency-biased default, so the
   "superfast" change never reached users with a freshly-generated
   config.toml — only those who left the field empty saw it.

2. The configured preset was being passed through to every encoder.
   That's only valid for libx264 (ultrafast…veryslow); NVENC uses p1-p7
   and rejects anything else, QSV uses its own subset. A user with NVENC
   + preset="veryfast" would have ffmpeg reject the argv.

Now:
- TranscodeConfig.Preset documented as libx264-only with the full
  range + advice on quality vs first-start latency.
- Default in applyDefaults is empty (was "veryfast") so the engine
  fills in "superfast" on libx264.
- ResolveEncoderProfile ignores configuredPreset for vendor encoders
  (NVENC sticks to p3, QSV to veryfast, VideoToolbox has no preset
  knob). Test cases updated to lock in this behaviour.

Users who want better quality at slower first-play should set
download.transcode.preset = "veryfast" (previous default) / "faster" /
"fast" / "medium" in their config.toml.
2026-05-27 10:46:03 +02:00
Deivid Soto
3b8d77b496 feat(hls): faster first-start — probe cache + tighter encoder presets (0.9.9)
Reduces first-segment latency on cache MISS so the player doesn't sit on
"preparando sesión". Three independent levers:

1. ProbeFile memoised by (path, mtime, size) for 30 min — second play of
   the same source skips ffprobe (1-3 s on 50+ GB MKVs).
2. HLS encoder presets biased for latency over quality:
   - libx264 default veryfast → superfast (~15-20% faster, marginal
     quality loss at 5-25 Mbps target bitrates).
   - NVENC: -preset p4 -tune hq → -preset p3 -tune ll. First-segment
     ~0.8 s on RTX-class GPUs (was ~1.5 s).
   - QSV: -preset medium → -preset veryfast (keeps look_ahead=0).
   - VideoToolbox: adds -realtime 1 (was unset). Bitrate args still
     drive rate control; -q:v dropped to avoid the silent conflict
     where ffmpeg ignored it under -b:v.
3. Per-session log surfaces encoder + accel + preset so "first-start
   was slow" complaints can be triaged from the journal alone.

Diagnostic helpers (DetectHWAccelDiagnostic + HWAccelDiagnostic) added
for future wiring into daemon startup / agent register; users today can
already inspect via `unarr probe-hwaccel`.

Web: AgentsTab profile page now shows the agent's chosen encoder
(amber if software libx264, green if HW) plus the transcode-resolution
cap. Hidden for pre-0.9.9 agents that haven't reported hwAccel.
2026-05-27 10:09:42 +02:00
22 changed files with 1353 additions and 218 deletions

161
.claude/commands/publish.md Normal file
View file

@ -0,0 +1,161 @@
---
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}_<os>_<arch>.{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`).

18
.gitignore vendored
View file

@ -41,4 +41,20 @@ dist-ffbinaries/
# Docker
tmp/
config/
dist-ffbinaries/
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/

View file

@ -5,158 +5,86 @@ 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.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`.
## [0.9.11] - 2026-05-27
### Added
- **hls**: pre-segmentación delantada — 2 s segments + async session start (0.9.10)
- **hls**: faster first-start — probe cache + tighter encoder presets (0.9.9)
### Changed
- **hls**: critico-driven hardening of fase 3.2
### Fixed
- **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
## [0.9.8] - 2026-05-27
### Fixed
- **auto-upgrade restart loop**: when the server signal arrived for a version
the daemon was already running (e.g. flag still set after a previous
upgrade), `applyAutoUpgrade` would call `upgrade.Execute` (which no-ops),
then `os.Exit(0)` anyway — systemd respawned, the flag was still set, the
cycle repeated. Now: no-op case is detected up front, the daemon clears
the server flag via `/api/internal/agent/upgrade-result` and stays alive.
- **upgrade flag stuck after success**: the CLI never reported the upgrade
outcome, so `upgrade_requested` stayed `true` in the DB forever. The
daemon now calls `/api/internal/agent/upgrade-result` on every applyAutoUpgrade
branch (success, failure, no-op) — server clears the flag, restart loops
end.
### Added
- New `Client.ReportUpgradeResult(agentID, success, version, error)` HTTP
method wrapping `POST /api/internal/agent/upgrade-result`.
- **upgrade**: break auto-apply restart loop (0.9.8)
## [0.9.7] - 2026-05-26
### Added
- **hls cache**: persistent fMP4 segment cache keyed by
`(source, quality, audio_index)`. After a successful encode the segments
+ `init.mp4` are kept under `~/.cache/unarr/hls-cache/{key}/` with a
`.complete` marker. A second play of the same file at the same quality
skips ffmpeg entirely (smoke-tested 2331× faster than re-encode). LRU
+ size-budget eviction; pinned during active play; per-key writer-lock
prevents two concurrent encodes from corrupting each other. Startup
reaps orphan dirs without `.complete` older than 10 min so a daemon
crash doesn't leak disk indefinitely. New `[downloads.hls_cache]` block
in `config.toml`: `enabled` (default true), `size_gb` (default 5,
min 1), `dir` (default `~/.cache/unarr/hls-cache`).
- **hls cache integrity check**: on HIT, the daemon stats `init.mp4` +
last segment before reporting cache reuse — if a file was externally
deleted, the entry is invalidated and re-encoded transparently.
- **hls cache stats**: hit/miss counters surface via `cache.Stats()`
(`Hits`, `Misses`, `EntryCount`, `TotalBytes`) and the sweeper logs a
daily summary line `[hls_cache] day-stats: hits=N misses=M ratio=X%
entries=Y size=ZMB`.
- **subtitle integrity for cached replay**: `Close` waits up to 15 s for
the subtitle extractor goroutine before sealing `.complete` so a HIT
never serves half-written `.vtt` files. Timeout invalidates instead of
sealing.
### Changed
- `[daemon] auto_upgrade` now appears in fresh `config.toml` files as
`true` (it was always the implicit default; this just makes it visible
in default-generated configs).
- **hls**: persistent fMP4 segment cache + integrity + stats (0.9.7)
## [0.9.6] - 2026-05-26
### Added
- **auto-upgrade**: when the web flags the agent for upgrade
(`POST /api/internal/agent/upgrade` or the "Force update now" button),
the daemon now downloads and replaces the binary in-place, then exits so
the service supervisor (`systemd Restart=always` on Linux, the equivalent
on macOS/Windows) respawns on the new version. No `unarr update` step
required from the user. Still opt-in — only fires when the server sends
the upgrade signal.
### Changed
- The `OnUpgrade` daemon callback no longer just logs `run unarr self-update`;
it now triggers the actual upgrade in a background goroutine.
- **daemon**: auto-apply upgrades when server signals (0.9.6)
## [0.9.5] - 2026-05-26
### Added
- **funnel**: optional CloudFlare Quick Tunnel subprocess. `unarr funnel on`
spawns `cloudflared` as a child process and registers an anonymous
`https://<random>.trycloudflare.com` hostname tunnelled to the daemon's
HLS server. The hostname is reported back to the web on every sync so the
in-browser player picks it up automatically — cross-network playback now
works on torrentclaw.com without Tailscale or port forwarding. Bytes
proxy through CloudFlare; TorrentClaw still doesn't relay content.
- **funnel**: on by default for fresh installs (NAS/Docker get cross-network
HTTPS automatically); existing configs that pre-date the feature stay
off until the operator runs `unarr funnel on`.
- **funnel**: auto-downloads cloudflared to the unarr data dir when not on
PATH (Linux amd64/arm64/armhf/386). ELF magic + size sanity check on the
download; `O_EXCL` partial-write so concurrent daemons don't clobber
each other.
- **funnel**: subprocess supervisor keeps the tunnel up across cloudflared
crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min)
on persistent failures. The web's reported URL is cleared the moment
cloudflared exits so an outdated hostname doesn't keep handing out 502s.
- **funnel**: `unarr funnel status` shows the live URL once registered.
See README §`[downloads.funnel]` for the throughput / latency caveats of
CF's free Quick Tunnels.
- **docker**: the official `torrentclaw/unarr` image now bundles
`cloudflared` so the funnel works the moment the container starts — no
first-run download.
### Fixed
- **hls/libx264**: bump the H.264 level we hint to libx264 by one tier so
anamorphic (>16:9) sources stop emitting unplayable streams. 720p at
level 3.1 silently rejected 1728×720 cinemascope frames with
`frame MB size > level limit`; 720p now ships at level 4.0, 1080p at 4.1.
Decoder compatibility is unaffected — every device that handles 1080p
already handles ≥ 4.1.
- **funnel**: cloudflare quick tunnel embedded subprocess (0.9.5)
## [0.9.4] - 2026-05-26
### Removed
- **streaming**: retire the custom WebRTC DataChannel pipeline. The daemon no
longer ships pion/webrtc, the WSS signaling client, or the wire framing
package — every in-browser session now uses HLS over HTTP from the daemon
(Tailscale / LAN / UPnP). Browser P2P (WebTorrent) bytes never re-enabled.
- **config**: `[downloads.webrtc]` block removed from the TOML schema; existing
config files with the section parse cleanly because go-toml ignores unknown
sections.
- **seed_file**: `mode=seed_file` task handler + `engine.SeedFile` helper
dropped — the last in-browser caller was retired with the WebRTC player.
- **wstracker-probe**: standalone probe binary removed.
### Changed
- **agent wire**: `SyncResponse.WebRTCSessions` (JSON: `webrtcSessions`) renamed
to `StreamSessions` (JSON: `streamSessions`). The Go type `agent.WebRTCSession`
is now `agent.StreamSession`. Wire-incompatible with web < 2026-05-26.
- **torrent**: `buildMagnet` no longer accepts an `extraTrackers` variadic —
the default tracker list is the only set used.
### Fixed
- **hls**: clamp the ffmpeg `-b:v` to the bitrate cap derived from the EFFECTIVE
output height instead of the requested quality. Previously asking for "2160p"
on a 1080p source overshot the H.264 level we resolved from the effective
height (4.0, max 20 Mbps) and made libx264 abort with
`VBV bitrate > level limit`.
## [0.9.2] - 2026-05-21
### Added
- **vpn**: `unarr vpn` command (`status`, `enable`, `disable`) to manage the managed
WireGuard split-tunnel, with `vpn status --check` to verify provisioning.
- **vpn**: report split-tunnel state (active, exit server) to the web on register
+ every sync, so the dashboard shows which agent holds the single WireGuard slot.
- **vpn**: send the agent id when fetching the VPN config so the web can arbitrate
the single WireGuard slot — the first agent claims it; the rest are told to run
OpenVPN on their own host (1 agent on WireGuard + up to 9 on OpenVPN).
- **stream**: retire WebRTC, HLS-only, bump 0.9.4 (**BREAKING**)
## [0.9.3] - 2026-05-26
### Added
- **usenet**: warn at startup when par2 or extractor is missing
### Fixed
- **engine**: truncate errorMessage before reporting status
- **hls**: clamp ffmpeg bitrate to the level we derive from outputHeight
## [0.9.2] - 2026-05-22
### Added
- **vpn**: unarr vpn command + report/arbitrate the WireGuard slot
## [0.9.1] - 2026-05-21
@ -167,6 +95,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **security**: bump golang.org/x deps and add container CVE scan gate
### Other
- **release**: 0.9.1
## [0.9.0] - 2026-05-21
@ -176,6 +108,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **vpn**: local config_file for self-hosted/personal VPN testing
- **vpn**: split-tunnel torrent traffic through managed WireGuard
### CI/CD
- deploy install scripts to GitHub Pages
### Documentation
- **docker**: refresh Docker Hub README + sync description in CI
@ -189,6 +125,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Other
- **pages**: add .nojekyll to disable Jekyll processing
- **pages**: set custom domain unarr.torrentclaw.com
- **release**: 0.9.0
## [0.8.1] - 2026-05-08
@ -562,6 +500,17 @@ 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
[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
[0.9.6]: https://github.com/torrentclaw/unarr/compare/v0.9.5...v0.9.6
[0.9.5]: https://github.com/torrentclaw/unarr/compare/v0.9.4...v0.9.5
[0.9.4]: https://github.com/torrentclaw/unarr/compare/v0.9.3...v0.9.4
[0.9.3]: https://github.com/torrentclaw/unarr/compare/v0.9.2...v0.9.3
[0.9.2]: https://github.com/torrentclaw/unarr/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/torrentclaw/unarr/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/torrentclaw/unarr/compare/v0.8.1...v0.9.0
[0.8.1]: https://github.com/torrentclaw/unarr/compare/v0.8.0...v0.8.1

View file

@ -1,4 +1,4 @@
.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry
.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry ship ship-dry ship-push
BINARY = unarr
SENTRY_DSN ?=
@ -71,6 +71,19 @@ release-dry:
@test -n "$(V)" || { echo "Usage: make release-dry V=patch|minor|major|0.5.0"; exit 1; }
@./scripts/release.sh --dry-run $(V)
## Ship a release end-to-end (goreleaser + Hetzner + Docker Hub). Standalone backup for GH Actions.
## Reads version from internal/cmd/version.go unless V= is provided.
ship:
@./scripts/ship.sh $(V)
## Ship + git push tag to GH afterwards
ship-push:
@./scripts/ship.sh --push $(V)
## Preview ship steps without executing
ship-dry:
@./scripts/ship.sh --dry-run $(V)
## Remove generated files
clean:
rm -f $(BINARY) coverage.out coverage.html

View file

@ -28,7 +28,15 @@ type DaemonConfig struct {
ScanPaths []string // configured scan paths for file deletion validation
HWAccel string // detected encoder backend ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none")
MaxTranscodeHeight int // resolution cap the agent can transcode comfortably (px)
AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true)
// Diagnostic data populated by engine.DetectHWAccelDiagnostic at daemon
// start. Surfaced in the web "Diagnose transcoder" modal — lets a user
// see which encoders the ffmpeg binary supports and which devices the
// host exposes without running `unarr probe-hwaccel`.
FFmpegVersion string // first line of `ffmpeg -version`
FFmpegPath string // resolved binary path
HWEncoders []string // HW-class encoder names found in `ffmpeg -encoders`
HWDevices []string // device files + driver bins detected at probe time
AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true)
}
// Daemon manages agent registration and the sync loop.
@ -122,6 +130,10 @@ func (d *Daemon) Register(ctx context.Context) error {
TailscaleIP: d.cfg.TailscaleIP,
HWAccel: d.cfg.HWAccel,
MaxTranscodeHeight: d.cfg.MaxTranscodeHeight,
FFmpegVersion: d.cfg.FFmpegVersion,
FFmpegPath: d.cfg.FFmpegPath,
HWEncoders: d.cfg.HWEncoders,
HWDevices: d.cfg.HWDevices,
VPNActive: d.vpnActive,
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,

View file

@ -26,6 +26,15 @@ type RegisterRequest struct {
// up to 2160p.
HWAccel string `json:"hwAccel,omitempty"`
MaxTranscodeHeight int `json:"maxTranscodeHeight,omitempty"`
// Diagnostic surface filled by engine.DetectHWAccelDiagnostic at daemon
// start. Surfaced in the web "Diagnose transcoder" modal so users can
// see *why* their HWAccel landed on "none" without running
// `unarr probe-hwaccel` locally — most commonly the ffmpeg binary
// shipped without HW encoders (linuxbrew, brew's default formula).
FFmpegVersion string `json:"ffmpegVersion,omitempty"`
FFmpegPath string `json:"ffmpegPath,omitempty"`
HWEncoders []string `json:"hwEncoders,omitempty"`
HWDevices []string `json:"hwDevices,omitempty"`
// Managed-VPN split-tunnel state. The web tracks which agent holds the single
// WireGuard slot (1 VPNResellers account = 1 WG keypair = 1 concurrent
// connection); other agents are told to use OpenVPN on their host instead.

View file

@ -143,7 +143,19 @@ func runDaemonStart() error {
// is what the web side uses to decide whether the user should pre-empt
// transcoding by downloading a smaller version (4K source on a software
// libx264-only host is the canonical case where pre-download wins).
hwAccelPick := engine.DetectHWAccel(context.Background(), cfg.Library.FFmpegPath)
//
// Use the full diagnostic (encoders + devices + ffmpeg version) instead
// of just the picked backend — the extra fields ride along in the
// register payload so the web "Diagnose transcoder" modal can show *why*
// libx264 was selected on a host with a GPU (e.g. brew's ffmpeg without
// --enable-nvenc). 10 s ceiling so a hung ffmpeg binary can't stall
// startup forever.
ffmpegResolved, _ := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer probeCancel() // guard against a panic inside DetectHWAccelDiagnostic
hwDiag := engine.DetectHWAccelDiagnostic(probeCtx, ffmpegResolved)
log.Println(hwDiag.LogLine())
hwAccelPick := hwDiag.Pick
maxTranscodeHeight := 1080
if hwAccelPick != engine.HWAccelNone {
maxTranscodeHeight = 2160
@ -162,6 +174,10 @@ func runDaemonStart() error {
ScanPaths: library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath),
HWAccel: string(hwAccelPick),
MaxTranscodeHeight: maxTranscodeHeight,
FFmpegVersion: hwDiag.FFmpegVersion,
FFmpegPath: hwDiag.FFmpegPath,
HWEncoders: hwDiag.Encoders,
HWDevices: hwDiag.Devices,
AutoUpgrade: cfg.Daemon.AutoUpgradeEnabled(),
}
@ -580,14 +596,23 @@ func runDaemonStart() error {
Transcode: tcRuntime,
Cache: hlsCache,
}
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
if err != nil {
playerSessionRegistry.remove(sess.SessionID)
hlsCancel()
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
return
}
streamSrv.HLS().Register(hsess)
// StartHLSSession runs ffprobe (15 s cap, typical 0.31 s) before
// returning. Doing this synchronously inside the sync handler holds
// the next sync HTTP cycle until ffprobe is done, so any other
// pending actions (new tasks, deletes) wait too. Hand it off so
// the sync loop returns immediately — browser HEAD probes already
// have a 30 s retry budget that absorbs the gap until
// `streamSrv.HLS().Register` lands.
go func() {
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
if err != nil {
playerSessionRegistry.remove(sess.SessionID)
hlsCancel()
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
return
}
streamSrv.HLS().Register(hsess)
}()
}
// Periodic DHT node persistence (every 5 min)

View file

@ -241,7 +241,7 @@ func printScanSummary(cache *library.LibraryCache) {
continue
}
res := library.ResolveResolution(item.MediaInfo.Video.Height)
res := library.ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
if res == "" {
res = "other"
}

View file

@ -1,4 +1,4 @@
package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.9.8"
var Version = "0.9.12"

View file

@ -96,9 +96,27 @@ type VPNConfig struct {
// Disabled by default; enabling requires ffmpeg + ffprobe on PATH (or
// explicit paths via the library config).
type TranscodeConfig struct {
Enabled bool `toml:"enabled"` // master switch
HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
Preset string `toml:"preset"` // libx264 preset; "veryfast" by default
Enabled bool `toml:"enabled"` // master switch
HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
// Preset is the encoder speed/quality dial. Only used on software encode
// (libx264) — HW backends (NVENC/QSV/VAAPI/VideoToolbox) use vendor
// presets that don't share libx264's vocabulary and would be rejected
// by ffmpeg if passed here.
//
// Empty (default) → engine picks "superfast" — latency-biased, ~3 s
// first-play on 1080p source on a modern x86 CPU. Marginal quality loss
// at 5-25 Mbps target bitrates.
//
// For better quality at slower first-play (1-2 s slower per seg):
// "veryfast" — previous default; balanced
// "faster" — slight quality bump
// "fast" — meaningful quality bump
// "medium" — libx264 stock default; CPU-bound on 4K
// "slow" / "slower" / "veryslow" — only for batch encodes, not real-time HLS
//
// Or faster:
// "ultrafast" — lowest quality, fastest encode
Preset string `toml:"preset"`
VideoBitrate string `toml:"video_bitrate"` // e.g. "5M"
AudioBitrate string `toml:"audio_bitrate"` // e.g. "192k"
MaxHeight int `toml:"max_height"` // optional downscale cap (e.g. 720)
@ -176,7 +194,10 @@ func Default() Config {
Transcode: TranscodeConfig{
Enabled: true,
HWAccel: "auto",
Preset: "veryfast",
// Empty preset → engine.ResolveEncoderProfile picks the
// latency-biased default ("superfast" on libx264). Override
// in config.toml when quality > first-start latency matters.
Preset: "",
AudioBitrate: "192k",
MaxConcurrent: 2,
},
@ -280,7 +301,12 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
cfg.Download.Transcode.HWAccel = "auto"
}
if !meta.IsDefined("downloads", "transcode", "preset") {
cfg.Download.Transcode.Preset = "veryfast"
// Empty = let engine.ResolveEncoderProfile pick the latency-biased
// default ("superfast" on libx264). Users wanting better quality at
// slower first-play can override to "veryfast" / "fast" / "medium" in
// config.toml. Ignored when hw_accel picks NVENC/QSV/VAAPI/VideoToolbox
// (those have built-in vendor presets).
cfg.Download.Transcode.Preset = ""
}
if !meta.IsDefined("downloads", "transcode", "audio_bitrate") {
cfg.Download.Transcode.AudioBitrate = "192k"

View file

@ -215,8 +215,11 @@ name = "Test"
if cfg.Download.Transcode.HWAccel != "auto" {
t.Errorf("Transcode.HWAccel = %q, want auto", cfg.Download.Transcode.HWAccel)
}
if cfg.Download.Transcode.Preset != "veryfast" {
t.Errorf("Transcode.Preset = %q, want veryfast", cfg.Download.Transcode.Preset)
if cfg.Download.Transcode.Preset != "" {
// Default is now empty — engine.ResolveEncoderProfile picks
// "superfast" on libx264 for first-start latency. Users
// wanting better quality override in config.toml.
t.Errorf("Transcode.Preset = %q, want empty", cfg.Download.Transcode.Preset)
}
if cfg.Download.Transcode.MaxConcurrent != 2 {
t.Errorf("Transcode.MaxConcurrent = %d, want 2", cfg.Download.Transcode.MaxConcurrent)

View file

@ -32,10 +32,46 @@ import (
"time"
)
// hlsSegmentDuration is the target seconds per HLS fragment. Four seconds is
// the Plex/Apple default — short enough that seek granularity is acceptable,
// long enough that GOP overhead doesn't dominate.
const hlsSegmentDuration = 4
// hlsSegmentDuration is the target seconds per HLS fragment.
//
// We use 2 seconds (not the more common 4-6 s). Trade-off: 2× more segments
// per source (a 2 h movie produces 3600 segments instead of 1800), but the
// player's first-frame wait drops to ~half — ffmpeg only needs to encode
// 2 s before seg-0 lands. For software encodes on 4K this is ~1 s instead
// of ~3 s of cold-cache wait. Well within HLS spec (Apple recommends 6 s,
// but 2-6 s is acceptable; Low-Latency HLS uses 1-2 s segments).
//
// Caveat for existing cached encodes: cache entries from 0.9.9 used 4 s
// segments. After this bump, VerifyComplete (which checks the highest
// expected segment index) returns false for those entries — they're
// invalidated + re-encoded with 2 s segments on next play. Self-healing.
const hlsSegmentDuration = 2
// segmentDurationFor returns the target duration (in whole seconds) for the
// segment at index idx. With uniform-duration segments this is always
// hlsSegmentDuration; the helper exists so a future short-first-segment
// variant can be slotted in here without touching every call site.
func segmentDurationFor(idx int) int {
return hlsSegmentDuration
}
// segmentStartSec returns the wall-clock start time of segment idx. Used
// to compute the `-ss` flag when ffmpeg restarts at a mid-file segment.
func segmentStartSec(idx int) float64 {
if idx <= 0 {
return 0
}
return float64(idx * hlsSegmentDuration)
}
// segmentCountForDuration returns how many segments cover a source of the
// given duration. Always returns at least 1.
func segmentCountForDuration(dur float64) int {
if dur <= 0 {
return 1
}
return int((dur + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
}
// hlsSessionTTL is how long a session can sit idle (no segment requests)
// before the manager kills ffmpeg + cleans the tmpdir.
@ -136,11 +172,13 @@ type HLSSession struct {
restartCount int // bounded auto-restart counter (resets on Close)
lastRestartAt time.Time
// readyCond + readyMax track which segments ffmpeg has finished writing.
// Handlers waiting on a future segment block on readyCond until the
// poller advances readyMax past their index (or ffmpeg exits).
// readyCh + readyMax track how many segments ffmpeg has finished writing.
// readyMax is a COUNT (not an index): readyMax=N means seg-0 … seg-(N-1)
// are fully on disk. A handler waiting on `idx` blocks until
// `idx < readyMax` (segment idx is present). The pollSegments goroutine
// advances readyMax and re-creates readyCh on every step.
readyMu sync.Mutex
readyMax int // highest segment index whose .m4s file is fully written
readyMax int
exitErr error
exited bool
readyCh chan struct{} // closed + replaced each time readyMax advances
@ -300,10 +338,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
// Integrity gate: HasComplete just stats the marker. If init.mp4 or
// the last segment vanished (external rm, partial-disk failure), we
// can't actually serve a HIT — drop the dir and re-encode.
segCountForVerify := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
if segCountForVerify < 1 {
segCountForVerify = 1
}
segCountForVerify := segmentCountForDuration(probe.DurationSec)
if cfg.Cache.HasComplete(cacheKey) && !cfg.Cache.VerifyComplete(cacheKey, segCountForVerify) {
log.Printf("[hls %s] cache %s sealed but failed integrity check — re-encoding",
shortHLSID(cfg.SessionID), cacheKey)
@ -355,10 +390,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
return nil, fmt.Errorf("hls: mkdir subs: %w", err)
}
segCount := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
if segCount < 1 {
segCount = 1
}
segCount := segmentCountForDuration(probe.DurationSec)
s := &HLSSession{
cfg: cfg,
@ -422,9 +454,19 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
if cfg.Cache != nil {
cachedNote = fmt.Sprintf(" (cache-miss %s)", cacheKey)
}
log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s)%s",
// Surface the encoder profile so a "first-start was slow" report can be
// triaged from the agent log alone — `encoder=libx264 accel=none` means
// the user's ffmpeg has no HW encoders compiled in, which is the most
// common root cause (linuxbrew, default brew formula on macOS).
profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
presetNote := ""
if profile.Preset != "" {
presetNote = " preset=" + profile.Preset
}
log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s, encoder=%s accel=%s%s)%s",
shortHLSID(cfg.SessionID), filepath.Base(cfg.SourcePath),
probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"), cachedNote)
probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"),
profile.Codec, string(cfg.Transcode.HWAccel), presetNote, cachedNote)
return s, nil
}
@ -899,8 +941,10 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
time.Sleep(50 * time.Millisecond)
}
// Build args for the new ffmpeg with -ss offset.
startSec := float64(targetIdx * hlsSegmentDuration)
// Build args for the new ffmpeg with -ss offset. Segments are non-uniform
// (seg-0 is hlsInitSegmentDuration s, the rest are hlsSegmentDuration s),
// so use segmentStartSec for the seek time instead of multiplying.
startSec := segmentStartSec(targetIdx)
args := buildHLSFFmpegArgsAt(s.cfg, s.probe, s.tmpDir, targetIdx, startSec)
ffCtx, cancel := context.WithCancel(context.Background())
@ -965,23 +1009,77 @@ func buildHLSFFmpegArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string)
return buildHLSFFmpegArgsAt(cfg, probe, tmpDir, 0, 0)
}
// EncoderProfile names the codec + preset + decoder hint combination the HLS
// pipeline picks for the given hardware backend + transcode config. Exposed
// so callers can log the chosen encoder before ffmpeg launches and so both
// the demuxer-side `-hwaccel` flag and the encoder-side argv stay in sync
// (otherwise the two switches in buildHLSFFmpegArgsAt could silently drift
// when adding a new backend).
type EncoderProfile struct {
Codec string // ffmpeg encoder name (e.g. "h264_nvenc", "libx264")
Preset string // preset string, or "" when the codec has no preset knob
DecodeHwAccel string // ffmpeg `-hwaccel` value (e.g. "cuda", "qsv", "vaapi"), or ""
}
// ResolveEncoderProfile mirrors the codec + preset selection inside
// buildHLSFFmpegArgsAt so callers (registry, log lines, diagnostic
// endpoints) can know what ffmpeg will be told to do without parsing argv.
//
// The configured preset is libx264-specific by vocabulary (ultrafast…
// veryslow). Passing it through to NVENC / QSV would have ffmpeg reject
// the argv (NVENC uses p1-p7, QSV uses its own subset). So vendor encoders
// always use their hardcoded vendor preset and ignore configuredPreset.
// VideoToolbox has no preset knob at all.
//
// DecodeHwAccel mirrors the encoder family — `-hwaccel cuda` for NVENC,
// `-hwaccel qsv` for QSV, `-hwaccel vaapi` for VAAPI. We intentionally
// do NOT pass `-hwaccel_output_format vaapi`: that pins decoded frames
// to GPU memory, but our filter chain (scale/format/setparams) runs on
// CPU and can't consume VAAPI surfaces. Keeping output frames on CPU
// makes the filter chain work and the VAAPI encoder still benefits from
// HW-accelerated DECODE on the input side.
func ResolveEncoderProfile(hw HWAccel, configuredPreset string) EncoderProfile {
codec := hw.FFmpegVideoCodec("h264")
switch codec {
case "libx264":
preset := configuredPreset
if preset == "" {
preset = "superfast"
}
return EncoderProfile{Codec: codec, Preset: preset, DecodeHwAccel: ""}
case "h264_nvenc":
return EncoderProfile{Codec: codec, Preset: "p3", DecodeHwAccel: "cuda"}
case "h264_qsv":
return EncoderProfile{Codec: codec, Preset: "veryfast", DecodeHwAccel: "qsv"}
case "h264_vaapi":
return EncoderProfile{Codec: codec, Preset: "", DecodeHwAccel: "vaapi"}
case "h264_videotoolbox":
// No preset knob for VideoToolbox; the speed/quality dial is `-q:v`.
// VideoToolbox uses per-encoder flags rather than a demuxer hint.
return EncoderProfile{Codec: codec, Preset: "", DecodeHwAccel: ""}
}
// Unknown / future codecs: software path.
return EncoderProfile{Codec: codec, Preset: "", DecodeHwAccel: ""}
}
// buildHLSFFmpegArgsAt returns the argv for an HLS encode that starts at the
// given segment index (`-ss <startSec>`) and writes segments numbered from
// startIdx so they slot into the existing manifest at the correct position.
// `-output_ts_offset` keeps the segment PTS aligned with manifest timeline.
func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string, startIdx int, startSec float64) []string {
hwHint := cfg.Transcode.HWAccel
profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
args := []string{"-y", "-hide_banner", "-loglevel", "warning"}
switch hwHint {
case HWAccelNVENC:
args = append(args, "-hwaccel", "cuda")
case HWAccelQSV:
args = append(args, "-hwaccel", "qsv")
case HWAccelVAAPI:
args = append(args, "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi")
case HWAccelNone, HWAccelVideoToolbox:
// No demuxer-side hint.
// Demuxer-side HW-decode hint. Sourced from the profile so a future
// codec/hint mismatch is impossible — the encoder + decode hint are
// computed once and stay coherent. Notably we do NOT add
// `-hwaccel_output_format vaapi` on the VAAPI path: that pins decoded
// frames to GPU memory but our CPU filter chain (scale, format,
// setparams) can't consume VAAPI surfaces. Letting frames flow on CPU
// keeps the filter chain working; the encoder still gets HW-accelerated
// decode on the input side.
if profile.DecodeHwAccel != "" {
args = append(args, "-hwaccel", profile.DecodeHwAccel)
}
// Seek before -i for fast keyframe-aligned start. The new ffmpeg writes
@ -1011,24 +1109,43 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
}
args = append(args, "-map", fmt.Sprintf("0:a:%d?", audioIdx))
// Video encode.
codec := hwHint.FFmpegVideoCodec("h264")
// Video encode. Codec + preset come from the EncoderProfile resolved at
// the top of this function so the demuxer hint, the encoder, and the
// per-session log line all stay consistent.
//
// Defaults are biased for FIRST-START LATENCY over quality — the player
// blocks on seg-0 before the first frame paints, and a slow seg-0 is
// what users notice ("preparando sesión" stuck). Users who want better
// quality can override via `download.transcode.preset` in config.toml.
codec := profile.Codec
args = append(args, "-c:v", codec)
// Encoder-specific tuning. Each HW encoder takes a different "preset"
// vocabulary; libx264 uses ultrafast→placebo, NVENC uses p1→p7, QSV uses
// veryfast→veryslow, VAAPI/VideoToolbox don't expose presets.
switch codec {
case "libx264":
preset := cfg.Transcode.Preset
if preset == "" {
preset = "veryfast"
}
args = append(args, "-preset", preset)
// superfast = ~15-20% faster than veryfast at marginal quality loss
// for the bitrates we target (5-25 Mbps). For 4K software encodes
// this is the difference between ~3 s and ~2.5 s per segment on a
// recent x86 CPU. `-threads 0` is libx264's default but explicit
// helps when the user has set GOMAXPROCS.
args = append(args, "-preset", profile.Preset, "-threads", "0")
case "h264_nvenc":
// p4 = balanced quality/speed; p1 fastest, p7 highest quality.
args = append(args, "-preset", "p4", "-rc", "vbr", "-tune", "hq")
// p3 + tune=ll trades ~0.3 dB PSNR for 1.5-2× faster encode vs the
// previous p4 + tune=hq pair — first-segment encode drops from
// ~1.5 s to ~0.8 s on RTX-class hardware.
args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-tune", "ll")
case "h264_qsv":
args = append(args, "-preset", "medium", "-look_ahead", "0")
// veryfast is the fastest realistic QSV preset; medium was too
// conservative for first-start. look_ahead=0 keeps the encoder
// truly low-latency (no rate-control look-ahead window).
args = append(args, "-preset", profile.Preset, "-look_ahead", "0")
case "h264_videotoolbox":
// VideoToolbox has no "preset" knob; `-realtime` flips into the
// low-latency path used by FaceTime. We let the `-b:v / -maxrate
// / -bufsize` block (added later in this function) drive rate
// control — adding `-q:v` here would conflict because ffmpeg's
// videotoolbox encoder treats `-b:v` as authoritative and
// silently ignores `-q:v`, so the constant-quality knob never
// took effect anyway.
args = append(args, "-realtime", "1")
}
// Derive H.264 level from the actual output height. A fixed "4.0" caps the
// encoder at 1080p — anything taller (1440p, 4K source on quality=original)
@ -1159,6 +1276,10 @@ func (s *HLSSession) extractSubtitles(ctx context.Context) {
// renderVideoPlaylist builds the VOD media playlist for the video stream.
// Segment count is derived from the source duration — the player learns the
// total timeline from the manifest before any segment is fetched.
//
// seg-0 is the short init segment (hlsInitSegmentDuration s); seg-1 onward
// are hlsSegmentDuration s each. The last segment may be shorter than the
// nominal duration when (duration - init) doesn't divide evenly.
func renderVideoPlaylist(durationSec float64, segCount int) string {
var b strings.Builder
b.WriteString("#EXTM3U\n")
@ -1169,7 +1290,7 @@ func renderVideoPlaylist(durationSec float64, segCount int) string {
b.WriteString(`#EXT-X-MAP:URI="init.mp4"` + "\n")
remaining := durationSec
for i := 0; i < segCount; i++ {
segDur := float64(hlsSegmentDuration)
segDur := float64(segmentDurationFor(i))
if remaining < segDur {
segDur = remaining
}

View file

@ -115,10 +115,11 @@ func TestRenderVideoPlaylist(t *testing.T) {
}
func TestRenderVideoPlaylistShortFinalSegment(t *testing.T) {
// 9.5s total, 4s segments → 3 segs of 4/4/1.5
out := renderVideoPlaylist(9.5, 3)
// 9.5s total, 2s segments → 5 segs of 2/2/2/2/1.5
segCount := segmentCountForDuration(9.5)
out := renderVideoPlaylist(9.5, segCount)
if !strings.Contains(out, "#EXTINF:1.500,") {
t.Errorf("expected final segment 1.5s in playlist, got:\n%s", out)
t.Errorf("expected final segment 1.5s in playlist (segCount=%d), got:\n%s", segCount, out)
}
}

View file

@ -86,6 +86,117 @@ func listFFmpegEncoders(ctx context.Context, ffmpegPath string) string {
return string(out)
}
// HWAccelDiagnostic bundles what we know about the host's ffmpeg + HW encode
// capabilities so the daemon can log a single coherent line at startup and the
// web side can surface "this agent is software-only" without re-running probes.
type HWAccelDiagnostic struct {
Pick HWAccel // backend selected by DetectHWAccel
FFmpegPath string // resolved ffmpeg binary
FFmpegVersion string // first line of `ffmpeg -version` (e.g. "ffmpeg version 6.1.1")
Encoders []string // HW + libsvtav1/libvpx9-class encoders found in -encoders output
Devices []string // device files / drivers detected at probe time
}
// DetectHWAccelDiagnostic returns the full diagnostic picture for the host's
// transcode pipeline. Unlike DetectHWAccel, this is NOT cached — callers pay
// for an ffmpeg subprocess on each call (one `-encoders`, one `-version`).
// Daemon startup is the natural caller; per-session lookups should keep using
// DetectHWAccel (cached) and only re-probe diagnostics if the user runs an
// explicit doctor command.
func DetectHWAccelDiagnostic(ctx context.Context, ffmpegPath string) HWAccelDiagnostic {
d := HWAccelDiagnostic{Pick: HWAccelNone, FFmpegPath: ffmpegPath}
if ffmpegPath == "" {
return d
}
d.FFmpegVersion = ffmpegVersionLine(ctx, ffmpegPath)
encoders := listFFmpegEncoders(ctx, ffmpegPath)
for _, name := range hwEncoderNames {
if strings.Contains(encoders, name) {
d.Encoders = append(d.Encoders, name)
}
}
// Device-file checks mirror the picks below so the log line tells the
// reader why a present encoder might still have been rejected (e.g. NVENC
// compiled in but /dev/nvidia0 missing inside a container).
if fileExists("/dev/nvidia0") {
d.Devices = append(d.Devices, "/dev/nvidia0")
}
if fileExists("/dev/dri/renderD128") {
d.Devices = append(d.Devices, "/dev/dri/renderD128")
}
if hasNvidiaDriver() {
d.Devices = append(d.Devices, "nvidia-smi")
}
d.Pick = DetectHWAccel(ctx, ffmpegPath)
return d
}
// LogLine returns a one-line human-readable summary of the diagnostic,
// suitable for daemon startup output. Format:
//
// "[transcode] ffmpeg 6.1.1 at /usr/bin/ffmpeg, HW=nvenc (h264_nvenc), devices=/dev/nvidia0,nvidia-smi"
// "[transcode] ffmpeg 6.1.1 at /home/linuxbrew/.../ffmpeg, HW=none (software libx264) — no HW encoders compiled in"
func (d HWAccelDiagnostic) LogLine() string {
var b strings.Builder
b.WriteString("[transcode] ")
if d.FFmpegVersion != "" {
b.WriteString(d.FFmpegVersion)
} else {
b.WriteString("ffmpeg")
}
if d.FFmpegPath != "" {
b.WriteString(" at ")
b.WriteString(d.FFmpegPath)
}
b.WriteString(", HW=")
b.WriteString(string(d.Pick))
if d.Pick == HWAccelNone {
if len(d.Encoders) == 0 {
b.WriteString(" (software libx264) — no HW encoders compiled in")
} else {
b.WriteString(" (software libx264) — encoders found but no matching device: ")
b.WriteString(strings.Join(d.Encoders, ","))
}
} else {
b.WriteString(" (")
b.WriteString(d.Pick.FFmpegVideoCodec("h264"))
b.WriteString(")")
if len(d.Devices) > 0 {
b.WriteString(", devices=")
b.WriteString(strings.Join(d.Devices, ","))
}
}
return b.String()
}
// hwEncoderNames lists the HW-accelerated encoders we care about for the
// startup log. Kept in lookup order so the output reads predictably across
// hosts.
var hwEncoderNames = []string{
"h264_nvenc", "hevc_nvenc",
"h264_qsv", "hevc_qsv",
"h264_vaapi", "hevc_vaapi",
"h264_videotoolbox", "hevc_videotoolbox",
}
// ffmpegVersionLine extracts the "ffmpeg version X.Y.Z" prefix from
// `ffmpeg -version`. Bounded to avoid hanging the daemon on a misbehaving
// binary.
func ffmpegVersionLine(ctx context.Context, ffmpegPath string) string {
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-version")
out, err := cmd.CombinedOutput()
if err != nil || len(out) == 0 {
return ""
}
line, _, _ := strings.Cut(string(out), "\n")
// "ffmpeg version 6.1.1-some-build-suffix Copyright..." → keep up to first
// space after "version 6.x" to avoid spamming build flags into the log.
if idx := strings.Index(line, "Copyright"); idx > 0 {
line = strings.TrimSpace(line[:idx])
}
return strings.TrimSpace(line)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil

View file

@ -1,6 +1,9 @@
package engine
import "testing"
import (
"strings"
"testing"
)
func TestHWAccelFFmpegVideoCodec(t *testing.T) {
cases := []struct {
@ -32,3 +35,122 @@ func TestDetectHWAccelEmptyPathReturnsNone(t *testing.T) {
t.Errorf("got %s, want %s", got, HWAccelNone)
}
}
func TestResolveEncoderProfileDefaults(t *testing.T) {
cases := []struct {
hw HWAccel
configured string
wantCodec string
wantPreset string
wantHint string
}{
// Empty configured preset → pick latency-biased default per backend.
// DecodeHwAccel matches the encoder family for HW encoders; libx264 +
// VideoToolbox have no demuxer hint.
{HWAccelNone, "", "libx264", "superfast", ""},
{HWAccelNVENC, "", "h264_nvenc", "p3", "cuda"},
{HWAccelQSV, "", "h264_qsv", "veryfast", "qsv"},
// VAAPI: decoder hint set, no preset, no `-hwaccel_output_format vaapi`
// (so the CPU filter chain can consume the decoded frames).
{HWAccelVAAPI, "", "h264_vaapi", "", "vaapi"},
// VideoToolbox has no preset knob — Preset should be "" regardless of input.
// VideoToolbox uses per-encoder flags, not a demuxer `-hwaccel` hint.
{HWAccelVideoToolbox, "p4", "h264_videotoolbox", "", ""},
{HWAccelVideoToolbox, "", "h264_videotoolbox", "", ""},
}
for _, tc := range cases {
got := ResolveEncoderProfile(tc.hw, tc.configured)
if got.Codec != tc.wantCodec || got.Preset != tc.wantPreset || got.DecodeHwAccel != tc.wantHint {
t.Errorf("ResolveEncoderProfile(%s, %q) = {codec=%s preset=%s hint=%s}, want {codec=%s preset=%s hint=%s}",
tc.hw, tc.configured,
got.Codec, got.Preset, got.DecodeHwAccel,
tc.wantCodec, tc.wantPreset, tc.wantHint)
}
}
}
func TestResolveEncoderProfileHonoursConfiguredPreset(t *testing.T) {
// Only libx264 honours the configured preset — the libx264 vocabulary
// (ultrafast…veryslow) doesn't apply to vendor encoders. NVENC has its
// own p1-p7 scale; QSV uses a different subset; VideoToolbox has no
// preset knob. Passing a libx264 preset to them would have ffmpeg reject
// the argv, so ResolveEncoderProfile always falls back to the hardcoded
// vendor preset for non-libx264 codecs.
cases := []struct {
hw HWAccel
configured string
wantPreset string
}{
{HWAccelNone, "ultrafast", "ultrafast"}, // libx264 honours
{HWAccelNone, "medium", "medium"}, // libx264 honours
{HWAccelNVENC, "p1", "p3"}, // NVENC ignores, sticks to p3
{HWAccelNVENC, "veryfast", "p3"}, // NVENC ignores libx264 vocab
{HWAccelQSV, "veryslow", "veryfast"}, // QSV ignores, sticks to veryfast
{HWAccelVideoToolbox, "veryfast", ""}, // VideoToolbox has no preset
}
for _, tc := range cases {
got := ResolveEncoderProfile(tc.hw, tc.configured)
if got.Preset != tc.wantPreset {
t.Errorf("ResolveEncoderProfile(%s, %q).Preset = %q, want %q",
tc.hw, tc.configured, got.Preset, tc.wantPreset)
}
}
}
func TestHWAccelDiagnosticLogLineNone(t *testing.T) {
d := HWAccelDiagnostic{
Pick: HWAccelNone,
FFmpegPath: "/usr/local/bin/ffmpeg",
FFmpegVersion: "ffmpeg version 6.1.1",
Encoders: nil,
Devices: nil,
}
line := d.LogLine()
wantSubstrings := []string{
"ffmpeg version 6.1.1",
"/usr/local/bin/ffmpeg",
"HW=none",
"software libx264",
"no HW encoders compiled in",
}
for _, want := range wantSubstrings {
if !strings.Contains(line, want) {
t.Errorf("expected substring %q in log line; got %q", want, line)
}
}
}
func TestHWAccelDiagnosticLogLineNVENCWithDevices(t *testing.T) {
d := HWAccelDiagnostic{
Pick: HWAccelNVENC,
FFmpegPath: "/usr/bin/ffmpeg",
FFmpegVersion: "ffmpeg version 6.0",
Encoders: []string{"h264_nvenc", "hevc_nvenc", "h264_qsv"},
Devices: []string{"/dev/nvidia0", "nvidia-smi"},
}
line := d.LogLine()
for _, want := range []string{"HW=nvenc", "h264_nvenc", "/dev/nvidia0", "nvidia-smi"} {
if !strings.Contains(line, want) {
t.Errorf("expected substring %q in log line; got %q", want, line)
}
}
}
func TestHWAccelDiagnosticLogLineSoftwareButEncodersFound(t *testing.T) {
// Edge case: ffmpeg compiled WITH nvenc but no /dev/nvidia0 (container w/o GPU).
// LogLine should flag the encoders so the user knows where the gap is.
d := HWAccelDiagnostic{
Pick: HWAccelNone,
FFmpegPath: "/usr/bin/ffmpeg",
FFmpegVersion: "ffmpeg version 6.0",
Encoders: []string{"h264_nvenc"},
Devices: nil,
}
line := d.LogLine()
for _, want := range []string{"HW=none", "encoders found but no matching device", "h264_nvenc"} {
if !strings.Contains(line, want) {
t.Errorf("expected substring %q in log line; got %q", want, line)
}
}
}

View file

@ -88,7 +88,15 @@ const (
)
// ProbeFile runs ffprobe and returns a StreamProbe view of the file.
//
// Result is memoised by (path, mtime, size) for probeCacheTTL — repeat plays
// of the same file at the same quality (the HLS cache HIT path) skip ffprobe
// entirely. ffprobe on a 50 GB MKV can cost 1-3 s; first-segment latency
// shrinks by the same amount on the second play.
func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe, error) {
if cached, ok := lookupProbeCache(filePath); ok {
return cached, nil
}
mi, err := mediainfo.ExtractMediaInfo(ctx, ffprobePath, filePath)
if err != nil {
return nil, fmt.Errorf("probe: %w", err)
@ -136,6 +144,7 @@ func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe,
})
}
}
storeProbeCache(filePath, probe)
return probe, nil
}

View file

@ -0,0 +1,141 @@
package engine
import (
"os"
"sync"
"time"
)
// probeCacheTTL is how long a cached probe stays usable. The cache key
// already incorporates mtime + size, so the TTL is a defense against
// runaway memory growth from stale paths, not a freshness guarantee — a
// rename + recreate at the same inode (rare) would still be caught by the
// mtime delta.
const probeCacheTTL = 30 * time.Minute
// probeCacheJanitorInterval is how often the background sweeper wakes to
// drop expired entries. Lookup-time eviction handles hot paths, but a
// user who browses 5k files and then stops would leak entries until each
// is individually re-touched. 5 min ≈ 6 sweeps per TTL window — enough
// to keep memory bounded without burning CPU.
const probeCacheJanitorInterval = 5 * time.Minute
type probeCacheEntry struct {
probe *StreamProbe
expires time.Time
}
type probeCacheKey struct {
path string
mtime int64 // ModTime().UnixNano()
size int64
}
var (
probeCacheMu sync.RWMutex
probeCache = make(map[probeCacheKey]probeCacheEntry)
probeCacheJanitor sync.Once
)
// startProbeCacheJanitor launches the background sweeper exactly once per
// process. Lazy — fired on first storeProbeCache. Drops expired entries
// every probeCacheJanitorInterval. Idempotent (sync.Once).
func startProbeCacheJanitor() {
probeCacheJanitor.Do(func() {
go func() {
ticker := time.NewTicker(probeCacheJanitorInterval)
defer ticker.Stop()
for range ticker.C {
sweepProbeCache(time.Now())
}
}()
})
}
// sweepProbeCache removes every entry whose expiry is at or before `now`.
// Exposed for tests; production code calls it indirectly via the janitor
// goroutine.
func sweepProbeCache(now time.Time) int {
probeCacheMu.Lock()
defer probeCacheMu.Unlock()
removed := 0
for k, e := range probeCache {
if !now.Before(e.expires) {
delete(probeCache, k)
removed++
}
}
return removed
}
// lookupProbeCache returns the cached StreamProbe for the given path if its
// mtime + size still match the value recorded at insert time, AND the cache
// entry hasn't expired. Any stat failure / mismatch returns (nil, false) so
// the caller falls through to a fresh ffprobe run.
func lookupProbeCache(path string) (*StreamProbe, bool) {
fi, err := os.Stat(path)
if err != nil {
return nil, false
}
key := probeCacheKey{
path: path,
mtime: fi.ModTime().UnixNano(),
size: fi.Size(),
}
probeCacheMu.RLock()
entry, ok := probeCache[key]
probeCacheMu.RUnlock()
if !ok {
return nil, false
}
if time.Now().After(entry.expires) {
// Re-check under the write lock so a concurrent re-insert (same key,
// fresh expiry) isn't accidentally evicted.
probeCacheMu.Lock()
if cur, stillThere := probeCache[key]; stillThere && time.Now().After(cur.expires) {
delete(probeCache, key)
}
probeCacheMu.Unlock()
return nil, false
}
return entry.probe, true
}
// storeProbeCache stashes a fresh probe result under the (path, mtime, size)
// key. A subsequent ffprobe-skipping HIT requires the file to still have the
// same mtime + size — anything else (re-encoded, renamed+recreated at the
// same path, truncated) misses and triggers a re-probe.
func storeProbeCache(path string, probe *StreamProbe) {
fi, err := os.Stat(path)
if err != nil {
return
}
key := probeCacheKey{
path: path,
mtime: fi.ModTime().UnixNano(),
size: fi.Size(),
}
probeCacheMu.Lock()
probeCache[key] = probeCacheEntry{
probe: probe,
expires: time.Now().Add(probeCacheTTL),
}
probeCacheMu.Unlock()
// Lazy janitor — fires once per process. No-op after first call.
startProbeCacheJanitor()
}
// ResetProbeCache clears the in-memory probe cache. Test-only.
func ResetProbeCache() {
probeCacheMu.Lock()
probeCache = make(map[probeCacheKey]probeCacheEntry)
probeCacheMu.Unlock()
}
// ProbeCacheSize returns the number of entries currently cached. Exposed
// for diagnostics + tests.
func ProbeCacheSize() int {
probeCacheMu.RLock()
defer probeCacheMu.RUnlock()
return len(probeCache)
}

View file

@ -0,0 +1,202 @@
package engine
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestProbeCache_LookupMissNonexistent(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
if _, ok := lookupProbeCache("/path/that/does/not/exist"); ok {
t.Fatal("expected MISS for non-existent path")
}
}
func TestProbeCache_StoreThenLookupHit(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("fake content"), 0o644); err != nil {
t.Fatalf("write tmp file: %v", err)
}
probe := &StreamProbe{VideoCodec: "h264", Width: 1920, Height: 1080, DurationSec: 5400}
storeProbeCache(path, probe)
got, ok := lookupProbeCache(path)
if !ok {
t.Fatal("expected HIT after store")
}
if got != probe {
t.Fatalf("expected pointer-identical probe; got different")
}
}
func TestProbeCache_MtimeChangeInvalidates(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
probe := &StreamProbe{VideoCodec: "h264", DurationSec: 100}
storeProbeCache(path, probe)
// Force mtime change. WriteFile doesn't guarantee a different mtime if
// the filesystem timestamp resolution is coarse, so set it explicitly
// to a value 1 hour in the future.
future := time.Now().Add(1 * time.Hour)
if err := os.Chtimes(path, future, future); err != nil {
t.Fatalf("chtimes: %v", err)
}
if _, ok := lookupProbeCache(path); ok {
t.Fatal("expected MISS after mtime change")
}
}
func TestProbeCache_SizeChangeInvalidates(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("aaaaa"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
originalMtime := time.Now().Add(-1 * time.Hour) // stable, in the past
if err := os.Chtimes(path, originalMtime, originalMtime); err != nil {
t.Fatalf("chtimes original: %v", err)
}
probe := &StreamProbe{VideoCodec: "h264", DurationSec: 100}
storeProbeCache(path, probe)
// Truncate to a different size, then reset mtime to the original so
// only `size` differs between store and lookup keys — isolates the
// size-check path. Without the Chtimes, WriteFile bumps mtime and the
// test would pass via mtime invalidation regardless of size logic.
if err := os.WriteFile(path, []byte("a"), 0o644); err != nil {
t.Fatalf("rewrite: %v", err)
}
if err := os.Chtimes(path, originalMtime, originalMtime); err != nil {
t.Fatalf("chtimes restore: %v", err)
}
if _, ok := lookupProbeCache(path); ok {
t.Fatal("expected MISS after size change")
}
}
func TestProbeCache_ExpiryDropsEntry(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("content"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
// Stash an entry whose expires is already in the past — simulates TTL
// having elapsed without sleeping for 30 min.
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
key := probeCacheKey{path: path, mtime: fi.ModTime().UnixNano(), size: fi.Size()}
probeCacheMu.Lock()
probeCache[key] = probeCacheEntry{
probe: &StreamProbe{VideoCodec: "h264"},
expires: time.Now().Add(-1 * time.Minute),
}
probeCacheMu.Unlock()
if _, ok := lookupProbeCache(path); ok {
t.Fatal("expected MISS for expired entry")
}
// Side-effect: lookup should have evicted the stale entry.
if ProbeCacheSize() != 0 {
t.Fatalf("expected cache size 0 after expiry eviction; got %d", ProbeCacheSize())
}
}
func TestProbeCache_ResetClears(t *testing.T) {
ResetProbeCache()
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
storeProbeCache(path, &StreamProbe{VideoCodec: "h264"})
if ProbeCacheSize() != 1 {
t.Fatalf("expected size 1 after store; got %d", ProbeCacheSize())
}
ResetProbeCache()
if ProbeCacheSize() != 0 {
t.Fatalf("expected size 0 after reset; got %d", ProbeCacheSize())
}
}
func TestProbeCache_StoreNonexistentNoOp(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
// Store on a non-existent path should silently do nothing (stat fails),
// not panic, and not poison the cache with a zero key.
storeProbeCache("/nope/never/exists.mkv", &StreamProbe{VideoCodec: "h264"})
if ProbeCacheSize() != 0 {
t.Fatalf("expected 0 entries; got %d", ProbeCacheSize())
}
}
func TestProbeCache_SweepDropsExpired(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
// Two entries: one expired, one fresh.
expiredPath := filepath.Join(dir, "old.mkv")
freshPath := filepath.Join(dir, "new.mkv")
if err := os.WriteFile(expiredPath, []byte("a"), 0o644); err != nil {
t.Fatalf("write expired: %v", err)
}
if err := os.WriteFile(freshPath, []byte("b"), 0o644); err != nil {
t.Fatalf("write fresh: %v", err)
}
now := time.Now()
fiExp, _ := os.Stat(expiredPath)
fiFresh, _ := os.Stat(freshPath)
probeCacheMu.Lock()
probeCache[probeCacheKey{path: expiredPath, mtime: fiExp.ModTime().UnixNano(), size: fiExp.Size()}] = probeCacheEntry{
probe: &StreamProbe{VideoCodec: "h264"},
expires: now.Add(-1 * time.Minute), // expired
}
probeCache[probeCacheKey{path: freshPath, mtime: fiFresh.ModTime().UnixNano(), size: fiFresh.Size()}] = probeCacheEntry{
probe: &StreamProbe{VideoCodec: "h264"},
expires: now.Add(10 * time.Minute), // fresh
}
probeCacheMu.Unlock()
removed := sweepProbeCache(now)
if removed != 1 {
t.Fatalf("expected 1 expired entry removed; got %d", removed)
}
if ProbeCacheSize() != 1 {
t.Fatalf("expected 1 fresh entry kept; got %d", ProbeCacheSize())
}
}

View file

@ -13,8 +13,17 @@ var (
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`)
)
// ResolveResolution maps a pixel height to a standard resolution label.
func ResolveResolution(height int) string {
// ResolveResolution maps video dimensions to a standard resolution label.
// Uses both width and height so cinematic aspect ratios (2.35:1, 2.39:1, 21:9)
// are not misclassified — e.g. a 1080p source presented as 1920×804 letterboxed
// would fall to 720p if classified by height alone.
func ResolveResolution(width, height int) string {
byHeight := resolutionByHeight(height)
byWidth := resolutionByWidth(width)
return maxResolution(byHeight, byWidth)
}
func resolutionByHeight(height int) string {
switch {
case height >= 2000:
return "2160p"
@ -29,6 +38,36 @@ func ResolveResolution(height int) string {
}
}
func resolutionByWidth(width int) string {
switch {
case width >= 3400:
return "2160p"
case width >= 1800:
return "1080p"
case width >= 1200:
return "720p"
case width >= 800:
return "480p"
default:
return ""
}
}
var resolutionRank = map[string]int{
"": 0,
"480p": 1,
"720p": 2,
"1080p": 3,
"2160p": 4,
}
func maxResolution(a, b string) string {
if resolutionRank[a] >= resolutionRank[b] {
return a
}
return b
}
// DeriveContentType guesses "movie" or "show" from parsed metadata.
func DeriveContentType(item LibraryItem) string {
if item.Season > 0 || item.Episode > 0 {

View file

@ -8,28 +8,31 @@ import (
func TestResolveResolution(t *testing.T) {
tests := []struct {
name string
width int
height int
want string
}{
{2160, "2160p"},
{2000, "2160p"},
{1080, "1080p"},
{1920, "1080p"}, // 1920 is width, not height — height for 1080p is ~1080
{900, "1080p"},
{720, "720p"},
{600, "720p"},
{576, "480p"},
{480, "480p"},
{400, "480p"},
{360, ""},
{0, ""},
{"4K square", 3840, 2160, "2160p"},
{"4K low height", 3840, 1600, "2160p"},
{"1080p square", 1920, 1080, "1080p"},
{"1080p cinematic 2.39:1", 1920, 804, "1080p"}, // anamorphic widescreen — must not fall to 720p
{"1080p cinematic 2.35:1", 1920, 818, "1080p"},
{"1080p 21:9", 2560, 1080, "1080p"},
{"720p square", 1280, 720, "720p"},
{"720p widescreen", 1280, 540, "720p"},
{"480p", 854, 480, "480p"},
{"sub-480", 640, 360, ""},
{"zero", 0, 0, ""},
}
for _, tt := range tests {
got := ResolveResolution(tt.height)
if got != tt.want {
t.Errorf("ResolveResolution(%d) = %q, want %q", tt.height, got, tt.want)
}
t.Run(tt.name, func(t *testing.T) {
got := ResolveResolution(tt.width, tt.height)
if got != tt.want {
t.Errorf("ResolveResolution(%d, %d) = %q, want %q", tt.width, tt.height, got, tt.want)
}
})
}
}

View file

@ -23,7 +23,7 @@ func BuildSyncItems(cache *LibraryCache) []agent.LibrarySyncItem {
if item.MediaInfo != nil {
if item.MediaInfo.Video != nil {
si.Resolution = ResolveResolution(item.MediaInfo.Video.Height)
si.Resolution = ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
si.VideoCodec = item.MediaInfo.Video.Codec
si.HDR = item.MediaInfo.Video.HDR
si.BitDepth = item.MediaInfo.Video.BitDepth

172
scripts/ship.sh Executable file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env bash
#
# ship.sh — End-to-end CLI release pipeline.
#
# Standalone backup for when GitHub Actions is unavailable (org shadow-ban,
# CI outage, etc). Mirrors what release.yml + docker job in CI would do.
#
# Pre-requisites:
# - scripts/release.sh already ran → version.go bumped + tag created locally
# - SENTRY_DSN exported (Sentry disabled in build if missing)
# - docker logged in to docker.io as the org user
# - SSH key for Hetzner publishing (see publish-cli-release.sh)
#
# Pipeline:
# 1. Sanity: clean tree, tag at HEAD, version.go matches
# 2. goreleaser build (skip GH publish — produces dist/*)
# 3. Rsync to Hetzner via web/scripts/publish-cli-release.sh
# 4. Multi-arch Docker build + push (amd64 + arm64) to Docker Hub
# 5. Smoke checks (torrentclaw.com/version + docker run image version)
# 6. Optional `git push --follow-tags`
#
# Usage:
# scripts/ship.sh Detect version from internal/cmd/version.go
# scripts/ship.sh 0.9.12 Explicit version
# scripts/ship.sh --dry-run Preview steps, no side effects
# scripts/ship.sh --push 0.9.12 Also git-push tag to GH afterwards
#
# Env knobs:
# SENTRY_DSN telemetry DSN injected at build time
# RELEASE_SIGNING_PUBKEY ed25519 pubkey (base64) for self-update signature check
# DOCKER_IMAGE default torrentclaw/unarr
# PUBLISH_SCRIPT default ../torrentclaw-web/scripts/publish-cli-release.sh
# SKIP_DOCKER=1 skip Docker build/push
# SKIP_HETZNER=1 skip Hetzner publish
# SKIP_SMOKE=1 skip smoke checks
#
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_DIR"
DOCKER_IMAGE="${DOCKER_IMAGE:-torrentclaw/unarr}"
PUBLISH_SCRIPT="${PUBLISH_SCRIPT:-$REPO_DIR/../torrentclaw-web/scripts/publish-cli-release.sh}"
SKIP_DOCKER="${SKIP_DOCKER:-0}"
SKIP_HETZNER="${SKIP_HETZNER:-0}"
SKIP_SMOKE="${SKIP_SMOKE:-0}"
DRY_RUN=false
PUSH_TAG=false
VERSION=""
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
info() { echo -e "${CYAN}${NC} $*"; }
ok() { echo -e "${GREEN}${NC} $*"; }
warn() { echo -e "${YELLOW}${NC} $*"; }
die() { echo -e "${RED}${NC} $*" >&2; exit 1; }
for a in "$@"; do
case "$a" in
--dry-run) DRY_RUN=true ;;
--push) PUSH_TAG=true ;;
-h|--help)
sed -n '2,/^set /p' "$0" | sed 's/^#\s\?//;$d'
exit 0 ;;
[0-9]*) VERSION="$a" ;;
*) die "unknown arg: $a (use --help)" ;;
esac
done
read_version_go() {
grep 'var Version' internal/cmd/version.go | sed 's/.*"\(.*\)".*/\1/'
}
REPO_VERSION="$(read_version_go)"
[ -z "$VERSION" ] && VERSION="$REPO_VERSION"
[ -n "$VERSION" ] || die "cannot detect version (pass explicit X.Y.Z)"
TAG="v$VERSION"
MINOR="${VERSION%.*}"
echo ""
echo -e " ${BOLD}Ship Plan${NC}"
echo -e " ─────────────────────────────"
echo -e " Version: ${GREEN}$TAG${NC}"
echo -e " Docker image: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}"
echo -e " Skip Hetzner: $SKIP_HETZNER"
echo -e " Skip Docker: $SKIP_DOCKER"
echo -e " Push to GH: $PUSH_TAG"
echo -e " Dry run: $DRY_RUN"
echo ""
# Sanity
[ "$REPO_VERSION" = "$VERSION" ] || die "version.go=$REPO_VERSION ≠ requested $VERSION (bump with make release-* first)"
if [ "$DRY_RUN" = false ]; then
[ -z "$(git status --porcelain)" ] || die "working tree dirty"
git rev-parse "$TAG" >/dev/null 2>&1 || die "tag $TAG missing — run scripts/release.sh first"
HEAD_SHA="$(git rev-parse HEAD)"
TAG_SHA="$(git rev-parse "$TAG^{commit}")"
[ "$HEAD_SHA" = "$TAG_SHA" ] || die "HEAD ($HEAD_SHA) ≠ tag commit ($TAG_SHA) — checkout $TAG first"
command -v goreleaser >/dev/null || die "goreleaser not installed"
[ "$SKIP_DOCKER" = "1" ] || command -v docker >/dev/null || die "docker not installed"
[ "$SKIP_HETZNER" = "1" ] || [ -x "$PUBLISH_SCRIPT" ] || die "publish script missing or not executable: $PUBLISH_SCRIPT"
if [ -z "${SENTRY_DSN:-}" ]; then
warn "SENTRY_DSN unset — built binaries will have Sentry disabled"
fi
fi
if [ "$DRY_RUN" = true ]; then
ok "Dry run complete — no changes made"
exit 0
fi
# 1. Build
info "goreleaser build ($TAG)"
SENTRY_DSN="${SENTRY_DSN:-}" RELEASE_SIGNING_PUBKEY="${RELEASE_SIGNING_PUBKEY:-}" \
goreleaser release --clean --skip=publish
ok "dist/ ready"
# 2. Hetzner
if [ "$SKIP_HETZNER" != "1" ]; then
info "publishing to Hetzner releases volume"
"$PUBLISH_SCRIPT" "$VERSION"
ok "Hetzner version.txt flipped to $VERSION"
fi
# 3. Docker
if [ "$SKIP_DOCKER" != "1" ]; then
info "docker buildx multi-arch push ($DOCKER_IMAGE:$VERSION, :$MINOR, :latest)"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg VERSION="$TAG" \
-t "$DOCKER_IMAGE:$VERSION" \
-t "$DOCKER_IMAGE:$MINOR" \
-t "$DOCKER_IMAGE:latest" \
--push .
ok "Docker Hub: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}"
fi
# 4. Smoke
if [ "$SKIP_SMOKE" != "1" ]; then
info "smoke checks"
if [ "$SKIP_HETZNER" != "1" ]; then
LIVE_VERSION="$(curl -fsSL https://torrentclaw.com/version 2>/dev/null | tr -d '[:space:]' || echo '')"
if [ "$LIVE_VERSION" = "$VERSION" ]; then
ok "torrentclaw.com/version = $LIVE_VERSION"
else
warn "torrentclaw.com/version = '$LIVE_VERSION' (expected $VERSION)"
fi
fi
if [ "$SKIP_DOCKER" != "1" ]; then
DOCKER_VERSION="$(docker run --rm "$DOCKER_IMAGE:$VERSION" version 2>/dev/null | grep -oE 'v[0-9.]+' | head -1)"
if [ "$DOCKER_VERSION" = "$TAG" ]; then
ok "docker image $DOCKER_IMAGE:$VERSION reports $DOCKER_VERSION"
else
warn "docker image reports '$DOCKER_VERSION' (expected $TAG)"
fi
fi
fi
# 5. Optional push
if [ "$PUSH_TAG" = true ]; then
info "git push origin main --follow-tags"
git push origin main --follow-tags
ok "tag $TAG pushed to GitHub"
fi
echo ""
ok "${BOLD}$TAG shipped${NC}"