Compare commits

..

12 commits
main ... master

Author SHA1 Message Date
Deivid Soto
54a995f0f8 fix(ci): unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN
Some checks failed
CI / Test (push) Successful in 2m41s
CI / Build (push) Successful in 1m32s
CI / Build-1 (push) Successful in 1m56s
CI / Build-2 (push) Successful in 1m31s
CI / Build-3 (push) Successful in 1m32s
CI / Build-4 (push) Successful in 1m33s
CI / Build-5 (push) Successful in 1m34s
CI / Lint (push) Failing after 2m29s
CI / Coverage (push) Successful in 2m51s
CI / Vet (push) Successful in 2m7s
Forgejo runner auto-injects GITHUB_TOKEN; combined with the GITEA_TOKEN we
set explicitly, goreleaser errors with 'multiple tokens'. Unset the GitHub
one inside the run step so goreleaser follows the Gitea/Forgejo release
path defined by .goreleaser.yml's gitea_urls block.
2026-05-27 16:15:57 +02:00
Deivid Soto
7a1af31ac2 refactor(ci): point Forgejo URLs at torrentclaw org (post-transfer)
Some checks failed
CI / Test (push) Failing after 2s
CI / Build (push) Failing after 2s
CI / Build-1 (push) Failing after 2s
CI / Build-2 (push) Failing after 2s
CI / Build-3 (push) Failing after 1s
CI / Build-4 (push) Failing after 2s
CI / Build-5 (push) Failing after 2s
CI / Lint (push) Failing after 2s
CI / Coverage (push) Failing after 2s
CI / Vet (push) Failing after 1s
Repos were transferred from the deivid user to a dedicated torrentclaw
organisation; the workflows reference the org path.
2026-05-27 15:58:45 +02:00
Deivid Soto
909eb70dea test(vaapi): dump full ffmpeg argv for smoke validation
Some checks failed
CI / Test (push) Failing after 2s
CI / Build (push) Failing after 1s
CI / Build-1 (push) Failing after 2s
CI / Build-2 (push) Failing after 2s
CI / Build-3 (push) Failing after 2s
CI / Build-4 (push) Failing after 2s
CI / Build-5 (push) Failing after 2s
CI / Lint (push) Failing after 2s
CI / Coverage (push) Failing after 2s
CI / Vet (push) Failing after 2s
Adds TestBuildHLSFFmpegArgsVAAPIDump alongside the existing assertion
tests. Logs the complete argv buildHLSFFmpegArgsAt emits for a
typical VAAPI session so an operator can paste it into a shell and
reproduce the encode without booting the dev stack — same effect as
`journalctl --user -u unarr-dev | grep ffmpeg`, no daemon needed.

Verified locally against AMD Raphael iGPU on this dev box: the
dumped argv encoded a 5 s 4K source → 720p in 3.1 s wall, produced
3 HLS segments + init.mp4 that decode cleanly under ffprobe.
2026-05-27 15:58:30 +02:00
Deivid Soto
1376357b20 fix(release): move gitea_urls to top-level (goreleaser v2 schema)
Some checks failed
Release / release (push) Failing after 2s
Release / docker (push) Has been skipped
goreleaser v2 dropped `release.gitea_urls`; the key is now top-level
on its own. With the old nested form `goreleaser release` failed with
`yaml: unmarshal errors: line 67: field gitea_urls not found in type
config.Release` before even starting the build.

Re-anchor to v0.9.14 so the ship pipeline can produce binaries.
2026-05-27 15:55:21 +02:00
Deivid Soto
2fc7ce1de0 feat(vaapi): hybrid CPU-scale + hwupload encode path (QW2, 0.9.14)
Some checks failed
CI / Test (push) Failing after 11s
CI / Build (push) Failing after 2s
CI / Build-1 (push) Failing after 2s
CI / Build-2 (push) Failing after 2s
CI / Build-3 (push) Failing after 1s
CI / Build-4 (push) Failing after 1s
CI / Build-5 (push) Failing after 2s
CI / Lint (push) Failing after 2s
CI / Coverage (push) Failing after 2s
CI / Vet (push) Failing after 2s
Release / release (push) Failing after 2s
Release / docker (push) Has been skipped
Closes QW2. Validated against the dev box's AMD Raphael iGPU
(/dev/dri/renderD128, radeonsi/mesa 25.2.8). The "proper" full-GPU
path via scale_vaapi triggers a known mesa 25 + Raphael bug
("Cannot allocate memory" per session start, encode still succeeds
but logs are spammy) — hybrid CPU scale → format=nv12 → hwupload
→ h264_vaapi encode delivers GPU surfaces to the encoder without
poking the broken scaler.

