feat(agent): add mirror failover, agent client refactor, status 401 detection

- Mirror pool with health tracking and exponential backoff for failed hosts
- Agent client routes requests through mirror pool with retry semantics
- New `unarr mirrors` command to inspect mirror state and force failover
- `unarr status` now detects 401 from /agent/register and suggests `unarr login`
  instead of the generic "Could not fetch account info" message
- Config supports multiple ScanPaths for upcoming multi-path library scan
- Draft plan for bidirectional library sync (CLI ↔ Web) under Docs/plans/
This commit is contained in:
Deivid Soto 2026-05-15 16:26:43 +02:00
parent bf18812a3d
commit a73e1a7756
12 changed files with 972 additions and 76 deletions

170
Docs/plans/library-sync.md Normal file
View file

@ -0,0 +1,170 @@
# Plan: Sincronización bidireccional de biblioteca (CLI ↔ Web)
## Context
La biblioteca web solo muestra descargas completadas (download_task + debrid). El `unarr scan` escanea ficheros con ffprobe y los sube al servidor, pero solo soporta un path, no detecta borrados del disco, y no permite borrar ficheros desde la web. El usuario quiere una biblioteca unificada que refleje el estado real de su colección y se sincronice en ambas direcciones.
## Protocolo de sincronización
### Forward Sync (Disco → Web)
1. CLI escanea todos los `ScanPaths` configurados
2. Para cada path: descubre ficheros, compara con cache (skip ffprobe si no cambió), sube a `/library-sync`
3. En `isLastBatch=true`: el servidor elimina items con ese `scanPath` que no estén en el batch (ficheros borrados del disco desaparecen de la web)
### Reverse Sync (Web → Disco)
1. CLI llama a `GET /agent/library-deletions` — items que el usuario soft-deleted desde la web
2. Si `AutoDelete=true` o `--yes`: borra ficheros del disco
3. Si no: muestra lista y pide confirmación interactiva
4. Llama a `POST /agent/library-deletions/confirm` con los IDs confirmados → hard-delete en DB
### Resolución de conflictos
- Fichero en disco pero no en web → forward sync lo añade
- Fichero en web pero no en disco → forward sync lo elimina (isLastBatch)
- Soft-deleted en web, aún en disco → reverse sync lo borra del disco y confirma
- Soft-deleted en web, ya borrado del disco → reverse sync confirma directamente
- Race condition (user borra en web mientras CLI escanea) → forward sync skippea rows con `deleted_at IS NOT NULL`
---
## Fase 1: Multi-path + Forward Sync mejorado
### 1.1 CLI — Config multi-path
**Archivo:** `torrentclaw-cli/internal/config/config.go`
- Añadir `ScanPaths []string` a `LibraryConfig`
- Migrar `ScanPath``ScanPaths[0]` en `Load()` si `ScanPaths` está vacío
- Añadir `AutoDelete bool` (default false)
### 1.2 CLI — Cache v2
**Archivo:** `torrentclaw-cli/internal/library/types.go`
- Cambiar `LibraryCache` a version 2: `Paths map[string][]LibraryItem`
- Migración v1→v2: `Path`+items → `Paths[Path]`
**Archivo:** `torrentclaw-cli/internal/library/cache.go`
- `LoadCache` detecta versión y migra
- `SaveCache` siempre guarda v2
### 1.3 CLI — Scan multi-path
**Archivo:** `torrentclaw-cli/internal/cmd/scan.go`
- `unarr scan` sin args → escanea todos los `ScanPaths`
- `unarr scan /path/a /path/b` → escanea paths específicos y los recuerda en config
- Loop: para cada path, scan + sync con su `scanPath`
### 1.4 CLI — Nuevo comando `unarr sync`
**Archivo nuevo:** `torrentclaw-cli/internal/cmd/sync.go`
- Forward sync: scan ligero (sin ffprobe para ficheros sin cambios) + upload
- Sin reverse sync todavía (Fase 3)
- Flags: `--dry-run`, `--paths`
### 1.5 Web — Columna `scan_path` en `library_item`
**Archivo:** `torrentclaw-web/src/lib/db/schema.ts`
- Añadir `scanPath: varchar(2048)` a tabla `libraryItem`
- Generar migración con `pnpm db:generate`
**Archivo:** `torrentclaw-web/src/lib/services/library-upgrade.ts`
- `syncLibraryItems()`: persistir `scanPath` en cada row al hacer upsert
### 1.6 CLI — Daemon multi-path
**Archivo:** `torrentclaw-cli/internal/cmd/daemon.go`
- `runAutoScan()` itera sobre todos los `ScanPaths`
---
## Fase 2: Reverse Sync (Web → Disco)
### 2.1 Web — Soft-delete
**Archivo:** `torrentclaw-web/src/lib/db/schema.ts`
- Añadir `deletedAt: timestamp` a tabla `libraryItem`
- Generar migración
### 2.2 Web — Endpoints de borrado
**Archivo nuevo:** `torrentclaw-web/src/app/api/internal/library/items/route.ts`
- `DELETE` — session auth, recibe `{itemIds: number[]}`, hace soft-delete (`deletedAt = NOW()`)
**Archivo nuevo:** `torrentclaw-web/src/app/api/internal/agent/library-deletions/route.ts`
- `GET` — agent auth, devuelve items con `deletedAt IS NOT NULL` para ese usuario
- `POST` — agent auth, recibe `{confirmedIds: number[]}`, hard-delete los rows
### 2.3 Web — Heartbeat con pendingDeletions
**Archivo:** endpoint de heartbeat del agente
- Añadir `pendingDeletions: number` al response (count de items con `deletedAt IS NOT NULL`)
### 2.4 Web — Forward sync respeta soft-deletes
**Archivo:** `torrentclaw-web/src/lib/services/library-upgrade.ts`
- `syncLibraryItems()` en `isLastBatch`: la query de DELETE excluye rows con `deletedAt IS NOT NULL`
### 2.5 CLI — Agent client nuevos métodos
**Archivo:** `torrentclaw-cli/internal/agent/client.go`
- `GetLibraryDeletions(ctx) → []DeletionItem`
- `ConfirmLibraryDeletions(ctx, ids []int) → error`
**Archivo:** `torrentclaw-cli/internal/agent/types.go`
- `DeletionItem {ID int, FilePath string, DeletedAt string}`
### 2.6 CLI — Sync reverse
**Archivo:** `torrentclaw-cli/internal/cmd/sync.go`
- Después del forward sync: llama a `GetLibraryDeletions()`
- Valida que cada fichero está dentro de un `ScanPaths` conocido (seguridad)
- Si `AutoDelete` o `--yes`: borra y confirma
- Si no: muestra lista interactiva, pide confirmación
- Flag `--no-delete` para skip reverse sync
- Si `BackupDir` configurado: mover a backup en vez de borrar
### 2.7 CLI — Daemon auto-delete
**Archivo:** `torrentclaw-cli/internal/cmd/daemon.go`
- Al final de `runAutoSync()`: si `AutoDelete=true`, procesa deletions automáticamente
- Si no: log warning "N files pending deletion, run `unarr sync`"
---
## Fase 3: Web UI (brief)
- Botón "Eliminar" en items de biblioteca → llama `DELETE /library/items`
- Badge "Pendiente de borrar" en items soft-deleted
- Posibilidad de cancelar el borrado (clear `deletedAt`)
- Vista unificada: scanned items + downloaded items en la misma vista
---
## Archivos clave
### CLI (Go)
| Archivo | Cambio |
|---------|--------|
| `internal/config/config.go` | ScanPaths, AutoDelete, migración |
| `internal/library/types.go` | Cache v2 con Paths map |
| `internal/library/cache.go` | Load/Save v2, migración v1 |
| `internal/library/sync.go` | BuildSyncItems (sin cambios) |
| `internal/cmd/scan.go` | Multi-path loop |
| `internal/cmd/sync.go` | **Nuevo** — comando sync bidireccional |
| `internal/cmd/daemon.go` | runAutoSync multi-path + reverse |
| `internal/agent/client.go` | GetLibraryDeletions, ConfirmLibraryDeletions |
| `internal/agent/types.go` | DeletionItem type |
### Web (TypeScript)
| Archivo | Cambio |
|---------|--------|
| `src/lib/db/schema.ts` | scanPath + deletedAt en library_item |
| `src/lib/services/library-upgrade.ts` | persistir scanPath, respetar soft-deletes |
| `src/app/api/internal/agent/library-deletions/route.ts` | **Nuevo** — GET + POST |
| `src/app/api/internal/library/items/route.ts` | **Nuevo** — DELETE soft-delete |
| Endpoint heartbeat del agente | pendingDeletions en response |
---
## Verificación
### Fase 1
1. `go build ./cmd/unarr/ && go test ./...`
2. Configurar 2 scan paths en config.toml, ejecutar `unarr scan` → ambos se escanean
3. Borrar un fichero del disco, ejecutar `unarr scan` → desaparece de la web
4. `pnpm build` en torrentclaw-web para verificar tipos
### Fase 2
1. Desde la web: borrar un item de la biblioteca
2. Ejecutar `unarr sync` → muestra el fichero pendiente de borrar, pedir confirmación
3. Confirmar → fichero se borra del disco y desaparece de la web
4. `unarr sync --dry-run` → muestra lo que haría sin hacer nada
5. Con `auto_delete = true` en config: el daemon borra automáticamente
### Fase 3
1. Verificar visualmente en Chrome DevTools la UI de borrado
2. Verificar que el badge "pendiente" aparece y desaparece correctamente