Three concrete changes in buildHLSFFmpegArgsAt:
  1. New `case "h264_vaapi"` adds `-vaapi_device /dev/dri/renderD128`.
     Multi-GPU hosts (this dev box has NVIDIA on renderD129 + AMD on
     renderD128) need it so the encoder doesn't bind to a non-VAAPI
     render node — without it the encoder fell back to NULL device
     in manual smoke testing.
  2. Filter chain branches on codec: VAAPI uses
     `scale=…,format=nv12,hwupload` while libx264 / NVENC / QSV
     keep the existing `scale=…,format=yuv420p,setparams=…` shape.
     The setparams color metadata block is dropped on VAAPI because
     VAAPI surfaces don't expose VUI fields and the encoder writes
     its own.
  3. Two new unit tests lock the argv shape so a future refactor
     doesn't accidentally merge the paths back together:
     TestBuildHLSFFmpegArgsVAAPI asserts the new flags + the
     ABSENCE of scale_vaapi; TestBuildHLSFFmpegArgsLibx264NoRegression
     verifies the software path keeps yuv420p + setparams + has
     none of the VAAPI extras.

Manual ffmpeg validation on the dev box:
  hybrid encode of 5 s 4K → 720p: 0.66 s wall, 472 % CPU, 268 KB
  output — no errors logged. scale_vaapi variant in comparison
  spammed "Cannot allocate memory" while emitting valid output.
2026-05-27 15:45:55 +02:00
Deivid Soto
4a00988ee1 ci: port workflows from .github/ to .forgejo/ (Forgejo Actions)
GitHub torrentclaw org is shadow-banned and the CI lives at git.torrentclaw.com
now. Forgejo Actions is enabled cluster-wide; this moves the workflows into the
runner's natively-watched .forgejo/workflows/ tree and adapts each step so the
existing Forgejo runner ('docker', 'ubuntu-latest' labels) can execute them
without leaning on GitHub-only tooling.

- ci.yml: drop actions/setup-go (use container: golang:1.25), replace
  golangci-lint-action with the upstream install.sh, drop codecov-action
  (third-party, can re-add later with a Forgejo-compatible variant).
- release.yml: drop goreleaser-action (install via curl), wire GITEA_TOKEN +
  the new release.gitea_urls block in .goreleaser.yml so goreleaser publishes
  to Forgejo. Sign step swaps 'gh release upload' for curl against the Forgejo
  releases API (via the in-cluster forgejo:3000 hostname). VirusTotal job
  dropped — depended heavily on 'gh release' wiring; can be reimplemented
  against the Forgejo API later if we re-enable it.
- docker-rebuild.yml: drop docker/login-action + docker/build-push-action,
  use raw 'docker' commands with manually-installed buildx + qemu. Same
  weekly schedule (Mon 04:17 UTC) and same 'latest' refresh behaviour.
- pages.yml: deleted — install.sh / install.ps1 are already served from the
  Hetzner releases volume at torrentclaw.com/install.sh, so the GitHub Pages
  copy was redundant even before the shadow-ban.

.goreleaser.yml: add release.gitea_urls (api=forgejo:3000, download via the
public Forgejo URL) + prerelease:auto. ship.sh uses '--skip=publish' so local
runs aren't affected by the new release block.
2026-05-27 15:44:48 +02:00
Deivid Soto
2826ee712e fix(daemon): defensive IsClosed check in watchSessionReady poll loop
Closes the deferred bajo-priority item from the fase 3.3b critico.

Without this the watcher kept polling a torn-down HLSSession for up
to 60 s — fine in current code paths (Close always pairs with ctx
cancel which makes the select{} branch fire), but the function's
correctness then leaned on a caller invariant rather than its own
state check. Adding IsClosed() as a public wrapper around the
existing isClosed() lets the watcher detect any future
session-shutdown path (registry replace, idle sweep, internal kill)
without touching the unexported helper.
2026-05-27 15:19:51 +02:00
Deivid Soto
16ce1cc30d fix(daemon): use parent ctx for MarkSessionReady so cancel propagates
Critico flag: rctx was rooted at context.Background() instead of the
session's hlsCtx, so a tab close / session cancel mid-POST left the
goroutine blocking on the in-flight webhook for up to 10 s. Switched
to a child of hlsCtx — the same scope the watchSessionReady loop
already respects via the outer ctx.Done() select.

Idempotent webhook means a now-orphan session getting marked ready
is cosmetic; the savings here are goroutine pinning + a slow webhook
on a torn-down session.
2026-05-27 15:02:24 +02:00
Deivid Soto
03fe5ca54a feat(agent): session-ready webhook for SSE-driven player handshake (0.9.13)
Some checks failed
Release / release (push) Failing after 1s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
Closes Fase 3.3b. Daemon now tells the server the moment a session's
first HLS segment + init.mp4 land on disk; the web side flips
streaming_session.ready_at = NOW(), which its SSE endpoint pushes to
subscribed players so the loading UI flips from "Preparando…" to
"Stream listo" without polling HEAD on the segment URL.

Surface:
  - New Client.MarkSessionReady(ctx, sessionId) HTTP method →
    POST /api/internal/agent/session-ready.
  - New engine.HLSSession.ReadyCount() + FromCache() accessors so the
    watcher goroutine doesn't reach into private state.
  - New cmd.watchSessionReady(ctx, client, hsess, sessionId) goroutine
    polls ReadyCount every 200 ms with a 60 s deadline + short-circuits
    for cache-HIT sessions (ready the moment StartHLSSession returns).
  - Daemon callback spawns it right after streamSrv.HLS().Register so
    the watcher's lifecycle matches the session's.

Best-effort: a transient network failure on the webhook is logged + the
goroutine exits — the player's existing HEAD-probe retry path still
discovers ready state independently. The webhook is an acceleration,
not a hard dependency.
2026-05-27 14:40:53 +02:00
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
15 changed files with 253 additions and 260 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`).

17
.gitignore vendored
View file

@ -43,5 +43,18 @@ tmp/
config/
dist-ffbinaries/
# Claude Code: keep entirely local, do not track
.claude/
# 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,63 +5,61 @@ 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.15] - 2026-05-27
### Added
- **sentry**: enhance error handling by skipping user input errors in CaptureError
## [0.9.14] - 2026-05-27
### Changed
- **ci**: point Forgejo URLs at torrentclaw org (post-transfer)
- **sentry**: decouple agent import via string-match, rename predicate
- **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.
### 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
- **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
- **agent**: session-ready webhook for SSE-driven player handshake (0.9.13)
- **agent**: send full transcoder diagnostic in register payload (0.9.12)
- **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`.
### 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
@ -79,10 +77,6 @@ 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
@ -545,9 +539,9 @@ 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.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.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

View file

@ -1,9 +1,8 @@
# unarr
**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.
**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.
**[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**

View file

@ -11,9 +11,9 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Go Version](https://img.shields.io/github/go-mod/go-version/torrentclaw/unarr)](go.mod)
The single-binary terminal client for torrent, debrid, and usenet downloads. **Free and open source.**
Powerful terminal tool for torrent search and management. **Free and open source.**
Built-in torrent engine, debrid (Real-Debrid / AllDebrid), and NZB support. Stream to mpv/vlc, transcode on the fly with hardware acceleration, and manage your library — one binary or a headless daemon with WireGuard split-tunnel and Cloudflare Funnel remote access.
Search 30+ torrent sources, inspect torrent quality, discover popular content, find streaming providers, and manage your media collection — all from your terminal.
<!-- GIF demo placeholder -->
<!-- ![unarr Demo](docs/demo.gif) -->

View file

@ -2,8 +2,6 @@ package agent
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
@ -11,13 +9,6 @@ import (
"github.com/torrentclaw/unarr/internal/config"
)
// ErrDaemonNotRunning is returned when no daemon state file exists on disk.
// Callers may wrap it with %w; downstream code uses errors.Is to detect it.
// NOTE: the message text is matched by the sentry package (string-match, to
// avoid an import cycle). Keep the prefix "daemon does not appear to be
// running" stable, or update sentry.daemonNotRunningMarker accordingly.
var ErrDaemonNotRunning = errors.New("daemon does not appear to be running (state file not found)")
// DaemonState is written to disk every heartbeat for external tools to read.
type DaemonState struct {
AgentID string `json:"agentId"`
@ -78,31 +69,17 @@ func WriteState(state *DaemonState) {
os.Rename(tmp, path)
}
// ReadState reads the daemon state from disk. Returns nil if not found or
// unreadable. Use LoadState when callers need to distinguish "not running"
// from "state file corrupted".
// ReadState reads the daemon state from disk. Returns nil if not found.
func ReadState() *DaemonState {
state, _ := LoadState()
return state
}
// LoadState reads the daemon state and returns explicit errors:
// - ErrDaemonNotRunning when the state file does not exist
// - a wrapped json error when the file exists but cannot be decoded
// (a real bug worth reporting to Sentry)
func LoadState() (*DaemonState, error) {
data, err := os.ReadFile(StateFilePath())
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrDaemonNotRunning
}
return nil, err
return nil
}
var state DaemonState
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("decode daemon state %s: %w", StateFilePath(), err)
if json.Unmarshal(data, &state) != nil {
return nil
}
return &state, nil
return &state
}
// RemoveState deletes the state file (called on clean shutdown).

View file

@ -1,7 +1,6 @@
package agent
import (
"errors"
"os"
"path/filepath"
"testing"
@ -105,39 +104,3 @@ func TestReadStateCorruptedJSON(t *testing.T) {
t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state)
}
}
func TestLoadStateNotFound(t *testing.T) {
tmpDir := t.TempDir()
origFn := stateFilePathFn
stateFilePathFn = func() string { return filepath.Join(tmpDir, "nonexistent.json") }
defer func() { stateFilePathFn = origFn }()
state, err := LoadState()
if state != nil {
t.Errorf("LoadState() state = %+v, want nil", state)
}
if !errors.Is(err, ErrDaemonNotRunning) {
t.Errorf("LoadState() err = %v, want ErrDaemonNotRunning", err)
}
}
func TestLoadStateCorruptedJSON(t *testing.T) {
tmpDir := t.TempDir()
origFn := stateFilePathFn
path := filepath.Join(tmpDir, "daemon.state.json")
stateFilePathFn = func() string { return path }
defer func() { stateFilePathFn = origFn }()
os.WriteFile(path, []byte("not valid json{{{"), 0o644)
state, err := LoadState()
if state != nil {
t.Errorf("LoadState() state = %+v, want nil", state)
}
if err == nil {
t.Fatal("LoadState() err = nil, want decode error")
}
if errors.Is(err, ErrDaemonNotRunning) {
t.Error("corrupt state must not be reported as ErrDaemonNotRunning — it would be filtered from Sentry")
}
}

View file

@ -1,7 +1,6 @@
package cmd
import (
"errors"
"fmt"
"os"
"os/exec"
@ -263,12 +262,9 @@ func runDaemonReload() error {
// stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID.
// Used as fallback on platforms without a service manager (and as Windows implementation).
func stopDaemonByPID() error {
state, err := agent.LoadState()
if err != nil {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return err
}
return fmt.Errorf("read daemon state: %w", err)
state := agent.ReadState()
if state == nil {
return fmt.Errorf("daemon does not appear to be running (state file not found)")
}
return killPID(state.PID)
}

View file

@ -3,7 +3,6 @@
package cmd
import (
"errors"
"fmt"
"log"
"os"
@ -44,12 +43,9 @@ func startReloadWatcher(rc *ReloadableConfig) {
// sendReloadSignal sends SIGUSR1 to the running daemon process.
func sendReloadSignal() error {
state, err := agent.LoadState()
if err != nil {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return err
}
return fmt.Errorf("read daemon state: %w", err)
state := agent.ReadState()
if state == nil {
return fmt.Errorf("daemon does not appear to be running (state file not found)")
}
p, err := os.FindProcess(state.PID)
if err != nil {

View file

@ -25,20 +25,16 @@ var (
func init() {
rootCmd = &cobra.Command{
Use: "unarr",
Version: Version,
Short: "Terminal torrent + debrid + usenet client — download, stream, transcode",
Long: `unarr is a terminal-native client that downloads torrents, debrid links,
and usenet (NZB) all from the same binary. It streams content straight
to mpv/vlc with sequential piece prioritization, transcodes on the fly via
ffmpeg with hardware acceleration (NVENC, QSV, VA-API, VideoToolbox), and
organizes your library into Movies/TV folders. Run it one-shot or as a
long-running daemon with a built-in WireGuard split-tunnel and remote
playback over Cloudflare Funnel.
Use: "unarr",
Short: "unarr — torrent search and management",
Long: `unarr is a powerful terminal tool for torrent search and management.
Search 30+ torrent sources, inspect torrent quality, discover popular content,
find streaming providers, and manage your media collection all from your terminal.
Get started:
unarr init First-time configuration wizard
unarr download <magnet|hash> Grab a torrent one-shot
unarr search "breaking bad" Search for content
unarr start Start the download daemon
Documentation: https://torrentclaw.com/cli
@ -59,7 +55,7 @@ Source: https://github.com/torrentclaw/unarr`,
// Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "start", Title: "Getting Started:"},
&cobra.Group{ID: "search", Title: "Catalog & Discovery:"},
&cobra.Group{ID: "search", Title: "Search & Discovery:"},
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},

View file

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

View file

@ -1,14 +1,12 @@
package sentry
import (
"errors"
"os"
"runtime"
"strings"
"time"
gosentry "github.com/getsentry/sentry-go"
"github.com/spf13/pflag"
)
// dsn is injected at build time via ldflags. If empty, Sentry is disabled.
@ -46,16 +44,9 @@ func Close() {
gosentry.Flush(flushTimeout)
}
// daemonNotRunningMarker matches the message of agent.ErrDaemonNotRunning
// without importing the agent package — avoids a sentry → agent dependency
// that would risk a cycle if agent ever needed to report errors itself.
const daemonNotRunningMarker = "daemon does not appear to be running"
// CaptureError sends a non-fatal error to Sentry with optional command context.
// Expected non-bug errors (bad CLI input, daemon not running) are skipped to
// keep the issue feed signal-heavy.
func CaptureError(err error, command string) {
if err == nil || shouldSkipSentry(err) {
if err == nil {
return
}
@ -67,21 +58,6 @@ func CaptureError(err error, command string) {
})
}
func shouldSkipSentry(err error) bool {
var notExist *pflag.NotExistError
var valueReq *pflag.ValueRequiredError
var invalidVal *pflag.InvalidValueError
var invalidSyn *pflag.InvalidSyntaxError
if errors.As(err, &notExist) || errors.As(err, &valueReq) ||
errors.As(err, &invalidVal) || errors.As(err, &invalidSyn) {
return true
}
msg := err.Error()
return strings.HasPrefix(msg, "unknown command ") ||
strings.HasPrefix(msg, "required flag(s)") ||
strings.Contains(msg, daemonNotRunningMarker)
}
// RecoverPanic captures a panic and re-panics after reporting.
// Usage: defer sentry.RecoverPanic()
func RecoverPanic() {

View file

@ -1,10 +1,6 @@
package sentry
import (
"errors"
"fmt"
"testing"
)
import "testing"
func TestEnvironment(t *testing.T) {
tests := []struct {
@ -49,16 +45,3 @@ func TestSetUser(t *testing.T) {
// Should not panic without initialization
SetUser("agent-123")
}
func TestShouldSkipSentryDaemonNotRunning(t *testing.T) {
// String must stay in sync with agent.ErrDaemonNotRunning. If that sentinel
// is reworded, this test fails loudly so the marker can be updated.
err := errors.New("daemon does not appear to be running (state file not found)")
if !shouldSkipSentry(err) {
t.Error("ErrDaemonNotRunning message should be skipped")
}
wrapped := fmt.Errorf("read daemon state: %w", err)
if !shouldSkipSentry(wrapped) {
t.Error("wrapped ErrDaemonNotRunning message should be skipped")
}
}

View file

@ -55,17 +55,6 @@ fi
CURRENT_BRANCH=$(git branch --show-current)
[ "$CURRENT_BRANCH" = "main" ] || warn "Not on main branch (current: $CURRENT_BRANCH)"
HEAD_SUBJECT=$(git log -1 --pretty=%s)
if [[ "$HEAD_SUBJECT" =~ \(([0-9]+\.[0-9]+\.[0-9]+)\) ]]; then
die "HEAD commit subject contains inline version bump: \"$HEAD_SUBJECT\"
Release contract: version bumps MUST live in a dedicated 'chore(release): X.Y.Z' commit.
Revert the inline bump and re-run this script — it will create the proper commit."
fi
if [[ "$HEAD_SUBJECT" =~ ^chore\(release\): ]]; then
die "HEAD is already a chore(release) commit: \"$HEAD_SUBJECT\"
Nothing new to release. Add commits since the last release or amend intentionally outside this script."
fi
# ── Resolve version ────────────────────────────────────────────────
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
LATEST_VERSION="${LATEST_TAG#v}"

View file

@ -17,8 +17,7 @@
# 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. Prune Forgejo releases older than FORGEJO_PRUNE_DAYS (default 90)
# 7. Optional `git push --follow-tags`
# 6. Optional `git push --follow-tags`
#
# Usage:
# scripts/ship.sh Detect version from internal/cmd/version.go
@ -34,10 +33,6 @@
# SKIP_DOCKER=1 skip Docker build/push
# SKIP_HETZNER=1 skip Hetzner publish
# SKIP_SMOKE=1 skip smoke checks
# SKIP_FORGEJO_PRUNE=1 skip Forgejo retention prune
# FORGEJO_TOKEN PAT with write:repository for prune (no token = skip + warn)
# FORGEJO_PRUNE_DAYS retention window, default 90 days
# FORGEJO_REPO default torrentclaw/unarr
#
set -euo pipefail
@ -49,10 +44,6 @@ PUBLISH_SCRIPT="${PUBLISH_SCRIPT:-$REPO_DIR/../torrentclaw-web/scripts/publish-c
SKIP_DOCKER="${SKIP_DOCKER:-0}"
SKIP_HETZNER="${SKIP_HETZNER:-0}"
SKIP_SMOKE="${SKIP_SMOKE:-0}"
SKIP_FORGEJO_PRUNE="${SKIP_FORGEJO_PRUNE:-0}"
FORGEJO_PRUNE_DAYS="${FORGEJO_PRUNE_DAYS:-90}"
FORGEJO_REPO="${FORGEJO_REPO:-torrentclaw/unarr}"
FORGEJO_BASE="${FORGEJO_BASE:-https://git.torrentclaw.com}"
DRY_RUN=false
PUSH_TAG=false
@ -170,48 +161,7 @@ if [ "$SKIP_SMOKE" != "1" ]; then
fi
fi
# 6. Forgejo retention prune
if [ "$SKIP_FORGEJO_PRUNE" != "1" ]; then
if [ -z "${FORGEJO_TOKEN:-}" ]; then
warn "FORGEJO_TOKEN not set — skipping Forgejo prune (set it to enable >${FORGEJO_PRUNE_DAYS}-day cleanup)"
else
info "pruning Forgejo releases older than $FORGEJO_PRUNE_DAYS days"
FORGEJO_API="$FORGEJO_BASE/api/v1/repos/$FORGEJO_REPO/releases"
RELEASES_JSON="$(curl -fsSL -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_API?limit=50" || echo '[]')"
PRUNE_IDS="$(echo "$RELEASES_JSON" | python3 -c "
import json, sys
from datetime import datetime, timedelta, timezone
days = int('${FORGEJO_PRUNE_DAYS}')
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
for r in json.load(sys.stdin):
created = datetime.fromisoformat(r['created_at'].replace('Z', '+00:00'))
if created < cutoff:
print(f\"{r['id']}\t{r['tag_name']}\t{r['created_at']}\")
" 2>/dev/null || true)"
DELETED=0
FAILED=0
if [ -n "$PRUNE_IDS" ]; then
while IFS=$'\t' read -r REL_ID REL_TAG REL_CREATED; do
[ -z "$REL_ID" ] && continue
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X DELETE -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_API/$REL_ID")"
if [ "$CODE" = "204" ]; then
echo " deleted $REL_TAG (created $REL_CREATED)"
DELETED=$((DELETED + 1))
else
warn " failed to delete $REL_TAG (id=$REL_ID, http=$CODE)"
FAILED=$((FAILED + 1))
fi
done <<< "$PRUNE_IDS"
fi
if [ "$FAILED" -gt 0 ]; then
warn "Forgejo prune: $DELETED removed, $FAILED failed"
else
ok "Forgejo prune: $DELETED release(s) removed (>${FORGEJO_PRUNE_DAYS} days old)"
fi
fi
fi
# 7. Optional push
# 5. Optional push
if [ "$PUSH_TAG" = true ]; then
info "git push origin main --follow-tags"
git push origin main --follow-tags