Compare commits
218
Commits
@@ -1,12 +1,6 @@
|
||||
# Silo Docker deployment
|
||||
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
|
||||
|
||||
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
|
||||
# Linux/macOS example for enabling NVIDIA override:
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
# Windows example:
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
|
||||
|
||||
# Required for docker compose: host path to your media library.
|
||||
# Example: MEDIA_ROOT=/srv/media
|
||||
MEDIA_ROOT=/srv/media
|
||||
@@ -26,19 +20,6 @@ SILO_IMAGE=ghcr.io/silo-server/silo-server:latest
|
||||
POSTGRES_USER=silo
|
||||
POSTGRES_PASSWORD=silo
|
||||
POSTGRES_DB=silo
|
||||
POSTGRES_SHM_SIZE=8gb
|
||||
|
||||
# PostgreSQL auto-tuning is enabled by default in docker-compose.yml. Silo
|
||||
# applies pgtune-style OLTP recommendations through ALTER SYSTEM at startup.
|
||||
# Set POSTGRES_TUNE=off if you manage PostgreSQL tuning yourself.
|
||||
# POSTGRES_TUNE=off
|
||||
# POSTGRES_TUNE_PROFILE=oltp
|
||||
# POSTGRES_TUNE_MEMORY=auto
|
||||
# POSTGRES_TUNE_MEMORY_BUDGET_PERCENT=75
|
||||
# POSTGRES_TUNE_CPUS=auto
|
||||
# POSTGRES_TUNE_STORAGE=ssd
|
||||
# POSTGRES_TUNE_DB_SIZE=auto
|
||||
# POSTGRES_TUNE_CONNECTIONS=100
|
||||
|
||||
# Optional host port overrides for docker-compose.yml.
|
||||
# PORT=8090
|
||||
@@ -46,19 +27,6 @@ POSTGRES_SHM_SIZE=8gb
|
||||
# PROXY_PORT=8083
|
||||
# TRANSCODE_PORT=8082
|
||||
|
||||
# Optional NVIDIA override controls (used by docker-compose.nvidia.yml).
|
||||
# NVIDIA_GPU_COUNT=1
|
||||
|
||||
# REQUIRED: master key for at-rest credential encryption. Silo refuses to start
|
||||
# without it. Integration API keys, S3 keys, and other server-owned secrets are
|
||||
# encrypted under a key derived from this value, so it must be at least 32
|
||||
# characters and kept secret. Generate one with:
|
||||
# openssl rand -base64 48
|
||||
# Back it up SEPARATELY from your database dumps (treat it like a CA private
|
||||
# key): losing it makes every encrypted secret unrecoverable. See
|
||||
# docs/architecture/secret-encryption.md.
|
||||
# SECRET_KEY=replace-with-output-of-openssl-rand-base64-48
|
||||
|
||||
# Run from source / advanced overrides
|
||||
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
|
||||
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
`cmd/silo` contains the main server entrypoint. Backend code lives in `internal/`, organized by domain (`api`, `catalog`, `metadata`, `playback`, `scanner`, `jellycompat`, etc.); keep new code in the package that owns the behavior instead of creating catch-all helpers. Database changes belong in `migrations/sql/` as Goose SQL migrations. Legacy converted migrations intentionally keep their original numeric versions so existing `schema_versions` rows can bootstrap cleanly into Goose. New migrations must use timestamped filenames created with `make migrate-create NAME=add_thing`; do not run `goose fix` or create paired `.up.sql` / `.down.sql` files. The React frontend lives in `web/src/`, with feature code split across `components/`, `pages/`, `hooks/`, `player/`, and `lib/`. Reference material belongs in `docs/architecture/` or `docs/superpowers/{specs,plans}/`; ad hoc SQL helpers live in `scripts/`.
|
||||
`cmd/silo` contains the main server entrypoint. Backend code lives in `internal/`, organized by domain (`api`, `catalog`, `metadata`, `playback`, `scanner`, `jellycompat`, etc.); keep new code in the package that owns the behavior instead of creating catch-all helpers. Database changes belong in `migrations/` as paired numbered `.up.sql` and `.down.sql` files. The React frontend lives in `web/src/`, with feature code split across `components/`, `pages/`, `hooks/`, `player/`, and `lib/`. Reference material belongs in `docs/architecture/` or `docs/superpowers/{specs,plans}/`; ad hoc SQL helpers live in `scripts/`.
|
||||
|
||||
When creating or editing `docs/superpowers/specs/` or `docs/superpowers/plans/`, never include local absolute filesystem paths or transient worktree IDs. Use repository-relative paths and wording like "Commands assume the repository root is the cwd."
|
||||
|
||||
@@ -53,7 +53,6 @@ Use the checked-in `Makefile` for the common paths:
|
||||
- `make dev-frontend`: start the Vite dev server with HMR
|
||||
- `make dev-proxy` / `make dev-transcode`: run standalone worker modes
|
||||
- `make lint`: run `golangci-lint` and frontend ESLint
|
||||
- `make migrate-status` / `make migrate-up`: inspect or apply Goose migrations through Silo's legacy-safe bootstrapping runner
|
||||
|
||||
Run before opening a merge request:
|
||||
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ RUN apt-get update && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 fonts-noto-core fonts-noto-cjk && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 && \
|
||||
apt-get purge -y gnupg && apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /tmp/silo-transcode
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ RUN apt-get update && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 fonts-noto-core fonts-noto-cjk && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 && \
|
||||
apt-get purge -y gnupg && apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /tmp/silo-transcode
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
|
||||
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check verify-local-paths install-hooks
|
||||
|
||||
GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null))
|
||||
MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..))
|
||||
SHARED_MAKEFILE_LOCAL := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/../Makefile.local))
|
||||
DEFAULT_PLUGIN_SDK_DIR := $(abspath ../silo-plugin-sdk)
|
||||
SHARED_PLUGIN_SDK_DIR := $(if $(MAIN_CHECKOUT_ROOT),$(abspath $(MAIN_CHECKOUT_ROOT)/../silo-plugin-sdk))
|
||||
GOOSE := go run github.com/pressly/goose/v3/cmd/goose@v3.27.1
|
||||
GOOSE_DIR := migrations/sql
|
||||
ENV_FILE ?= .env
|
||||
|
||||
ifneq ($(wildcard $(DEFAULT_PLUGIN_SDK_DIR)),)
|
||||
DEV_PLUGIN_SDK_DIR ?= $(DEFAULT_PLUGIN_SDK_DIR)
|
||||
@@ -17,20 +14,13 @@ endif
|
||||
|
||||
JELLYFIN_WEB_OUTPUT_DIR ?= third_party/jellyfin-web
|
||||
|
||||
# Build version stamping: inject the git revision so the admin Build panel shows a
|
||||
# version even when Go's VCS metadata isn't embedded (mirrors the Dockerfile ldflags).
|
||||
BUILDINFO_PKG := github.com/Silo-Server/silo-server/internal/buildinfo
|
||||
BUILD_REVISION ?= $(shell git rev-parse HEAD 2>/dev/null)
|
||||
BUILD_DIRTY ?= $(shell test -n "$$(git status --porcelain 2>/dev/null)" && echo true || echo false)
|
||||
GO_LDFLAGS := -X $(BUILDINFO_PKG).revisionOverride=$(BUILD_REVISION) -X $(BUILDINFO_PKG).dirtyOverride=$(BUILD_DIRTY)
|
||||
|
||||
# Build the frontend (requires pnpm)
|
||||
frontend:
|
||||
cd web && pnpm install --frozen-lockfile && pnpm run build
|
||||
|
||||
# Build the Go binary (depends on frontend)
|
||||
build: frontend
|
||||
go build -ldflags "$(GO_LDFLAGS)" -o silo ./cmd/silo/
|
||||
go build -o silo ./cmd/silo/
|
||||
|
||||
# Run frontend dev server (proxies API to localhost:8080)
|
||||
dev-frontend:
|
||||
@@ -57,23 +47,6 @@ lint:
|
||||
verify-local-paths:
|
||||
scripts/check-local-path-leaks.sh
|
||||
|
||||
# Create a timestamped Goose SQL migration. Usage: make migrate-create NAME=add_thing
|
||||
migrate-create:
|
||||
@if [ -z "$(NAME)" ]; then echo "usage: make migrate-create NAME=add_thing"; exit 1; fi
|
||||
$(GOOSE) -dir $(GOOSE_DIR) create "$(NAME)" sql
|
||||
|
||||
# Validate Goose migration annotations and SQL parsing without touching a database.
|
||||
migrate-validate:
|
||||
$(GOOSE) -dir $(GOOSE_DIR) validate
|
||||
|
||||
# Show Goose migration status through Silo's bootstrapping runner.
|
||||
migrate-status:
|
||||
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-status
|
||||
|
||||
# Apply pending Goose migrations through Silo's bootstrapping runner.
|
||||
migrate-up:
|
||||
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-only
|
||||
|
||||
# Install repo-local git hooks for this checkout/worktree.
|
||||
install-hooks:
|
||||
@existing="$$(git config --local core.hooksPath 2>/dev/null || true)"; \
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
A self-hosted media streaming server with a React frontend and Go backend. Supports direct play, remuxing, and hardware-accelerated transcoding. Compatible with Jellyfin/Emby clients (VidHub, Findroid) via a built-in compatibility layer.
|
||||
|
||||
Join the community on [Discord](https://discord.gg/4RxuUQAEnW).
|
||||
|
||||
## Deploy with Docker (recommended)
|
||||
|
||||
The easiest way to run Silo is with Docker Compose. The default stack assumes you do not already have PostgreSQL and Redis available, so it bundles PostgreSQL, Redis, FFmpeg, and the application for a one-command start.
|
||||
@@ -34,29 +32,6 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
|
||||
|
||||
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
|
||||
|
||||
### Optional NVIDIA/NVENC
|
||||
|
||||
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
|
||||
|
||||
Install the NVIDIA Container Toolkit and use a Docker Compose version with GPU reservation support before enabling this override.
|
||||
|
||||
Use the optional override file when you want NVENC:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
|
||||
```
|
||||
|
||||
If you want this controlled from `.env`, set `COMPOSE_FILE`:
|
||||
|
||||
```dotenv
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
NVIDIA_GPU_COUNT=1
|
||||
```
|
||||
|
||||
Windows uses `;` instead of `:` between compose files.
|
||||
|
||||
Then `docker compose up -d` will include the NVIDIA override automatically.
|
||||
|
||||
4. **Configure through the admin UI**
|
||||
|
||||
Add libraries, users, metadata providers, and playback settings from the web interface.
|
||||
@@ -201,36 +176,8 @@ Technical notes:
|
||||
| `make dev-backend` | Run Go backend (integrated mode) |
|
||||
| `make dev-proxy` | Run a standalone proxy node |
|
||||
| `make dev-transcode` | Run a standalone transcode node |
|
||||
| `make migrate-create NAME=add_thing` | Create a timestamped Goose SQL migration |
|
||||
| `make migrate-validate` | Validate Goose migration files without touching a database |
|
||||
| `make migrate-status` | Show Goose migration status using Silo's bootstrapping runner |
|
||||
| `make migrate-up` | Apply pending Goose migrations using Silo's bootstrapping runner |
|
||||
| `make clean` | Remove build artifacts |
|
||||
|
||||
### Database Migrations
|
||||
|
||||
PostgreSQL schema migrations are managed by Goose. Migration SQL files live in
|
||||
`migrations/sql/` and use Goose annotations. Converted legacy migrations keep
|
||||
their original numeric versions so existing `schema_versions` rows can bootstrap
|
||||
cleanly into Goose without replaying old SQL. New migrations should be created
|
||||
with timestamped filenames:
|
||||
|
||||
```sh
|
||||
make migrate-create NAME=add_thing
|
||||
make migrate-validate
|
||||
```
|
||||
|
||||
Do not run `goose fix`; timestamped migrations are the repository policy because
|
||||
they avoid version collisions across parallel PRs. The existing `001`-style
|
||||
files are historical compatibility records, not the naming pattern for new work.
|
||||
Runtime migrations are applied by the integrated/API server only. Proxy and
|
||||
transcode modes never mutate schema.
|
||||
For existing installs, use `make migrate-status` and `make migrate-up` rather
|
||||
than invoking the Goose CLI directly; those targets copy legacy
|
||||
`schema_versions` rows into `public.goose_db_version` under the migration lock
|
||||
before reading or applying migrations. Set `ENV_FILE=path/to/.env` when the
|
||||
database URL should be read from a non-default env file.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```sh
|
||||
@@ -269,57 +216,93 @@ Silo requires only a `DATABASE_URL` when running from source or against external
|
||||
| `proxy` | Stream proxy node that connects to the shared deployment database and Redis |
|
||||
| `transcode` | HLS transcode worker node that connects to the shared deployment database and Redis |
|
||||
|
||||
## PostgreSQL Auto-Tuning
|
||||
## PostgreSQL Performance Tuning
|
||||
|
||||
The default Docker Compose stack does not require a checked-in `postgresql.conf`.
|
||||
It enables Silo's [pgtune](https://github.com/le0pard/pgtune)-style OLTP tuning
|
||||
by default:
|
||||
The default PostgreSQL configuration is conservative and not optimized for any particular workload. For better performance, use [PGTune](https://pgtune.leopard.in.ua/) to generate settings tailored to your hardware.
|
||||
|
||||
**Recommended PGTune settings:**
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
| DB Type | **Web application** |
|
||||
| Total Memory | Your server's RAM |
|
||||
| Number of CPUs | Your server's CPU count |
|
||||
| Number of Connections | 20 (Silo's default pool size) |
|
||||
| Data Storage | SSD or HDD depending on your setup |
|
||||
|
||||
Silo's workload is predominantly read-heavy API queries (browse, search, metadata lookups) with the database small enough to fit in RAM for most libraries. The **Web application** profile optimizes for this: CPU-bound simple queries served from cached pages, high concurrent reads, and low write volume.
|
||||
|
||||
If you have a very large library (50,000+ items) or heavy activity logging, consider switching to the **OLTP** profile instead.
|
||||
|
||||
### Recommended Defaults for Modern Hardware
|
||||
|
||||
Assuming you're using modern hardware with fast NVMe storage this is what I'm currently running.
|
||||
|
||||
```ini
|
||||
# Connections
|
||||
max_connections = 100
|
||||
|
||||
# Memory (tuned to keep PG under ~8GB total)
|
||||
shared_buffers = 4GB # holds the entire DB in cache (typical DB is 1-4GB)
|
||||
effective_cache_size = 6GB # planner hint only, not an allocation
|
||||
work_mem = 32MB # per-operation sort/hash memory
|
||||
maintenance_work_mem = 512MB # VACUUM, CREATE INDEX, catalog imports
|
||||
|
||||
# WAL / Write Performance
|
||||
wal_buffers = 64MB
|
||||
min_wal_size = 512MB
|
||||
max_wal_size = 2GB
|
||||
checkpoint_completion_target = 0.9
|
||||
|
||||
# Storage (NVMe/Optane)
|
||||
random_page_cost = 1.1 # default 4.0; NVMe random ≈ sequential
|
||||
effective_io_concurrency = 200 # NVMe handles many concurrent requests
|
||||
|
||||
# Parallelism
|
||||
max_worker_processes = 8
|
||||
max_parallel_workers_per_gather = 4
|
||||
max_parallel_workers = 8
|
||||
max_parallel_maintenance_workers = 4
|
||||
|
||||
# Huge Pages
|
||||
huge_pages = try
|
||||
```
|
||||
|
||||
These settings keep PostgreSQL under ~8GB of RAM while still caching the entire database. For HDD storage, change `random_page_cost` to `4.0` and `effective_io_concurrency` to `2`.
|
||||
|
||||
### Applying Settings
|
||||
|
||||
**Docker (recommended):** Save the settings above to a `postgresql.conf` file and mount it into your container:
|
||||
|
||||
```yaml
|
||||
POSTGRES_TUNE: auto
|
||||
# docker-compose.yml
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
volumes:
|
||||
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
```
|
||||
|
||||
When enabled, Silo connects with `DATABASE_URL` and applies recommendations with
|
||||
`ALTER SYSTEM`, which writes to PostgreSQL's `postgresql.auto.conf` inside the
|
||||
database data directory. Reloadable settings are applied immediately with
|
||||
`pg_reload_conf()`. Settings that PostgreSQL marks as restart-only are written
|
||||
too, and Silo logs the setting names so you can restart PostgreSQL once:
|
||||
Then restart: `docker compose restart postgres`
|
||||
|
||||
```sh
|
||||
docker compose restart postgres
|
||||
**Existing PostgreSQL:** You can apply settings without editing config files using `ALTER SYSTEM`, which writes to `postgresql.auto.conf` inside the data directory:
|
||||
|
||||
```sql
|
||||
ALTER SYSTEM SET work_mem = '32MB';
|
||||
ALTER SYSTEM SET effective_cache_size = '6GB';
|
||||
ALTER SYSTEM SET random_page_cost = 1.1;
|
||||
ALTER SYSTEM SET effective_io_concurrency = 200;
|
||||
-- Apply without restart:
|
||||
SELECT pg_reload_conf();
|
||||
|
||||
-- These require a full restart:
|
||||
ALTER SYSTEM SET shared_buffers = '4GB';
|
||||
ALTER SYSTEM SET max_connections = 100;
|
||||
ALTER SYSTEM SET max_worker_processes = 8;
|
||||
ALTER SYSTEM SET huge_pages = 'try';
|
||||
```
|
||||
|
||||
The default Compose database user has the required PostgreSQL permissions. If
|
||||
you use an external PostgreSQL server, make sure the configured `DATABASE_URL`
|
||||
user can run `ALTER SYSTEM`, or set `POSTGRES_TUNE=off` and manage
|
||||
PostgreSQL yourself.
|
||||
|
||||
For `POSTGRES_TUNE_MEMORY=auto`, Silo uses the first trustworthy memory source:
|
||||
a finite Docker cgroup limit, the read-only `/host/proc/meminfo` mount supplied
|
||||
by the bundled Compose file, then `/proc/meminfo` with container safety guards.
|
||||
Auto-detected memory is treated as a PostgreSQL budget, defaulting to 75% of
|
||||
detected RAM so Silo, Redis, plugins, transcodes, and the OS retain headroom.
|
||||
`POSTGRES_TUNE_DB_SIZE=auto` queries `pg_database_size(current_database())` and
|
||||
classifies the workload by comparing the database size to that memory budget.
|
||||
|
||||
Optional tuning overrides:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---:|---|
|
||||
| `POSTGRES_TUNE_PROFILE` | `oltp` | Tuning profile. Only `oltp` is currently supported. |
|
||||
| `POSTGRES_TUNE_MEMORY` | `auto` | Server/container RAM, such as `8GB` or `32GB`; explicit values are used as-is. |
|
||||
| `POSTGRES_TUNE_MEMORY_BUDGET_PERCENT` | `75` | Percent of auto-detected RAM used for PostgreSQL recommendations. |
|
||||
| `POSTGRES_TUNE_CPUS` | `auto` | CPU count used for worker recommendations. |
|
||||
| `POSTGRES_TUNE_STORAGE` | `ssd` | One of `hdd`, `ssd`, `san`, or `nvme`. |
|
||||
| `POSTGRES_TUNE_DB_SIZE` | `auto` | Use `less_ram` when the database comfortably fits in RAM, `mid_ram`, or `greater_ram` for very large databases. |
|
||||
| `POSTGRES_TUNE_CONNECTIONS` | `100` | PostgreSQL `max_connections`; automatically raised if Silo's app pool is configured higher. |
|
||||
| `POSTGRES_SHM_SIZE` | `8gb` | Docker `/dev/shm` size for the bundled PostgreSQL container. |
|
||||
|
||||
Advanced operators can still supply their own PostgreSQL configuration or
|
||||
override these env vars. Set `POSTGRES_TUNE=off` when you do not want Silo to
|
||||
change PostgreSQL server settings. Settings already written with `ALTER SYSTEM`
|
||||
remain in `postgresql.auto.conf`; reset those PostgreSQL parameters if you later
|
||||
move fully to a custom `postgresql.conf`.
|
||||
Settings like `shared_buffers`, `max_connections`, `max_worker_processes`, and `huge_pages` require a PostgreSQL restart. Memory/planner settings (`work_mem`, `effective_cache_size`, `random_page_cost`) can be reloaded live.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -336,5 +319,5 @@ internal/
|
||||
scanner/ Media file discovery and FFProbe
|
||||
worker/ Background jobs (scan, match, reconcile)
|
||||
web/ React + TypeScript frontend (Vite, Tailwind, shadcn/ui)
|
||||
migrations/sql/ Goose-managed PostgreSQL schema migrations
|
||||
migrations/ PostgreSQL schema migrations
|
||||
```
|
||||
|
||||
+105
-553
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -13,11 +15,8 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -25,13 +24,12 @@ import (
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
sdkcapability "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/capability"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/activitylog"
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/api"
|
||||
@@ -39,7 +37,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks"
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks/podcastfeed"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/autoscan"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/catalogseed"
|
||||
@@ -56,6 +53,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/logfilter"
|
||||
"github.com/Silo-Server/silo-server/internal/logstream"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/markers/introdb"
|
||||
"github.com/Silo-Server/silo-server/internal/mdblist"
|
||||
"github.com/Silo-Server/silo-server/internal/metadata"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -77,7 +75,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/s3client"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/secret"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
"github.com/Silo-Server/silo-server/internal/server"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
@@ -142,7 +139,7 @@ func mustGetSetting(store interface {
|
||||
func configureOperationalLogging(
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
settingsRepo catalog.SettingsStore,
|
||||
settingsRepo *catalog.ServerSettingsRepo,
|
||||
redisCfg config.RedisConfig,
|
||||
logStreamHub *logstream.Hub,
|
||||
baseHandler slog.Handler,
|
||||
@@ -189,106 +186,8 @@ func configureOperationalLogging(
|
||||
return operationalWriter, opslog.NewRepo(pool), opsPM
|
||||
}
|
||||
|
||||
func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxConnections int, mode string) {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "", "integrated", "api":
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
opts, err := database.LoadPostgresTuneOptionsFromEnv(appMaxConnections)
|
||||
if err != nil {
|
||||
slog.Warn("postgres auto-tuning disabled", "error", err)
|
||||
return
|
||||
}
|
||||
if !opts.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
tuneCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := database.ApplyPostgresTuning(tuneCtx, pool, opts)
|
||||
for _, failure := range result.Failures {
|
||||
slog.Warn("postgres auto-tuning setting failed",
|
||||
"name", failure.Name,
|
||||
"value", failure.Value,
|
||||
"error", failure.Err,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("postgres auto-tuning failed",
|
||||
"error", err,
|
||||
"applied", result.Applied,
|
||||
"failures", len(result.Failures),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("postgres auto-tuning applied",
|
||||
"profile", opts.Profile,
|
||||
"postgres_major", result.PostgresMajorVersion,
|
||||
"settings", result.Applied,
|
||||
"resets", len(result.Reset),
|
||||
"failures", len(result.Failures),
|
||||
"memory_budget_bytes", opts.MemoryBudgetBytes,
|
||||
"detected_memory_bytes", opts.DetectedMemoryBytes,
|
||||
"memory_source", opts.MemorySource,
|
||||
"memory_budget_percent", opts.MemoryBudgetPercent,
|
||||
"cpus", opts.CPUs,
|
||||
"connections", opts.Connections,
|
||||
"storage", opts.Storage,
|
||||
"db_size", result.DBSize,
|
||||
"database_size_bytes", result.DatabaseSizeBytes,
|
||||
)
|
||||
if len(result.RestartRequired) > 0 {
|
||||
slog.Warn("postgres restart required to finish applying auto-tuned settings",
|
||||
"settings", strings.Join(result.RestartRequired, ","),
|
||||
)
|
||||
}
|
||||
if len(result.Reset) > 0 {
|
||||
slog.Info("postgres auto-tuning reset stale settings",
|
||||
"settings", strings.Join(result.Reset, ","),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// runCredentialBackfills sweeps any plaintext server-owned credential to
|
||||
// ciphertext on the primary (migration-running) node. All passes are
|
||||
// best-effort: a failed row leaves the prior plaintext (no new exposure) and
|
||||
// still reads via the read-path pass-through, so a backfill error must never
|
||||
// block boot. The sensitive-settings pass runs first so the arr
|
||||
// resolve-then-encrypt pass sees consistent referenced settings.
|
||||
func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) {
|
||||
settingsN, err := settings.BackfillSensitiveSettings(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: sensitive settings", "error", err)
|
||||
}
|
||||
columnsN, err := secret.BackfillColumns(ctx, pool, cipher, secret.ColumnBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: credential columns", "error", err)
|
||||
}
|
||||
historyServersN, err := historyimport.NewRepository(pool, cipher).BackfillSessionServerSecrets(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: history import session server credentials", "error", err)
|
||||
}
|
||||
// The arr resolver is the encrypting settings decorator: it decrypts a
|
||||
// sensitive target (e.g. requests.radarr.api_key) or passes through a
|
||||
// plaintext custom key, exactly replicating the deleted resolveAPIKey.
|
||||
arrN, err := secret.BackfillReferencedColumns(ctx, pool, cipher, settings.Get, secret.ArrKeyBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: arr api keys", "error", err)
|
||||
}
|
||||
if total := settingsN + columnsN + historyServersN + arrN; total > 0 {
|
||||
slog.Info("secret backfill: encrypted plaintext credentials at rest",
|
||||
"settings", settingsN, "columns", columnsN, "history_session_servers", historyServersN, "arr_keys", arrN, "total", total)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
envFile := flag.String("env", ".env", "path to .env bootstrap file")
|
||||
migrateOnly := flag.Bool("migrate-only", false, "apply database migrations and exit")
|
||||
migrateStatus := flag.Bool("migrate-status", false, "show database migration status and exit")
|
||||
flag.Parse()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -299,15 +198,6 @@ func main() {
|
||||
log.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
// Construct the at-rest credential cipher from SECRET_KEY immediately after
|
||||
// bootstrap, before any settings repo is built. It is threaded explicitly as
|
||||
// a dependency into every repo that stores a server-owned secret — never a
|
||||
// package-level global.
|
||||
dataCipher, err := secret.New(bc.SecretKey)
|
||||
if err != nil {
|
||||
log.Fatalf("secret cipher: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Connect to PostgreSQL (bootstrap pool with default max connections)
|
||||
bootstrapDBCfg := config.DatabaseConfig{URL: bc.DatabaseURL, MaxConnections: 20}
|
||||
pool, err := database.NewPool(ctx, bootstrapDBCfg)
|
||||
@@ -317,52 +207,12 @@ func main() {
|
||||
defer pool.Close()
|
||||
slog.Info("connected to PostgreSQL")
|
||||
|
||||
if *migrateStatus {
|
||||
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
statuses, statusErr := database.MigrationStatuses(migCtx, pool, migrations.FS, "sql")
|
||||
migCancel()
|
||||
if statusErr != nil {
|
||||
log.Fatalf("failed to read migration status: %v", statusErr)
|
||||
}
|
||||
|
||||
fmt.Printf("%-8s %8s %-25s %s\n", "STATE", "VERSION", "APPLIED_AT", "MIGRATION")
|
||||
for _, status := range statuses {
|
||||
appliedAt := "-"
|
||||
if !status.AppliedAt.IsZero() {
|
||||
appliedAt = status.AppliedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
source := status.Source
|
||||
if source != "" {
|
||||
source = filepath.Base(source)
|
||||
} else {
|
||||
source = "-"
|
||||
}
|
||||
fmt.Printf("%-8s %8d %-25s %s\n", status.State, status.Version, appliedAt, source)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if *migrateOnly {
|
||||
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql")
|
||||
migCancel()
|
||||
if migErr != nil {
|
||||
log.Fatalf("failed to run migrations: %v", migErr)
|
||||
}
|
||||
slog.Info("database migrations applied")
|
||||
return
|
||||
}
|
||||
|
||||
// Run migrations only for integrated/api modes. Proxy and transcode nodes
|
||||
// should never alter the schema — they may scale independently and would
|
||||
// race or apply migrations before the primary node is deliberately upgraded.
|
||||
// The same gate decides whether this node runs the credential-encryption
|
||||
// backfills: only the primary (migration-running) node sweeps plaintext to
|
||||
// ciphertext; secondary nodes read whatever the primary encrypted.
|
||||
isPrimaryNode := bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == ""
|
||||
if isPrimaryNode {
|
||||
if bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == "" {
|
||||
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
if migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql"); migErr != nil {
|
||||
if migErr := database.RunMigrations(migCtx, pool, migrations.FS, "."); migErr != nil {
|
||||
migCancel()
|
||||
log.Fatalf("failed to run migrations: %v", migErr)
|
||||
}
|
||||
@@ -370,14 +220,8 @@ func main() {
|
||||
slog.Info("database migrations applied")
|
||||
}
|
||||
|
||||
// Step 3: Load settings from DB. settingsRepo is the encrypting decorator so
|
||||
// every consumer (config.LoadFromDB, admin, ABS, watchers) transparently sees
|
||||
// plaintext while sensitive keys rest as ciphertext. The settings backfill
|
||||
// (run after migrations, before this GetAll) is wired further below.
|
||||
settingsRepo := catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
|
||||
if isPrimaryNode {
|
||||
runCredentialBackfills(ctx, pool, dataCipher, settingsRepo)
|
||||
}
|
||||
// Step 3: Load settings from DB
|
||||
settingsRepo := catalog.NewServerSettingsRepo(pool)
|
||||
settings, err := settingsRepo.GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("loading settings: %v", err)
|
||||
@@ -448,9 +292,7 @@ func main() {
|
||||
log.Fatalf("recreating pool with configured max_connections: %v", err)
|
||||
}
|
||||
}
|
||||
// Re-wrap with the encrypting decorator so the recreated pool's settings repo
|
||||
// still encrypts/decrypts — no raw settings repo may escape into later wiring.
|
||||
settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
|
||||
settingsRepo = catalog.NewServerSettingsRepo(pool)
|
||||
nodeID := resolveNodeIdentity()
|
||||
|
||||
// Step 9: Validate
|
||||
@@ -474,13 +316,10 @@ func main() {
|
||||
slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet)))
|
||||
|
||||
mode := cfg.Server.Mode
|
||||
maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode)
|
||||
slog.Info("silo starting", "mode", mode, "listen", cfg.Server.Listen, "log_level", cfg.Server.LogLevel, "node_id", nodeID)
|
||||
|
||||
appCtx, appCancel := context.WithCancel(ctx)
|
||||
defer appCancel()
|
||||
restartReqCh := make(chan struct{}, 1)
|
||||
var restartRequested atomic.Bool
|
||||
|
||||
eventBus := cache.NewEventBus(cfg.Redis.URL)
|
||||
logStreamHub := logstream.NewHub(nodeID, eventBus)
|
||||
@@ -514,7 +353,7 @@ func main() {
|
||||
DatabaseURL: cfg.Database.URL,
|
||||
JFListen: cfg.JellyfinCompat.Listen,
|
||||
}
|
||||
watcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, bootstrap)
|
||||
watcher := nodeconfig.NewWatcher(pool, eventBus, bootstrap)
|
||||
if err := watcher.Start(appCtx); err != nil {
|
||||
slog.Error("config watcher start failed", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -573,7 +412,6 @@ func main() {
|
||||
BootstrapSensitiveValues: bootstrapSensitiveValues,
|
||||
AppContext: appCtx,
|
||||
DB: pool,
|
||||
SecretCipher: dataCipher,
|
||||
EventBus: eventBus,
|
||||
LogStreamHub: logStreamHub,
|
||||
RealtimeHub: realtimeHub,
|
||||
@@ -582,22 +420,14 @@ func main() {
|
||||
OpsLogRepo: opsRepo,
|
||||
FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID),
|
||||
PublicURL: os.Getenv("SILO_PUBLIC_URL"),
|
||||
RequestServerRestart: func(context.Context) error {
|
||||
if !restartRequested.CompareAndSwap(false, true) {
|
||||
return handlers.ErrServerRestartAlreadyRequested
|
||||
}
|
||||
restartReqCh <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
|
||||
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
|
||||
audiobooksEnabled, err := audiobooksService.Enabled(appCtx)
|
||||
if err != nil {
|
||||
slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err)
|
||||
absCompatEnabled = false
|
||||
slog.Warn("audiobooks feature disabled; failed to read setting", "err", err)
|
||||
audiobooksEnabled = false
|
||||
}
|
||||
adminJobCancelRegistry := adminjob.NewCancelRegistry()
|
||||
deps.AdminJobCancelRegistry = adminJobCancelRegistry
|
||||
deps.AudiobooksEnabled = audiobooksEnabled
|
||||
if needsWorkers && deps.DB != nil {
|
||||
deps.IntroRepository = intromarkers.NewRepository(deps.DB)
|
||||
deps.IntroAnalyzer = intromarkers.NewAnalyzer(
|
||||
@@ -608,34 +438,36 @@ func main() {
|
||||
}
|
||||
if deps.DB != nil {
|
||||
markerRegistry := markers.NewRegistry(slog.Default())
|
||||
markerProviderConfig := markers.NewProviderConfigStore(deps.DB)
|
||||
if err := markerProviderConfig.Reload(appCtx); err != nil {
|
||||
slog.Warn("load marker provider config failed; falling back to registration-order fetch",
|
||||
introdbAPIKey, err := settingsRepo.Get(appCtx, "introdb.api_key")
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
slog.Warn("load introdb.api_key from settings failed; provider will run with no key",
|
||||
"error", err)
|
||||
} else {
|
||||
markerRegistry.UseConfigStore(markerProviderConfig)
|
||||
if deps.EventBus != nil {
|
||||
if err := deps.EventBus.Subscribe(appCtx, cache.ChannelAdmin, func(event cache.Event) {
|
||||
if event.Type != cache.EventMarkerProviderConfigChanged {
|
||||
return
|
||||
}
|
||||
if err := markerProviderConfig.Reload(appCtx); err != nil {
|
||||
slog.Warn("reload marker provider config failed", "provider", event.Payload, "error", err)
|
||||
}
|
||||
}); err != nil {
|
||||
slog.Warn("subscribe marker provider config reload failed", "error", err)
|
||||
}
|
||||
introdbAPIKey = ""
|
||||
}
|
||||
introdbClient := introdb.NewClient(introdbAPIKey)
|
||||
if err := markerRegistry.Register(introdb.NewProvider(introdbClient)); err != nil {
|
||||
log.Fatalf("register introdb marker provider: %v", err)
|
||||
}
|
||||
deps.OnServerSettingUpdated = func(_ context.Context, key, value string) {
|
||||
if key == "introdb.api_key" {
|
||||
introdbClient.SetAPIKey(value)
|
||||
}
|
||||
}
|
||||
deps.MarkerProviderConfig = markerProviderConfig
|
||||
if eventBus != nil {
|
||||
_ = eventBus.Subscribe(appCtx, cache.ChannelAdmin, func(event cache.Event) {
|
||||
if event.Type != cache.EventSettingsChanged || event.Payload != "introdb.api_key" {
|
||||
return
|
||||
}
|
||||
value, loadErr := settingsRepo.Get(context.Background(), "introdb.api_key")
|
||||
if loadErr != nil {
|
||||
slog.Warn("introdb api key reload failed", "error", loadErr)
|
||||
return
|
||||
}
|
||||
introdbClient.SetAPIKey(value)
|
||||
})
|
||||
}
|
||||
deps.MarkerRegistry = markerRegistry
|
||||
markerResolver := markers.NewDBExternalIDResolver(deps.DB)
|
||||
deps.MarkerResolver = markerResolver
|
||||
markerContributionStore := markers.NewContributionStore(deps.DB)
|
||||
deps.MarkerContributionStore = markerContributionStore
|
||||
deps.MarkerContributionService = markers.NewContributionService(
|
||||
markerRegistry, markerResolver, markerProviderConfig, markerContributionStore, slog.Default(),
|
||||
)
|
||||
deps.MarkerResolver = markers.NewDBExternalIDResolver(deps.DB)
|
||||
}
|
||||
var watchProviderService *watchsync.Service
|
||||
if deps.DB != nil {
|
||||
@@ -650,7 +482,7 @@ func main() {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
watchProviderService = watchsync.NewService(
|
||||
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
|
||||
watchsync.NewPostgresRepository(deps.DB),
|
||||
watchProviderRegistry,
|
||||
)
|
||||
deps.WatchProviderService = watchProviderService
|
||||
@@ -847,22 +679,6 @@ func main() {
|
||||
installer,
|
||||
plugins.NewHostAdapter(pluginHost),
|
||||
)
|
||||
if deps.MarkerRegistry != nil && deps.MarkerProviderConfig != nil {
|
||||
markerPluginResolver := markers.NewPluginResolverAdapter(pluginService)
|
||||
pluginService.AddLifecycleHook(func(ctx context.Context) {
|
||||
if err := reloadMarkerPluginProviders(
|
||||
ctx,
|
||||
deps.MarkerRegistry,
|
||||
deps.MarkerProviderConfig,
|
||||
installationStore,
|
||||
runtimeConfigStore,
|
||||
settingsRepo,
|
||||
markerPluginResolver,
|
||||
); err != nil {
|
||||
slog.Warn("reload marker plugin providers failed", "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := pluginService.PreloadEnabled(appCtx); err != nil {
|
||||
log.Fatalf("preload enabled plugins: %v", err)
|
||||
}
|
||||
@@ -919,12 +735,6 @@ func main() {
|
||||
pluginService.OnLifecycleChange(appCtx)
|
||||
}
|
||||
|
||||
// backgroundInit collects non-critical startup work (catalog-size-dependent
|
||||
// seeding, network-bound reconciliation) that must not block the HTTP
|
||||
// listener. The steps run sequentially in a background goroutine once the
|
||||
// server is ready to serve. Failures are logged, never fatal.
|
||||
var backgroundInit []func(context.Context)
|
||||
|
||||
// Step 4b: Create metadata service and match worker (if needed).
|
||||
var metadataService *metadata.MetadataService
|
||||
var personRefreshService *metadata.PersonRefreshService
|
||||
@@ -998,8 +808,6 @@ func main() {
|
||||
providerIDRepo := catalog.NewProviderIDRepository(deps.DB)
|
||||
movieQueueRepo = metadata.NewMovieMatchQueueRepository(deps.DB, deps.FileRepo)
|
||||
seriesQueueRepo = metadata.NewSeriesRootMatchQueueRepository(deps.DB)
|
||||
deps.MovieMatchQueueRepo = movieQueueRepo
|
||||
deps.SeriesRootMatchQueueRepo = seriesQueueRepo
|
||||
matchQueueCoordinator = metadata.NewMatchQueueCoordinator(movieQueueRepo, seriesQueueRepo)
|
||||
rootClaimRepo = catalog.NewRootClaimRepository(deps.DB)
|
||||
groupClaimRepo = catalog.NewGroupClaimRepository(deps.DB)
|
||||
@@ -1049,33 +857,27 @@ func main() {
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
matchWorker.SetRealtimeHub(deps.RealtimeHub)
|
||||
if movieQueueRepo != nil {
|
||||
matchWorker.SetMovieFileClaimer(movieQueueRepo)
|
||||
}
|
||||
if seriesQueueRepo != nil {
|
||||
if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(appCtx); err != nil {
|
||||
slog.Warn("failed to clean legacy series group queue rows", "error", err)
|
||||
} else if cleaned > 0 {
|
||||
slog.Info("cleaned legacy series group queue rows", "count", cleaned)
|
||||
}
|
||||
matchWorker.SetSeriesRootClaimer(seriesQueueRepo, cfg.Matcher.TVSeriesRootQueueEnabled())
|
||||
backgroundInit = append(backgroundInit, func(ctx context.Context) {
|
||||
if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(ctx); err != nil {
|
||||
slog.Warn("failed to clean legacy series group queue rows", "error", err)
|
||||
} else if cleaned > 0 {
|
||||
slog.Info("cleaned legacy series group queue rows", "count", cleaned)
|
||||
}
|
||||
})
|
||||
}
|
||||
if deps.FolderRepo != nil {
|
||||
backgroundInit = append(backgroundInit, func(ctx context.Context) {
|
||||
start := time.Now()
|
||||
enabledFolders, err := deps.FolderRepo.GetEnabled(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("failed to seed metadata queues", "error", err)
|
||||
return
|
||||
}
|
||||
enabledFolders, err := deps.FolderRepo.GetEnabled(appCtx)
|
||||
if err != nil {
|
||||
slog.Warn("failed to seed metadata queues", "error", err)
|
||||
} else {
|
||||
seedMovieQueue := func(folderID int) {
|
||||
if movieQueueRepo == nil {
|
||||
return
|
||||
}
|
||||
if err := movieQueueRepo.SyncForFolder(ctx, folderID); err != nil {
|
||||
if err := movieQueueRepo.SyncForFolder(appCtx, folderID); err != nil {
|
||||
slog.Warn("failed to seed movie match queue", "folder_id", folderID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -1083,7 +885,7 @@ func main() {
|
||||
if seriesQueueRepo == nil {
|
||||
return
|
||||
}
|
||||
if err := seriesQueueRepo.SyncForFolder(ctx, folderID); err != nil {
|
||||
if err := seriesQueueRepo.SyncForFolder(appCtx, folderID); err != nil {
|
||||
slog.Warn("failed to seed series root queue", "folder_id", folderID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -1101,8 +903,7 @@ func main() {
|
||||
seedMovieQueue(folder.ID)
|
||||
}
|
||||
}
|
||||
slog.Info("deferred init: metadata match queues seeded", "folders", len(enabledFolders), "duration", time.Since(start))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deps.SkippedRootRepo = skippedRootRepo
|
||||
@@ -1235,17 +1036,15 @@ func main() {
|
||||
deps.UserStoreProvider = userStoreProvider
|
||||
}
|
||||
if watchProviderService != nil {
|
||||
historyRepo := historyimport.NewRepository(deps.DB, deps.SecretCipher)
|
||||
historyRepo := historyimport.NewRepository(deps.DB)
|
||||
historyIdentity := watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, catalog.NewProviderIDRepository(deps.DB))
|
||||
watchProviderService.
|
||||
WithMatcher(historyimport.NewMatcher(historyRepo)).
|
||||
WithWatchState(watchstate.NewService(userStoreProvider).WithStableIdentityResolver(historyIdentity)).
|
||||
WithUserStoreProvider(userStoreProvider)
|
||||
backgroundInit = append(backgroundInit, func(ctx context.Context) {
|
||||
if err := watchProviderService.SweepOpenScrobbles(ctx); err != nil {
|
||||
slog.Warn("failed to sweep open watch provider scrobbles", "error", err)
|
||||
}
|
||||
})
|
||||
if err := watchProviderService.SweepOpenScrobbles(appCtx); err != nil {
|
||||
slog.Warn("failed to sweep open watch provider scrobbles", "error", err)
|
||||
}
|
||||
}
|
||||
deps.SessionMgr = sessionMgr
|
||||
deps.PlaybackRealtimeHub = playback.NewRealtimeHub()
|
||||
@@ -1451,7 +1250,6 @@ func main() {
|
||||
// Construct collection service for both the router and the collection sync scheduler.
|
||||
var collectionSyncScheduler *catalog.CollectionSyncScheduler
|
||||
var userCollectionScheduler *usercollections.Scheduler
|
||||
var trendingRefresher *sections.TrendingRefresher
|
||||
if needsWorkers && deps.DB != nil {
|
||||
collectionRepo := catalog.NewLibraryCollectionRepository(deps.DB)
|
||||
collItemRepo := catalog.NewItemRepository(deps.DB)
|
||||
@@ -1461,19 +1259,6 @@ func main() {
|
||||
deps.CollectionService = collectionService
|
||||
collectionSyncScheduler = catalog.NewCollectionSyncScheduler(collectionRepo, collectionService, slog.Default())
|
||||
|
||||
// The trending refresher reuses the section repo (to find used source/
|
||||
// window combos), a snapshot repo, an item repo (external-ID matching),
|
||||
// and the TMDB fetcher. The Trakt fetcher needs settingsRepo and is
|
||||
// propagated onto deps.TrendingRefresher later in router.go.
|
||||
trendingRefresher = sections.NewTrendingRefresher(
|
||||
sectionRepo,
|
||||
sections.NewTrendingSnapshotRepository(pool),
|
||||
catalog.NewItemRepository(deps.DB),
|
||||
collectionService.TMDBCollections,
|
||||
collectionService.TraktCollections,
|
||||
)
|
||||
deps.TrendingRefresher = trendingRefresher
|
||||
|
||||
if deps.UserStoreProvider != nil {
|
||||
userSync := usercollections.NewService(deps.UserStoreProvider, collItemRepo, libraryItemRepo, nil, slog.Default())
|
||||
userSync.TMDBCollections = collectionService.TMDBCollections
|
||||
@@ -1498,15 +1283,9 @@ func main() {
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil {
|
||||
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
|
||||
}
|
||||
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
|
||||
if deps.IntroAnalyzer != nil {
|
||||
taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo))
|
||||
}
|
||||
if deps.MarkerContributionService != nil && deps.MarkerProviderConfig != nil && deps.MarkerContributionStore != nil && deps.FileRepo != nil {
|
||||
taskMgr.Register(tasks.NewContributeMarkersTask(
|
||||
deps.MarkerContributionService, deps.MarkerProviderConfig, deps.MarkerContributionStore, deps.FileRepo,
|
||||
))
|
||||
}
|
||||
if chapterBackfiller, ok := deps.ChapterThumbnailQueuer.(*chapterthumbs.Service); ok {
|
||||
taskMgr.Register(tasks.NewChapterThumbnailBackfillTask(chapterBackfiller, 25))
|
||||
}
|
||||
@@ -1524,9 +1303,6 @@ func main() {
|
||||
if collectionSyncScheduler != nil {
|
||||
taskMgr.Register(tasks.NewSyncCollectionsTask(collectionSyncScheduler))
|
||||
}
|
||||
if trendingRefresher != nil {
|
||||
taskMgr.Register(tasks.NewRefreshTrendingDiscoverTask(trendingRefresher))
|
||||
}
|
||||
if userCollectionScheduler != nil {
|
||||
taskMgr.Register(tasks.NewSyncUserCollectionsTask(userCollectionScheduler))
|
||||
}
|
||||
@@ -1534,49 +1310,16 @@ func main() {
|
||||
taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService))
|
||||
}
|
||||
requestReconcileSvc := mediarequests.NewService(
|
||||
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
|
||||
mediarequests.NewRepository(deps.DB),
|
||||
nil,
|
||||
mediarequests.NewCatalogPresence(
|
||||
catalog.NewItemRepository(deps.DB),
|
||||
catalog.NewProviderIDRepository(deps.DB),
|
||||
),
|
||||
)
|
||||
requestReconcileSvc.SetSecretResolver(settingsRepo)
|
||||
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
if userStoreProvider != nil {
|
||||
reconcileResolver := access.NewResolver(
|
||||
auth.NewUserRepository(deps.DB),
|
||||
userStoreProvider,
|
||||
access.NewProfileTokenService(cfg.Auth.JWTSecret, 0),
|
||||
)
|
||||
requestReconcileSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(reconcileResolver))
|
||||
}
|
||||
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil {
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
|
||||
if err := autoscanRepo.MarkInterruptedEvents(appCtx); err != nil {
|
||||
slog.Warn("autoscan: failed to mark interrupted polls", "err", err)
|
||||
}
|
||||
autoscanSvc := api.BuildAutoscanService(
|
||||
autoscanRepo,
|
||||
pluginService,
|
||||
pluginInstallationStore,
|
||||
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
|
||||
deps.FolderRepo,
|
||||
deps.LibraryScanQueue,
|
||||
deps.RedisClient,
|
||||
)
|
||||
// The poll task's default interval seeds the schedule from the stored
|
||||
// settings (DefaultPollIntervalSeconds); per-cycle gating still runs
|
||||
// off the live settings inside PollOnce. Seed in MILLISECONDS as
|
||||
// seconds*1000 — the SAME computation HandleUpdateSettings uses to
|
||||
// reschedule — so startup and reschedule agree for sub-minute and
|
||||
// non-60-multiple intervals (the old seconds/60 minutes path diverged).
|
||||
var intervalMs int64 = 10 * 60 * 1000
|
||||
if settings, serr := autoscanRepo.GetSettings(appCtx); serr == nil && settings.DefaultPollIntervalSeconds > 0 {
|
||||
intervalMs = int64(settings.DefaultPollIntervalSeconds) * 1000
|
||||
}
|
||||
taskMgr.Register(tasks.NewAutoscanPollTask(autoscanSvc, intervalMs))
|
||||
}
|
||||
reconcileProviderIDRepo := catalog.NewProviderIDRepository(deps.DB)
|
||||
reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB)
|
||||
historyResolver := watchstate.NewStableIdentityResolver(nil, reconcileEpisodeRepo, reconcileProviderIDRepo)
|
||||
@@ -1584,7 +1327,7 @@ func main() {
|
||||
taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler))
|
||||
taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler))
|
||||
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB)))
|
||||
if audiobookEnricher != nil {
|
||||
if audiobooksEnabled && audiobookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
@@ -1607,7 +1350,7 @@ func main() {
|
||||
// available. Routes are mounted at the root level by NewRouter (not under
|
||||
// /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and
|
||||
// /abs/socket.io/* without path prefix hacks.
|
||||
if absCompatEnabled && deps.DB != nil {
|
||||
if audiobooksEnabled && deps.DB != nil {
|
||||
absUserRepo := auth.NewUserRepository(deps.DB)
|
||||
absSessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
absJWTService := auth.NewJWTService(
|
||||
@@ -1846,7 +1589,6 @@ func main() {
|
||||
templateBundleApplyExecutor,
|
||||
deps.RealtimeHub,
|
||||
)
|
||||
adminJobRunner.SetCancelRegistry(adminJobCancelRegistry)
|
||||
adminJobRunner.Start()
|
||||
defer adminJobRunner.Stop()
|
||||
|
||||
@@ -1880,7 +1622,6 @@ func main() {
|
||||
compatDeps := jellycompat.Dependencies{
|
||||
Config: cfg,
|
||||
DB: deps.DB,
|
||||
SecretCipher: dataCipher,
|
||||
ClientIPResolver: ipResolver,
|
||||
ProxyPool: deps.ProxyPool,
|
||||
TranscodePool: deps.TranscodePool,
|
||||
@@ -1934,7 +1675,7 @@ func main() {
|
||||
compatDeps.FileResolver = deps.FileRepo
|
||||
}
|
||||
|
||||
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB, deps.SecretCipher)
|
||||
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB)
|
||||
|
||||
// Construct auth service for jellycompat login.
|
||||
userRepo := auth.NewUserRepository(deps.DB)
|
||||
@@ -1950,17 +1691,47 @@ func main() {
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
|
||||
|
||||
// Access filter resolver for viewer-scoped library access.
|
||||
// Backed by the shared access.Resolver so account-level library
|
||||
// restrictions (users.library_ids), profile restrictions,
|
||||
// user-disabled libraries, and rating/quality ceilings apply to
|
||||
// the compat API exactly as they do to the native API.
|
||||
if userStoreProvider != nil {
|
||||
compatDeps.AccessFilterFn = jellycompat.NewScopeAccessFilter(access.NewResolver(
|
||||
userRepo,
|
||||
userStoreProvider,
|
||||
nil, // profile tokens unused: compat login already verifies PINs
|
||||
))
|
||||
// Access filter resolver for profile-scoped library access.
|
||||
compatDeps.AccessFilterFn = func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
|
||||
if userStoreProvider == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
store, err := userStoreProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
profile, err := store.GetProfile(ctx, profileID)
|
||||
if err != nil || profile == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
filter := catalog.AccessFilter{
|
||||
MaxContentRating: profile.MaxContentRating,
|
||||
}
|
||||
if profile.LibraryRestrictionsEnabled && len(profile.AllowedLibraryIDs) > 0 {
|
||||
filter.AllowedLibraryIDs = profile.AllowedLibraryIDs
|
||||
}
|
||||
// Apply user-disabled library IDs.
|
||||
if raw, err := store.GetSetting(ctx, "disabled_library_ids"); err == nil && raw != "" {
|
||||
var disabled []int
|
||||
if json.Unmarshal([]byte(raw), &disabled) == nil && len(disabled) > 0 {
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
filtered := make([]int, 0, len(filter.AllowedLibraryIDs))
|
||||
disSet := make(map[int]struct{}, len(disabled))
|
||||
for _, id := range disabled {
|
||||
disSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range filter.AllowedLibraryIDs {
|
||||
if _, ok := disSet[id]; !ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
filter.AllowedLibraryIDs = filtered
|
||||
} else {
|
||||
filter.DisabledLibraryIDs = disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1995,30 +1766,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Run non-critical startup work in the background so it doesn't delay the
|
||||
// HTTP listener from accepting connections. Steps run sequentially and stop
|
||||
// early if the app context is cancelled (shutdown).
|
||||
if len(backgroundInit) > 0 {
|
||||
go func() {
|
||||
start := time.Now()
|
||||
for _, step := range backgroundInit {
|
||||
if appCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
slog.Error("deferred startup init step panicked; continuing",
|
||||
"panic", p, "stack", string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
step(appCtx)
|
||||
}()
|
||||
}
|
||||
slog.Info("deferred startup init completed", "steps", len(backgroundInit), "duration", time.Since(start))
|
||||
}()
|
||||
}
|
||||
|
||||
errCh := make(chan error, 3)
|
||||
go func() {
|
||||
slog.Info("HTTP server listening", "addr", cfg.Server.Listen)
|
||||
@@ -2052,9 +1799,6 @@ func main() {
|
||||
case sig := <-sigCh:
|
||||
appCancel()
|
||||
slog.Info("received signal, shutting down", "signal", sig)
|
||||
case <-restartReqCh:
|
||||
appCancel()
|
||||
slog.Info("server restart requested, shutting down")
|
||||
case serverErr := <-errCh:
|
||||
appCancel()
|
||||
slog.Error("server error, shutting down", "error", serverErr)
|
||||
@@ -2224,198 +1968,6 @@ func configureS3Clients(cfg *config.Config, deps *api.Dependencies) {
|
||||
}
|
||||
}
|
||||
|
||||
type markerPluginCapabilityStore interface {
|
||||
ListEnabled(ctx context.Context) ([]*plugins.Installation, error)
|
||||
ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error)
|
||||
}
|
||||
|
||||
type markerPluginRuntimeConfigStore interface {
|
||||
ListGlobalConfigs(ctx context.Context, installationID int) ([]*plugins.RuntimeConfig, error)
|
||||
PutGlobalConfig(ctx context.Context, installationID int, key string, value map[string]any) error
|
||||
}
|
||||
|
||||
type markerLegacySettingsStore interface {
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
func reloadMarkerPluginProviders(
|
||||
ctx context.Context,
|
||||
registry *markers.Registry,
|
||||
configStore *markers.ProviderConfigStore,
|
||||
store markerPluginCapabilityStore,
|
||||
runtimeConfigs markerPluginRuntimeConfigStore,
|
||||
legacySettings markerLegacySettingsStore,
|
||||
resolver *markers.PluginResolverAdapter,
|
||||
) error {
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
var providers []markers.Provider
|
||||
if store == nil || resolver == nil {
|
||||
return registry.SetProviders(providers)
|
||||
}
|
||||
|
||||
installations, err := store.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list enabled plugin installations: %w", err)
|
||||
}
|
||||
sort.Slice(installations, func(i, j int) bool {
|
||||
if installations[i] == nil {
|
||||
return false
|
||||
}
|
||||
if installations[j] == nil {
|
||||
return true
|
||||
}
|
||||
return installations[i].ID < installations[j].ID
|
||||
})
|
||||
|
||||
nextPriority := 1000
|
||||
for _, installation := range installations {
|
||||
if installation == nil {
|
||||
continue
|
||||
}
|
||||
capabilities, err := store.ListCapabilities(ctx, installation.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list marker provider capabilities for installation %d: %w", installation.ID, err)
|
||||
}
|
||||
sort.Slice(capabilities, func(i, j int) bool {
|
||||
if capabilities[i] == nil {
|
||||
return false
|
||||
}
|
||||
if capabilities[j] == nil {
|
||||
return true
|
||||
}
|
||||
return capabilities[i].ID < capabilities[j].ID
|
||||
})
|
||||
for _, capability := range capabilities {
|
||||
if capability == nil || capability.Type != sdkcapability.MarkerProvider {
|
||||
continue
|
||||
}
|
||||
descriptor, err := plugins.DecodeCapability(capability)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode marker provider capability %d/%s: %w", installation.ID, capability.ID, err)
|
||||
}
|
||||
metadataMap := markerCapabilityMetadata(descriptor)
|
||||
provider, err := markers.NewPluginProvider(markers.PluginProviderOptions{
|
||||
InstallationID: installation.ID,
|
||||
CapabilityID: capability.ID,
|
||||
DisplayName: firstNonEmptyMarkerText(descriptor.GetDisplayName(), capability.ID),
|
||||
PluginID: installation.PluginID,
|
||||
RequiredExternalIDs: markers.PluginRequiredExternalIDsFromMetadata(metadataMap),
|
||||
}, resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
|
||||
priority := nextPriority
|
||||
nextPriority++
|
||||
if configuredPriority, ok := markers.PluginDefaultFetchPriorityFromMetadata(metadataMap); ok {
|
||||
priority = configuredPriority
|
||||
}
|
||||
if configStore != nil {
|
||||
defaultConfig := markers.ProviderConfig{
|
||||
Provider: provider.ID(),
|
||||
FetchEnabled: true,
|
||||
FetchPriority: priority,
|
||||
ContributeEnabled: false,
|
||||
ContributeAutoLocal: false,
|
||||
ContributeMinConfidence: 0.95,
|
||||
}
|
||||
if legacy, ok := legacyIntroDBProviderConfig(configStore, installation, capability, provider.ID()); ok {
|
||||
defaultConfig = legacy
|
||||
}
|
||||
if err := configStore.Ensure(ctx, defaultConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := copyLegacyIntroDBPluginConfig(ctx, runtimeConfigs, legacySettings, installation, capability); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return registry.SetProviders(providers)
|
||||
}
|
||||
|
||||
func legacyIntroDBProviderConfig(
|
||||
configStore *markers.ProviderConfigStore,
|
||||
installation *plugins.Installation,
|
||||
capability *plugins.Capability,
|
||||
providerID string,
|
||||
) (markers.ProviderConfig, bool) {
|
||||
if configStore == nil ||
|
||||
installation == nil ||
|
||||
capability == nil ||
|
||||
installation.PluginID != "silo.theintrodb" ||
|
||||
capability.ID != "introdb" {
|
||||
return markers.ProviderConfig{}, false
|
||||
}
|
||||
if _, exists := configStore.Get(providerID); exists {
|
||||
return markers.ProviderConfig{}, false
|
||||
}
|
||||
legacy, ok := configStore.Get("introdb")
|
||||
if !ok {
|
||||
return markers.ProviderConfig{}, false
|
||||
}
|
||||
legacy.Provider = providerID
|
||||
return legacy, true
|
||||
}
|
||||
|
||||
func copyLegacyIntroDBPluginConfig(
|
||||
ctx context.Context,
|
||||
runtimeConfigs markerPluginRuntimeConfigStore,
|
||||
legacySettings markerLegacySettingsStore,
|
||||
installation *plugins.Installation,
|
||||
capability *plugins.Capability,
|
||||
) error {
|
||||
if runtimeConfigs == nil ||
|
||||
legacySettings == nil ||
|
||||
installation == nil ||
|
||||
capability == nil ||
|
||||
installation.PluginID != "silo.theintrodb" ||
|
||||
capability.ID != "introdb" {
|
||||
return nil
|
||||
}
|
||||
configs, err := runtimeConfigs.ListGlobalConfigs(ctx, installation.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list TheIntroDB plugin config: %w", err)
|
||||
}
|
||||
for _, config := range configs {
|
||||
if config != nil && config.Key == "account" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
apiKey, err := legacySettings.Get(ctx, "introdb.api_key")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load legacy introdb.api_key: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := runtimeConfigs.PutGlobalConfig(ctx, installation.ID, "account", map[string]any{
|
||||
"api_key": strings.TrimSpace(apiKey),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("copy legacy introdb.api_key to plugin config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markerCapabilityMetadata(descriptor *pluginv1.CapabilityDescriptor) map[string]any {
|
||||
if descriptor == nil || descriptor.GetMetadata() == nil {
|
||||
return nil
|
||||
}
|
||||
return descriptor.GetMetadata().AsMap()
|
||||
}
|
||||
|
||||
func firstNonEmptyMarkerText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mapFolderTypeToMediaType maps silo's MediaFolder.Type values
|
||||
// ("movies", "series", "mixed") to the SDK's MediaType values
|
||||
// ("movie", "tv", "mixed"). Unknown values map to "mixed".
|
||||
@@ -2434,7 +1986,7 @@ func mapFolderTypeToMediaType(t string) string {
|
||||
// exposes Get) to the audiobooks.SettingsReader interface (which
|
||||
// requires GetString). The two signatures are identical modulo name.
|
||||
type audiobooksSettingsAdapter struct {
|
||||
repo catalog.SettingsStore
|
||||
repo *catalog.ServerSettingsRepo
|
||||
}
|
||||
|
||||
func (a *audiobooksSettingsAdapter) GetString(ctx context.Context, key string) (string, error) {
|
||||
|
||||
@@ -132,9 +132,6 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) {
|
||||
RequestedMediaFileID: 41,
|
||||
PlayMethod: playback.PlayTranscode,
|
||||
BasePlayMethod: playback.PlayRemux,
|
||||
TranscodeHWAccel: "qsv",
|
||||
Position: 125.5,
|
||||
IsPaused: true,
|
||||
},
|
||||
want: "transcode",
|
||||
},
|
||||
@@ -167,18 +164,6 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) {
|
||||
if got.SessionID != tc.session.ID {
|
||||
t.Fatalf("SessionID = %q, want %q", got.SessionID, tc.session.ID)
|
||||
}
|
||||
if got.ProfileID != tc.session.ProfileID {
|
||||
t.Fatalf("ProfileID = %q, want %q", got.ProfileID, tc.session.ProfileID)
|
||||
}
|
||||
if got.PositionSeconds != tc.session.Position {
|
||||
t.Fatalf("PositionSeconds = %v, want %v", got.PositionSeconds, tc.session.Position)
|
||||
}
|
||||
if got.IsPaused != tc.session.IsPaused {
|
||||
t.Fatalf("IsPaused = %v, want %v", got.IsPaused, tc.session.IsPaused)
|
||||
}
|
||||
if got.TranscodeHWAccel != tc.session.TranscodeHWAccel {
|
||||
t.Fatalf("TranscodeHWAccel = %q, want %q", got.TranscodeHWAccel, tc.session.TranscodeHWAccel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
)
|
||||
|
||||
type fakeMarkerRuntimeConfigs struct {
|
||||
configs map[int][]*plugins.RuntimeConfig
|
||||
puts []plugins.RuntimeConfig
|
||||
}
|
||||
|
||||
func (f *fakeMarkerRuntimeConfigs) ListGlobalConfigs(_ context.Context, installationID int) ([]*plugins.RuntimeConfig, error) {
|
||||
return append([]*plugins.RuntimeConfig(nil), f.configs[installationID]...), nil
|
||||
}
|
||||
|
||||
func (f *fakeMarkerRuntimeConfigs) PutGlobalConfig(_ context.Context, installationID int, key string, value map[string]any) error {
|
||||
f.puts = append(f.puts, plugins.RuntimeConfig{InstallationID: installationID, Key: key, Value: value})
|
||||
f.configs[installationID] = append(f.configs[installationID], &plugins.RuntimeConfig{
|
||||
InstallationID: installationID,
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeMarkerLegacySettings map[string]string
|
||||
|
||||
func (f fakeMarkerLegacySettings) Get(_ context.Context, key string) (string, error) {
|
||||
return f[key], nil
|
||||
}
|
||||
|
||||
func TestCopyLegacyIntroDBPluginConfigCopiesAPIKeyOnce(t *testing.T) {
|
||||
runtimeConfigs := &fakeMarkerRuntimeConfigs{configs: map[int][]*plugins.RuntimeConfig{}}
|
||||
installation := &plugins.Installation{ID: 42, PluginID: "silo.theintrodb"}
|
||||
capability := &plugins.Capability{ID: "introdb"}
|
||||
|
||||
if err := copyLegacyIntroDBPluginConfig(
|
||||
context.Background(),
|
||||
runtimeConfigs,
|
||||
fakeMarkerLegacySettings{"introdb.api_key": " legacy-key "},
|
||||
installation,
|
||||
capability,
|
||||
); err != nil {
|
||||
t.Fatalf("copyLegacyIntroDBPluginConfig: %v", err)
|
||||
}
|
||||
if len(runtimeConfigs.puts) != 1 {
|
||||
t.Fatalf("puts = %d, want 1", len(runtimeConfigs.puts))
|
||||
}
|
||||
got := runtimeConfigs.puts[0]
|
||||
if got.InstallationID != 42 || got.Key != "account" || got.Value["api_key"] != "legacy-key" {
|
||||
t.Fatalf("put = %+v, want account api_key copy", got)
|
||||
}
|
||||
|
||||
if err := copyLegacyIntroDBPluginConfig(
|
||||
context.Background(),
|
||||
runtimeConfigs,
|
||||
fakeMarkerLegacySettings{"introdb.api_key": "new-key"},
|
||||
installation,
|
||||
capability,
|
||||
); err != nil {
|
||||
t.Fatalf("second copyLegacyIntroDBPluginConfig: %v", err)
|
||||
}
|
||||
if len(runtimeConfigs.puts) != 1 {
|
||||
t.Fatalf("second copy overwrote config; puts = %d, want 1", len(runtimeConfigs.puts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyLegacyIntroDBPluginConfigIgnoresOtherPlugins(t *testing.T) {
|
||||
runtimeConfigs := &fakeMarkerRuntimeConfigs{configs: map[int][]*plugins.RuntimeConfig{}}
|
||||
if err := copyLegacyIntroDBPluginConfig(
|
||||
context.Background(),
|
||||
runtimeConfigs,
|
||||
fakeMarkerLegacySettings{"introdb.api_key": "legacy-key"},
|
||||
&plugins.Installation{ID: 7, PluginID: "silo.other"},
|
||||
&plugins.Capability{ID: "introdb"},
|
||||
); err != nil {
|
||||
t.Fatalf("copyLegacyIntroDBPluginConfig: %v", err)
|
||||
}
|
||||
if len(runtimeConfigs.puts) != 0 {
|
||||
t.Fatalf("puts = %d, want 0 for non-TheIntroDB plugin", len(runtimeConfigs.puts))
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,8 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
|
||||
TargetVideoCodec: s.TargetVideoCodec,
|
||||
TargetAudioCodec: s.TargetAudioCodec,
|
||||
TargetBitrateKbps: s.TargetBitrateKbps,
|
||||
TranscodeHWAccel: s.TranscodeHWAccel,
|
||||
StartedAt: s.StartedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
PositionSeconds: s.Position,
|
||||
IsPaused: s.IsPaused,
|
||||
HasWebSocket: s.HasWebSocket,
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
services:
|
||||
silo:
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: ${NVIDIA_GPU_COUNT:-1}
|
||||
capabilities: [gpu]
|
||||
|
||||
# Optional distributed-mode example. Most single-host installs should
|
||||
# just use the integrated service above.
|
||||
# silo-transcode:
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: ${NVIDIA_GPU_COUNT:-1}
|
||||
# capabilities: [gpu]
|
||||
+4
-16
@@ -2,7 +2,7 @@ services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
restart: unless-stopped
|
||||
shm_size: ${POSTGRES_SHM_SIZE:-8gb}
|
||||
shm_size: 1gb
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-silo}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silo}
|
||||
@@ -11,7 +11,8 @@ services:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/postgres:/var/lib/postgresql
|
||||
command: ["postgres", "-c", "listen_addresses=*"]
|
||||
- ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-silo}"]
|
||||
interval: 5s
|
||||
@@ -36,27 +37,20 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: integrated
|
||||
# Master key for at-rest credential encryption. Required — the server
|
||||
# refuses to start without it. Generate with: openssl rand -base64 48
|
||||
# Back it up SEPARATELY from your database dumps; losing it makes encrypted
|
||||
# secrets unrecoverable. See docs/architecture/secret-encryption.md.
|
||||
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
REDIS_URL: redis://redis:6379
|
||||
SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
|
||||
POSTGRES_TUNE: ${POSTGRES_TUNE:-auto}
|
||||
ports:
|
||||
- "${PORT:-8090}:8080"
|
||||
- "${JF_PORT:-8096}:8096"
|
||||
- "${ABS_PORT:-13378}:13378"
|
||||
volumes:
|
||||
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}}:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers
|
||||
- /proc/meminfo:/host/proc/meminfo:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -65,8 +59,6 @@ services:
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
|
||||
# Optional distributed-mode example. Most single-host installs should
|
||||
# just use the integrated service above.
|
||||
@@ -76,8 +68,6 @@ services:
|
||||
# profiles: [proxy]
|
||||
# environment:
|
||||
# MODE: proxy
|
||||
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
|
||||
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
# REDIS_URL: redis://redis:6379
|
||||
# PORT: "8080"
|
||||
@@ -102,8 +92,6 @@ services:
|
||||
# profiles: [transcode]
|
||||
# environment:
|
||||
# MODE: transcode
|
||||
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
|
||||
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
# REDIS_URL: redis://redis:6379
|
||||
# SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# At-rest credential encryption
|
||||
|
||||
Silo encrypts third-party integration credentials and other server-owned secrets
|
||||
at rest. Sonarr/Radarr API keys, S3 keys, watch-sync OAuth tokens, the
|
||||
Audiobookshelf-compat signing key, and the sensitive `server_settings` entries
|
||||
are stored as ciphertext in PostgreSQL and decrypted only in memory. The key that
|
||||
protects them lives **outside** the database, so a full DB dump or compromise
|
||||
does not expose the secrets.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Cipher:** AES-256-GCM with a random 12-byte nonce per value. Implemented in
|
||||
`internal/secret`.
|
||||
- **Key derivation:** the data key is HKDF-SHA256–derived from the `SECRET_KEY`
|
||||
environment variable with a versioned domain label. `SECRET_KEY` is the
|
||||
encryption root; it is never stored in the database.
|
||||
- **Envelope:** every ciphertext is stored as `enc:v1:<base64url(nonce‖sealed)>`.
|
||||
The `enc:v1:` prefix is the version marker (see *Key rotation* below).
|
||||
- **Row binding (AAD):** each ciphertext is GCM-bound to its logical row via
|
||||
additional-authenticated data — `table:column:<pk>` for per-row columns,
|
||||
`server_settings:<key>` for settings. A DB-write attacker therefore cannot move
|
||||
a credential blob from one row/column to another: decrypting under a different
|
||||
binding fails authentication.
|
||||
- **Read path:** an empty value stays empty; a non-`enc:v1:` value is treated as
|
||||
legacy plaintext and passed through unchanged (so a not-yet-migrated row keeps
|
||||
working); an `enc:v1:` value is decrypted and **any** failure (wrong key,
|
||||
tampering, truncation) is surfaced as an error — it is never silently used as a
|
||||
credential.
|
||||
|
||||
## SECRET_KEY
|
||||
|
||||
`SECRET_KEY` is **required**. The server refuses to start without it (it fatals
|
||||
in `config.LoadBootstrap`), and it must be at least 32 characters.
|
||||
|
||||
Generate one with:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 48
|
||||
```
|
||||
|
||||
Set it in the deployment environment (or in `.env` for source/dev runs, which
|
||||
`godotenv` loads at startup). See `.env.example`.
|
||||
|
||||
### Back it up — separately from the database
|
||||
|
||||
Treat `SECRET_KEY` like a CA private key:
|
||||
|
||||
- **Store it outside your database backups.** A DB dump contains only ciphertext;
|
||||
the value of at-rest encryption is entirely lost if the key travels with the
|
||||
dump.
|
||||
- Keep it in a secrets manager / sealed secret / password manager, not in the
|
||||
repo or in the same bucket as your Postgres backups.
|
||||
- Rotating the *deployment* (new host, restored DB) requires the **same**
|
||||
`SECRET_KEY`. Restoring a database backup onto a node with a different
|
||||
`SECRET_KEY` makes every encrypted secret unreadable.
|
||||
|
||||
### Key loss
|
||||
|
||||
If `SECRET_KEY` is lost, the encrypted secrets are **unrecoverable** — that is the
|
||||
point of keeping the key out of the database. Recovery means re-entering the
|
||||
affected credentials:
|
||||
|
||||
- Re-enter Sonarr/Radarr and Autoscan API keys, S3 keys, watch-sync connections,
|
||||
history-import tokens, and subtitle provider credentials.
|
||||
- `auth.jwt_secret` becomes unreadable, so all existing sessions are invalid and
|
||||
users must log in again (a new secret is generated on next boot if the row is
|
||||
cleared — see below).
|
||||
|
||||
## Rollout (automatic backfill)
|
||||
|
||||
On the first boot after deploying this change, the primary (migration-running)
|
||||
node runs an idempotent, best-effort startup backfill that encrypts any
|
||||
remaining plaintext in place:
|
||||
|
||||
1. sensitive `server_settings` keys,
|
||||
2. the per-table credential columns (subtitles, watch-sync, webhook-sync,
|
||||
history-import, including temporary server-list credentials stored in
|
||||
session JSON),
|
||||
3. the two arr `api_key_ref` columns — these are **resolved-then-encrypted**: a
|
||||
legacy row that held a `server_settings` reference (e.g.
|
||||
`requests.radarr.api_key`) is collapsed to the real credential before being
|
||||
encrypted.
|
||||
|
||||
The backfill is safe to run repeatedly: already-encrypted values are skipped, and
|
||||
a per-row guard makes concurrent multi-node boots converge without
|
||||
double-encrypting. A failed row is logged and left as plaintext (no new exposure)
|
||||
rather than blocking boot. Proxy/transcode nodes skip the backfill; they read
|
||||
whatever the primary encrypted. No manual steps are required.
|
||||
|
||||
## Rollback / downgrade hazard
|
||||
|
||||
Downgrading to a binary that predates this change is **not** safe while secrets
|
||||
are encrypted, because the old binary has no read path: it would read
|
||||
`enc:v1:auth.jwt_secret` as a literal JWT secret (invalidating all sessions) and
|
||||
read `enc:v1:`-prefixed integration keys as garbage credentials.
|
||||
|
||||
To downgrade safely you must first return the affected values to plaintext, for
|
||||
example:
|
||||
|
||||
- Decrypt the sensitive `server_settings` back to plaintext (run a one-off that
|
||||
reads each via the encrypting repo and writes the plaintext via the raw repo),
|
||||
**or**
|
||||
- At minimum, clear `auth.jwt_secret` so the older binary regenerates a fresh
|
||||
plaintext one (this still logs everyone out), and re-enter any integration
|
||||
credentials as plaintext.
|
||||
|
||||
There is no automatic "decrypt everything" downgrade path in this release; plan
|
||||
downgrades accordingly.
|
||||
|
||||
## Key rotation (future)
|
||||
|
||||
The `enc:v1:` envelope and the HKDF domain label are versioned. A future rotation
|
||||
would introduce `enc:v2:` with a new derived key (and `Decrypt` already dispatches
|
||||
on the version, returning an explicit error for an unknown version). Rotation is
|
||||
out of scope for this release; the versioning exists so it can be added without a
|
||||
data migration of the envelope format.
|
||||
|
||||
## Scope and known gaps
|
||||
|
||||
Covered: arr (Requests + Autoscan) API keys, S3 keys, all sensitive
|
||||
`server_settings`, watch-sync tokens, webhook-sync `access_token`,
|
||||
history-import admin/session tokens and temporary server-list credentials,
|
||||
subtitle provider `api_key`/`password`,
|
||||
the Jellyfin-compat session's bridged Silo access/refresh tokens
|
||||
(`jellycompat_sessions.streamapp_access_token` / `streamapp_refresh_token`), and
|
||||
the ABS signing key.
|
||||
|
||||
Deliberately **not** encrypted (tracked as follow-ups):
|
||||
|
||||
- **Equality-looked-up secrets** — these are matched by exact value
|
||||
(`WHERE … = $1`), and AES-GCM is randomized, so encrypting them would break the
|
||||
lookup. They need a deterministic **blind-index hash** column instead:
|
||||
`api_keys.api_key`, `webhook_sync_connections.webhook_secret`,
|
||||
`jellycompat_sessions.token`, and `watch_together_rooms.join_token`.
|
||||
- **Plugin runtime config** — `plugin_runtime_configs.config_value` is opaque
|
||||
plugin-defined JSONB whose secret fields are manifest-declared and whose runtime
|
||||
lives in a separate repo; encrypting it correctly needs a coordinated,
|
||||
manifest-aware design.
|
||||
|
||||
Excluded (not a gap): `plex_sync_connections.*` is a dead table (zero Go
|
||||
references); `oauth_completion.token_ciphertext` is already AES-GCM;
|
||||
`users.password_hash` and the `*_hash` columns are already hashed.
|
||||
@@ -1,145 +0,0 @@
|
||||
# First-Party Audio API
|
||||
|
||||
This API is the Silo-native contract for audiobook playback. Clients should use
|
||||
it for Silo first-party audio experiences instead of the Audiobookshelf
|
||||
compatibility API. Music can reuse the same session, track, and progress
|
||||
concepts when the music UX lands.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require normal Silo user authentication. Playback start, sync,
|
||||
close, and bookmark writes also require the active profile headers used by the
|
||||
first-party clients.
|
||||
|
||||
Track stream URLs are returned from the start response and are scoped to the
|
||||
created audio session. The server validates that the session still belongs to
|
||||
the authenticated user, and to the active profile when a profile is attached.
|
||||
|
||||
## Start Playback
|
||||
|
||||
`POST /api/v1/audio/playback/start`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"content_id": "128814341867700228",
|
||||
"start_position": 120,
|
||||
"restart": false
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"content_id": "128814341867700228",
|
||||
"type": "audiobook",
|
||||
"title": "Book Title",
|
||||
"subtitle": "Author or series subtitle",
|
||||
"poster_url": "https://server/api/v1/images/...",
|
||||
"backdrop_url": "https://server/api/v1/images/...",
|
||||
"total_duration_seconds": 8746,
|
||||
"resume_position_seconds": 120,
|
||||
"tracks": [
|
||||
{
|
||||
"index": 0,
|
||||
"file_id": 123,
|
||||
"file_name": "Part 1.mp3",
|
||||
"duration_seconds": 3600,
|
||||
"start_offset_seconds": 0,
|
||||
"stream_url": "/api/v1/audio/playback/{session_id}/tracks/0",
|
||||
"stream_type": "progressive",
|
||||
"play_method": "direct",
|
||||
"codec": "mp3",
|
||||
"container": "mp3",
|
||||
"bitrate": 128000,
|
||||
"chapters": []
|
||||
}
|
||||
],
|
||||
"chapters": [
|
||||
{
|
||||
"index": 0,
|
||||
"title": "Chapter 1",
|
||||
"start_seconds": 0,
|
||||
"end_seconds": 1800,
|
||||
"track_index": 0
|
||||
}
|
||||
],
|
||||
"bookmarks": []
|
||||
}
|
||||
```
|
||||
|
||||
`total_duration_seconds`, `resume_position_seconds`, global chapter times, and
|
||||
sync positions are cumulative book seconds, not per-file seconds. Clients map
|
||||
global time to the active track by finding the last track whose
|
||||
`start_offset_seconds` is less than or equal to the target time.
|
||||
|
||||
Tracks are ordered by presentation part index when available, then by path and
|
||||
file id. The server direct-plays AVFoundation-safe formats and returns
|
||||
`play_method: "aac"` for fallback streams that are remuxed/transcoded to an
|
||||
Apple-safe AAC container.
|
||||
|
||||
## Stream Track
|
||||
|
||||
`GET /api/v1/audio/playback/{session_id}/tracks/{track_index}`
|
||||
|
||||
The response is the media stream for the selected track. Clients should request
|
||||
the track for the current global time and seek within that track using:
|
||||
|
||||
`local_seconds = global_seconds - track.start_offset_seconds`
|
||||
|
||||
## Sync Progress
|
||||
|
||||
`PATCH /api/v1/audio/playback/{session_id}/sync`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 1234,
|
||||
"duration": 8746,
|
||||
"is_paused": false
|
||||
}
|
||||
```
|
||||
|
||||
The server persists `position` as the audiobook progress position. The client
|
||||
should send cumulative book seconds on a throttle while playing, on manual
|
||||
seeks, and once more before closing the session.
|
||||
|
||||
## Close Playback
|
||||
|
||||
`POST /api/v1/audio/playback/{session_id}/close`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 1234,
|
||||
"duration": 8746,
|
||||
"is_paused": true
|
||||
}
|
||||
```
|
||||
|
||||
Close performs a final progress sync and expires the in-memory audio session.
|
||||
|
||||
## Bookmarks
|
||||
|
||||
`GET /api/v1/audio/items/{content_id}/bookmarks`
|
||||
|
||||
`POST /api/v1/audio/items/{content_id}/bookmarks`
|
||||
|
||||
`DELETE /api/v1/audio/items/{content_id}/bookmarks/{time_seconds}`
|
||||
|
||||
Create request:
|
||||
|
||||
```json
|
||||
{
|
||||
"time_seconds": 1234,
|
||||
"title": "Chapter note"
|
||||
}
|
||||
```
|
||||
|
||||
Bookmark positions are cumulative book seconds so they remain stable across
|
||||
multi-file audiobooks and client platforms.
|
||||
@@ -17,14 +17,14 @@
|
||||
| Path | Created/Modified | Purpose |
|
||||
|---|---|---|
|
||||
| `docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md` | Create | Findings doc produced by Task 1. Locks the remaining schema/code decisions. |
|
||||
| `migrations/147_abs_sessions.up.sql` | Create | New table parallel to `jellycompat_sessions`. |
|
||||
| `migrations/147_abs_sessions.down.sql` | Create | Drop the table. |
|
||||
| `migrations/157_podcast_feeds.up.sql` | Create | New table for RSS-subscribed podcast metadata. |
|
||||
| `migrations/157_podcast_feeds.down.sql` | Create | Drop the table. |
|
||||
| `migrations/159_media_folders_kind_noop.up.sql` | Create (conditional — see Task 4 step 1) | Document `media_folders.type='audiobooks'`; no column change needed. |
|
||||
| `migrations/159_media_folders_kind_noop.down.sql` | Create (same condition) | No-op rollback. |
|
||||
| `migrations/160_audiobooks_feature_flag.up.sql` | Create | Insert `audiobooks.enabled='false'` into `server_settings`. |
|
||||
| `migrations/160_audiobooks_feature_flag.down.sql` | Create | Delete the setting row. |
|
||||
| `migrations/139_abs_sessions.up.sql` | Create | New table parallel to `jellycompat_sessions`. |
|
||||
| `migrations/139_abs_sessions.down.sql` | Create | Drop the table. |
|
||||
| `migrations/140_podcast_feeds.up.sql` | Create | New table for RSS-subscribed podcast metadata. |
|
||||
| `migrations/140_podcast_feeds.down.sql` | Create | Drop the table. |
|
||||
| `migrations/141_media_libraries_kind.up.sql` | Create (conditional — see Task 4 step 1) | Add `kind` column to `media_libraries`. |
|
||||
| `migrations/141_media_libraries_kind.down.sql` | Create (same condition) | Drop the column. |
|
||||
| `migrations/142_audiobooks_feature_flag.up.sql` | Create | Insert `audiobooks.enabled='false'` into `server_settings`. |
|
||||
| `migrations/142_audiobooks_feature_flag.down.sql` | Create | Delete the setting row. |
|
||||
| `internal/audiobooks/doc.go` | Create | Package comment + intent. |
|
||||
| `internal/audiobooks/service.go` | Create | Empty `Service` struct, `New` constructor, `Enabled()` reader. |
|
||||
| `internal/audiobooks/service_test.go` | Create | One unit test proving the package compiles and `Enabled()` returns the setting value. |
|
||||
@@ -240,15 +240,15 @@ EOF
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migration 147 — `abs_sessions`
|
||||
## Task 2: Migration 139 — `abs_sessions`
|
||||
|
||||
Parallel of `jellycompat_sessions` for Audiobookshelf clients. Lets ABS
|
||||
mobile/desktop apps reconnect without re-authenticating against silo's
|
||||
main `auth_sessions`.
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/147_abs_sessions.up.sql`
|
||||
- Create: `migrations/147_abs_sessions.down.sql`
|
||||
- Create: `migrations/139_abs_sessions.up.sql`
|
||||
- Create: `migrations/139_abs_sessions.down.sql`
|
||||
|
||||
> **Note:** If Task 1, Step 1.2 found the next number is not 139, rename
|
||||
> both filenames to match. All other tasks downstream of 139 shift by the
|
||||
@@ -266,7 +266,7 @@ main `auth_sessions`.
|
||||
|
||||
### Step 2.2: Create the up migration
|
||||
|
||||
- [ ] **Create** `migrations/147_abs_sessions.up.sql` with:
|
||||
- [ ] **Create** `migrations/139_abs_sessions.up.sql` with:
|
||||
|
||||
```sql
|
||||
-- Audiobookshelf-compatible client sessions. Parallel to
|
||||
@@ -277,7 +277,7 @@ main `auth_sessions`.
|
||||
CREATE TABLE IF NOT EXISTS public.abs_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT,
|
||||
client_name TEXT,
|
||||
@@ -287,8 +287,8 @@ main `auth_sessions`.
|
||||
revoked_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_abs_sessions_token_hash
|
||||
ON public.abs_sessions (token_hash);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_abs_sessions_token
|
||||
ON public.abs_sessions (token);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abs_sessions_user_device
|
||||
ON public.abs_sessions (user_id, device_id);
|
||||
@@ -299,12 +299,12 @@ main `auth_sessions`.
|
||||
|
||||
### Step 2.3: Create the down migration
|
||||
|
||||
- [ ] **Create** `migrations/147_abs_sessions.down.sql` with:
|
||||
- [ ] **Create** `migrations/139_abs_sessions.down.sql` with:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.idx_abs_sessions_last_seen;
|
||||
DROP INDEX IF EXISTS public.idx_abs_sessions_user_device;
|
||||
DROP INDEX IF EXISTS public.idx_abs_sessions_token_hash;
|
||||
DROP INDEX IF EXISTS public.idx_abs_sessions_token;
|
||||
DROP TABLE IF EXISTS public.abs_sessions;
|
||||
```
|
||||
|
||||
@@ -328,27 +328,27 @@ main `auth_sessions`.
|
||||
- [ ] **Run** (each command should now succeed):
|
||||
```bash
|
||||
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.abs_sessions"
|
||||
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT version FROM schema_versions WHERE version = 147;"
|
||||
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT version FROM schema_versions WHERE version = 139;"
|
||||
```
|
||||
Expected: `\d` prints the full column list; the `SELECT` returns one row with `version=147`.
|
||||
Expected: `\d` prints the full column list; the `SELECT` returns one row with `version=139`.
|
||||
|
||||
- [ ] **Run** a sample insert/delete to prove constraints work:
|
||||
```bash
|
||||
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
|
||||
INSERT INTO abs_sessions (user_id, token_hash, device_id, device_name, client_name, client_version)
|
||||
VALUES ((SELECT id FROM users ORDER BY id LIMIT 1), 'test-token-hash-001', 'test-device-001', 'Test Device', 'AbsTestClient', '1.0.0');
|
||||
DELETE FROM abs_sessions WHERE token_hash = 'test-token-hash-001';
|
||||
INSERT INTO abs_sessions (user_id, token, device_id, device_name, client_name, client_version)
|
||||
VALUES (1, 'test-token-001', 'test-device-001', 'Test Device', 'AbsTestClient', '1.0.0');
|
||||
DELETE FROM abs_sessions WHERE token = 'test-token-001';
|
||||
"
|
||||
```
|
||||
Expected: `INSERT 0 1` then `DELETE 1`. (At least one user must exist.)
|
||||
Expected: `INSERT 0 1` then `DELETE 1`. (User id 1 must exist — silo's first user.)
|
||||
|
||||
### Step 2.6: Commit
|
||||
|
||||
- [ ] **Run:**
|
||||
```bash
|
||||
git add migrations/147_abs_sessions.up.sql migrations/147_abs_sessions.down.sql
|
||||
git add migrations/139_abs_sessions.up.sql migrations/139_abs_sessions.down.sql
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(audiobooks): migration 147 add abs_sessions table
|
||||
feat(audiobooks): migration 139 add abs_sessions table
|
||||
|
||||
Parallel of jellycompat_sessions for Audiobookshelf-compatible clients.
|
||||
Lets ABS mobile/desktop apps maintain a device-bound session that
|
||||
@@ -361,15 +361,15 @@ EOF
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migration 157 — `podcast_feeds`
|
||||
## Task 3: Migration 140 — `podcast_feeds`
|
||||
|
||||
One row per subscribed podcast. The podcast itself lives in `media_items`
|
||||
(`type='podcast'`); this side table carries the RSS-specific metadata the
|
||||
feed refresher needs.
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/157_podcast_feeds.up.sql`
|
||||
- Create: `migrations/157_podcast_feeds.down.sql`
|
||||
- Create: `migrations/140_podcast_feeds.up.sql`
|
||||
- Create: `migrations/140_podcast_feeds.down.sql`
|
||||
|
||||
### Step 3.1: Write the failing assertion
|
||||
|
||||
@@ -381,7 +381,7 @@ feed refresher needs.
|
||||
|
||||
### Step 3.2: Create the up migration
|
||||
|
||||
- [ ] **Create** `migrations/157_podcast_feeds.up.sql` with:
|
||||
- [ ] **Create** `migrations/140_podcast_feeds.up.sql` with:
|
||||
|
||||
```sql
|
||||
-- RSS-feed metadata for subscribed podcasts. One row per podcast
|
||||
@@ -416,7 +416,7 @@ feed refresher needs.
|
||||
|
||||
### Step 3.3: Create the down migration
|
||||
|
||||
- [ ] **Create** `migrations/157_podcast_feeds.down.sql` with:
|
||||
- [ ] **Create** `migrations/140_podcast_feeds.down.sql` with:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.idx_podcast_feeds_due_for_refresh;
|
||||
@@ -438,17 +438,17 @@ feed refresher needs.
|
||||
- [ ] **Run:**
|
||||
```bash
|
||||
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.podcast_feeds"
|
||||
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT version FROM schema_versions WHERE version = 157;"
|
||||
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT version FROM schema_versions WHERE version = 140;"
|
||||
```
|
||||
Expected: column list printed; one row with `version=157`.
|
||||
Expected: column list printed; one row with `version=140`.
|
||||
|
||||
### Step 3.6: Commit
|
||||
|
||||
- [ ] **Run:**
|
||||
```bash
|
||||
git add migrations/157_podcast_feeds.up.sql migrations/157_podcast_feeds.down.sql
|
||||
git add migrations/140_podcast_feeds.up.sql migrations/140_podcast_feeds.down.sql
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(audiobooks): migration 157 add podcast_feeds table
|
||||
feat(audiobooks): migration 140 add podcast_feeds table
|
||||
|
||||
Side table on media_items for RSS-subscribed podcasts. Holds feed URL,
|
||||
ETag/Last-Modified for conditional fetches, last-refresh timestamp, and
|
||||
@@ -462,7 +462,7 @@ EOF
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Migration 159 — `media_libraries.kind` audiobook value (no-op)
|
||||
## Task 4: Migration 141 — `media_libraries.kind` (conditional)
|
||||
|
||||
Adds a discriminator column so the scanner can branch on
|
||||
`audiobooks`/`podcasts` libraries without filename heuristics.
|
||||
@@ -474,8 +474,8 @@ Adds a discriminator column so the scanner can branch on
|
||||
> Otherwise, proceed with the steps below.
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/159_media_folders_kind_noop.up.sql`
|
||||
- Create: `migrations/159_media_folders_kind_noop.down.sql`
|
||||
- Create: `migrations/141_media_libraries_kind.up.sql`
|
||||
- Create: `migrations/141_media_libraries_kind.down.sql`
|
||||
|
||||
### Step 4.1: Write the failing assertion
|
||||
|
||||
@@ -487,7 +487,7 @@ Adds a discriminator column so the scanner can branch on
|
||||
|
||||
### Step 4.2: Create the up migration
|
||||
|
||||
- [ ] **Create** `migrations/159_media_folders_kind_noop.up.sql` with:
|
||||
- [ ] **Create** `migrations/141_media_libraries_kind.up.sql` with:
|
||||
|
||||
```sql
|
||||
-- Per-library content discriminator. Lets the scanner choose the right
|
||||
@@ -509,7 +509,7 @@ Adds a discriminator column so the scanner can branch on
|
||||
|
||||
### Step 4.3: Create the down migration
|
||||
|
||||
- [ ] **Create** `migrations/159_media_folders_kind_noop.down.sql` with:
|
||||
- [ ] **Create** `migrations/141_media_libraries_kind.down.sql` with:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.idx_media_libraries_kind;
|
||||
@@ -551,9 +551,9 @@ Adds a discriminator column so the scanner can branch on
|
||||
|
||||
- [ ] **Run:**
|
||||
```bash
|
||||
git add migrations/159_media_folders_kind_noop.up.sql migrations/159_media_folders_kind_noop.down.sql
|
||||
git add migrations/141_media_libraries_kind.up.sql migrations/141_media_libraries_kind.down.sql
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(audiobooks): migration 159 document audiobooks media folder type
|
||||
feat(audiobooks): migration 141 add media_libraries.kind
|
||||
|
||||
Per-library discriminator that lets the scanner pick the right parser
|
||||
('movies', 'tv', 'audiobooks', 'podcasts') instead of inferring from
|
||||
@@ -567,15 +567,15 @@ EOF
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migration 160 — `audiobooks.enabled` feature flag
|
||||
## Task 5: Migration 142 — `audiobooks.enabled` feature flag
|
||||
|
||||
Adds a `server_settings` row so subsequent sub-plans can branch on
|
||||
"audiobooks compiled in but turned off" vs "fully live". Default is
|
||||
`false` so this sub-plan's landing is a strict no-op for users.
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/160_audiobooks_feature_flag.up.sql`
|
||||
- Create: `migrations/160_audiobooks_feature_flag.down.sql`
|
||||
- Create: `migrations/142_audiobooks_feature_flag.up.sql`
|
||||
- Create: `migrations/142_audiobooks_feature_flag.down.sql`
|
||||
|
||||
### Step 5.1: Write the failing assertion
|
||||
|
||||
@@ -589,11 +589,11 @@ Adds a `server_settings` row so subsequent sub-plans can branch on
|
||||
|
||||
### Step 5.2: Create the up migration
|
||||
|
||||
- [ ] **Create** `migrations/160_audiobooks_feature_flag.up.sql` with:
|
||||
- [ ] **Create** `migrations/142_audiobooks_feature_flag.up.sql` with:
|
||||
|
||||
```sql
|
||||
-- Master kill-switch for the absorbed audiobooks feature. Defaults to
|
||||
-- 'false' so landing migrations 147/157/159/160 is a no-op for users; operators
|
||||
-- 'false' so landing migrations 139-142 is a no-op for users; operators
|
||||
-- flip this to 'true' at cutover.
|
||||
|
||||
INSERT INTO server_settings (key, value) VALUES ('audiobooks.enabled', 'false')
|
||||
@@ -602,7 +602,7 @@ Adds a `server_settings` row so subsequent sub-plans can branch on
|
||||
|
||||
### Step 5.3: Create the down migration
|
||||
|
||||
- [ ] **Create** `migrations/160_audiobooks_feature_flag.down.sql` with:
|
||||
- [ ] **Create** `migrations/142_audiobooks_feature_flag.down.sql` with:
|
||||
|
||||
```sql
|
||||
DELETE FROM server_settings WHERE key = 'audiobooks.enabled';
|
||||
@@ -625,9 +625,9 @@ Adds a `server_settings` row so subsequent sub-plans can branch on
|
||||
|
||||
- [ ] **Run:**
|
||||
```bash
|
||||
git add migrations/160_audiobooks_feature_flag.up.sql migrations/160_audiobooks_feature_flag.down.sql
|
||||
git add migrations/142_audiobooks_feature_flag.up.sql migrations/142_audiobooks_feature_flag.down.sql
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(audiobooks): migration 160 add audiobooks.enabled flag
|
||||
feat(audiobooks): migration 142 add audiobooks.enabled flag
|
||||
|
||||
Server-settings row that gates the absorbed audiobooks feature.
|
||||
Defaults to 'false' so sub-plan 1 lands as a strict no-op; subsequent
|
||||
@@ -646,7 +646,7 @@ EOF
|
||||
Creates an empty but compiling Go package, wires its constructor into
|
||||
`cmd/silo/main.go` so the package is referenced from the binary, and
|
||||
ships a unit test that proves the `Enabled()` reader returns the flag
|
||||
value seeded by migration 160.
|
||||
value seeded by migration 142.
|
||||
|
||||
This task proves the package compiles inside the rest of silo without
|
||||
changing any runtime behavior. No routes are mounted. No scheduled
|
||||
@@ -721,7 +721,7 @@ tasks are registered. No DB writes happen.
|
||||
|
||||
### Step 6.2: Run the test to verify it fails
|
||||
|
||||
- [ ] **Run:** `go test ./internal/audiobooks/...`
|
||||
- [ ] **Run:** `cd /opt/silo-server && go test ./internal/audiobooks/...`
|
||||
|
||||
Expected: build failure with messages like `undefined: New` and
|
||||
`package audiobooks not found`.
|
||||
@@ -777,9 +777,9 @@ tasks are registered. No DB writes happen.
|
||||
return &Service{settings: settings}
|
||||
}
|
||||
|
||||
// Enabled reports whether the audiobooks feature flag (set by
|
||||
// 160_audiobooks_feature_flag and toggled by operators) is currently true.
|
||||
// Any value other than the literal string "true" reads as false; this matches how silo
|
||||
// Enabled reports whether the audiobooks feature flag (set by migration
|
||||
// 142 and toggled by operators) is currently true. Any value other than
|
||||
// the literal string "true" reads as false; this matches how silo
|
||||
// treats other boolean server_settings rows.
|
||||
func (s *Service) Enabled(ctx context.Context) (bool, error) {
|
||||
if s == nil || s.settings == nil {
|
||||
@@ -795,7 +795,7 @@ tasks are registered. No DB writes happen.
|
||||
|
||||
### Step 6.5: Run the test to verify it passes
|
||||
|
||||
- [ ] **Run:** `go test ./internal/audiobooks/...`
|
||||
- [ ] **Run:** `cd /opt/silo-server && go test ./internal/audiobooks/...`
|
||||
Expected: `ok github.com/Silo-Server/silo-server/internal/audiobooks ...`
|
||||
|
||||
### Step 6.6: Wire the constructor into `cmd/silo/main.go`
|
||||
@@ -831,13 +831,13 @@ are constructed and pass a settings store to.
|
||||
|
||||
### Step 6.7: Verify everything still compiles and tests pass
|
||||
|
||||
- [ ] **Run:** `go build ./...`
|
||||
- [ ] **Run:** `cd /opt/silo-server && go build ./...`
|
||||
Expected: exit 0, no output.
|
||||
|
||||
- [ ] **Run:** `go test ./internal/audiobooks/... ./cmd/...`
|
||||
- [ ] **Run:** `cd /opt/silo-server && go test ./internal/audiobooks/... ./cmd/...`
|
||||
Expected: all passes.
|
||||
|
||||
- [ ] **Run:** `make lint`
|
||||
- [ ] **Run:** `cd /opt/silo-server && make lint`
|
||||
Expected: no new lint findings introduced by this task.
|
||||
|
||||
### Step 6.8: Smoke-test the running binary
|
||||
@@ -883,9 +883,9 @@ EOF
|
||||
| Spec section | Task(s) that cover it |
|
||||
|---|---|
|
||||
| Data model: reused tables | Task 1 (verification only) |
|
||||
| Data model: `abs_sessions` (migration 147) | Task 2 |
|
||||
| Data model: `podcast_feeds` (migration 157) | Task 3 |
|
||||
| Data model: `media_folders.type` audiobook value (migration 159 no-op) | Task 4 |
|
||||
| Data model: `abs_sessions` (mig 134→139) | Task 2 |
|
||||
| Data model: `podcast_feeds` (mig 135→140) | Task 3 |
|
||||
| Data model: `media_libraries.kind` (mig 136→141) | Task 4 |
|
||||
| Data model: data migration = none | n/a — confirmed in spec |
|
||||
| Feature flag (`audiobooks.enabled`) | Task 5 |
|
||||
| Architecture: `internal/audiobooks/` package | Task 6 |
|
||||
@@ -909,8 +909,9 @@ fires).
|
||||
test matches the production interface defined in `service.go`. Method
|
||||
name `GetString`, signature `(ctx context.Context, key string) (string, error)` — consistent across both files.
|
||||
|
||||
**Note on migration numbering:** Tasks 2–5 originally used numbers `139..142`
|
||||
based on a snapshot showing `138_search_number_word_normalization.up.sql` as
|
||||
the highest existing migration. The landed implementation was renumbered to
|
||||
`147_abs_sessions`, `157_podcast_feeds`, `159_media_folders_kind_noop`, and
|
||||
`160_audiobooks_feature_flag`.
|
||||
**Note on migration numbering:** Tasks 2–5 use numbers `139..142` based
|
||||
on a snapshot showing `138_search_number_word_normalization.up.sql` as
|
||||
the highest existing migration. Task 1 Step 1.2 re-verifies this; if
|
||||
the executing agent finds the highest existing number has moved, it
|
||||
must shift filenames in Tasks 2–5 by the same delta and update the
|
||||
matching task headings and commit-message references.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make silo's existing scanner recognize audiobook + podcast libraries (`media_folders.type='audiobooks'` or `'podcasts'`), walk them to find audio files, and write `media_items.type='audiobook'` / `media_items.type='podcast'` rows with chapter metadata and author/narrator role links. No external metadata enrichment runs in this sub-plan; the scanner extracts local tags and identity hints, and the audiobook metadata plugin/provider enriches canonical metadata later.
|
||||
**Goal:** Make silo's existing scanner recognize audiobook + podcast libraries (`media_folders.type='audiobooks'` or `'podcasts'`), walk them to find audio files, and write `media_items.type='audiobook'` / `media_items.type='podcast'` rows with chapter metadata and author/narrator role links. No external metadata enrichment in this sub-plan — file tags only.
|
||||
|
||||
**Architecture:** New constants (`PersonKindAuthor=7`, `PersonKindNarrator=8`, `audioExtensions`). New helpers (`isAudiobookLibraryType`, `isPodcastLibraryType`, `SupportsAudioFile`). New parser file `internal/scanner/audiobook.go` for the folder-to-media-items mapping. The chapter extraction reuses the existing `probe.go` ffprobe helper. Author/narrator names are upserted into the existing `people` and `item_people` tables via the same code paths used for movie actors. Matching/enrichment queue integration for the audiobook metadata plugin/provider is deferred.
|
||||
**Architecture:** New constants (`PersonKindAuthor=7`, `PersonKindNarrator=8`, `audioExtensions`). New helpers (`isAudiobookLibraryType`, `isPodcastLibraryType`, `SupportsAudioFile`). New parser file `internal/scanner/audiobook.go` for the folder-to-media-items mapping. The chapter extraction reuses the existing `probe.go` ffprobe helper. Author/narrator names are upserted into the existing `people` and `item_people` tables via the same code paths used for movie actors. No changes to the matching/enrichment metadata queues — those are deferred.
|
||||
|
||||
**Tech Stack:** Go 1.26 + pgx; ffprobe via `os/exec` (already vendored as `internal/scanner/probe.go`); ID3/MP4 tag parsing — verify what silo already vendors before introducing a new library (acceptable: `github.com/dhowden/tag` if not already present, OR shell out to ffprobe which exposes tag dictionaries).
|
||||
|
||||
@@ -408,8 +408,8 @@ ffmpeg -y -f lavfi -i "anullsrc=r=22050:cl=mono" -t 10 -metadata title="Test Aud
|
||||
-metadata artist="Test Author" -metadata album="Test Series" \
|
||||
-metadata:s:a:0 language=eng \
|
||||
internal/scanner/testdata/audiobook_fixtures/single_book/raw.m4a
|
||||
# Add chapters via a repo-local metadata fixture
|
||||
cat > internal/scanner/testdata/audiobook_fixtures/single_book/chapters.ffmeta <<'EOF'
|
||||
# Add chapters via a temporary metadata file
|
||||
cat > /tmp/chapters.ffmeta <<'EOF'
|
||||
;FFMETADATA1
|
||||
[CHAPTER]
|
||||
TIMEBASE=1/1000
|
||||
@@ -423,9 +423,9 @@ END=10000
|
||||
title=Outro
|
||||
EOF
|
||||
ffmpeg -y -i internal/scanner/testdata/audiobook_fixtures/single_book/raw.m4a \
|
||||
-i internal/scanner/testdata/audiobook_fixtures/single_book/chapters.ffmeta -map_metadata 1 -codec copy \
|
||||
-i /tmp/chapters.ffmeta -map_metadata 1 -codec copy \
|
||||
internal/scanner/testdata/audiobook_fixtures/single_book/book.m4b
|
||||
rm internal/scanner/testdata/audiobook_fixtures/single_book/raw.m4a internal/scanner/testdata/audiobook_fixtures/single_book/chapters.ffmeta
|
||||
rm internal/scanner/testdata/audiobook_fixtures/single_book/raw.m4a /tmp/chapters.ffmeta
|
||||
```
|
||||
|
||||
Add a test that calls the chapter-extraction function on `book.m4b` and asserts:
|
||||
@@ -1008,6 +1008,7 @@ Verify the full sub-plan landed cleanly.
|
||||
### Step 10.1: Full tests + build
|
||||
|
||||
```bash
|
||||
cd /opt/silo-server
|
||||
go build ./...
|
||||
go test ./internal/scanner/... ./internal/models/... ./internal/audiobooks/...
|
||||
go vet ./...
|
||||
@@ -1018,7 +1019,7 @@ All green.
|
||||
### Step 10.2: Rebuild + recreate silo container
|
||||
|
||||
```bash
|
||||
sudo docker build -t silo:latest .
|
||||
sudo docker build -t silo:latest /opt/silo-server
|
||||
sudo docker compose -p silo-prod up -d --force-recreate silo
|
||||
until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' silo-prod-silo-1 2>/dev/null)" = "healthy" ]; do sleep 2; done; echo healthy
|
||||
```
|
||||
@@ -1064,7 +1065,7 @@ Otherwise: no commit needed.
|
||||
| Multi-file audiobooks | T7 |
|
||||
| Filesystem podcasts | T9 |
|
||||
| RSS podcasts | deferred to sub-plan 5 |
|
||||
| External enrichment through audiobook metadata plugin/provider | deferred per spec |
|
||||
| External enrichment (OpenLibrary/Google Books) | dropped per spec |
|
||||
|
||||
**Placeholder scan:** Task 8 has language like "grep for upsertMediaItem or similar in scanner.go to find it" — this is intentional. The scanner's write helpers are too deeply embedded for me to enumerate without reading more code; the implementer subagent will discover them. If a subagent reports BLOCKED on Task 8 because the write helpers aren't shaped how this plan assumes, the controller dispatches a discovery sub-task.
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
**Goal:** Port the plugin's `podcastfeed` package as a first-party scheduled task in silo, so RSS-subscribed podcasts get their episode lists kept up to date.
|
||||
|
||||
**Source plugin:** `../silo-plugin-audiobooks/internal/podcastfeed/refresher.go` (353 LOC) + `refresher_test.go` (295 LOC)
|
||||
**Source plugin:** `/opt/silo_plugins/silo-plugin-audiobooks/internal/podcastfeed/refresher.go` (353 LOC) + `refresher_test.go` (295 LOC)
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Port the refresher package
|
||||
- Copy `refresher.go` + `refresher_test.go` from plugin to `internal/audiobooks/podcastfeed/`
|
||||
- Copy `refresher.go` + `refresher_test.go` from plugin to `/opt/silo-server/internal/audiobooks/podcastfeed/`
|
||||
- Update imports (drop plugin SDK; use silo's `pgxpool` + `log/slog`)
|
||||
- Rewrite SQL queries against silo's `podcast_feeds` table (created in sub-plan 1 migration 140), `media_items` (type='podcast'), and `episodes`
|
||||
- Drop the plugin's `presentation_libraries` reference; subscriptions are tracked by `podcast_feeds.media_item_id`
|
||||
|
||||
@@ -1,724 +0,0 @@
|
||||
# Resilient Library Deletion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rewrite `delete_library` so it deletes a library in small, autocommitted, deadlock-retrying batches instead of one multi-minute transaction that deadlocks and rolls back on large libraries.
|
||||
|
||||
**Architecture:** `FolderRepository.DeleteWithStats` becomes a phased, batched operation (orphan items → folder media_files → remaining shared junctions → folder row). Each batch is one autocommit statement wrapped in a `retryOnDeadlock` helper, so locks are held briefly and the job is resumable. Two small helpers (`retryOnDeadlock`, `deleteInBatches`) are unit-tested in pure Go; the full path is verified against the live dev database.
|
||||
|
||||
**Tech Stack:** Go, pgx v5 (`pgxpool`, `pgconn`), PostgreSQL. Spec: `docs/superpowers/specs/2026-05-28-library-delete-resilience-design.md`.
|
||||
|
||||
All commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `internal/catalog/folder_repo.go`
|
||||
- Add constants `orphanDeleteBatch`, `folderChildDeleteBatch`.
|
||||
- Add package vars `deadlockMaxAttempts`, `deadlockBaseBackoff` and func `retryOnDeadlock`.
|
||||
- Add func `deleteInBatches`.
|
||||
- Add interface `rowQuerier`; change `collectImageDirs` and `filterUnreferencedImageDirs` to take `rowQuerier` instead of `pgx.Tx`; extract `collectRawImageDirs`.
|
||||
- Rewrite `DeleteWithStats`; add helpers `collectOrphanBatch`, `dirSetToSlice`.
|
||||
- Leave `collectOrphanIDs` and `Delete` unchanged.
|
||||
- **Create** `internal/catalog/folder_delete_test.go` — pure-Go unit tests for `retryOnDeadlock` and `deleteInBatches`.
|
||||
- **Do not modify** `internal/catalog/library_repo.go` — its `collectImageDirs`/`collectOrphanIDs` calls keep compiling because `pgx.Tx` satisfies `rowQuerier`. (Verified by build.)
|
||||
- **Unchanged behavior** in `internal/adminjob/library_delete.go` (executor), `internal/adminjob/runner.go` (heartbeat).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Deadlock-retry helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/catalog/folder_repo.go`
|
||||
- Test: `internal/catalog/folder_delete_test.go` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `internal/catalog/folder_delete_test.go`:
|
||||
|
||||
```go
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// withFastDeadlockRetry shrinks retry timing/attempts for tests and restores
|
||||
// the originals on cleanup. Tests using it must not call t.Parallel().
|
||||
func withFastDeadlockRetry(t *testing.T, maxAttempts int) {
|
||||
t.Helper()
|
||||
oldMax, oldBackoff := deadlockMaxAttempts, deadlockBaseBackoff
|
||||
deadlockMaxAttempts = maxAttempts
|
||||
deadlockBaseBackoff = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
deadlockMaxAttempts = oldMax
|
||||
deadlockBaseBackoff = oldBackoff
|
||||
})
|
||||
}
|
||||
|
||||
func TestRetryOnDeadlockRetriesThenSucceeds(t *testing.T) {
|
||||
withFastDeadlockRetry(t, 5)
|
||||
calls := 0
|
||||
err := retryOnDeadlock(context.Background(), func() error {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return &pgconn.PgError{Code: "40P01"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("expected 3 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryOnDeadlockReturnsNonRetryableImmediately(t *testing.T) {
|
||||
withFastDeadlockRetry(t, 5)
|
||||
sentinel := errors.New("boom")
|
||||
calls := 0
|
||||
err := retryOnDeadlock(context.Background(), func() error {
|
||||
calls++
|
||||
return sentinel
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected sentinel, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryOnDeadlockGivesUpAfterMaxAttempts(t *testing.T) {
|
||||
withFastDeadlockRetry(t, 4)
|
||||
calls := 0
|
||||
err := retryOnDeadlock(context.Background(), func() error {
|
||||
calls++
|
||||
return &pgconn.PgError{Code: "40P01"}
|
||||
})
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "40P01" {
|
||||
t.Fatalf("expected 40P01 pg error, got %v", err)
|
||||
}
|
||||
if calls != 4 {
|
||||
t.Fatalf("expected 4 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryOnDeadlockStopsOnCanceledContext(t *testing.T) {
|
||||
withFastDeadlockRetry(t, 5)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
calls := 0
|
||||
err := retryOnDeadlock(ctx, func() error {
|
||||
calls++
|
||||
return &pgconn.PgError{Code: "40P01"}
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected 1 call before cancel, got %d", calls)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/catalog/ -run TestRetryOnDeadlock -v`
|
||||
Expected: compile failure / FAIL — `undefined: retryOnDeadlock`, `deadlockMaxAttempts`, `deadlockBaseBackoff`.
|
||||
|
||||
- [ ] **Step 3: Implement the helper**
|
||||
|
||||
In `internal/catalog/folder_repo.go`, add near the top of the file (after the `import` block, before the sentinel errors var is fine):
|
||||
|
||||
```go
|
||||
// Retry parameters for transient serialization/deadlock failures. They are
|
||||
// package vars (not consts) only so tests can shrink them; production code
|
||||
// never mutates them.
|
||||
var (
|
||||
deadlockMaxAttempts = 5
|
||||
deadlockBaseBackoff = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// retryOnDeadlock runs op, retrying when Postgres reports a deadlock (40P01) or
|
||||
// serialization failure (40001), with exponential backoff. It returns
|
||||
// immediately for any other error, and honors context cancellation between
|
||||
// attempts.
|
||||
func retryOnDeadlock(ctx context.Context, op func() error) error {
|
||||
backoff := deadlockBaseBackoff
|
||||
for attempt := 1; ; attempt++ {
|
||||
err := op()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if attempt < deadlockMaxAttempts && errors.As(err, &pgErr) &&
|
||||
(pgErr.Code == "40P01" || pgErr.Code == "40001") {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`context`, `errors`, `time`, and `github.com/jackc/pgx/v5/pgconn` are already imported in this file.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/catalog/ -run TestRetryOnDeadlock -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Format and commit**
|
||||
|
||||
```bash
|
||||
gofmt -w internal/catalog/folder_repo.go internal/catalog/folder_delete_test.go
|
||||
git add internal/catalog/folder_repo.go internal/catalog/folder_delete_test.go
|
||||
git commit -m "feat(catalog): add deadlock-retry helper for batched deletes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Batched-delete loop helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/catalog/folder_repo.go`
|
||||
- Test: `internal/catalog/folder_delete_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `internal/catalog/folder_delete_test.go`:
|
||||
|
||||
```go
|
||||
func TestDeleteInBatchesLoopsUntilUnderBatchSize(t *testing.T) {
|
||||
withFastDeadlockRetry(t, 5)
|
||||
counts := []int64{5, 5, 2}
|
||||
idx := 0
|
||||
total, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) {
|
||||
n := counts[idx]
|
||||
idx++
|
||||
return n, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 12 {
|
||||
t.Fatalf("expected total 12, got %d", total)
|
||||
}
|
||||
if idx != 3 {
|
||||
t.Fatalf("expected 3 batches, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteInBatchesStopsImmediatelyWhenFirstBatchUnderSize(t *testing.T) {
|
||||
withFastDeadlockRetry(t, 5)
|
||||
calls := 0
|
||||
total, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) {
|
||||
calls++
|
||||
return 0, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total != 0 || calls != 1 {
|
||||
t.Fatalf("expected total 0 and 1 call, got total=%d calls=%d", total, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteInBatchesReturnsError(t *testing.T) {
|
||||
withFastDeadlockRetry(t, 5)
|
||||
sentinel := errors.New("delete failed")
|
||||
_, err := deleteInBatches(context.Background(), 5, func(context.Context) (int64, error) {
|
||||
return 0, sentinel
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected sentinel, got %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/catalog/ -run TestDeleteInBatches -v`
|
||||
Expected: FAIL — `undefined: deleteInBatches`.
|
||||
|
||||
- [ ] **Step 3: Implement the helper**
|
||||
|
||||
In `internal/catalog/folder_repo.go`, add after `retryOnDeadlock`:
|
||||
|
||||
```go
|
||||
// deleteInBatches repeatedly runs deleteBatch (each a single autocommit
|
||||
// statement) until a batch removes fewer than batchSize rows. Each batch is
|
||||
// retried on deadlock. It returns the total number of rows deleted.
|
||||
func deleteInBatches(
|
||||
ctx context.Context,
|
||||
batchSize int,
|
||||
deleteBatch func(ctx context.Context) (int64, error),
|
||||
) (int64, error) {
|
||||
var total int64
|
||||
for {
|
||||
var affected int64
|
||||
if err := retryOnDeadlock(ctx, func() error {
|
||||
n, e := deleteBatch(ctx)
|
||||
affected = n
|
||||
return e
|
||||
}); err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += affected
|
||||
if affected < int64(batchSize) {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `go test ./internal/catalog/ -run TestDeleteInBatches -v`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Format and commit**
|
||||
|
||||
```bash
|
||||
gofmt -w internal/catalog/folder_repo.go internal/catalog/folder_delete_test.go
|
||||
git add internal/catalog/folder_repo.go internal/catalog/folder_delete_test.go
|
||||
git commit -m "feat(catalog): add deleteInBatches loop helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Generalize image-dir helpers to a querier interface
|
||||
|
||||
This lets the image-dir helpers run on either a `*pgxpool.Pool` (new batched path) or a `pgx.Tx` (existing `library_repo.go` path), and splits raw collection from filtering so the batched path can collect cheaply per batch and filter once.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/catalog/folder_repo.go:547-635` (the `collectImageDirs` / `filterUnreferencedImageDirs` block)
|
||||
|
||||
- [ ] **Step 1: Add the `rowQuerier` interface**
|
||||
|
||||
In `internal/catalog/folder_repo.go`, add (near the other type declarations, e.g. just above `collectOrphanIDs`):
|
||||
|
||||
```go
|
||||
// rowQuerier is satisfied by both *pgxpool.Pool and pgx.Tx, letting read
|
||||
// helpers run inside or outside an explicit transaction.
|
||||
type rowQuerier interface {
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Split `collectImageDirs` into raw collection + filtering**
|
||||
|
||||
Replace the existing `collectImageDirs` function (currently `internal/catalog/folder_repo.go:549-584`) with these two functions:
|
||||
|
||||
```go
|
||||
// collectImageDirs returns S3 directory prefixes for images belonging to the
|
||||
// given content IDs that are not still referenced by other surviving content.
|
||||
func collectImageDirs(ctx context.Context, q rowQuerier, contentIDs []string) ([]string, error) {
|
||||
dirs, err := collectRawImageDirs(ctx, q, contentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterUnreferencedImageDirs(ctx, q, dirs, contentIDs)
|
||||
}
|
||||
|
||||
// collectRawImageDirs returns the deduped S3 directory prefixes referenced by
|
||||
// the given content IDs (items, their seasons, and their episodes), without
|
||||
// filtering out dirs still used by other content.
|
||||
func collectRawImageDirs(ctx context.Context, q rowQuerier, contentIDs []string) ([]string, error) {
|
||||
imgRows, err := q.Query(ctx, `
|
||||
SELECT poster_path, backdrop_path, logo_path FROM media_items WHERE content_id = ANY($1)
|
||||
UNION ALL
|
||||
SELECT poster_path, '', '' FROM seasons WHERE series_id = ANY($1)
|
||||
UNION ALL
|
||||
SELECT still_path, '', '' FROM episodes WHERE series_id = ANY($1)
|
||||
`, contentIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collecting image paths: %w", err)
|
||||
}
|
||||
defer imgRows.Close()
|
||||
dirSet := make(map[string]struct{})
|
||||
for imgRows.Next() {
|
||||
var p1, p2, p3 string
|
||||
if err := imgRows.Scan(&p1, &p2, &p3); err != nil {
|
||||
return nil, fmt.Errorf("scanning image path: %w", err)
|
||||
}
|
||||
for _, p := range []string{p1, p2, p3} {
|
||||
if p != "" && !strings.Contains(p, "://") {
|
||||
if dir := pathDir(p); dir != "" {
|
||||
dirSet[dir] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := imgRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating image paths: %w", err)
|
||||
}
|
||||
dirs := make([]string, 0, len(dirSet))
|
||||
for dir := range dirSet {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
return dirs, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Change `filterUnreferencedImageDirs` to take `rowQuerier`**
|
||||
|
||||
In `internal/catalog/folder_repo.go`, change the signature (currently `internal/catalog/folder_repo.go:586`) from:
|
||||
|
||||
```go
|
||||
func filterUnreferencedImageDirs(ctx context.Context, tx pgx.Tx, dirs, deletingContentIDs []string) ([]string, error) {
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
func filterUnreferencedImageDirs(ctx context.Context, q rowQuerier, dirs, deletingContentIDs []string) ([]string, error) {
|
||||
```
|
||||
|
||||
and change the single `tx.Query(ctx, ...)` call inside it to `q.Query(ctx, ...)`. Leave the SQL and the rest of the body unchanged.
|
||||
|
||||
- [ ] **Step 4: Verify the build (proves `library_repo.go` still compiles with `pgx.Tx`)**
|
||||
|
||||
Run: `go build ./... && go vet ./internal/catalog/`
|
||||
Expected: no errors. (`library_repo.go` passes a `pgx.Tx` to `collectImageDirs`, which now accepts `rowQuerier`; `pgx.Tx` satisfies it.)
|
||||
|
||||
- [ ] **Step 5: Run the catalog tests**
|
||||
|
||||
Run: `go test ./internal/catalog/...`
|
||||
Expected: PASS (existing tests plus Tasks 1–2 helpers).
|
||||
|
||||
- [ ] **Step 6: Format and commit**
|
||||
|
||||
```bash
|
||||
gofmt -w internal/catalog/folder_repo.go
|
||||
git add internal/catalog/folder_repo.go
|
||||
git commit -m "refactor(catalog): make image-dir helpers querier-agnostic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Rewrite `DeleteWithStats` as a batched, phased operation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/catalog/folder_repo.go:415-511` (the `DeleteWithStats` body) and add two helpers + two constants.
|
||||
|
||||
- [ ] **Step 1: Add the batch-size constants**
|
||||
|
||||
In `internal/catalog/folder_repo.go`, add near the retry vars from Task 1:
|
||||
|
||||
```go
|
||||
const (
|
||||
// orphanDeleteBatch is small because each media_items row cascades across
|
||||
// ~15 child tables.
|
||||
orphanDeleteBatch = 1000
|
||||
// folderChildDeleteBatch covers the lighter folder-scoped media_files and
|
||||
// junction deletes.
|
||||
folderChildDeleteBatch = 5000
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the `DeleteWithStats` body**
|
||||
|
||||
Replace the entire existing `DeleteWithStats` function (`internal/catalog/folder_repo.go:415-511`) with:
|
||||
|
||||
```go
|
||||
func (r *FolderRepository) DeleteWithStats(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
progress func(current, total int, message string),
|
||||
) (*DeleteFolderStats, error) {
|
||||
stats := &DeleteFolderStats{}
|
||||
|
||||
// Phase 0: preflight reads (no long-lived transaction).
|
||||
if err := r.pool.QueryRow(ctx, `SELECT name FROM media_folders WHERE id = $1`, id).Scan(&stats.LibraryName); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrFolderNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("loading folder before delete: %w", err)
|
||||
}
|
||||
if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM media_files WHERE media_folder_id = $1`, id).Scan(&stats.MediaFiles); err != nil {
|
||||
return nil, fmt.Errorf("counting media files: %w", err)
|
||||
}
|
||||
if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = $1`, id).Scan(&stats.MediaItemLinks); err != nil {
|
||||
return nil, fmt.Errorf("counting media item links: %w", err)
|
||||
}
|
||||
var orphanTotal int
|
||||
if err := r.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM media_item_libraries mil
|
||||
WHERE mil.media_folder_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_item_libraries other
|
||||
WHERE other.content_id = mil.content_id
|
||||
AND other.media_folder_id <> $1
|
||||
)`, id).Scan(&orphanTotal); err != nil {
|
||||
return nil, fmt.Errorf("counting orphaned items: %w", err)
|
||||
}
|
||||
|
||||
// Phase 1: delete orphaned media_items in detect-then-delete batches.
|
||||
// Cascade removes their junctions, provider IDs, episodes, seasons, etc.
|
||||
if progress != nil {
|
||||
progress(0, orphanTotal, "Deleting orphaned items")
|
||||
}
|
||||
rawDirs := make(map[string]struct{})
|
||||
for {
|
||||
ids, err := r.collectOrphanBatch(ctx, id, orphanDeleteBatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
break
|
||||
}
|
||||
dirs, err := collectRawImageDirs(ctx, r.pool, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range dirs {
|
||||
rawDirs[d] = struct{}{}
|
||||
}
|
||||
if err := retryOnDeadlock(ctx, func() error {
|
||||
_, e := r.pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids)
|
||||
return e
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("deleting orphaned items: %w", err)
|
||||
}
|
||||
stats.OrphanedItems += len(ids)
|
||||
if progress != nil {
|
||||
progress(stats.OrphanedItems, orphanTotal, "Deleting orphaned items")
|
||||
}
|
||||
}
|
||||
|
||||
// Filter accumulated image dirs once, now that orphans are gone, against
|
||||
// any surviving content. Empty deleting-set means "exclude nothing".
|
||||
if len(rawDirs) > 0 {
|
||||
filtered, err := filterUnreferencedImageDirs(ctx, r.pool, dirSetToSlice(rawDirs), []string{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.OrphanedImageDirs = filtered
|
||||
}
|
||||
|
||||
// Phase 2: delete this folder's media_files (folder-tied, not item-tied).
|
||||
if progress != nil {
|
||||
progress(orphanTotal, orphanTotal, "Deleting media files")
|
||||
}
|
||||
if _, err := deleteInBatches(ctx, folderChildDeleteBatch, func(ctx context.Context) (int64, error) {
|
||||
tag, e := r.pool.Exec(ctx, `
|
||||
DELETE FROM media_files
|
||||
WHERE id IN (
|
||||
SELECT id FROM media_files WHERE media_folder_id = $1 LIMIT $2
|
||||
)`, id, folderChildDeleteBatch)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("deleting media files: %w", err)
|
||||
}
|
||||
|
||||
// Phase 3: delete remaining folder memberships (shared items kept; only the
|
||||
// membership in this folder is removed).
|
||||
if progress != nil {
|
||||
progress(orphanTotal, orphanTotal, "Removing library memberships")
|
||||
}
|
||||
if _, err := deleteInBatches(ctx, folderChildDeleteBatch, func(ctx context.Context) (int64, error) {
|
||||
tag, e := r.pool.Exec(ctx, `
|
||||
DELETE FROM media_item_libraries
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2
|
||||
)`, id, folderChildDeleteBatch)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("deleting media item links: %w", err)
|
||||
}
|
||||
|
||||
// Phase 4: delete the now-lightweight folder row. Tolerate 0 rows so a
|
||||
// resumed run that already removed it still succeeds.
|
||||
if err := retryOnDeadlock(ctx, func() error {
|
||||
_, e := r.pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, id)
|
||||
return e
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("deleting folder: %w", err)
|
||||
}
|
||||
|
||||
if progress != nil {
|
||||
progress(orphanTotal, orphanTotal, "Library deletion completed")
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// collectOrphanBatch returns up to limit content IDs whose only library
|
||||
// membership is the given folder.
|
||||
func (r *FolderRepository) collectOrphanBatch(ctx context.Context, folderID, limit int) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT mil.content_id
|
||||
FROM media_item_libraries mil
|
||||
WHERE mil.media_folder_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_item_libraries other
|
||||
WHERE other.content_id = mil.content_id
|
||||
AND other.media_folder_id <> $1
|
||||
)
|
||||
LIMIT $2`, folderID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finding orphaned items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var contentID string
|
||||
if err := rows.Scan(&contentID); err != nil {
|
||||
return nil, fmt.Errorf("scanning orphan content_id: %w", err)
|
||||
}
|
||||
ids = append(ids, contentID)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// dirSetToSlice returns the keys of set as a slice (nil if empty).
|
||||
func dirSetToSlice(set map[string]struct{}) []string {
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
dirs := make([]string, 0, len(set))
|
||||
for d := range set {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
```
|
||||
|
||||
Note: this removes the old single-transaction body (`tx, err := r.pool.Begin(...)`, the inline orphan-collection loop, the `collectImageDirs` call, the CASCADE folder drop, and `tx.Commit`). `collectOrphanIDs` and `Delete` are untouched. `collectImageDirs` remains (still used by `library_repo.go`).
|
||||
|
||||
- [ ] **Step 3: Verify the build and vet**
|
||||
|
||||
Run: `go build ./... && go vet ./internal/catalog/`
|
||||
Expected: no errors. If `go vet` flags an unused function, confirm it is genuinely unused before removing — `collectImageDirs`, `collectOrphanIDs`, and `pathDir` must all remain (still referenced).
|
||||
|
||||
- [ ] **Step 4: Run the full catalog + adminjob test suites**
|
||||
|
||||
Run: `go test ./internal/catalog/... ./internal/adminjob/...`
|
||||
Expected: PASS. (No DB-backed test exercises `DeleteWithStats`; the helper unit tests and existing tests must stay green.)
|
||||
|
||||
- [ ] **Step 5: Lint**
|
||||
|
||||
Run: `golangci-lint run ./internal/catalog/...`
|
||||
Expected: no findings. (If `golangci-lint` is unavailable, run `gofmt -l internal/catalog/` and ensure it prints nothing.)
|
||||
|
||||
- [ ] **Step 6: Format and commit**
|
||||
|
||||
```bash
|
||||
gofmt -w internal/catalog/folder_repo.go
|
||||
git add internal/catalog/folder_repo.go
|
||||
git commit -m "fix(catalog): delete libraries in deadlock-retrying batches
|
||||
|
||||
Replaces the single multi-minute delete transaction with phased, batched
|
||||
autocommit deletes (orphan items, media files, memberships, folder row),
|
||||
each retried on deadlock. Holds only short locks, survives concurrent
|
||||
writers, and is resumable on failure."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Verify on the dev server
|
||||
|
||||
This is the integration test (no catalog DB harness exists). Follow the deployment-debugging runbook. The Audiobooks library (folder id 8) is currently stuck with ~249K items / ~339K files.
|
||||
|
||||
- [ ] **Step 1: Deploy the branch to dev**
|
||||
|
||||
Run: `make dev-deploy`
|
||||
Expected: builds `silo:dev-local`, recreates the `silo` service. Then confirm health:
|
||||
`ssh "$DEV_HOST" 'curl -s http://localhost:8090/api/v1/ready'`
|
||||
|
||||
- [ ] **Step 2: Capture the starting counts**
|
||||
|
||||
Run (psql via the dev compose `postgres` service, user/db `continuum`):
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM media_files WHERE media_folder_id = 8) AS files,
|
||||
(SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = 8) AS links,
|
||||
(SELECT EXISTS (SELECT 1 FROM media_folders WHERE id = 8)) AS folder_exists;
|
||||
```
|
||||
|
||||
Expected: non-zero files/links, `folder_exists = t`.
|
||||
|
||||
- [ ] **Step 3: Trigger the deletion**
|
||||
|
||||
Re-trigger the library deletion through the normal path (admin UI "delete library" for Audiobooks, or the admin delete endpoint). Confirm a new job is queued:
|
||||
|
||||
```sql
|
||||
SELECT id, job_type, status, requested_at
|
||||
FROM admin_jobs WHERE job_type = 'delete_library'
|
||||
ORDER BY requested_at DESC LIMIT 3;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Monitor to completion**
|
||||
|
||||
Watch the job and the draining counts (re-run periodically):
|
||||
|
||||
```sql
|
||||
SELECT id, status, progress_current, progress_total, error_message, heartbeat_at
|
||||
FROM admin_jobs WHERE job_type = 'delete_library'
|
||||
ORDER BY requested_at DESC LIMIT 1;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = 8;
|
||||
```
|
||||
|
||||
Also confirm deadlock retries (if any) are recovered rather than fatal:
|
||||
`ssh "$DEV_HOST" "$DEV_COMPOSE_CMD logs postgres 2>&1 | grep -i deadlock | tail -20"`
|
||||
Expected: the job reaches `status = completed`; counts trend to 0; any deadlocks are transient (the job does not fail).
|
||||
|
||||
- [ ] **Step 5: Confirm full removal**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM media_files WHERE media_folder_id = 8) AS files,
|
||||
(SELECT COUNT(*) FROM media_item_libraries WHERE media_folder_id = 8) AS links,
|
||||
(SELECT EXISTS (SELECT 1 FROM media_folders WHERE id = 8)) AS folder_exists;
|
||||
```
|
||||
|
||||
Expected: `files = 0`, `links = 0`, `folder_exists = f`.
|
||||
|
||||
- [ ] **Step 6: Report**
|
||||
|
||||
Summarize: starting counts, time to completion, number of batches/retries observed, and final state. No commit (verification only).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Phased batched deletion (Phases 0–4) → Task 4. ✔
|
||||
- `retryOnDeadlock` (40P01/40001, 5 attempts, backoff, ctx-aware) → Task 1. ✔
|
||||
- Batch sizes `orphanDeleteBatch=1000`, `folderChildDeleteBatch=5000` → Task 4 Step 1. ✔
|
||||
- Autocommit per batch / no long transaction → Task 4 Step 2 (uses `r.pool.Exec`, no `Begin`). ✔
|
||||
- Stats + progress + heartbeat reliance → Task 4 Step 2; runner heartbeat unchanged. ✔
|
||||
- Resumability (folder row last, tolerate 0 rows) → Task 4 Phase 4. ✔
|
||||
- Unit tests for retry + batch loop → Tasks 1–2. ✔
|
||||
- `collectImageDirs` shared with `library_repo.go` preserved → Task 3 (interface widening), Task 4 Step 3 build check. ✔
|
||||
- Dev integration verification → Task 5. ✔
|
||||
- Out of scope (writer coordination, runner/executor changes, schema changes) → not present in any task. ✔
|
||||
|
||||
**Placeholder scan:** No TBD/TODO; every code step has complete code; every command has expected output. ✔
|
||||
|
||||
**Type consistency:** `retryOnDeadlock(ctx, func() error) error`, `deleteInBatches(ctx, int, func(ctx) (int64,error)) (int64,error)`, `rowQuerier{ Query(ctx,string,...any)(pgx.Rows,error) }`, `collectRawImageDirs`/`collectImageDirs`/`filterUnreferencedImageDirs(ctx, rowQuerier, ...)`, `collectOrphanBatch(ctx,int,int)([]string,error)`, `dirSetToSlice(map[string]struct{})[]string`, consts `orphanDeleteBatch`/`folderChildDeleteBatch`, vars `deadlockMaxAttempts`/`deadlockBaseBackoff` — names used consistently across tasks. ✔
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,779 +0,0 @@
|
||||
# Autoscan Host Backend Implementation Plan (Part 1 of 2)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the silo-server backend for the standalone Autoscan category: poll `scan_source.v1` plugins on a timer, resolve their Silo-native paths to library folders, and enqueue rescans — with connections decoupled from Requests.
|
||||
|
||||
**Architecture:** Mirror the existing `scheduled_task.v1` host dispatch (`pluginhost.Client` capability wrapper + `plugins.Service` resolver + a `taskmanager` task). Generalize the closed PR #43 autoscan engine (resolve → suppress → enqueue) so its input is "Silo-native paths from a scan-source plugin" instead of "arr history." Replace the old `request_integrations`-coupled schema with `autoscan_connections` + `autoscan_sources`.
|
||||
|
||||
**Tech Stack:** Go, pgx/v5, the `internal/plugins`/`internal/pluginhost` runtime, `internal/scantrigger`/`internal/scanqueue`, `internal/taskmanager`, numbered SQL migrations, `github.com/Silo-Server/silo-plugin-sdk` (the new `scan_source.v1` capability).
|
||||
|
||||
Commands assume the silo-server repository root is the cwd. This is **Part 1 (backend)**; the Autoscan admin UI is Part 2.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **SDK dependency.** This plan needs the SDK's `scan_source.v1` symbols (`runtime.Client.ScanSource()`, `pluginv1.ScanSourceClient`, `pluginv1.PollChangesRequest/Response`, `capability.ScanSource`). They are in `silo-plugin-sdk` PR #2 but **not yet released**. Until `v0.5.0` is tagged, add a local replace directive so the host builds against the working checkout:
|
||||
|
||||
```bash
|
||||
# from the repository root (cwd)
|
||||
go mod edit -replace github.com/Silo-Server/silo-plugin-sdk=../silo-plugin-sdk
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
This points the replace at the `silo-plugin-sdk` checkout (a sibling of this repo).
|
||||
|
||||
The final task removes the replace and bumps to the tagged version.
|
||||
|
||||
2. **Closed PR #43 is on `main`.** The in-process arr autoscan (`internal/autoscan/*`, `internal/api/handlers/autoscan.go`, `internal/taskmanager/tasks/autoscan_poll.go`, router routes, migration `171_autoscan`, the `AdminRequests.tsx` tab) is the salvage source. This plan **generalizes the engine and replaces the schema/wiring**; it does not preserve the arr-specific history client (that moves to the arr plugin in a separate plan).
|
||||
|
||||
3. **Tests that touch `internal/api/handlers` or `internal/api` link libvips via CGO.** Run the suite in the libvips-equipped container (see project memory `silo-test-libvips-gotcha`), not the bare host, or those packages report `[build failed]` and skip silently.
|
||||
|
||||
---
|
||||
|
||||
## Pattern references (read before starting)
|
||||
|
||||
- Host capability client wrapper: `internal/pluginhost/client.go` — `ScheduledTaskClient` type (l.42), `Client.ScheduledTask` accessor (l.111), `requireCapability` (l.157), `Run` (l.226).
|
||||
- Host resolver: `internal/plugins/service.go:413` `ScheduledTaskClient(ctx, installationID, capabilityID)` → `ensureClient` → `client.ScheduledTask(capabilityID)`.
|
||||
- Timer task over plugin capabilities: `internal/plugins/task_registry.go` (iterate enabled installations, filter capability type, build a `taskmanager.Task`).
|
||||
- Salvageable engine (generalize, don't rewrite): `internal/autoscan/service.go` (resolve/suppress/enqueue loop), `dedupe.go` (`uniqueParentDirs`), `suppress.go` (`Suppressor`). Discard `history.go`, `rewrite.go`, `suggest*.go` (arr-specific → arr plugin).
|
||||
- Migration mechanics: paired `migrations/NNN_*.up.sql`/`.down.sql`, embedded via `migrations/embed.go`, auto-applied on boot.
|
||||
- Credential decryption for the reuse-from-Requests case: `internal/requests/repository.go` (`integrationColumns` has `base_url, api_key_ref`); Fernet resolution via `catalog.ServerSettingsRepo.Get` (the same path PR #43's `SecretResolver` used).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
|---|---|---|
|
||||
| `internal/pluginhost/client.go` | `ScanSourceClient` wrapper + `Client.ScanSource()` accessor | Modify |
|
||||
| `internal/pluginhost/client_test.go` | `requireCapability` gate for `scan_source.v1` | Modify |
|
||||
| `internal/plugins/service.go` | `ScanSourceClient(ctx, installationID, capabilityID)` resolver | Modify |
|
||||
| `migrations/172_autoscan_v2.up.sql` / `.down.sql` | `autoscan_connections` + `autoscan_sources`; drop the `171` tables | Create |
|
||||
| `internal/autoscan/types.go` | `Source`, `Connection`, `Settings`, `PollChanges` provider seam | Rewrite |
|
||||
| `internal/autoscan/repository.go` | CRUD for connections + sources; `AdvanceMarker` | Rewrite |
|
||||
| `internal/autoscan/provider.go` | `ScanSourceProvider` interface + adapter over the plugins resolver | Create |
|
||||
| `internal/autoscan/connection.go` | Resolve a `Connection` (own or Requests-linked) → `{base_url, api_key}` | Create |
|
||||
| `internal/autoscan/service.go` | Generic engine: per source → `PollChanges(marker)` → resolve → suppress → enqueue → store marker | Rewrite |
|
||||
| `internal/autoscan/dedupe.go`, `suppress.go` | Unchanged salvage | Keep |
|
||||
| `internal/autoscan/history.go`, `rewrite.go`, `suggest*.go` | Arr-specific | Delete |
|
||||
| `internal/taskmanager/tasks/autoscan_poll.go` | Poll task → `Service.PollOnce` | Keep (retarget) |
|
||||
| `internal/api/handlers/autoscan.go` | Admin API (settings/connections/sources/trigger/status) | Rewrite |
|
||||
| `internal/api/router.go`, `cmd/silo/main.go` | Wire the rebuilt service + connection resolver; drop request-coupled wiring | Modify |
|
||||
| `go.mod` | SDK replace (dev) → `v0.5.0` (final) | Modify |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Host `pluginhost` client wrapper for `scan_source.v1`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/pluginhost/client.go`
|
||||
- Modify: `internal/pluginhost/client_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
In `internal/pluginhost/client_test.go`, add a test mirroring the existing `scheduled_task` capability-gate test (find it first; it constructs a `Client` with a manifest declaring a capability and asserts the accessor errors when the capability is absent and succeeds when present):
|
||||
|
||||
```go
|
||||
func TestClientScanSourceRequiresCapability(t *testing.T) {
|
||||
// Client whose manifest does NOT declare scan_source.v1
|
||||
c := newTestClientWithCapabilities(t, "metadata_provider.v1")
|
||||
if _, err := c.ScanSource("missing"); err == nil {
|
||||
t.Fatal("ScanSource: expected error when capability absent")
|
||||
}
|
||||
// Client WHOSE manifest declares scan_source.v1 with id "arr"
|
||||
c2 := newTestClientWithCapabilities(t, "scan_source.v1:arr")
|
||||
if _, err := c2.ScanSource("arr"); err != nil {
|
||||
t.Fatalf("ScanSource: unexpected error when capability present: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use whatever helper the sibling `scheduled_task` test uses to construct a `Client` with declared capabilities; mirror it exactly (do not invent a helper that does not exist — read `client_test.go` and copy the construction).
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/pluginhost/ -run TestClientScanSourceRequiresCapability`
|
||||
Expected: FAIL — `c.ScanSource undefined`.
|
||||
|
||||
- [ ] **Step 3: Add the wrapper type and accessor**
|
||||
|
||||
In `internal/pluginhost/client.go`, after the `ScheduledTaskClient` type (l.42), add:
|
||||
|
||||
```go
|
||||
type ScanSourceClient struct {
|
||||
client pluginv1.ScanSourceClient
|
||||
timeout time.Duration
|
||||
}
|
||||
```
|
||||
|
||||
After the `Client.ScheduledTask` accessor (l.117), add:
|
||||
|
||||
```go
|
||||
func (c *Client) ScanSource(capabilityID string) (*ScanSourceClient, error) {
|
||||
if err := c.requireCapability("scan_source.v1", capabilityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanSourceClient{
|
||||
client: c.rpc.ScanSource(),
|
||||
timeout: DefaultControlTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *ScanSourceClient) PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.PollChanges(callCtx, req)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./internal/pluginhost/ -run TestClientScanSourceRequiresCapability`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/pluginhost/client.go internal/pluginhost/client_test.go
|
||||
git commit -m "feat(pluginhost): scan_source.v1 capability client wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `plugins.Service` resolver for `scan_source.v1`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/plugins/service.go`
|
||||
|
||||
- [ ] **Step 1: Add the resolver method**
|
||||
|
||||
After `ScheduledTaskClient` (l.413–423), add the identical shape for scan source:
|
||||
|
||||
```go
|
||||
func (s *Service) ScanSourceClient(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
capabilityID string,
|
||||
) (*pluginhost.ScanSourceClient, error) {
|
||||
client, err := s.ensureClient(ctx, installationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.ScanSource(capabilityID)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify it compiles**
|
||||
|
||||
Run: `go build ./internal/plugins/`
|
||||
Expected: exits 0.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/plugins/service.go
|
||||
git commit -m "feat(plugins): expose scan_source.v1 client resolver"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Schema — `autoscan_connections` + `autoscan_sources`
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/172_autoscan_v2.up.sql`, `migrations/172_autoscan_v2.down.sql`
|
||||
|
||||
- [ ] **Step 1: Write the up migration**
|
||||
|
||||
Create `migrations/172_autoscan_v2.up.sql`. It drops PR #43's `171` tables and creates the decoupled schema (no FK into `request_integrations`; the optional reuse link is `ON DELETE SET NULL`):
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS public.autoscan_sources;
|
||||
DROP TABLE IF EXISTS public.autoscan_settings;
|
||||
|
||||
CREATE TABLE public.autoscan_settings (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
default_poll_interval_seconds integer NOT NULL DEFAULT 600,
|
||||
debounce_seconds integer NOT NULL DEFAULT 60,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT autoscan_settings_singleton CHECK (id),
|
||||
CONSTRAINT autoscan_settings_interval_pos CHECK (default_poll_interval_seconds > 0),
|
||||
CONSTRAINT autoscan_settings_debounce_nonneg CHECK (debounce_seconds >= 0)
|
||||
);
|
||||
INSERT INTO public.autoscan_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE public.autoscan_connections (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
kind text NOT NULL,
|
||||
-- Own credentials (used when request_integration_id IS NULL):
|
||||
base_url text,
|
||||
api_key_ref text,
|
||||
-- Optional soft reuse of a Requests arr server (live link; SET NULL on delete):
|
||||
request_integration_id text REFERENCES public.request_integrations(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT autoscan_connections_source_present
|
||||
CHECK (request_integration_id IS NOT NULL OR base_url IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE TABLE public.autoscan_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
installation_id integer NOT NULL,
|
||||
capability_id text NOT NULL,
|
||||
connection_id uuid NOT NULL REFERENCES public.autoscan_connections(id) ON DELETE RESTRICT,
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
poll_interval_seconds integer,
|
||||
marker text,
|
||||
last_run_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT autoscan_sources_interval_pos CHECK (poll_interval_seconds IS NULL OR poll_interval_seconds > 0),
|
||||
CONSTRAINT autoscan_sources_capability_unique UNIQUE (installation_id, capability_id)
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the down migration**
|
||||
|
||||
Create `migrations/172_autoscan_v2.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS public.autoscan_sources;
|
||||
DROP TABLE IF EXISTS public.autoscan_connections;
|
||||
DROP TABLE IF EXISTS public.autoscan_settings;
|
||||
```
|
||||
|
||||
(Recreating the `171` tables on down-migration is unnecessary: `171` is unreleased and superseded; the down simply removes `172`'s tables.)
|
||||
|
||||
- [ ] **Step 3: Apply and verify against a disposable DB**
|
||||
|
||||
Run the migrations against a throwaway Postgres (the project's disposable-DB pattern) and confirm boot applies cleanly:
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
go run ./cmd/silo --migrate-only 2>&1 | grep -i "migrations applied" || echo "check migration output"
|
||||
```
|
||||
Expected: migrations apply without error; `\d autoscan_connections` shows the soft FK `request_integration_id`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add migrations/172_autoscan_v2.up.sql migrations/172_autoscan_v2.down.sql
|
||||
git commit -m "feat(migrations): autoscan v2 schema (connections + sources, no requests FK)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Types and repository
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `internal/autoscan/types.go`
|
||||
- Rewrite: `internal/autoscan/repository.go`
|
||||
|
||||
- [ ] **Step 1: Define the types**
|
||||
|
||||
Replace `internal/autoscan/types.go` with the v2 model:
|
||||
|
||||
```go
|
||||
package autoscan
|
||||
|
||||
import "time"
|
||||
|
||||
type Settings struct {
|
||||
Enabled bool
|
||||
DefaultPollIntervalSeconds int
|
||||
DebounceSeconds int
|
||||
}
|
||||
|
||||
// Connection is an arr server the host can reach: either own credentials, or a
|
||||
// live reference to a Requests integration (RequestIntegrationID set).
|
||||
type Connection struct {
|
||||
ID string
|
||||
Name string
|
||||
Kind string
|
||||
BaseURL string // own; empty when linked
|
||||
APIKeyRef string // own; empty when linked
|
||||
RequestIntegrationID *string
|
||||
}
|
||||
|
||||
// Source ties a scan_source plugin capability instance to a connection plus the
|
||||
// host-owned scheduling/bookkeeping state.
|
||||
type Source struct {
|
||||
ID string
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
ConnectionID string
|
||||
Enabled bool
|
||||
PollIntervalSeconds *int // nil => use settings default
|
||||
Marker *string // opaque; nil on first run
|
||||
LastRunAt *time.Time
|
||||
LastError *string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write a failing repository test**
|
||||
|
||||
Add `internal/autoscan/repository_test.go` exercising round-trip + `AdvanceMarker` against a disposable DB (gate with the project's DB-test build tag/helper if one exists; otherwise a `//go:build integration` tag matching sibling DB tests). Minimal:
|
||||
|
||||
```go
|
||||
func TestRepositoryMarkerRoundTrip(t *testing.T) {
|
||||
repo := newTestRepo(t) // disposable-DB helper used by sibling repo tests
|
||||
connID := repo.mustCreateConnection(t, Connection{Name: "Sonarr", Kind: "sonarr", BaseURL: "http://x", APIKeyRef: "k"})
|
||||
srcID := repo.mustCreateSource(t, Source{InstallationID: 1, CapabilityID: "arr", ConnectionID: connID, Enabled: true})
|
||||
|
||||
if err := repo.AdvanceMarker(ctx, srcID, "2026-06-02T14:10:00Z"); err != nil {
|
||||
t.Fatalf("AdvanceMarker: %v", err)
|
||||
}
|
||||
got, _ := repo.ListEnabledSources(ctx)
|
||||
if len(got) != 1 || got[0].Marker == nil || *got[0].Marker != "2026-06-02T14:10:00Z" {
|
||||
t.Fatalf("marker not persisted: %+v", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run to verify it fails**
|
||||
|
||||
Run: `go test ./internal/autoscan/ -run TestRepositoryMarkerRoundTrip` (with the DB helper available)
|
||||
Expected: FAIL — undefined repository methods.
|
||||
|
||||
- [ ] **Step 4: Implement the repository**
|
||||
|
||||
Rewrite `internal/autoscan/repository.go` with: `GetSettings`, `UpdateSettings`, connection CRUD (`CreateConnection`, `UpdateConnection`, `DeleteConnection`, `ListConnections`, `GetConnection`), source CRUD (`UpsertSource`, `ListEnabledSources`, `ListSources`, `GetSource`), and `AdvanceMarker(ctx, sourceID, marker string)` (sets `marker`, `last_run_at = now()`, clears `last_error`). Use pgx; follow the column/scan style of the existing (pre-rewrite) `repository.go` and `internal/requests/repository.go`.
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes**
|
||||
|
||||
Run: `go test ./internal/autoscan/ -run TestRepositoryMarkerRoundTrip`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/autoscan/types.go internal/autoscan/repository.go internal/autoscan/repository_test.go
|
||||
git commit -m "feat(autoscan): v2 types and repository (connections, sources, markers)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Connection resolution (own or Requests-linked)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/autoscan/connection.go`
|
||||
- Create: `internal/autoscan/connection_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
func TestResolveConnectionOwnVsLinked(t *testing.T) {
|
||||
reqLookup := fakeRequestIntegrationLookup{"req-1": creds{BaseURL: "http://req:7878", APIKeyRef: "rk"}}
|
||||
secrets := fakeSecrets{"rk": "REQKEY", "ownref": "OWNKEY"}
|
||||
r := NewConnectionResolver(reqLookup, secrets)
|
||||
|
||||
own, err := r.Resolve(ctx, Connection{BaseURL: "http://own:8989", APIKeyRef: "ownref"})
|
||||
if err != nil || own.BaseURL != "http://own:8989" || own.APIKey != "OWNKEY" {
|
||||
t.Fatalf("own resolve = %+v, err=%v", own, err)
|
||||
}
|
||||
id := "req-1"
|
||||
linked, err := r.Resolve(ctx, Connection{RequestIntegrationID: &id})
|
||||
if err != nil || linked.BaseURL != "http://req:7878" || linked.APIKey != "REQKEY" {
|
||||
t.Fatalf("linked resolve = %+v, err=%v", linked, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConnectionLinkedMissingFallsBack(t *testing.T) {
|
||||
r := NewConnectionResolver(fakeRequestIntegrationLookup{}, fakeSecrets{})
|
||||
id := "gone"
|
||||
if _, err := r.Resolve(ctx, Connection{RequestIntegrationID: &id}); err == nil {
|
||||
t.Fatal("expected error when linked Requests integration is missing")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `go test ./internal/autoscan/ -run TestResolveConnection`
|
||||
Expected: FAIL — undefined `NewConnectionResolver`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `internal/autoscan/connection.go`:
|
||||
|
||||
```go
|
||||
package autoscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ResolvedConnection is concrete credentials handed to the plugin.
|
||||
type ResolvedConnection struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
type RequestIntegrationLookup interface {
|
||||
// Returns base URL + encrypted api key ref for a Requests integration.
|
||||
Get(ctx context.Context, integrationID string) (baseURL, apiKeyRef string, err error)
|
||||
}
|
||||
|
||||
type SecretResolver interface {
|
||||
Get(ctx context.Context, ref string) (string, error)
|
||||
}
|
||||
|
||||
type ConnectionResolver struct {
|
||||
requests RequestIntegrationLookup
|
||||
secrets SecretResolver
|
||||
}
|
||||
|
||||
func NewConnectionResolver(r RequestIntegrationLookup, s SecretResolver) *ConnectionResolver {
|
||||
return &ConnectionResolver{requests: r, secrets: s}
|
||||
}
|
||||
|
||||
func (cr *ConnectionResolver) Resolve(ctx context.Context, c Connection) (ResolvedConnection, error) {
|
||||
baseURL, apiKeyRef := c.BaseURL, c.APIKeyRef
|
||||
if c.RequestIntegrationID != nil {
|
||||
u, ref, err := cr.requests.Get(ctx, *c.RequestIntegrationID)
|
||||
if err != nil {
|
||||
return ResolvedConnection{}, fmt.Errorf("autoscan: linked requests integration %q: %w", *c.RequestIntegrationID, err)
|
||||
}
|
||||
baseURL, apiKeyRef = u, ref
|
||||
}
|
||||
apiKey := apiKeyRef
|
||||
if cr.secrets != nil && apiKeyRef != "" {
|
||||
resolved, err := cr.secrets.Get(ctx, apiKeyRef)
|
||||
if err != nil {
|
||||
return ResolvedConnection{}, fmt.Errorf("autoscan: resolve api key: %w", err)
|
||||
}
|
||||
if resolved != "" {
|
||||
apiKey = resolved
|
||||
}
|
||||
}
|
||||
return ResolvedConnection{BaseURL: baseURL, APIKey: apiKey}, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `go test ./internal/autoscan/ -run TestResolveConnection`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/autoscan/connection.go internal/autoscan/connection_test.go
|
||||
git commit -m "feat(autoscan): resolve connections (own credentials or Requests-linked)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Provider seam over the plugin resolver
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/autoscan/provider.go`
|
||||
|
||||
- [ ] **Step 1: Define the seam and adapter**
|
||||
|
||||
The engine must be testable without a live plugin, so it depends on a narrow interface, with a production adapter over `plugins.Service.ScanSourceClient`. Create `internal/autoscan/provider.go`:
|
||||
|
||||
```go
|
||||
package autoscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// ScanSourceProvider yields changed paths for one source. The engine calls
|
||||
// PollChanges; production wraps the plugins.Service scan_source resolver.
|
||||
type ScanSourceProvider interface {
|
||||
PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection) (paths []string, nextMarker string, err error)
|
||||
}
|
||||
|
||||
// pluginScanSourceClient is the slice of *pluginhost.ScanSourceClient used here.
|
||||
type pluginScanSourceClient interface {
|
||||
PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error)
|
||||
}
|
||||
|
||||
type scanSourceResolver interface {
|
||||
ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (pluginScanSourceClient, error)
|
||||
}
|
||||
|
||||
type pluginProvider struct{ resolver scanSourceResolver }
|
||||
|
||||
func NewPluginProvider(resolver scanSourceResolver) ScanSourceProvider {
|
||||
return &pluginProvider{resolver: resolver}
|
||||
}
|
||||
|
||||
func (p *pluginProvider) PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection) ([]string, string, error) {
|
||||
client, err := p.resolver.ScanSourceClient(ctx, installationID, capabilityID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := client.PollChanges(ctx, &pluginv1.PollChangesRequest{CapabilityId: capabilityID, Marker: marker})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.GetChangedPaths(), resp.GetNextMarker(), nil
|
||||
}
|
||||
```
|
||||
|
||||
Note: the connection (`conn`) is delivered to the plugin out-of-band as runtime config when the source is configured (the plugin reads its own `{base_url, api_key}`); it is passed here so a future provider variant could inject per-call. For v1 the production path configures the plugin instance with the resolved connection at upsert time; document this in `NewPluginProvider`'s comment.
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `go build ./internal/autoscan/`
|
||||
Expected: exits 0.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/autoscan/provider.go
|
||||
git commit -m "feat(autoscan): scan-source provider seam over the plugin resolver"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Generalize the engine (`Service.PollOnce`)
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `internal/autoscan/service.go`
|
||||
- Modify: `internal/autoscan/service_test.go` (generalize the salvaged tests)
|
||||
|
||||
- [ ] **Step 1: Adapt the salvaged tests to the provider seam**
|
||||
|
||||
The PR #43 `service_test.go` used a `fakeHistory`; replace it with a `fakeProvider` implementing `ScanSourceProvider`, keeping the same assertions (dedupe to one folder; distinct paths same folder → two scans; disabled no-op; provider error keeps marker; enqueue failure releases claims + keeps marker). Add an opaque-marker assertion: the provider's returned `nextMarker` is stored verbatim via `AdvanceMarker`.
|
||||
|
||||
```go
|
||||
type fakeProvider struct {
|
||||
paths map[string][]string // key: capabilityID
|
||||
nextMarker string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeProvider) PollChanges(_ context.Context, _ int, capabilityID, _ string, _ ResolvedConnection) ([]string, string, error) {
|
||||
if f.err != nil {
|
||||
return nil, "", f.err
|
||||
}
|
||||
return f.paths[capabilityID], f.nextMarker, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify the tests fail**
|
||||
|
||||
Run: `go test ./internal/autoscan/ -run TestPollOnce`
|
||||
Expected: FAIL — `Service` signature/`fakeProvider` not yet wired.
|
||||
|
||||
- [ ] **Step 3: Rewrite the engine**
|
||||
|
||||
Rewrite `internal/autoscan/service.go`. Keep the salvaged resolve→suppress→enqueue loop verbatim (it is provider-agnostic already), but drive it from sources + the provider seam:
|
||||
|
||||
```go
|
||||
func (s *Service) PollOnce(ctx context.Context) error {
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return nil
|
||||
}
|
||||
sources, err := s.store.ListEnabledSources(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ttl := time.Duration(settings.DebounceSeconds) * time.Second
|
||||
|
||||
for _, src := range sources {
|
||||
conn, cerr := s.resolveConnection(ctx, src.ConnectionID)
|
||||
if cerr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: resolve connection failed", "source_id", src.ID, "err", cerr)
|
||||
continue
|
||||
}
|
||||
marker := ""
|
||||
if src.Marker != nil {
|
||||
marker = *src.Marker
|
||||
}
|
||||
paths, next, perr := s.provider.PollChanges(ctx, src.InstallationID, src.CapabilityID, marker, conn)
|
||||
if perr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: poll changes failed", "source_id", src.ID, "err", perr)
|
||||
_ = s.store.RecordError(ctx, src.ID, perr.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
targets, claimed := s.resolveAndClaim(ctx, paths, ttl) // salvaged dedupe→resolve→suppress
|
||||
if len(targets) > 0 {
|
||||
if eerr := s.queue.EnqueueScans(ctx, targets); eerr != nil {
|
||||
s.releaseClaims(ctx, claimed)
|
||||
slog.WarnContext(ctx, "autoscan: enqueue failed", "source_id", src.ID, "err", eerr)
|
||||
continue // do NOT advance marker
|
||||
}
|
||||
}
|
||||
if aerr := s.store.AdvanceMarker(ctx, src.ID, next); aerr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: advance marker failed", "source_id", src.ID, "err", aerr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Extract the salvaged loop body into `resolveAndClaim`/`releaseClaims` helpers (lifted verbatim from PR #43's `service.go` lines that build `targets`/`claimed`). Keep `uniqueParentDirs`, `Suppressor`, the `fmt.Sprintf("%d|%s", folderID, target.Path)` suppression key, and the `scantrigger.RequestError` quiet-skip.
|
||||
|
||||
- [ ] **Step 4: Run to verify the tests pass**
|
||||
|
||||
Run: `go test ./internal/autoscan/ -run TestPollOnce`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/autoscan/service.go internal/autoscan/service_test.go
|
||||
git commit -m "feat(autoscan): generic engine drives sources via scan_source provider"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Delete arr-specific salvage residue
|
||||
|
||||
**Files:**
|
||||
- Delete: `internal/autoscan/history.go`, `history_test.go`, `rewrite.go`, `rewrite_test.go`, `suggest.go`, `suggest_deps.go`, `suggest_test.go`
|
||||
|
||||
- [ ] **Step 1: Remove the files**
|
||||
|
||||
```bash
|
||||
git rm internal/autoscan/history.go internal/autoscan/history_test.go \
|
||||
internal/autoscan/rewrite.go internal/autoscan/rewrite_test.go \
|
||||
internal/autoscan/suggest.go internal/autoscan/suggest_deps.go internal/autoscan/suggest_test.go
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to confirm no remaining references**
|
||||
|
||||
Run: `go build ./internal/autoscan/...`
|
||||
Expected: exits 0 (the engine no longer references the deleted arr logic). If it fails, a reference was missed — remove it.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(autoscan): drop arr-specific history/rewrite/suggest (moved to plugin)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Admin API rewrite
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `internal/api/handlers/autoscan.go`
|
||||
- Modify: `internal/api/handlers/autoscan_test.go`
|
||||
|
||||
- [ ] **Step 1: Define the endpoints**
|
||||
|
||||
Rewrite the handler to back the new model. Endpoints (admin-gated, mounted in Task 10):
|
||||
- `GET/PUT /admin/autoscan/settings`
|
||||
- `GET/POST /admin/autoscan/connections`, `PUT/DELETE /admin/autoscan/connections/{id}`
|
||||
- `GET /admin/autoscan/sources`, `PUT /admin/autoscan/sources/{id}` (enable, interval, connection binding)
|
||||
- `POST /admin/autoscan/trigger` (async `PollOnce`, 202 — keep the detached-goroutine + channel-tested pattern from the closed PR's `autoscan_test.go`)
|
||||
- `GET /admin/autoscan/status`
|
||||
|
||||
Strip credentials from responses (never emit `api_key_ref`/resolved keys), matching the `_SENSITIVE_METADATA_KEYS` defense-in-depth posture.
|
||||
|
||||
- [ ] **Step 2: Port + extend the handler tests**
|
||||
|
||||
Carry over the trigger test (the `done`-channel synchronization fix from `d3ee373`), the FK→404 test (now: unknown source/connection → 404), and a secrets-not-leaked test for the connections list.
|
||||
|
||||
- [ ] **Step 3: Run the handler tests (libvips container)**
|
||||
|
||||
Run (in the libvips-equipped container per Prerequisites #3):
|
||||
`go test ./internal/api/handlers/ -run Autoscan`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/api/handlers/autoscan.go internal/api/handlers/autoscan_test.go
|
||||
git commit -m "feat(api): autoscan v2 admin endpoints (settings, connections, sources)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Wiring + rate limits + retire old coupling
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/router.go`
|
||||
- Modify: `cmd/silo/main.go`
|
||||
|
||||
- [ ] **Step 1: Rebuild the service wiring**
|
||||
|
||||
In `internal/api/router.go` and `cmd/silo/main.go`, construct the v2 service:
|
||||
|
||||
```go
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB)
|
||||
provider := autoscan.NewPluginProvider(pluginServiceScanSourceAdapter{pluginService})
|
||||
connResolver := autoscan.NewConnectionResolver(requestIntegrationLookup{requestsRepo}, serverSettingsSecretResolver{settingsRepo})
|
||||
autoscanSvc := autoscan.NewService(autoscanRepo, provider, connResolver, deps.Resolver, deps.ScanQueue, autoscan.NewRedisSuppressor(deps.RedisClient))
|
||||
autoscanHandler = handlers.NewAutoscanHandler(autoscanRepo, autoscanSvc)
|
||||
```
|
||||
|
||||
Replace the old route block with the Task 9 routes. Keep the `catalog_*`/admin rate-limit conventions consistent with the security standards (per-user/admin caps on the new endpoints; mirror the request-integrations admin tier).
|
||||
|
||||
- [ ] **Step 2: Retarget the poll task**
|
||||
|
||||
`internal/taskmanager/tasks/autoscan_poll.go` calls `Service.PollOnce` — unchanged behavior; confirm it compiles against the rebuilt service and uses `Settings.DefaultPollIntervalSeconds` for the interval (per-source overrides are honored inside `PollOnce`).
|
||||
|
||||
- [ ] **Step 3: Build the whole tree**
|
||||
|
||||
Run (libvips container): `go build ./...`
|
||||
Expected: exits 0.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/api/router.go cmd/silo/main.go internal/taskmanager/tasks/autoscan_poll.go
|
||||
git commit -m "feat(autoscan): wire v2 service, routes, and poll task"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Full sweep + finalize SDK dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `go.mod`, `go.sum`
|
||||
|
||||
- [ ] **Step 1: Full build + test (libvips container)**
|
||||
|
||||
Run in the libvips-equipped container (Prerequisites #3):
|
||||
```bash
|
||||
go build ./... && go test ./... && go vet ./...
|
||||
```
|
||||
Expected: build OK; all packages `ok`; vet clean. `gofmt -l internal migrations` prints nothing.
|
||||
|
||||
- [ ] **Step 2: Finalize the SDK version (after `v0.5.0` is tagged upstream)**
|
||||
|
||||
Once `silo-plugin-sdk` PR #2 is merged and `v0.5.0` tagged:
|
||||
```bash
|
||||
go mod edit -dropreplace github.com/Silo-Server/silo-plugin-sdk
|
||||
go get github.com/Silo-Server/silo-plugin-sdk@v0.5.0
|
||||
go mod tidy
|
||||
go build ./...
|
||||
```
|
||||
Expected: builds against the released tag with no replace directive.
|
||||
|
||||
If `v0.5.0` is not yet tagged when this plan is otherwise complete, leave the replace directive in place, commit it, and note in the PR that the dependency must be finalized before merge (do not merge a `replace` pointing at a local path).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add go.mod go.sum
|
||||
git commit -m "build: depend on silo-plugin-sdk v0.5.0 (scan_source.v1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review notes
|
||||
|
||||
- **Spec coverage:** §7 engine (Tasks 6–7), §8 connections incl. reuse-or-own + soft Requests link (Tasks 3,5), §9 data model decoupled from Requests + Autoscan-owned state (Tasks 3–4,9–10), §5/§11 host consumes `scan_source.v1` (Tasks 1–2). §10 (arr plugin) and §9 UI are separate plans.
|
||||
- **Salvage map honored (§13):** engine/dedupe/suppress kept and generalized (Tasks 6–8); arr history/rewrite/suggest deleted (Task 8); `last_poll_at` → opaque `marker` (Tasks 3–4,7).
|
||||
- **Out of scope here:** the Autoscan admin **UI** category (Part 2 plan) and the **arr plugin** (separate repo plan). This backend is testable on its own via the repository/engine/handler tests.
|
||||
- **Risk carried from spec §14:** plugin multiplicity (one installation per arr server vs. many connections per installation) is modeled as one `autoscan_sources` row per `(installation_id, capability_id)`; if the runtime supports multiple capability instances per installation this maps cleanly, otherwise it implies one installation per server. Confirm during Task 1–2 by inspecting `ensureClient`/installation handling.
|
||||
- **Type consistency:** `ResolvedConnection{BaseURL,APIKey}`, `Connection{BaseURL,APIKeyRef,RequestIntegrationID}`, `Source{InstallationID,CapabilityID,ConnectionID,Marker}`, `ScanSourceProvider.PollChanges(...)`, `Service.PollOnce` used consistently across Tasks 4–10.
|
||||
@@ -1,694 +0,0 @@
|
||||
# Autoscan Rewrite-Sync Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** A "Sync rewrites from arr" action that suggests autoscan `path_rewrites` for an instance by matching its arr root folders to Silo media folders on shared trailing path segments, previewed and committed by the admin.
|
||||
|
||||
**Architecture:** A pure `suggestRewrites` matcher in `internal/autoscan`, a `Service.SuggestRewrites` method backed by two new injected deps (an arr root-folder client and a Silo folder lister), one read-only admin endpoint returning a preview, and a "Sync from arr" button in the existing Autoscan tab that merges proposals into the editor (admin saves via the existing per-source PUT). Manual rewrites stay the source of truth.
|
||||
|
||||
**Tech Stack:** Go (standard `testing`), React/TypeScript.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-02-autoscan-rewrite-sync-design.md`
|
||||
|
||||
**Commands assume the repository root is the cwd.** Go tests: `go test ./internal/autoscan/...`. Frontend: `cd web && pnpm exec tsc -b && pnpm run lint`. Ensure the Go toolchain is on `PATH` (prepend its `bin` directory if `go` is missing). `internal/api`/`internal/api/handlers` cannot be compiled without libvips; verify those via `gofmt` + the Docker build.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Branch
|
||||
|
||||
- [ ] **Step 0.1: Confirm branch**
|
||||
|
||||
```bash
|
||||
git rev-parse --abbrev-ref HEAD # expect: feat/autoscan-arr-polling
|
||||
```
|
||||
This feature extends the autoscan branch. If you're elsewhere: `git checkout feat/autoscan-arr-polling`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Core matcher (pure, TDD)
|
||||
|
||||
### Task 1: suggestRewrites + types
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/autoscan/suggest.go`
|
||||
- Create: `internal/autoscan/suggest_test.go`
|
||||
|
||||
- [ ] **Step 1.1: Write the failing test**
|
||||
|
||||
`internal/autoscan/suggest_test.go`:
|
||||
```go
|
||||
package autoscan
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSuggestRewrites(t *testing.T) {
|
||||
silo := []string{
|
||||
"/mnt/media/happy/storage2/tvshows1",
|
||||
"/mnt/media/happy4k/4ktv7",
|
||||
"/mnt/media/storage/Anime/Subs",
|
||||
"/mnt/media/storage/Anime2/Subs",
|
||||
"/library/Films",
|
||||
"/tank/television/Show",
|
||||
}
|
||||
|
||||
t.Run("multi-segment unique match", func(t *testing.T) {
|
||||
got := suggestRewrites([]string{"/mnt/happy/storage2/tvshows1"}, silo, nil)
|
||||
if len(got.Proposed) != 1 || got.Proposed[0].To != "/mnt/media/happy/storage2/tvshows1" || got.Proposed[0].MatchDepth != 2 {
|
||||
t.Fatalf("proposed=%+v", got.Proposed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single-segment unique match (different parents)", func(t *testing.T) {
|
||||
got := suggestRewrites([]string{"/mnt/kodama/storage2/4ktv7"}, silo, nil)
|
||||
if len(got.Proposed) != 1 || got.Proposed[0].To != "/mnt/media/happy4k/4ktv7" || got.Proposed[0].MatchDepth != 1 {
|
||||
t.Fatalf("proposed=%+v", got.Proposed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("longest-suffix disambiguation", func(t *testing.T) {
|
||||
got := suggestRewrites([]string{"/mnt/kodama/storage1/Anime/Subs"}, silo, nil)
|
||||
if len(got.Proposed) != 1 || got.Proposed[0].To != "/mnt/media/storage/Anime/Subs" || got.Proposed[0].MatchDepth != 2 {
|
||||
t.Fatalf("expected Anime/Subs (depth 2), got %+v", got.Proposed)
|
||||
}
|
||||
if len(got.Ambiguous) != 0 {
|
||||
t.Fatalf("should not be ambiguous: %+v", got.Ambiguous)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unmatched when no shared segment", func(t *testing.T) {
|
||||
got := suggestRewrites([]string{"/data/Movies"}, silo, nil)
|
||||
if len(got.Unmatched) != 1 || got.Unmatched[0] != "/data/Movies" || len(got.Proposed) != 0 {
|
||||
t.Fatalf("got=%+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaf match across unlike layouts", func(t *testing.T) {
|
||||
got := suggestRewrites([]string{"/srv/tv/Show"}, silo, nil)
|
||||
if len(got.Proposed) != 1 || got.Proposed[0].To != "/tank/television/Show" {
|
||||
t.Fatalf("got=%+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ambiguous tie", func(t *testing.T) {
|
||||
// a root whose only shared segment is "Subs" ties two folders
|
||||
got := suggestRewrites([]string{"/foo/bar/Subs"}, silo, nil)
|
||||
if len(got.Ambiguous) != 1 || len(got.Ambiguous[0].Candidates) != 2 {
|
||||
t.Fatalf("expected ambiguous with 2 candidates, got %+v", got.Ambiguous)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("covered by existing rule", func(t *testing.T) {
|
||||
existing := []PathRewrite{{From: "/mnt/happy", To: "/mnt/media/happy"}}
|
||||
got := suggestRewrites([]string{"/mnt/happy/storage2/tvshows1"}, silo, existing)
|
||||
if len(got.Covered) != 1 || len(got.Proposed) != 0 {
|
||||
t.Fatalf("expected covered, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("normalization: trailing slash, backslashes, dup slashes", func(t *testing.T) {
|
||||
got := suggestRewrites([]string{`\mnt\happy\\storage2\tvshows1\`}, silo, nil)
|
||||
if len(got.Proposed) != 1 || got.Proposed[0].From != "/mnt/happy/storage2/tvshows1" || got.Proposed[0].To != "/mnt/media/happy/storage2/tvshows1" {
|
||||
t.Fatalf("normalization failed: %+v", got.Proposed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommonSuffixLen(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b []string
|
||||
want int
|
||||
}{
|
||||
{[]string{"a", "b", "c"}, []string{"x", "b", "c"}, 2},
|
||||
{[]string{"4ktv7"}, []string{"happy4k", "4ktv7"}, 1},
|
||||
{[]string{"4ktv7"}, []string{"4ktv70"}, 0}, // full-segment, not substring
|
||||
{[]string{"a"}, []string{"b"}, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := commonSuffixLen(tc.a, tc.b); got != tc.want {
|
||||
t.Fatalf("commonSuffixLen(%v,%v)=%d want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: Run it — expect failure**
|
||||
|
||||
```bash
|
||||
go test ./internal/autoscan/ -run 'TestSuggestRewrites|TestCommonSuffixLen' -v
|
||||
```
|
||||
Expected: FAIL (`suggestRewrites` undefined).
|
||||
|
||||
- [ ] **Step 1.3: Implement** `internal/autoscan/suggest.go`:
|
||||
```go
|
||||
package autoscan
|
||||
|
||||
import "strings"
|
||||
|
||||
// RewriteSuggestions is the result of matching arr root folders to Silo folders.
|
||||
type RewriteSuggestions struct {
|
||||
Proposed []ProposedRewrite `json:"proposed"`
|
||||
Unmatched []string `json:"unmatched"`
|
||||
Ambiguous []AmbiguousRoot `json:"ambiguous"`
|
||||
Covered []string `json:"covered"`
|
||||
}
|
||||
|
||||
// ProposedRewrite is a suggested rewrite plus its confidence (shared trailing segments).
|
||||
type ProposedRewrite struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
MatchDepth int `json:"match_depth"`
|
||||
}
|
||||
|
||||
// AmbiguousRoot is an arr root that tied across multiple Silo folders.
|
||||
type AmbiguousRoot struct {
|
||||
Root string `json:"root"`
|
||||
Candidates []string `json:"candidates"`
|
||||
}
|
||||
|
||||
// normalizePath makes a path comparable: backslashes -> '/', collapse duplicate
|
||||
// slashes, strip a trailing slash (but keep a bare "/").
|
||||
func normalizePath(p string) string {
|
||||
p = normalizeSeparators(strings.TrimSpace(p))
|
||||
for strings.Contains(p, "//") {
|
||||
p = strings.ReplaceAll(p, "//", "/")
|
||||
}
|
||||
if len(p) > 1 {
|
||||
p = strings.TrimRight(p, "/")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func segments(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
// commonSuffixLen counts equal trailing segments (full-segment equality).
|
||||
func commonSuffixLen(a, b []string) int {
|
||||
i, j, n := len(a)-1, len(b)-1, 0
|
||||
for i >= 0 && j >= 0 && a[i] == b[j] {
|
||||
n++
|
||||
i--
|
||||
j--
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// coveredBy reports whether an existing rewrite already matches root (same
|
||||
// boundary rule as applyRewrites).
|
||||
func coveredBy(root string, existing []PathRewrite) bool {
|
||||
for _, rw := range existing {
|
||||
from := strings.TrimRight(strings.TrimSpace(rw.From), "/")
|
||||
if from == "" {
|
||||
continue
|
||||
}
|
||||
if root == from || strings.HasPrefix(root, from+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// suggestRewrites matches each arr root to the Silo folder sharing the most
|
||||
// trailing path segments (unique winner). Roots already handled by an existing
|
||||
// rewrite are reported as Covered; roots with no shared segment as Unmatched;
|
||||
// ties as Ambiguous. Pure: no I/O, no deployment constants.
|
||||
func suggestRewrites(arrRoots, siloFolderPaths []string, existing []PathRewrite) RewriteSuggestions {
|
||||
siloNorm := make([]string, 0, len(siloFolderPaths))
|
||||
siloSegs := make([][]string, 0, len(siloFolderPaths))
|
||||
for _, p := range siloFolderPaths {
|
||||
n := normalizePath(p)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
siloNorm = append(siloNorm, n)
|
||||
siloSegs = append(siloSegs, segments(n))
|
||||
}
|
||||
|
||||
var out RewriteSuggestions
|
||||
for _, raw := range arrRoots {
|
||||
root := normalizePath(raw)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
if coveredBy(root, existing) {
|
||||
out.Covered = append(out.Covered, root)
|
||||
continue
|
||||
}
|
||||
rootSegs := segments(root)
|
||||
best := 0
|
||||
var winners []string
|
||||
for i, segs := range siloSegs {
|
||||
n := commonSuffixLen(rootSegs, segs)
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if n > best {
|
||||
best, winners = n, []string{siloNorm[i]}
|
||||
} else if n == best {
|
||||
winners = append(winners, siloNorm[i])
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case best == 0:
|
||||
out.Unmatched = append(out.Unmatched, root)
|
||||
case len(winners) == 1:
|
||||
out.Proposed = append(out.Proposed, ProposedRewrite{From: root, To: winners[0], MatchDepth: best})
|
||||
default:
|
||||
out.Ambiguous = append(out.Ambiguous, AmbiguousRoot{Root: root, Candidates: winners})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.4: Run it — expect pass; commit**
|
||||
|
||||
```bash
|
||||
go test ./internal/autoscan/ -run 'TestSuggestRewrites|TestCommonSuffixLen' -v
|
||||
gofmt -l internal/autoscan/suggest.go internal/autoscan/suggest_test.go
|
||||
git add internal/autoscan/suggest.go internal/autoscan/suggest_test.go
|
||||
git commit -m "feat(autoscan): suffix-match rewrite suggester"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Service support
|
||||
|
||||
### Task 2: Repository.GetSource
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/autoscan/repository.go`
|
||||
|
||||
- [ ] **Step 2.1: Implement** `GetSource` (single-source join, includes disabled). Add after `ListEnabledSources`:
|
||||
```go
|
||||
// GetSource returns one instance's autoscan state joined with its
|
||||
// request_integrations row (kind/base_url/api_key_ref), regardless of enabled.
|
||||
func (r *Repository) GetSource(ctx context.Context, integrationID string) (*Source, error) {
|
||||
row := r.pool.QueryRow(ctx, sourceSelect+` WHERE ri.id = $1`, integrationID)
|
||||
src, err := scanSource(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrIntegrationNotFound, integrationID)
|
||||
}
|
||||
return nil, fmt.Errorf("get autoscan source: %w", err)
|
||||
}
|
||||
return &src, nil
|
||||
}
|
||||
```
|
||||
(`errors`, `pgx`, `fmt`, `ErrIntegrationNotFound`, and `sourceSelect`/`scanSource` already exist in the file.)
|
||||
|
||||
- [ ] **Step 2.2: Build + commit**
|
||||
|
||||
```bash
|
||||
go build ./internal/autoscan/... && go test ./internal/autoscan/...
|
||||
git add internal/autoscan/repository.go
|
||||
git commit -m "feat(autoscan): GetSource single-source lookup"
|
||||
```
|
||||
|
||||
### Task 3: RootFolderClient + FolderLister adapters
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/autoscan/suggest_deps.go`
|
||||
|
||||
- [ ] **Step 3.1: Implement the two adapters + interfaces**
|
||||
```go
|
||||
package autoscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/arrclient"
|
||||
)
|
||||
|
||||
// RootFolderClient lists a Radarr/Sonarr instance's configured root folder paths.
|
||||
type RootFolderClient interface {
|
||||
RootFolders(ctx context.Context, baseURL, apiKey string) ([]string, error)
|
||||
}
|
||||
|
||||
// FolderLister lists every Silo media-folder path.
|
||||
type FolderLister interface {
|
||||
ListFolderPaths(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
type arrRootFolderClient struct{ httpClient *http.Client }
|
||||
|
||||
// NewArrRootFolderClient returns a RootFolderClient backed by the shared arrclient.
|
||||
func NewArrRootFolderClient(httpClient *http.Client) RootFolderClient {
|
||||
return &arrRootFolderClient{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (c *arrRootFolderClient) RootFolders(ctx context.Context, baseURL, apiKey string) ([]string, error) {
|
||||
client := arrclient.New(baseURL, apiKey, c.httpClient)
|
||||
folders, err := arrclient.ListRootFolders(ctx, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths := make([]string, 0, len(folders))
|
||||
for _, f := range folders {
|
||||
if f.Path != "" {
|
||||
paths = append(paths, f.Path)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
type catalogFolderLister struct{ repo *catalog.FolderRepository }
|
||||
|
||||
// NewCatalogFolderLister adapts catalog.FolderRepository to FolderLister.
|
||||
func NewCatalogFolderLister(repo *catalog.FolderRepository) FolderLister {
|
||||
return &catalogFolderLister{repo: repo}
|
||||
}
|
||||
|
||||
func (l *catalogFolderLister) ListFolderPaths(ctx context.Context) ([]string, error) {
|
||||
folders, err := l.repo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var paths []string
|
||||
for _, f := range folders {
|
||||
paths = append(paths, f.Paths...)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
```
|
||||
Note: `autoscan` already depends on `catalog` (via `scantrigger`) and on `requests/arrclient` (via `history.go`), so these imports add no new module-level coupling and no cycle (`catalog` does not import `autoscan`).
|
||||
|
||||
- [ ] **Step 3.2: Build + commit**
|
||||
|
||||
```bash
|
||||
go build ./internal/autoscan/...
|
||||
git add internal/autoscan/suggest_deps.go
|
||||
git commit -m "feat(autoscan): arr root-folder client + Silo folder lister"
|
||||
```
|
||||
|
||||
### Task 4: Service.SuggestRewrites
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/autoscan/service.go`
|
||||
- Modify: `internal/autoscan/service_test.go`
|
||||
|
||||
- [ ] **Step 4.1: Write the failing test** (append to `service_test.go`):
|
||||
```go
|
||||
type fakeRootFolders struct {
|
||||
paths []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeRootFolders) RootFolders(context.Context, string, string) ([]string, error) {
|
||||
return f.paths, f.err
|
||||
}
|
||||
|
||||
type fakeFolderLister struct{ paths []string }
|
||||
|
||||
func (f fakeFolderLister) ListFolderPaths(context.Context) ([]string, error) { return f.paths, nil }
|
||||
|
||||
type sourceGetterStore struct {
|
||||
fakeStore
|
||||
src *Source
|
||||
}
|
||||
|
||||
func (s *sourceGetterStore) GetSource(context.Context, string) (*Source, error) { return s.src, nil }
|
||||
|
||||
func TestSuggestRewritesService(t *testing.T) {
|
||||
store := &sourceGetterStore{src: &Source{IntegrationID: "i1", Kind: "sonarr", BaseURL: "http://x", APIKeyRef: "k"}}
|
||||
svc := NewService(store, &fakeHistory{}, fakeResolver{}, &recordingQueuer{}, allowSuppressor{}, nil)
|
||||
svc.SetRewriteResolvers(
|
||||
fakeRootFolders{paths: []string{"/mnt/happy/storage2/tvshows1", "/data/Movies"}},
|
||||
fakeFolderLister{paths: []string{"/mnt/media/happy/storage2/tvshows1"}},
|
||||
)
|
||||
got, err := svc.SuggestRewrites(context.Background(), "i1")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestRewrites: %v", err)
|
||||
}
|
||||
if len(got.Proposed) != 1 || got.Proposed[0].To != "/mnt/media/happy/storage2/tvshows1" {
|
||||
t.Fatalf("proposed=%+v", got.Proposed)
|
||||
}
|
||||
if len(got.Unmatched) != 1 || got.Unmatched[0] != "/data/Movies" {
|
||||
t.Fatalf("unmatched=%+v", got.Unmatched)
|
||||
}
|
||||
}
|
||||
```
|
||||
> The `Store` interface needs a `GetSource` method so the service can call it. Add `GetSource(ctx context.Context, integrationID string) (*Source, error)` to the `Store` interface in `service.go`. `*Repository` already implements it (Task 2). The test's `sourceGetterStore` embeds the existing `fakeStore` and adds `GetSource`. If `fakeStore` does not satisfy the widened interface on its own, that's fine — only `sourceGetterStore` is used here; but ensure the package still compiles (other tests construct `fakeStore` directly and pass it where `Store` is required — add a `GetSource` method to `fakeStore` returning `(nil, nil)` so it still satisfies `Store`).
|
||||
|
||||
- [ ] **Step 4.2: Run it — expect failure**
|
||||
|
||||
```bash
|
||||
go test ./internal/autoscan/ -run TestSuggestRewritesService -v
|
||||
```
|
||||
Expected: FAIL (`SetRewriteResolvers`/`SuggestRewrites` undefined).
|
||||
|
||||
- [ ] **Step 4.3: Implement** in `service.go`. Add fields + setter + method, and add `GetSource` to `Store`:
|
||||
```go
|
||||
// add to the Store interface:
|
||||
// GetSource(ctx context.Context, integrationID string) (*Source, error)
|
||||
|
||||
// add fields to Service struct:
|
||||
// rootFolders RootFolderClient
|
||||
// folders FolderLister
|
||||
|
||||
// SetRewriteResolvers wires the deps used by SuggestRewrites (optional; only the
|
||||
// admin-facing service needs them).
|
||||
func (s *Service) SetRewriteResolvers(rootFolders RootFolderClient, folders FolderLister) {
|
||||
s.rootFolders = rootFolders
|
||||
s.folders = folders
|
||||
}
|
||||
|
||||
// SuggestRewrites matches an instance's arr root folders to Silo media folders.
|
||||
func (s *Service) SuggestRewrites(ctx context.Context, integrationID string) (RewriteSuggestions, error) {
|
||||
if s.rootFolders == nil || s.folders == nil {
|
||||
return RewriteSuggestions{}, fmt.Errorf("autoscan: rewrite suggestion not configured")
|
||||
}
|
||||
src, err := s.store.GetSource(ctx, integrationID)
|
||||
if err != nil {
|
||||
return RewriteSuggestions{}, err
|
||||
}
|
||||
apiKey := src.APIKeyRef
|
||||
if s.secrets != nil && apiKey != "" {
|
||||
if resolved, rerr := s.secrets.Get(ctx, apiKey); rerr == nil && resolved != "" {
|
||||
apiKey = resolved
|
||||
}
|
||||
}
|
||||
arrRoots, err := s.rootFolders.RootFolders(ctx, src.BaseURL, apiKey)
|
||||
if err != nil {
|
||||
return RewriteSuggestions{}, fmt.Errorf("autoscan: list arr root folders: %w", err)
|
||||
}
|
||||
siloPaths, err := s.folders.ListFolderPaths(ctx)
|
||||
if err != nil {
|
||||
return RewriteSuggestions{}, fmt.Errorf("autoscan: list silo folders: %w", err)
|
||||
}
|
||||
return suggestRewrites(arrRoots, siloPaths, src.PathRewrites), nil
|
||||
}
|
||||
```
|
||||
Add `GetSource` to `fakeStore` in `service_test.go` (`func (f *fakeStore) GetSource(context.Context, string) (*Source, error) { return nil, nil }`).
|
||||
|
||||
- [ ] **Step 4.4: Run tests — expect pass; commit**
|
||||
|
||||
```bash
|
||||
go test ./internal/autoscan/...
|
||||
gofmt -l internal/autoscan/service.go internal/autoscan/service_test.go
|
||||
git add internal/autoscan/service.go internal/autoscan/service_test.go
|
||||
git commit -m "feat(autoscan): Service.SuggestRewrites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — API endpoint
|
||||
|
||||
### Task 5: Handler + route
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/autoscan.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Modify: `internal/api/handlers/autoscan_test.go`
|
||||
|
||||
- [ ] **Step 5.1: Extend the handler's service interface + add the handler method**
|
||||
|
||||
In `autoscan.go`, add `SuggestRewrites` to the `autoscanTriggerer` interface:
|
||||
```go
|
||||
type autoscanTriggerer interface {
|
||||
PollOnce(ctx context.Context) error
|
||||
SuggestRewrites(ctx context.Context, integrationID string) (autoscan.RewriteSuggestions, error)
|
||||
}
|
||||
```
|
||||
Add the handler:
|
||||
```go
|
||||
func (h *AutoscanHandler) HandleRewriteSuggestions(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
suggestions, err := h.svc.SuggestRewrites(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, autoscan.ErrIntegrationNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Autoscan source not found")
|
||||
return
|
||||
}
|
||||
// arr unreachable / bad key / folder list failure
|
||||
writeError(w, http.StatusBadGateway, "arr_unreachable", "Could not load root folders from the arr instance")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, suggestions)
|
||||
}
|
||||
```
|
||||
(`errors`, `autoscan`, `chi`, `strings`, `writeError`, `writeJSON` already imported.)
|
||||
|
||||
- [ ] **Step 5.2: Mount the route** in `router.go`, inside the autoscan route block:
|
||||
```go
|
||||
r.Get("/autoscan/sources/{id}/rewrite-suggestions", autoscanHandler.HandleRewriteSuggestions)
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3: Wire the service deps** in `router.go` where `autoscanSvc` is built (after `autoscan.NewService(...)`):
|
||||
```go
|
||||
autoscanSvc.SetRewriteResolvers(
|
||||
autoscan.NewArrRootFolderClient(nil),
|
||||
autoscan.NewCatalogFolderLister(deps.FolderRepo),
|
||||
)
|
||||
```
|
||||
(This is inside the existing `if deps.FolderRepo != nil && deps.LibraryScanQueue != nil` block, so `deps.FolderRepo` is non-nil here.)
|
||||
|
||||
- [ ] **Step 5.4: Update the handler test fake**
|
||||
|
||||
In `autoscan_test.go`, the fake satisfying `autoscanTriggerer` (e.g. `fakeAutoscanTriggerer`) gains:
|
||||
```go
|
||||
func (f *fakeAutoscanTriggerer) SuggestRewrites(context.Context, string) (autoscan.RewriteSuggestions, error) {
|
||||
return autoscan.RewriteSuggestions{}, nil
|
||||
}
|
||||
```
|
||||
Add a test asserting `HandleRewriteSuggestions` returns 200 with JSON for a known id (mirror the existing handler tests).
|
||||
|
||||
- [ ] **Step 5.5: Verify (limited) + commit**
|
||||
|
||||
```bash
|
||||
# ensure the Go toolchain is on PATH
|
||||
go vet ./internal/autoscan/...
|
||||
gofmt -l internal/api/handlers/autoscan.go internal/api/router.go internal/api/handlers/autoscan_test.go
|
||||
# internal/api cannot compile here (libvips) — Docker verifies later.
|
||||
git add internal/api/handlers/autoscan.go internal/api/router.go internal/api/handlers/autoscan_test.go
|
||||
git commit -m "feat(autoscan): rewrite-suggestions endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Frontend
|
||||
|
||||
### Task 6: Types + hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/api/types.ts`
|
||||
- Modify: `web/src/hooks/queries/useAutoscan.ts`
|
||||
|
||||
- [ ] **Step 6.1: Types** — add to `types.ts`:
|
||||
```ts
|
||||
export interface AutoscanProposedRewrite {
|
||||
from: string;
|
||||
to: string;
|
||||
match_depth: number;
|
||||
}
|
||||
export interface AutoscanAmbiguousRoot {
|
||||
root: string;
|
||||
candidates: string[];
|
||||
}
|
||||
export interface AutoscanRewriteSuggestions {
|
||||
proposed: AutoscanProposedRewrite[];
|
||||
unmatched: string[];
|
||||
ambiguous: AutoscanAmbiguousRoot[];
|
||||
covered: string[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2: Hook** — add to `useAutoscan.ts` a mutation that fetches suggestions for an id:
|
||||
```ts
|
||||
export function useAutoscanRewriteSuggestions() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api<AutoscanRewriteSuggestions>(`/admin/autoscan/sources/${id}/rewrite-suggestions`),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "Could not sync rewrites from the arr instance"),
|
||||
});
|
||||
}
|
||||
```
|
||||
Match the import/`api`/`toast`/`useMutation` conventions already in the file (it imports `api` and `toast` already; reuse them; import the new type).
|
||||
|
||||
- [ ] **Step 6.3: Lint + commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b && pnpm run lint && pnpm exec prettier --check src/api/types.ts src/hooks/queries/useAutoscan.ts
|
||||
git add web/src/api/types.ts web/src/hooks/queries/useAutoscan.ts
|
||||
git commit -m "feat(web): autoscan rewrite-suggestions types and hook"
|
||||
```
|
||||
|
||||
### Task 7: Sync button + preview in AutoscanSourceEditor
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/AdminRequests.tsx`
|
||||
|
||||
- [ ] **Step 7.1: Add the button + preview**
|
||||
|
||||
In `AutoscanSourceEditor`:
|
||||
- Call `const suggest = useAutoscanRewriteSuggestions();` and add local state `const [preview, setPreview] = useState<AutoscanRewriteSuggestions | null>(null);` and `const [selected, setSelected] = useState<Set<string>>(new Set());`.
|
||||
- Add a **"Sync from arr"** `Button` beside Save: `onClick={async () => { const s = await suggest.mutateAsync(source.integration_id); setPreview(s); setSelected(new Set(s.proposed.map((p) => p.from))); }}` (disabled while `suggest.isPending`).
|
||||
- When `preview` is set, render a panel (reuse the file's `Dialog` or an inline bordered panel) with three sections:
|
||||
- **Proposed:** each `proposed[]` row — a checkbox bound to `selected.has(p.from)` (toggle updates the set), `from → to`, and a `Badge` showing confidence: `match_depth >= 2 ? "${match_depth} segments" : "1 segment — weak"` (destructive/secondary variant for weak).
|
||||
- **Unmatched:** muted list of `unmatched[]` ("no Silo match — add manually if needed").
|
||||
- **Ambiguous:** list of `ambiguous[]` (`root` + its `candidates` joined) — info only.
|
||||
- An **"Add selected to rewrites"** button that merges the checked proposals into the editor's existing rewrites state, deduped by `from` (skip any whose `from` already exists), then `setPreview(null)`. Do NOT auto-save — the admin then clicks the existing **Save**.
|
||||
- Keep the existing manual path-rewrite editor unchanged; sync only appends rows to it.
|
||||
|
||||
Reuse existing `Button`/`Badge`/`Field`/`Dialog` primitives and the editor's existing rewrites-state setter.
|
||||
|
||||
- [ ] **Step 7.2: Lint + commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b && pnpm run lint && pnpm exec prettier --check src/pages/AdminRequests.tsx
|
||||
git add web/src/pages/AdminRequests.tsx
|
||||
git commit -m "feat(web): autoscan sync-rewrites preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Verification
|
||||
|
||||
### Task 8: Full verification
|
||||
|
||||
- [ ] **Step 8.1: Go**
|
||||
|
||||
```bash
|
||||
# ensure the Go toolchain is on PATH
|
||||
go test ./internal/autoscan/... 2>&1 | tail
|
||||
go vet ./internal/autoscan/...
|
||||
gofmt -l internal/autoscan internal/api/handlers/autoscan.go internal/api/router.go
|
||||
```
|
||||
Expected: all pass; vet/gofmt clean.
|
||||
|
||||
- [ ] **Step 8.2: Frontend**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b && pnpm run lint && pnpm run format:check
|
||||
```
|
||||
|
||||
- [ ] **Step 8.3: Docker build (closes the libvips gap)**
|
||||
|
||||
```bash
|
||||
docker build --build-arg BUILD_REVISION=$(git rev-parse HEAD) --build-arg BUILD_DIRTY=false -t silo-server:autoscan .
|
||||
```
|
||||
Expected: exit 0 (handlers + router + frontend compile).
|
||||
|
||||
- [ ] **Step 8.4: Smoke (optional)**
|
||||
|
||||
On a deploy with arr instances configured: open an instance in the Autoscan tab, click **Sync from arr**, confirm the preview shows proposed (with confidence) + unmatched + ambiguous, "Add selected" appends deduped rows, and Save persists them.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (resolved)
|
||||
|
||||
- **Spec §1 matcher** → Task 1 (incl. normalization, covered-first, suffix depth, unlike-deployment fixtures). **§2 endpoint/service** → Tasks 2–5 (`GetSource`, `RootFolderClient`/`FolderLister`, `SuggestRewrites`, endpoint, 404/502 mapping). **§3 frontend** → Tasks 6–7. **§4 testing** → Tasks 1,4,5 + Task 8.
|
||||
- **Manual-preserving**: sync only *appends* deduped rows to the editor; the admin saves via the existing `PUT` (Task 7). Never auto-writes.
|
||||
- **Generic**: `suggestRewrites` takes plain string slices, no deployment constants; tests include `/data/Movies` and `/srv/tv` fixtures (Task 1).
|
||||
- **Optional deps**: `SetRewriteResolvers` keeps `main.go`'s poll service untouched; `SuggestRewrites` errors if unset (Task 4).
|
||||
- **Type consistency**: `RewriteSuggestions`/`ProposedRewrite`/`AmbiguousRoot` (Go) ↔ `AutoscanRewriteSuggestions`/`AutoscanProposedRewrite`/`AutoscanAmbiguousRoot` (TS); `match_depth` JSON tag matches.
|
||||
@@ -1,375 +0,0 @@
|
||||
# `scan_source.v1` SDK Capability — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a new, additive `scan_source.v1` plugin capability to `silo-plugin-sdk` so the Silo host can pull changed paths from a scan-source plugin on a timer.
|
||||
|
||||
**Architecture:** Mirror the existing `scheduled_task.v1` capability exactly — a single-RPC, request/response gRPC service defined in a `.proto`, generated into `pkg/pluginproto`, then registered in three Go helper sites (`capability.KnownTypes`, the `runtime.CapabilityServers` struct, and the `runtime.GRPCPlugin` server registration). Nothing existing is modified in shape; only new symbols are added. Tag a new SDK release so the host and the arr plugin can build against it.
|
||||
|
||||
**Tech Stack:** Go, Protocol Buffers (proto3), `buf` codegen (`buf.gen.yaml`), `google.golang.org/protobuf`, `google.golang.org/grpc`, the `hashicorp/go-plugin` runtime the SDK wraps.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites (read before starting)
|
||||
|
||||
This plan targets the **`silo-plugin-sdk`** repository (Go module `github.com/Silo-Server/silo-plugin-sdk`), which is **not currently checked out** in this workspace — only `silo-server` and `silo-plugin-tmdb` are. Before executing:
|
||||
|
||||
1. Clone `silo-plugin-sdk` as a writable working copy beside the other repos. Commands below assume **the silo-plugin-sdk repository root is the cwd**.
|
||||
2. Ensure the proto toolchain is available: the `make proto` target auto-installs `buf`, `protoc-gen-go@v1.36.11`, and `protoc-gen-go-grpc@v1.6.1` into `$(GO_BIN)`, but it requires the **`protoc`** binary to already be on `PATH` (it errors `protoc is required` otherwise). Install `protoc` if absent.
|
||||
3. This SDK is the **long pole**: the silo-server host plan and the arr-plugin plan both depend on the version tagged at the end of this plan (Task 6). Do not start those until this is tagged.
|
||||
|
||||
**Pattern reference (current `scheduled_task.v1`, the template this mirrors):**
|
||||
- Proto: `proto/silo/plugin/v1/scheduled_task.proto`
|
||||
- Generated: `pkg/pluginproto/silo/plugin/v1/scheduled_task.pb.go` + `scheduled_task_grpc.pb.go`
|
||||
- Capability const + allowlist: `pkg/pluginsdk/capability/capability.go`
|
||||
- Runtime wiring: `pkg/pluginsdk/runtime/runtime.go` (struct field, `Client` accessor, `GRPCServer` registration)
|
||||
- Manifest acceptance test pattern: `pkg/pluginsdk/manifest/manifest_test.go`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
|---|---|---|
|
||||
| `proto/silo/plugin/v1/scan_source.proto` | The `ScanSource` service + `PollChanges` request/response messages | Create |
|
||||
| `pkg/pluginproto/silo/plugin/v1/scan_source.pb.go` | Generated message types | Create (codegen) |
|
||||
| `pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go` | Generated client/server stubs | Create (codegen) |
|
||||
| `pkg/pluginsdk/capability/capability.go` | `ScanSource` const + add to `KnownTypes` allowlist | Modify |
|
||||
| `pkg/pluginsdk/capability/capability_test.go` | Assert `ScanSource` is a known type | Create or Modify |
|
||||
| `pkg/pluginsdk/runtime/runtime.go` | `CapabilityServers.ScanSource` field, `Client.ScanSource()` accessor, `GRPCServer` registration | Modify |
|
||||
| `pkg/pluginsdk/runtime/runtime_test.go` | Assert a `ScanSource` server registers without error | Create or Modify |
|
||||
| `pkg/pluginsdk/manifest/manifest_test.go` | Assert a manifest declaring `scan_source.v1` loads | Modify |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Define and generate the `scan_source.v1` proto
|
||||
|
||||
**Files:**
|
||||
- Create: `proto/silo/plugin/v1/scan_source.proto`
|
||||
- Create (via codegen): `pkg/pluginproto/silo/plugin/v1/scan_source.pb.go`, `pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go`
|
||||
|
||||
- [ ] **Step 1: Write the proto**
|
||||
|
||||
Create `proto/silo/plugin/v1/scan_source.proto` (the `go_package` option must match the sibling protos exactly so generated code lands in package `pluginv1`):
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
|
||||
package silo.plugin.v1;
|
||||
|
||||
option go_package = "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1;pluginv1";
|
||||
|
||||
// ScanSource lets the host pull changed filesystem paths from a provider
|
||||
// (e.g. Sonarr/Radarr, inotify, Ceph). Pull only: the host owns the timer and
|
||||
// calls PollChanges; the plugin never initiates.
|
||||
service ScanSource {
|
||||
rpc PollChanges(PollChangesRequest) returns (PollChangesResponse);
|
||||
}
|
||||
|
||||
message PollChangesRequest {
|
||||
// Which configured scan_source capability instance is being polled.
|
||||
string capability_id = 1;
|
||||
// Opaque continuation token from the previous PollChanges. Empty on first
|
||||
// run, which the provider treats as "start from now" (do not replay history).
|
||||
string marker = 2;
|
||||
}
|
||||
|
||||
message PollChangesResponse {
|
||||
// Absolute paths already translated into Silo's filesystem namespace (the
|
||||
// plugin has applied its own path rewrites). Files or directories.
|
||||
repeated string changed_paths = 1;
|
||||
// Opaque continuation token. The host stores it verbatim and echoes it back
|
||||
// on the next PollChanges; the host never parses it.
|
||||
string next_marker = 2;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate the Go code**
|
||||
|
||||
Run: `make proto`
|
||||
Expected: exits 0; creates `pkg/pluginproto/silo/plugin/v1/scan_source.pb.go` and `scan_source_grpc.pb.go`. (If it prints `protoc is required`, install `protoc` and re-run.)
|
||||
|
||||
- [ ] **Step 3: Verify the generated symbols exist and compile**
|
||||
|
||||
Run: `go build ./... && grep -l "func NewScanSourceClient" pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go && grep -l "RegisterScanSourceServer" pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go`
|
||||
Expected: build succeeds; both `grep`s print the filename (confirming `ScanSourceClient`, `ScanSourceServer`, `NewScanSourceClient`, `RegisterScanSourceServer` were generated — `require_unimplemented_servers=false` in `buf.gen.yaml` means no forced `UnimplementedScanSourceServer` embedding).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add proto/silo/plugin/v1/scan_source.proto pkg/pluginproto/silo/plugin/v1/scan_source.pb.go pkg/pluginproto/silo/plugin/v1/scan_source_grpc.pb.go
|
||||
git commit -m "feat(proto): add scan_source.v1 capability service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Register the capability type in the allowlist
|
||||
|
||||
The manifest loader rejects capability types not in `capability.KnownTypes` (see `TestLoadRejectsUnknownCapabilityType`). A manifest declaring `scan_source.v1` cannot load until the type is registered here.
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/pluginsdk/capability/capability.go`
|
||||
- Create or Modify: `pkg/pluginsdk/capability/capability_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `pkg/pluginsdk/capability/capability_test.go` (create the file with this `package capability` header if it does not exist):
|
||||
|
||||
```go
|
||||
package capability
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScanSourceIsKnownType(t *testing.T) {
|
||||
if ScanSource != "scan_source.v1" {
|
||||
t.Fatalf("ScanSource const = %q, want %q", ScanSource, "scan_source.v1")
|
||||
}
|
||||
found := false
|
||||
for _, k := range KnownTypes {
|
||||
if k == ScanSource {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("ScanSource (%q) missing from KnownTypes %v", ScanSource, KnownTypes)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./pkg/pluginsdk/capability/ -run TestScanSourceIsKnownType -v`
|
||||
Expected: FAIL — `undefined: ScanSource` (the const does not exist yet).
|
||||
|
||||
- [ ] **Step 3: Add the const and allowlist entry**
|
||||
|
||||
In `pkg/pluginsdk/capability/capability.go`, add the const inside the existing `const (...)` block (after `EbookBackend`):
|
||||
|
||||
```go
|
||||
EbookBackend = "ebook_backend.v1"
|
||||
ScanSource = "scan_source.v1"
|
||||
```
|
||||
|
||||
and add it to the `KnownTypes` slice (append `ScanSource` to the existing list):
|
||||
|
||||
```go
|
||||
ScanSource,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./pkg/pluginsdk/capability/ -run TestScanSourceIsKnownType -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/pluginsdk/capability/capability.go pkg/pluginsdk/capability/capability_test.go
|
||||
git commit -m "feat(capability): register scan_source.v1 as a known capability type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire the capability into the runtime
|
||||
|
||||
Add the `ScanSource` server to `CapabilityServers`, a `Client.ScanSource()` accessor, and the conditional registration in `GRPCServer` — mirroring `ScheduledTask` in `pkg/pluginsdk/runtime/runtime.go`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/pluginsdk/runtime/runtime.go`
|
||||
- Create or Modify: `pkg/pluginsdk/runtime/runtime_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `pkg/pluginsdk/runtime/runtime_test.go` (create with the `package runtime` header if absent). This test constructs a real `*grpc.Server`, supplies a minimal `ScanSource` server, and asserts `GRPCServer` registers it without error:
|
||||
|
||||
```go
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type stubScanSource struct{}
|
||||
|
||||
func (stubScanSource) PollChanges(context.Context, *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) {
|
||||
return &pluginv1.PollChangesResponse{}, nil
|
||||
}
|
||||
|
||||
func TestGRPCServerRegistersScanSource(t *testing.T) {
|
||||
p := &GRPCPlugin{Servers: CapabilityServers{
|
||||
Runtime: stubRuntime{}, // existing stub used by sibling runtime tests
|
||||
ScanSource: stubScanSource{},
|
||||
}}
|
||||
srv := grpc.NewServer()
|
||||
if err := p.GRPCServer(nil, srv); err != nil {
|
||||
t.Fatalf("GRPCServer with ScanSource = %v, want nil", err)
|
||||
}
|
||||
if _, ok := srv.GetServiceInfo()["silo.plugin.v1.ScanSource"]; !ok {
|
||||
t.Fatalf("ScanSource service not registered; got %v", srv.GetServiceInfo())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `GRPCServer` requires `Servers.Runtime` to be non-nil (it errors otherwise). Reuse the existing runtime stub the sibling tests use. If no such stub exists in `runtime_test.go`, add a minimal one:
|
||||
|
||||
```go
|
||||
type stubRuntime struct{ pluginv1.RuntimeServer }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./pkg/pluginsdk/runtime/ -run TestGRPCServerRegistersScanSource -v`
|
||||
Expected: FAIL — `unknown field 'ScanSource' in struct literal of type CapabilityServers` (the field does not exist yet).
|
||||
|
||||
- [ ] **Step 3: Add the struct field**
|
||||
|
||||
In `pkg/pluginsdk/runtime/runtime.go`, add to the `CapabilityServers` struct (after the `ScheduledTask` field):
|
||||
|
||||
```go
|
||||
ScheduledTask pluginv1.ScheduledTaskServer
|
||||
ScanSource pluginv1.ScanSourceServer
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `Client` accessor**
|
||||
|
||||
After the existing `func (c *Client) ScheduledTask() ...` method, add:
|
||||
|
||||
```go
|
||||
func (c *Client) ScanSource() pluginv1.ScanSourceClient {
|
||||
return pluginv1.NewScanSourceClient(c.conn)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the conditional registration in `GRPCServer`**
|
||||
|
||||
In `func (p *GRPCPlugin) GRPCServer(...)`, after the `ScheduledTask` registration block, add:
|
||||
|
||||
```go
|
||||
if p.Servers.ScheduledTask != nil {
|
||||
pluginv1.RegisterScheduledTaskServer(server, p.Servers.ScheduledTask)
|
||||
}
|
||||
if p.Servers.ScanSource != nil {
|
||||
pluginv1.RegisterScanSourceServer(server, p.Servers.ScanSource)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./pkg/pluginsdk/runtime/ -run TestGRPCServerRegistersScanSource -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/pluginsdk/runtime/runtime.go pkg/pluginsdk/runtime/runtime_test.go
|
||||
git commit -m "feat(runtime): serve and dial scan_source.v1 capability"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Prove a `scan_source.v1` manifest loads
|
||||
|
||||
Guards the end-to-end path: a plugin manifest declaring the new capability must pass the loader (it would not before Task 2's allowlist entry).
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/pluginsdk/manifest/manifest_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
The test file is package `manifest_test` (external) and loads from a JSON literal via `manifest.Load([]byte)`. Append this test, mirroring `TestLoadAcceptsRequestRouterCapability` exactly with the type swapped to `scan_source.v1`:
|
||||
|
||||
```go
|
||||
func TestLoadAcceptsScanSourceCapability(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"plugin_id": "silo.example",
|
||||
"version": "1.0.0",
|
||||
"silo_api_version": "v1",
|
||||
"capabilities": [
|
||||
{"type": "scan_source.v1", "id": "arr", "display_name": "X", "description": "Y"}
|
||||
]
|
||||
}`)
|
||||
m, err := manifest.Load(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned unexpected error: %v", err)
|
||||
}
|
||||
if len(m.GetCapabilities()) != 1 {
|
||||
t.Fatalf("expected 1 capability, got %d", len(m.GetCapabilities()))
|
||||
}
|
||||
if got := m.GetCapabilities()[0].GetType(); got != "scan_source.v1" {
|
||||
t.Fatalf("capability type = %q, want scan_source.v1", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails (before this task's dependency was in place) / passes now**
|
||||
|
||||
Run: `go test ./pkg/pluginsdk/manifest/ -run TestLoadAcceptsScanSourceCapability -v`
|
||||
Expected: PASS (Task 2 already added `scan_source.v1` to `KnownTypes`). If it FAILS with an unknown-capability-type error, Task 2 was not completed correctly — fix that first.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/pluginsdk/manifest/manifest_test.go
|
||||
git commit -m "test(manifest): accept scan_source.v1 capability in manifests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Full build + test sweep
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Build everything**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: exits 0.
|
||||
|
||||
- [ ] **Step 2: Run the full SDK test suite**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: all packages `ok`. Confirms the additive change broke no existing capability, manifest, or runtime test.
|
||||
|
||||
- [ ] **Step 3: Vet + format**
|
||||
|
||||
Run: `go vet ./... && gofmt -l pkg proto 2>/dev/null; test -z "$(gofmt -l pkg)"`
|
||||
Expected: vet clean; `gofmt -l pkg` prints nothing.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Tag the release
|
||||
|
||||
Downstream repos (silo-server host, arr plugin) consume the SDK as a tagged module version.
|
||||
|
||||
**Files:** none (release action)
|
||||
|
||||
- [ ] **Step 1: Confirm the version to tag**
|
||||
|
||||
Check the latest tag: `git tag --list 'v*' | sort -V | tail -3`. The current consumed version is `v0.4.0`; this additive feature warrants a minor bump to **`v0.5.0`** (no breaking changes).
|
||||
|
||||
- [ ] **Step 2: Tag and push**
|
||||
|
||||
```bash
|
||||
git tag v0.5.0
|
||||
git push origin v0.5.0
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Record the contract for downstream plans**
|
||||
|
||||
Note in the host and arr-plugin plans that they must require `github.com/Silo-Server/silo-plugin-sdk v0.5.0` and use:
|
||||
- `capability.ScanSource` (`"scan_source.v1"`)
|
||||
- `runtime.CapabilityServers{ ScanSource: <impl> }` (plugin side)
|
||||
- `client.ScanSource()` → `pluginv1.ScanSourceClient.PollChanges(ctx, &pluginv1.PollChangesRequest{CapabilityId, Marker})` (host side)
|
||||
- response fields `PollChangesResponse.ChangedPaths []string`, `PollChangesResponse.NextMarker string`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review notes
|
||||
|
||||
- **Spec coverage:** Implements spec §5 (the `scan_source.v1` contract: `PollChanges`, opaque marker, Silo-native paths) and §11 (additive-only; registered in `KnownTypes`; no existing capability modified). Spec §12 build-order step 1 (tag the SDK) is Task 6.
|
||||
- **Out of scope here:** the host engine (spec §7), connections (§8), the Autoscan category (§9), and the arr plugin (§10) are separate per-repo plans built against the `v0.5.0` tag produced by Task 6.
|
||||
- **Marker is opaque end-to-end:** the proto carries `marker`/`next_marker` as plain strings with no server-side interpretation, satisfying the "any provider's bookmark" requirement.
|
||||
@@ -1,265 +0,0 @@
|
||||
# Sonarr/Radarr Scan-Source Plugin Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a new installable Silo plugin that implements `scan_source.v1` for Sonarr/Radarr: when the host pulls, it polls arr `/history`, extracts imported + renamed paths, applies its own path rewrites, and returns Silo-native paths + a marker.
|
||||
|
||||
**Architecture:** A standalone Go module structured like `silo-plugin-tmdb` — a `manifest.json` declaring `scan_source.v1`, a `main.go` serving `runtime.CapabilityServers{Runtime, ScanSource}` via `runtime.Serve`, and the arr-specific logic ported from the closed silo-server PR #43 (`internal/autoscan/history.go`, `rewrite.go`, `suggest.go`).
|
||||
|
||||
**Tech Stack:** Go, `github.com/Silo-Server/silo-plugin-sdk` (`v0.5.0`, the `scan_source.v1` capability), `hashicorp/go-plugin` runtime (via the SDK), arr HTTP API (`X-Api-Key`).
|
||||
|
||||
Commands assume the **new plugin repository root** is the cwd (the `silo-plugin-autoscan-arr` checkout, a sibling of this repo like the `silo-plugin-sdk` checkout).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **SDK `v0.5.0`.** Needs `scan_source.v1` from `silo-plugin-sdk` PR #2. Until tagged, depend on the local checkout via replace:
|
||||
```bash
|
||||
go mod edit -replace github.com/Silo-Server/silo-plugin-sdk=../silo-plugin-sdk
|
||||
```
|
||||
This points at the `silo-plugin-sdk` checkout (a sibling of this repo). Task 8 finalizes to `v0.5.0`.
|
||||
2. **Template:** the `silo-plugin-tmdb` checkout (a sibling of this repo) — copy its `main.go` runtime scaffold, `Makefile`, and `manifest.json` shape.
|
||||
3. **Salvage source:** the arr logic is on silo-server `main` at `internal/autoscan/history.go` (imports + renames extraction, bounded window), `rewrite.go` (`applyRewrites`, `normalizeSeparators`), `suggest.go`/`suggest_deps.go` (root-folder suffix-match suggester). Port these verbatim where possible.
|
||||
4. **New repo:** create `Silo-Server/silo-plugin-autoscan-arr` (or fork pattern). The plugin is registered in the `silo-plugins` catalog in a separate follow-up (Task 9 notes it).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
|---|---|---|
|
||||
| `go.mod` | module `github.com/Silo-Server/silo-plugin-autoscan-arr`; SDK dep | Create |
|
||||
| `manifest.json` | declares `scan_source.v1` capability + `global_config_schema` (path rewrites only; connection arrives per request) | Create |
|
||||
| `main.go` | `runtimeServer` (GetManifest/Configure) + `scanSourceServer` (PollChanges); `runtime.Serve` | Create |
|
||||
| `internal/arr/history.go` | poll `/history`, imports + renames, bounded window | Create (port) |
|
||||
| `internal/arr/rewrite.go` | `applyRewrites`, `normalizeSeparators` | Create (port) |
|
||||
| `internal/arr/client.go` | arr HTTP client (`X-Api-Key`, response cap, timeout) | Create |
|
||||
| `internal/config/config.go` | parse Configure payload → `{rewrites}` (connection comes per request, not config) | Create |
|
||||
| `Makefile` | build (cross-platform), like tmdb | Create |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffold the plugin (serves an empty `scan_source.v1`)
|
||||
|
||||
**Files:** Create `go.mod`, `manifest.json`, `main.go`, `Makefile`.
|
||||
|
||||
- [ ] **Step 1: Module + SDK dep**
|
||||
|
||||
```bash
|
||||
go mod init github.com/Silo-Server/silo-plugin-autoscan-arr
|
||||
go mod edit -replace github.com/Silo-Server/silo-plugin-sdk=../silo-plugin-sdk # the silo-plugin-sdk checkout, a sibling of this repo
|
||||
go get github.com/Silo-Server/silo-plugin-sdk@v0.4.0 # replace points at the scan_source branch checkout
|
||||
```
|
||||
|
||||
- [ ] **Step 2: manifest.json**
|
||||
|
||||
Mirror tmdb's shape, declaring the scan source:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin_id": "silo.autoscan.arr",
|
||||
"version": "0.1.0",
|
||||
"checksum": "__CHECKSUM__",
|
||||
"silo_api_version": "v1",
|
||||
"supported_platforms": [
|
||||
{"os": "linux", "arch": "amd64"},
|
||||
{"os": "linux", "arch": "arm64"},
|
||||
{"os": "darwin", "arch": "arm64"}
|
||||
],
|
||||
"capabilities": [
|
||||
{
|
||||
"type": "scan_source.v1",
|
||||
"id": "arr",
|
||||
"display_name": "Sonarr / Radarr",
|
||||
"description": "Triggers Silo rescans from Sonarr/Radarr import and rename history."
|
||||
}
|
||||
],
|
||||
"global_config_schema": [
|
||||
{"key": "path_rewrites", "type": "json", "required": false, "display_name": "Path rewrites"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Connection is NOT plugin config.** The arr base URL + API key are delivered by the host in each `PollChangesRequest.connection` (resolved from the operator's Autoscan connection — own creds or a reused Requests link). The plugin reads them from the request and stores no credentials. Only path rewrites (and other provider-specific behaviour) live in plugin config.
|
||||
|
||||
(Confirm the exact `global_config_schema` entry shape against `silo-plugin-sdk` `pkg/pluginsdk/config` — adapt field names to the SDK's config schema type.)
|
||||
|
||||
- [ ] **Step 3: main.go scaffold**
|
||||
|
||||
Copy tmdb's `runtimeServer` (GetManifest from embedded manifest; Configure stores config) and add a `scanSourceServer` returning empty for now:
|
||||
|
||||
```go
|
||||
type scanSourceServer struct{ cfg *config.Config }
|
||||
|
||||
func (s *scanSourceServer) PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) {
|
||||
return &pluginv1.PollChangesResponse{}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
manifest := mustLoadManifest()
|
||||
rt := &runtimeServer{manifest: manifest}
|
||||
runtime.Serve(runtime.ServeConfig{
|
||||
Servers: runtime.CapabilityServers{
|
||||
Runtime: rt,
|
||||
ScanSource: &scanSourceServer{cfg: rt.cfg},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: exits 0.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git init && git add -A
|
||||
git commit -m "feat: scaffold autoscan-arr scan_source.v1 plugin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Config parsing (Configure → path rewrites only)
|
||||
|
||||
**Files:** Create `internal/config/config.go`, `internal/config/config_test.go`.
|
||||
|
||||
> Connection (base_url/api_key) is NOT config — it arrives per poll in `PollChangesRequest.connection` (Task 5). Plugin config carries only path rewrites.
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```go
|
||||
func TestParseConfig(t *testing.T) {
|
||||
cfg, err := Parse(map[string]string{
|
||||
"path_rewrites": `[{"from":"/mnt/arr/tv","to":"/mnt/media/tv"}]`,
|
||||
})
|
||||
if err != nil { t.Fatalf("Parse: %v", err) }
|
||||
if len(cfg.Rewrites) != 1 || cfg.Rewrites[0].From != "/mnt/arr/tv" { t.Fatalf("bad rewrites: %+v", cfg.Rewrites) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run → fail.** `go test ./internal/config/` → undefined `Parse`.
|
||||
|
||||
- [ ] **Step 3: Implement** `Parse(map[string]string) (*Config, error)` with `Config{Rewrites []Rewrite}` where `Rewrite{From, To string}`; unmarshal `path_rewrites` JSON. Wire `runtimeServer.Configure` to store the parsed config so `scanSourceServer` reads the rewrites.
|
||||
|
||||
- [ ] **Step 4: Run → pass.**
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(config): parse arr connection and path rewrites`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: arr history client (imports + renames, bounded window)
|
||||
|
||||
**Files:** Create `internal/arr/client.go`, `internal/arr/history.go`, `internal/arr/history_test.go`.
|
||||
|
||||
Port from silo-server `main:internal/autoscan/history.go` (the `ChangedPaths` logic: `downloadFolderImported.importedPath`; `episodeFileRenamed`/`movieFileRenamed` → `path` + `sourcePath`; deletes ignored) and the client (`X-Api-Key`, 1 MiB cap, timeout). The marker is an RFC3339 timestamp; empty marker ⇒ "now".
|
||||
|
||||
- [ ] **Step 1: Failing test** — copy the salvaged `history_test.go` (`TestArrHistoryChangedPaths`: imports + both rename paths returned, `grabbed`/`episodeFileDeleted` ignored), renamed to this package, asserting the returned `(paths, nextMarker)`.
|
||||
|
||||
- [ ] **Step 2: Run → fail.**
|
||||
|
||||
- [ ] **Step 3: Implement** `ChangedPaths(ctx, baseURL, apiKey, since time.Time) (paths []string, newest time.Time, err error)` — port `history.go`, additionally returning the newest history timestamp as the next marker. Apply the bounded window (24h max-lookback floor + overlap buffer) here.
|
||||
|
||||
- [ ] **Step 4: Run → pass.**
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(arr): poll history for imports and renames`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Path rewrites (→ Silo-native paths)
|
||||
|
||||
**Files:** Create `internal/arr/rewrite.go`, `internal/arr/rewrite_test.go`.
|
||||
|
||||
Port `applyRewrites` (boundary-safe prefix: `path==trimmed || HasPrefix(path, trimmed+"/")`) and `normalizeSeparators` from silo-server `main:internal/autoscan/rewrite.go`.
|
||||
|
||||
- [ ] **Step 1: Failing test** — copy salvaged `rewrite_test.go` cases.
|
||||
- [ ] **Step 2: Run → fail.**
|
||||
- [ ] **Step 3: Implement** the port.
|
||||
- [ ] **Step 4: Run → pass.**
|
||||
- [ ] **Step 5: Commit** `feat(arr): boundary-safe path rewrites`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire `PollChanges` end-to-end
|
||||
|
||||
**Files:** Modify `main.go` (`scanSourceServer.PollChanges`), add `main_test.go`.
|
||||
|
||||
- [ ] **Step 1: Failing test** — a `scanSourceServer` (rewrites configured) called with a `PollChangesRequest` whose `Connection` points at a stub arr server (httptest); assert `PollChanges` with empty marker returns rewritten Silo-native paths and a non-empty `next_marker`, and that the request actually hit the stub at `connection.base_url` with `X-Api-Key: connection.api_key`. A second call with the returned marker queries arr with the right `since`. Also assert a request with a nil/empty `Connection` returns an error (no credentials to poll with).
|
||||
|
||||
- [ ] **Step 2: Run → fail.**
|
||||
|
||||
- [ ] **Step 3: Implement** — read the connection from the request, not from config:
|
||||
|
||||
```go
|
||||
func (s *scanSourceServer) PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error) {
|
||||
conn := req.GetConnection()
|
||||
if conn == nil || conn.GetBaseUrl() == "" {
|
||||
return nil, fmt.Errorf("scan_source: no connection supplied")
|
||||
}
|
||||
since := time.Time{} // empty marker => now (history client floors to now)
|
||||
if m := req.GetMarker(); m != "" {
|
||||
if t, err := time.Parse(time.RFC3339, m); err == nil { since = t }
|
||||
}
|
||||
raw, newest, err := arr.ChangedPaths(ctx, conn.GetBaseUrl(), conn.GetApiKey(), since)
|
||||
if err != nil { return nil, err }
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, p := range raw {
|
||||
out = append(out, arr.ApplyRewrites(arr.NormalizeSeparators(p), s.cfg.Rewrites))
|
||||
}
|
||||
return &pluginv1.PollChangesResponse{ChangedPaths: out, NextMarker: newest.UTC().Format(time.RFC3339)}, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run → pass.**
|
||||
|
||||
- [ ] **Step 5: Commit** `feat: PollChanges returns Silo-native paths from arr history`.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Rewrite suggester ("Sync from arr") — root folders
|
||||
|
||||
**Files:** Create `internal/arr/suggest.go`, `suggest_test.go`.
|
||||
|
||||
Port `suggestRewrites` (suffix-match arr root folders → Silo folders) from `main:internal/autoscan/suggest.go`. **Open question (spec §14 risk #2):** how the operator triggers "Sync" and how the plugin reaches Silo's media-folder list.
|
||||
|
||||
- [ ] **Step 1:** Decide the delivery mechanism — recommended: the plugin reads Silo's folder list via the host library-listing service (`pluginhost` library lister, reachable through the SDK `runtime.Host()` client). Confirm that path exists in `v0.5.0`; if not, scope "Sync" to a follow-up and ship Tasks 1–5 (manual rewrites only) first.
|
||||
- [ ] **Step 2:** Port the suffix-match suggester + its tests (unique/ambiguous/covered/no-op/normalization cases from `suggest_test.go`).
|
||||
- [ ] **Step 3:** Expose it — either via an `http_routes.v1` capability the admin UI calls, or as part of the plugin config flow. Document the chosen mechanism.
|
||||
- [ ] **Step 4:** Tests pass; commit `feat(arr): suffix-match rewrite suggester`.
|
||||
|
||||
> If §14 risk #2 is unresolved at execution time, **skip this task**, ship manual rewrites (Tasks 1–5), and file the suggester as a follow-up. The plugin is fully functional without it.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Cross-repo smoke test against the host
|
||||
|
||||
**Files:** none (verification).
|
||||
|
||||
- [ ] **Step 1:** Build the plugin binary; install it into a dev silo-server (with the Part-1 backend) via the plugin installer.
|
||||
- [ ] **Step 2:** Configure a source pointing at a real/stub arr; trigger an autoscan poll; confirm the host enqueues a scan for an imported path. (This exercises the full SDK contract end-to-end.)
|
||||
- [ ] **Step 3:** Note results; no commit unless fixes are needed.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Finalize SDK dependency
|
||||
|
||||
- [ ] After `silo-plugin-sdk v0.5.0` is tagged: `go mod edit -dropreplace ...; go get .../silo-plugin-sdk@v0.5.0; go mod tidy; go build ./...`.
|
||||
- [ ] Commit `build: depend on silo-plugin-sdk v0.5.0`.
|
||||
- [ ] Do not publish/tag the plugin with a `replace` directive in `go.mod`.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Catalog registration (follow-up, separate repo)
|
||||
|
||||
- [ ] Add `silo.autoscan.arr` to the `silo-plugins` catalog manifest so it is installable. This is a separate PR in the `silo-plugins` repo (not this module) — note it; out of scope for this plan's repo.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review notes
|
||||
|
||||
- **Spec coverage:** §10 (arr plugin: history imports + renames, bounded window, rewrites, Silo-native paths) Tasks 3–5; §5 contract (`PollChanges`, opaque marker = RFC3339 timestamp, first-run "now") Task 5; "Sync from arr" §10/§14-risk-2 Task 6 (with an explicit skip path).
|
||||
- **Salvage:** history/rewrite/suggest ported from the closed PR (Tasks 3,4,6) — exactly the files the host backend plan deletes (Part-1 Task 8).
|
||||
- **Risk:** §14 risk #1 (one plugin installation per arr server vs. many) — this plugin holds one connection per installation/config; multiple arr servers ⇒ multiple installs unless the runtime supports multiple capability instances. §14 risk #2 (Sync UI) gated in Task 6 with a clean skip.
|
||||
- **Type consistency:** `Config{Rewrites}` (no creds — connection arrives in `PollChangesRequest.connection`), `Rewrite{From,To}`, `arr.ChangedPaths(ctx, baseURL, apiKey, since) ([]string, time.Time, error)`, `arr.ApplyRewrites`/`NormalizeSeparators`, `scanSourceServer.PollChanges` consistent across Tasks 2–6.
|
||||
@@ -1,209 +0,0 @@
|
||||
# Autoscan Admin UI Implementation Plan (Part 2 of 2)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Give Autoscan its own admin category (page + sidebar nav), separate from Requests: manage Connections (reuse a Requests arr server or enter own), Sources (per scan-source plugin: enable, interval, connection), and global settings — backed by the Part-1 v2 API.
|
||||
|
||||
**Architecture:** Extract the Autoscan tab out of `AdminRequests.tsx` into a standalone `AdminAutoscan.tsx` page; register a route and a sidebar item; rebuild `useAutoscan.ts` hooks + `types.ts` against the v2 endpoints (`/admin/autoscan/{settings,connections,sources,trigger,status}`).
|
||||
|
||||
**Tech Stack:** React + TypeScript, React Query (`@tanstack/react-query`), the project's `ui/` component library (`Tabs`, `Table`, `Card`, etc.), `react-router` routes in `App.tsx`, 2-space indent / double quotes / 100-col (`web/.prettierrc`).
|
||||
|
||||
Commands assume the silo-server repository root is the cwd. **Depends on Part 1 (backend)** being implemented (the v2 endpoints must exist).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Part-1 backend merged/available** — the v2 endpoints and response shapes (connections with `request_integration_id`, sources with `marker`/`last_error`, settings with `default_poll_interval_seconds`).
|
||||
2. **Reference the existing autoscan tab** at `web/src/pages/AdminRequests.tsx` (the `autoscan` `TabsContent`, the `AutoscanTab`/`AutoscanSourceEditor` components) and hooks at `web/src/hooks/queries/useAutoscan.ts` — these are the starting material; they are reshaped, not discarded.
|
||||
3. **Run before MR:** `cd web && pnpm run lint && pnpm run format:check`. Note the 20 pre-existing failing frontend tests are unrelated (project memory); do not be alarmed if they appear — only newly-introduced failures matter.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
|---|---|---|
|
||||
| `web/src/api/types.ts` | `AutoscanConnection`, `AutoscanSource`, `AutoscanSettings` (v2 shapes) | Modify |
|
||||
| `web/src/hooks/queries/useAutoscan.ts` | hooks for settings/connections/sources/trigger/status | Rewrite |
|
||||
| `web/src/hooks/queries/keys.ts` | query keys for connections/sources | Modify |
|
||||
| `web/src/pages/AdminAutoscan.tsx` | the new standalone Autoscan page (Connections + Sources + Settings tabs) | Create |
|
||||
| `web/src/pages/admin/autoscan/ConnectionsPanel.tsx` | list/add/edit/delete connections (own or Requests-linked) | Create |
|
||||
| `web/src/pages/admin/autoscan/SourcesPanel.tsx` | per-source enable, interval, connection binding, status | Create |
|
||||
| `web/src/pages/AdminRequests.tsx` | remove the Autoscan tab | Modify |
|
||||
| `web/src/App.tsx` | add `<Route path="autoscan" element={<AdminAutoscan />} />` | Modify |
|
||||
| `web/src/components/AdminSidebar.tsx` | add the Autoscan nav item under "Content" | Modify |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: v2 types + hooks
|
||||
|
||||
**Files:** Modify `web/src/api/types.ts`, `web/src/hooks/queries/keys.ts`; rewrite `web/src/hooks/queries/useAutoscan.ts`.
|
||||
|
||||
- [ ] **Step 1: Types**
|
||||
|
||||
Add to `web/src/api/types.ts`:
|
||||
|
||||
```ts
|
||||
export interface AutoscanSettings {
|
||||
enabled: boolean;
|
||||
default_poll_interval_seconds: number;
|
||||
debounce_seconds: number;
|
||||
}
|
||||
|
||||
export interface AutoscanConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
// present when the connection has its own credentials:
|
||||
base_url?: string;
|
||||
// present when linked to a Requests integration (live reuse):
|
||||
request_integration_id?: string | null;
|
||||
// NOTE: api_key_ref / resolved keys are never sent by the backend.
|
||||
}
|
||||
|
||||
export interface AutoscanSource {
|
||||
id: string;
|
||||
installation_id: number;
|
||||
capability_id: string;
|
||||
connection_id: string;
|
||||
enabled: boolean;
|
||||
poll_interval_seconds: number | null;
|
||||
last_run_at: string | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Hooks**
|
||||
|
||||
Rewrite `useAutoscan.ts` with: `useAutoscanSettings`/`useUpdateAutoscanSettings`, `useAutoscanConnections`/`useCreateAutoscanConnection`/`useUpdateAutoscanConnection`/`useDeleteAutoscanConnection`, `useAutoscanSources`/`useUpdateAutoscanSource`, `useAutoscanStatus`, `useTriggerAutoscan`. Follow the existing hook style (the current `useAutoscan.ts` is the template for query/mutation + invalidation). Add query keys in `keys.ts`.
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `cd web && pnpm exec tsc --noEmit`
|
||||
Expected: no type errors in the autoscan files.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/api/types.ts web/src/hooks/queries/useAutoscan.ts web/src/hooks/queries/keys.ts
|
||||
git commit -m "feat(web): autoscan v2 types and query hooks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Connections panel
|
||||
|
||||
**Files:** Create `web/src/pages/admin/autoscan/ConnectionsPanel.tsx`.
|
||||
|
||||
- [ ] **Step 1: Build the panel**
|
||||
|
||||
A `Card` + `Table` listing connections (name, kind, and a "Reused from Requests" / "Own" badge derived from `request_integration_id`). An "Add connection" dialog offers two modes:
|
||||
- **Reuse from Requests** — a select populated from the existing request-integrations hook (filter to arr kinds), storing `request_integration_id`.
|
||||
- **Enter own** — name + URL + API key fields, posting own credentials.
|
||||
|
||||
Edit + delete actions per row. Never render any key material (the backend doesn't send it).
|
||||
|
||||
- [ ] **Step 2: Lint/format/typecheck**
|
||||
|
||||
Run: `cd web && pnpm run lint && pnpm exec tsc --noEmit`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 3: Commit** `feat(web): autoscan connections panel (reuse or own)`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Sources panel
|
||||
|
||||
**Files:** Create `web/src/pages/admin/autoscan/SourcesPanel.tsx`.
|
||||
|
||||
- [ ] **Step 1: Build the panel**
|
||||
|
||||
A `Table` of sources, one row per installed `scan_source` capability instance: plugin/capability label, a connection `<select>` (bound to `connection_id`), an enable toggle, a poll-interval input (blank = settings default), and a status column (`last_run_at`, `last_error`). Reuse the spinner/"Syncing…" affordances from the old `AutoscanSourceEditor` where relevant. Wire to `useAutoscanSources`/`useUpdateAutoscanSource`.
|
||||
|
||||
- [ ] **Step 2: Lint/format/typecheck** — clean.
|
||||
- [ ] **Step 3: Commit** `feat(web): autoscan sources panel`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: The Autoscan page + global settings + manual trigger
|
||||
|
||||
**Files:** Create `web/src/pages/AdminAutoscan.tsx`.
|
||||
|
||||
- [ ] **Step 1: Compose the page**
|
||||
|
||||
A page with a `Tabs` (or sections): **Sources**, **Connections**, **Settings**. Settings = global enable toggle, default poll interval, debounce. A "Run now" button calling `useTriggerAutoscan` (202 → toast "Autoscan triggered"). Mirror the header/layout of `AdminRequests.tsx` for visual consistency.
|
||||
|
||||
- [ ] **Step 2: Lint/format/typecheck** — clean.
|
||||
- [ ] **Step 3: Commit** `feat(web): standalone Autoscan admin page`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Route + sidebar nav
|
||||
|
||||
**Files:** Modify `web/src/App.tsx`, `web/src/components/AdminSidebar.tsx`.
|
||||
|
||||
- [ ] **Step 1: Route**
|
||||
|
||||
In `web/src/App.tsx`, next to `<Route path="requests" element={<AdminRequests />} />` (l.374), add:
|
||||
|
||||
```tsx
|
||||
<Route path="autoscan" element={<AdminAutoscan />} />
|
||||
```
|
||||
|
||||
with the matching `import AdminAutoscan from "@/pages/AdminAutoscan";`.
|
||||
|
||||
- [ ] **Step 2: Sidebar item**
|
||||
|
||||
In `web/src/components/AdminSidebar.tsx`, under the **Content** group (after the Requests item, ~l.112), add:
|
||||
|
||||
```tsx
|
||||
{
|
||||
label: "Autoscan",
|
||||
icon: <RefreshCw className="h-[18px] w-[18px]" />,
|
||||
href: "/admin/autoscan",
|
||||
},
|
||||
```
|
||||
|
||||
(Import an appropriate icon from the icon set already used in the file.)
|
||||
|
||||
- [ ] **Step 3: Verify navigation**
|
||||
|
||||
Run: `cd web && pnpm run build`
|
||||
Expected: build succeeds. Manually confirm `/admin/autoscan` renders and the sidebar item highlights.
|
||||
|
||||
- [ ] **Step 4: Commit** `feat(web): route and sidebar nav for Autoscan category`.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Remove the Autoscan tab from Requests
|
||||
|
||||
**Files:** Modify `web/src/pages/AdminRequests.tsx`.
|
||||
|
||||
- [ ] **Step 1: Excise the tab**
|
||||
|
||||
Remove the `<TabsTrigger value="autoscan">` (l.146) and its `<TabsContent value="autoscan">`, the `AutoscanTab`/`AutoscanSourceEditor` definitions (now superseded by the new panels), and `autoscan` from `ADMIN_REQUEST_TABS`. Remove now-unused imports.
|
||||
|
||||
- [ ] **Step 2: Lint/format/typecheck/build**
|
||||
|
||||
Run: `cd web && pnpm run lint && pnpm run format:check && pnpm run build`
|
||||
Expected: clean; build succeeds; no dangling references to removed components.
|
||||
|
||||
- [ ] **Step 3: Commit** `refactor(web): move Autoscan out of Requests into its own category`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Final sweep
|
||||
|
||||
- [ ] **Step 1:** `cd web && pnpm run lint && pnpm run format:check && pnpm run build` — all clean.
|
||||
- [ ] **Step 2:** `cd web && pnpm test` — confirm no **newly** failing tests (the 20 pre-existing failures, per project memory, are unrelated; compare against baseline).
|
||||
- [ ] **Step 3:** Manual pass: add a connection (both modes), bind a source, toggle enable, "Run now", confirm status updates.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review notes
|
||||
|
||||
- **Spec coverage:** §9 (Autoscan as its own category, decoupled from Requests; Connections reuse-or-own; per-source enable/interval/status) — Tasks 1–6. The reuse-from-Requests UI (Task 2) surfaces the soft link from spec §8.
|
||||
- **Salvage:** the old `AutoscanTab`/`AutoscanSourceEditor` and `useAutoscan.ts` are reshaped (Tasks 1–3), then the Requests tab is removed (Task 6).
|
||||
- **Out of scope:** plugin-side config UI (the path-rewrite table lives on the plugin's own settings screen per spec §10 — that is the arr-plugin plan / spec §14 risk #2, not this page).
|
||||
- **Type consistency:** `AutoscanConnection{request_integration_id?}`, `AutoscanSource{connection_id,poll_interval_seconds,last_error}`, `AutoscanSettings{default_poll_interval_seconds}` used consistently across hooks (Task 1) and panels (Tasks 2–4).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,174 +0,0 @@
|
||||
# Audiobooks Stacked PR Split Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the oversized audiobook PR with a stacked series of smaller, reviewable PRs.
|
||||
|
||||
**Architecture:** Preserve the existing `feat/audiobooks` work as the source branch, then create branch cut points that expose one logical layer at a time. Each PR targets the previous branch so reviewers see only the incremental diff.
|
||||
|
||||
**Tech Stack:** Git, GitHub CLI, Go, pnpm/Vite, PostgreSQL migrations.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Close The Oversized PR
|
||||
|
||||
**Files:**
|
||||
- Modify: GitHub PR #17 only.
|
||||
|
||||
- [ ] **Step 1: Post a replacement note**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr comment 17 --repo Silo-Server/silo-server --body 'Closing this oversized PR in favor of a stacked series of smaller PRs. The existing branch is preserved as the source-of-truth while the stack is rebuilt into logical review units.'
|
||||
```
|
||||
|
||||
Expected: GitHub prints the created comment URL.
|
||||
|
||||
- [ ] **Step 2: Close PR #17 without deleting the branch**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr close 17 --repo Silo-Server/silo-server
|
||||
```
|
||||
|
||||
Expected: PR #17 state becomes `CLOSED`; branch `feat/audiobooks` remains available.
|
||||
|
||||
### Task 2: Create Stacked Branches
|
||||
|
||||
**Files:**
|
||||
- Modify: remote Git branches only.
|
||||
|
||||
- [ ] **Step 1: Create branch 1, foundation**
|
||||
|
||||
Branch name: `stack/audiobooks-foundation`
|
||||
|
||||
Scope:
|
||||
- audiobook feature flag/settings
|
||||
- audiobook and podcast schema foundations
|
||||
- scanner support
|
||||
- audiobook media item write path
|
||||
- no native UI
|
||||
- no ABS listener
|
||||
|
||||
- [ ] **Step 2: Create branch 2, native UI MVP**
|
||||
|
||||
Branch name: `stack/audiobooks-native-ui`
|
||||
|
||||
Base: `stack/audiobooks-foundation`
|
||||
|
||||
Scope:
|
||||
- `/api/v1/audiobooks`
|
||||
- audiobook detail/progress endpoints
|
||||
- audiobook frontend route/sidebar/player MVP
|
||||
|
||||
- [ ] **Step 3: Create branch 3, ABS core**
|
||||
|
||||
Branch name: `stack/audiobooks-abs-core`
|
||||
|
||||
Base: `stack/audiobooks-native-ui`
|
||||
|
||||
Scope:
|
||||
- ABS auth/login/refresh/logout
|
||||
- ABS listener and feature-flag listener gating
|
||||
- ABS library/items/play/progress
|
||||
- ABS access control and security hardening
|
||||
|
||||
- [ ] **Step 4: Create branch 4, ABS collections**
|
||||
|
||||
Branch name: `stack/audiobooks-abs-collections`
|
||||
|
||||
Base: `stack/audiobooks-abs-core`
|
||||
|
||||
Scope:
|
||||
- ABS bookmarks
|
||||
- ABS collections
|
||||
- ABS playlists
|
||||
- ABS smart collections
|
||||
- unified collection migration
|
||||
|
||||
- [ ] **Step 5: Create branch 5, extras and polish**
|
||||
|
||||
Branch name: `stack/audiobooks-extras`
|
||||
|
||||
Base: `stack/audiobooks-abs-collections`
|
||||
|
||||
Scope:
|
||||
- podcasts/RSS
|
||||
- stats
|
||||
- author/series extras
|
||||
- catalog audiobook filters/typeahead
|
||||
- performance and enrichment cleanup
|
||||
|
||||
### Task 3: Open The Stacked PRs
|
||||
|
||||
**Files:**
|
||||
- Modify: GitHub PRs only.
|
||||
|
||||
- [ ] **Step 1: Open PR 1**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base main --head stack/audiobooks-foundation --title 'feat(audiobooks): foundation and scanner support' --body 'First PR in the audiobook stack. Adds the schema/settings/scanner foundation without native UI or ABS compatibility.'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Open PR 2**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-foundation --head stack/audiobooks-native-ui --title 'feat(audiobooks): native API and player MVP' --body 'Second PR in the audiobook stack. Adds native Silo audiobook endpoints and the MVP web player UI.'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Open PR 3**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-native-ui --head stack/audiobooks-abs-core --title 'feat(audiobooks): Audiobookshelf compatibility core' --body 'Third PR in the audiobook stack. Adds ABS auth, listener, library/items/play/progress, and security hardening.'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Open PR 4**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-core --head stack/audiobooks-abs-collections --title 'feat(audiobooks): ABS collections, playlists, and smart collections' --body 'Fourth PR in the audiobook stack. Adds ABS bookmarks, collections, playlists, smart collections, and unified collection storage.'
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Open PR 5**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-collections --head stack/audiobooks-extras --title 'feat(audiobooks): podcasts, catalog filters, and enrichment polish' --body 'Final PR in the audiobook stack. Adds podcast/RSS extras, audiobook catalog filters/typeahead, and scan/enrichment performance polish.'
|
||||
```
|
||||
|
||||
### Task 4: Verify And Report
|
||||
|
||||
**Files:**
|
||||
- Modify: none.
|
||||
|
||||
- [ ] **Step 1: Verify PR states**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr list --repo Silo-Server/silo-server --state open --search 'audiobooks in:title' --json number,title,headRefName,baseRefName,url
|
||||
```
|
||||
|
||||
Expected: five open stacked PRs with the base/head chain shown above.
|
||||
|
||||
- [ ] **Step 2: Run branch verification where feasible**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && pnpm run build
|
||||
```
|
||||
|
||||
Expected: both commands pass on the final stack tip.
|
||||
|
||||
@@ -1,617 +0,0 @@
|
||||
# TheIntroDB read-path correctness (Phase 1 / Layer A) — implementation plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix the three read-path correctness gaps in the TheIntroDB provider so it matches the
|
||||
real v3 API contract: (1) honor **TVDB** ids in lookups (today they are silently dropped),
|
||||
(2) capture and use the **real per-segment `confidence`** instead of a hardcoded `0.9`, and
|
||||
(3) when the API returns multiple candidate segments, **pick the most-submitted / highest-confidence**
|
||||
one instead of the first usable. This is Phase 1 of
|
||||
[the marker sources & contribution design](../specs/2026-06-06-marker-sources-and-contribution-design.md);
|
||||
it stands alone, ships value immediately (anime / TheTVDB-first libraries start getting markers),
|
||||
and unblocks the "query-all / best-wins" merge in later phases.
|
||||
|
||||
**Architecture:** All changes are contained within `internal/markers/introdb` plus one added
|
||||
field on `markers.Marker`. The resolver already populates the TVDB id into
|
||||
`Request.ExternalIDs` — only the provider and HTTP client drop it. The HTTP `Client` gains a
|
||||
`tvdbID` argument on `FetchEpisode`/`FetchMovie` (query preference `tmdb → tvdb → imdb`, cache
|
||||
keys extended); the provider reads `ExternalIDKeyTVDB`; `segmentTimestamps` gains `confidence`
|
||||
and `submission_count`; `pickMarker` uses real confidence (with a named default) and ranks
|
||||
candidates. The write path (`BuildUpdatePayload` / `UpsertMarkers`) is **not** touched here.
|
||||
|
||||
**Out of scope (deferred to Phase 2):** threading *per-segment* confidence through
|
||||
`MarkerUpdatePayload` and `UpsertMarkers`. That refactor only becomes observable once
|
||||
`Registry.FetchMerged` can combine segments from different providers; with a single provider
|
||||
today the existing "max confidence across segments" collapse is equivalent, and changing it now
|
||||
would break `TestBuildUpdatePayloadAggregatesConfidence` for no functional gain. It lands with
|
||||
the merge dispatch in Phase 2.
|
||||
|
||||
**Tech Stack:** Go (the `internal/markers/introdb` package is pure Go — no libvips/CGO),
|
||||
PostgreSQL is not involved (no migration), no frontend changes. Tests run in a throwaway Go
|
||||
container (the host has no Go toolchain); a named volume caches module downloads.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Running tests (host has no Go toolchain)
|
||||
|
||||
`internal/markers` and `internal/markers/introdb` are pure Go, so no libvips is needed:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/markers/... -v
|
||||
```
|
||||
|
||||
To run a single test, append `-run TestName` and narrow the package path, e.g.
|
||||
`go test ./internal/markers/introdb/ -run TestFetchEpisodeSendsTVDBWhenNoTMDB -v`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
- `internal/markers/introdb/types.go` — **modify**. Add `Confidence` + `SubmissionCount` to `segmentTimestamps`; add the `defaultConfidence` const.
|
||||
- `internal/markers/types.go` — **modify**. Add `SubmissionCount` to `markers.Marker`.
|
||||
- `internal/markers/introdb/client.go` — **modify**. `tvdbID` arg on `FetchEpisode`/`FetchMovie`; query preference `tmdb → tvdb → imdb`; cache keys include tvdb.
|
||||
- `internal/markers/introdb/provider.go` — **modify**. Read `ExternalIDKeyTVDB` and pass it through; rewrite `pickMarker` for real confidence + best-candidate.
|
||||
- `internal/markers/introdb/client_test.go` — **create**. httptest-based client tests (TVDB query, TMDB preference, caching).
|
||||
- `internal/markers/introdb/provider_test.go` — **create**. End-to-end provider tests (TVDB-only resolves, real/default confidence, best-candidate).
|
||||
|
||||
No migration, no wiring change (`cmd/silo/main.go` constructs the provider but never calls the
|
||||
client directly), no frontend change.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Type plumbing — capture confidence + submission_count
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/markers/introdb/types.go`
|
||||
- Modify: `internal/markers/types.go`
|
||||
|
||||
This task is pure struct plumbing (no behavior yet); later tasks' tests exercise it. Verify by
|
||||
compilation.
|
||||
|
||||
- [ ] **Step 1: Add the response fields + default constant in `introdb/types.go`**
|
||||
|
||||
Replace the `Algorithm` const block to add `defaultConfidence` after it:
|
||||
|
||||
```go
|
||||
// Algorithm is the algorithm tag written alongside markers. The version
|
||||
// suffix lets us invalidate or refresh markers if the upstream contract
|
||||
// changes.
|
||||
const Algorithm = "introdb:v3"
|
||||
|
||||
// defaultConfidence is applied when TheIntroDB omits a per-segment confidence
|
||||
// in the /media response. Real per-segment confidence is preferred when present.
|
||||
const defaultConfidence = 0.9
|
||||
```
|
||||
|
||||
Replace the `segmentTimestamps` struct with the version that decodes the two extra fields the
|
||||
v3 `/media` response carries:
|
||||
|
||||
```go
|
||||
// segmentTimestamps is the per-occurrence shape returned by TheIntroDB.
|
||||
// Either bound may be nil — for intro/recap, start may be omitted (segment
|
||||
// begins at file start); for credits/preview, end may be omitted (segment
|
||||
// runs to file end). Confidence and SubmissionCount are optional per-segment
|
||||
// quality signals used to pick among multiple candidates.
|
||||
type segmentTimestamps struct {
|
||||
StartMs *int64 `json:"start_ms,omitempty"`
|
||||
EndMs *int64 `json:"end_ms,omitempty"`
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
SubmissionCount *int `json:"submission_count,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `SubmissionCount` to `markers.Marker`**
|
||||
|
||||
In `internal/markers/types.go`, replace the `Marker` struct:
|
||||
|
||||
```go
|
||||
type Marker struct {
|
||||
Kind MarkerKind
|
||||
Start time.Duration
|
||||
End time.Duration
|
||||
Confidence float64
|
||||
SubmissionCount int
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the packages still compile**
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go build ./internal/markers/...
|
||||
```
|
||||
|
||||
Expected: no output, exit 0. (Existing `write_test.go` / `types_test.go` use field-keyed
|
||||
literals, so the added field does not break them.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/markers/introdb/types.go internal/markers/types.go
|
||||
git commit -m "feat(markers): decode introdb per-segment confidence + submission_count"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: TVDB lookups (gap #1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/markers/introdb/client.go`
|
||||
- Modify: `internal/markers/introdb/provider.go`
|
||||
- Create: `internal/markers/introdb/client_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing client tests**
|
||||
|
||||
Create `internal/markers/introdb/client_test.go`:
|
||||
|
||||
```go
|
||||
package introdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchEpisodeSendsTVDBWhenNoTMDB(t *testing.T) {
|
||||
var gotQuery url.Values
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.Query()
|
||||
_, _ = w.Write([]byte(`{"type":"episode"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("")
|
||||
c.SetBaseURL(srv.URL)
|
||||
if _, err := c.FetchEpisode(context.Background(), "", "55555", "tt1234567", 2, 3, 0); err != nil {
|
||||
t.Fatalf("FetchEpisode: %v", err)
|
||||
}
|
||||
if gotQuery.Get("tvdb_id") != "55555" {
|
||||
t.Errorf("tvdb_id = %q, want 55555", gotQuery.Get("tvdb_id"))
|
||||
}
|
||||
if gotQuery.Get("tmdb_id") != "" {
|
||||
t.Errorf("tmdb_id = %q, want empty", gotQuery.Get("tmdb_id"))
|
||||
}
|
||||
if gotQuery.Get("imdb_id") != "" {
|
||||
t.Errorf("imdb_id should be omitted when tvdb present, got %q", gotQuery.Get("imdb_id"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchEpisodePrefersTMDBOverTVDBAndIMDB(t *testing.T) {
|
||||
var gotQuery url.Values
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.Query()
|
||||
_, _ = w.Write([]byte(`{"type":"episode"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("")
|
||||
c.SetBaseURL(srv.URL)
|
||||
if _, err := c.FetchEpisode(context.Background(), "111", "222", "tt333", 1, 1, 0); err != nil {
|
||||
t.Fatalf("FetchEpisode: %v", err)
|
||||
}
|
||||
if gotQuery.Get("tmdb_id") != "111" {
|
||||
t.Errorf("tmdb_id = %q, want 111", gotQuery.Get("tmdb_id"))
|
||||
}
|
||||
if gotQuery.Get("tvdb_id") != "" || gotQuery.Get("imdb_id") != "" {
|
||||
t.Errorf("only tmdb_id expected, got tvdb=%q imdb=%q", gotQuery.Get("tvdb_id"), gotQuery.Get("imdb_id"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMovieSendsTVDB(t *testing.T) {
|
||||
var gotQuery url.Values
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.Query()
|
||||
_, _ = w.Write([]byte(`{"type":"movie"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("")
|
||||
c.SetBaseURL(srv.URL)
|
||||
if _, err := c.FetchMovie(context.Background(), "", "888", "", 0); err != nil {
|
||||
t.Fatalf("FetchMovie: %v", err)
|
||||
}
|
||||
if gotQuery.Get("tvdb_id") != "888" {
|
||||
t.Errorf("tvdb_id = %q, want 888", gotQuery.Get("tvdb_id"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchEpisodeCachesByID(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
_, _ = w.Write([]byte(`{"type":"episode"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("")
|
||||
c.SetBaseURL(srv.URL)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := c.FetchEpisode(context.Background(), "", "999", "", 1, 1, 0); err != nil {
|
||||
t.Fatalf("FetchEpisode: %v", err)
|
||||
}
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("server hits = %d, want 1 (cached after first)", hits)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/markers/introdb/ -run 'TestFetch' -v
|
||||
```
|
||||
|
||||
Expected: FAIL — compile error (`FetchEpisode`/`FetchMovie` still take the old 6/4-arg
|
||||
signatures without `tvdbID`).
|
||||
|
||||
- [ ] **Step 3: Add `tvdbID` to `FetchEpisode` in `client.go`**
|
||||
|
||||
Replace the whole `FetchEpisode` method:
|
||||
|
||||
```go
|
||||
// FetchEpisode looks up segment timestamps for a TV episode.
|
||||
// At least one of tmdbID, tvdbID, or imdbID must be non-empty.
|
||||
func (c *Client) FetchEpisode(ctx context.Context, tmdbID, tvdbID, imdbID string, season, episode int, durationMS int64) (*mediaResponse, error) {
|
||||
if tmdbID == "" && tvdbID == "" && imdbID == "" {
|
||||
return nil, fmt.Errorf("introdb: tmdb_id, tvdb_id, or imdb_id required")
|
||||
}
|
||||
if season <= 0 || episode <= 0 {
|
||||
return nil, fmt.Errorf("introdb: episode lookup requires season and episode > 0 (got %d/%d)", season, episode)
|
||||
}
|
||||
q := url.Values{}
|
||||
switch {
|
||||
case tmdbID != "":
|
||||
q.Set("tmdb_id", tmdbID)
|
||||
case tvdbID != "":
|
||||
q.Set("tvdb_id", tvdbID)
|
||||
default:
|
||||
q.Set("imdb_id", imdbID)
|
||||
}
|
||||
q.Set("season", strconv.Itoa(season))
|
||||
q.Set("episode", strconv.Itoa(episode))
|
||||
if durationMS > 0 {
|
||||
q.Set("duration_ms", strconv.FormatInt(durationMS, 10))
|
||||
}
|
||||
return c.fetch(ctx, q, cacheKeyEpisode(tmdbID, tvdbID, imdbID, season, episode, durationMS))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `tvdbID` to `FetchMovie` in `client.go`**
|
||||
|
||||
Replace the whole `FetchMovie` method:
|
||||
|
||||
```go
|
||||
// FetchMovie looks up segment timestamps for a movie.
|
||||
// At least one of tmdbID, tvdbID, or imdbID must be non-empty.
|
||||
func (c *Client) FetchMovie(ctx context.Context, tmdbID, tvdbID, imdbID string, durationMS int64) (*mediaResponse, error) {
|
||||
if tmdbID == "" && tvdbID == "" && imdbID == "" {
|
||||
return nil, fmt.Errorf("introdb: tmdb_id, tvdb_id, or imdb_id required")
|
||||
}
|
||||
q := url.Values{}
|
||||
switch {
|
||||
case tmdbID != "":
|
||||
q.Set("tmdb_id", tmdbID)
|
||||
case tvdbID != "":
|
||||
q.Set("tvdb_id", tvdbID)
|
||||
default:
|
||||
q.Set("imdb_id", imdbID)
|
||||
}
|
||||
if durationMS > 0 {
|
||||
q.Set("duration_ms", strconv.FormatInt(durationMS, 10))
|
||||
}
|
||||
return c.fetch(ctx, q, cacheKeyMovie(tmdbID, tvdbID, imdbID, durationMS))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Extend the cache-key helpers in `client.go`**
|
||||
|
||||
Replace both `cacheKeyEpisode` and `cacheKeyMovie`:
|
||||
|
||||
```go
|
||||
func cacheKeyEpisode(tmdbID, tvdbID, imdbID string, season, episode int, durationMS int64) string {
|
||||
switch {
|
||||
case tmdbID != "":
|
||||
return fmt.Sprintf("tmdb:%s:s%de%d:d%d", tmdbID, season, episode, durationMS)
|
||||
case tvdbID != "":
|
||||
return fmt.Sprintf("tvdb:%s:s%de%d:d%d", tvdbID, season, episode, durationMS)
|
||||
default:
|
||||
return fmt.Sprintf("imdb:%s:s%de%d:d%d", imdbID, season, episode, durationMS)
|
||||
}
|
||||
}
|
||||
|
||||
func cacheKeyMovie(tmdbID, tvdbID, imdbID string, durationMS int64) string {
|
||||
switch {
|
||||
case tmdbID != "":
|
||||
return fmt.Sprintf("tmdb:movie:%s:d%d", tmdbID, durationMS)
|
||||
case tvdbID != "":
|
||||
return fmt.Sprintf("tvdb:movie:%s:d%d", tvdbID, durationMS)
|
||||
default:
|
||||
return fmt.Sprintf("imdb:movie:%s:d%d", imdbID, durationMS)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Read TVDB in the provider and pass it through (`provider.go`)**
|
||||
|
||||
Replace the id-extraction block in `FetchMarkers` (the `tmdbID`/`imdbID` lines and the empty
|
||||
guard):
|
||||
|
||||
```go
|
||||
tmdbID := strings.TrimSpace(req.ExternalIDs[markers.ExternalIDKeyTMDB])
|
||||
tvdbID := strings.TrimSpace(req.ExternalIDs[markers.ExternalIDKeyTVDB])
|
||||
imdbID := strings.TrimSpace(req.ExternalIDs[markers.ExternalIDKeyIMDB])
|
||||
if tmdbID == "" && tvdbID == "" && imdbID == "" {
|
||||
return markers.Result{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Then update the two client calls in the `switch req.Kind` block to pass `tvdbID`:
|
||||
|
||||
```go
|
||||
resp, err = p.client.FetchEpisode(ctx, tmdbID, tvdbID, imdbID, req.SeasonNumber, req.EpisodeNumber, durationMS)
|
||||
```
|
||||
```go
|
||||
resp, err = p.client.FetchMovie(ctx, tmdbID, tvdbID, imdbID, durationMS)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the client tests to verify they pass**
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/markers/introdb/ -run 'TestFetch' -v
|
||||
```
|
||||
|
||||
Expected: PASS (all four `TestFetch*` tests).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/markers/introdb/client.go internal/markers/introdb/provider.go internal/markers/introdb/client_test.go
|
||||
git commit -m "fix(markers): honor TVDB ids in TheIntroDB lookups"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Real confidence + best-candidate selection (gaps #2, #3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/markers/introdb/provider.go` (`pickMarker`)
|
||||
- Create: `internal/markers/introdb/provider_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing provider tests**
|
||||
|
||||
Create `internal/markers/introdb/provider_test.go`:
|
||||
|
||||
```go
|
||||
package introdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
)
|
||||
|
||||
func newProvider(t *testing.T, body string) *Provider {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := NewClient("")
|
||||
c.SetBaseURL(srv.URL)
|
||||
return NewProvider(c)
|
||||
}
|
||||
|
||||
func episodeReq(ids map[string]string) markers.Request {
|
||||
return markers.Request{
|
||||
Kind: markers.ItemKindEpisode,
|
||||
ExternalIDs: ids,
|
||||
SeasonNumber: 1,
|
||||
EpisodeNumber: 1,
|
||||
Duration: 30 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderResolvesTVDBOnly(t *testing.T) {
|
||||
p := newProvider(t, `{"type":"episode","intro":[{"end_ms":60000}]}`)
|
||||
res, err := p.FetchMarkers(context.Background(), episodeReq(map[string]string{markers.ExternalIDKeyTVDB: "777"}))
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMarkers: %v", err)
|
||||
}
|
||||
if len(res.Markers) != 1 || res.Markers[0].Kind != markers.MarkerKindIntro {
|
||||
t.Fatalf("expected one intro marker, got %+v", res.Markers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUsesRealConfidence(t *testing.T) {
|
||||
p := newProvider(t, `{"type":"episode","intro":[{"end_ms":60000,"confidence":0.42}]}`)
|
||||
res, _ := p.FetchMarkers(context.Background(), episodeReq(map[string]string{markers.ExternalIDKeyTMDB: "1"}))
|
||||
if len(res.Markers) != 1 {
|
||||
t.Fatalf("want 1 marker, got %d", len(res.Markers))
|
||||
}
|
||||
if res.Markers[0].Confidence != 0.42 {
|
||||
t.Errorf("confidence = %v, want 0.42 (real value, not hardcoded)", res.Markers[0].Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDefaultsConfidenceWhenAbsent(t *testing.T) {
|
||||
p := newProvider(t, `{"type":"episode","intro":[{"end_ms":60000}]}`)
|
||||
res, _ := p.FetchMarkers(context.Background(), episodeReq(map[string]string{markers.ExternalIDKeyTMDB: "1"}))
|
||||
if res.Markers[0].Confidence != defaultConfidence {
|
||||
t.Errorf("confidence = %v, want default %v", res.Markers[0].Confidence, defaultConfidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderPicksMostSubmittedCandidate(t *testing.T) {
|
||||
body := `{"type":"episode","intro":[
|
||||
{"end_ms":50000,"confidence":0.6,"submission_count":2},
|
||||
{"end_ms":61000,"confidence":0.5,"submission_count":9}
|
||||
]}`
|
||||
p := newProvider(t, body)
|
||||
res, _ := p.FetchMarkers(context.Background(), episodeReq(map[string]string{markers.ExternalIDKeyTMDB: "1"}))
|
||||
if len(res.Markers) != 1 {
|
||||
t.Fatalf("want 1 marker, got %d", len(res.Markers))
|
||||
}
|
||||
if got := res.Markers[0].End; got != 61*time.Second {
|
||||
t.Errorf("picked end = %v, want 61s (the submission_count=9 candidate)", got)
|
||||
}
|
||||
if res.Markers[0].SubmissionCount != 9 {
|
||||
t.Errorf("submission_count = %d, want 9", res.Markers[0].SubmissionCount)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/markers/introdb/ -run TestProvider -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `TestProviderUsesRealConfidence` sees `0.9` (hardcoded), `TestProviderPicksMostSubmittedCandidate`
|
||||
gets the first usable (`end=50s`, `SubmissionCount=0`). (`TestProviderResolvesTVDBOnly` already
|
||||
passes from Task 2 — it guards against regression.)
|
||||
|
||||
- [ ] **Step 3: Rewrite `pickMarker` in `provider.go`**
|
||||
|
||||
Replace the entire `pickMarker` function:
|
||||
|
||||
```go
|
||||
// pickMarker selects the best usable segment from a TheIntroDB response array.
|
||||
// `requireEnd` is true for segments where the end timestamp is the load-bearing
|
||||
// field (intro, recap) — they're allowed to start at 0 if `start_ms` is omitted.
|
||||
// For trailing segments (credits, preview) the start is required but the end
|
||||
// defaults to the file duration. When several candidates are usable (e.g. no
|
||||
// duration match narrowed the set), the most-submitted one wins, with higher
|
||||
// confidence breaking ties; with a single candidate this is the previous
|
||||
// first-usable behavior. Real per-segment confidence is used when present,
|
||||
// falling back to defaultConfidence only when the API omits it.
|
||||
func pickMarker(stamps []segmentTimestamps, kind markers.MarkerKind, totalDuration time.Duration, requireEnd bool) (markers.Marker, bool) {
|
||||
best := markers.Marker{}
|
||||
bestSubs := -1
|
||||
found := false
|
||||
for _, s := range stamps {
|
||||
start := time.Duration(0)
|
||||
end := totalDuration
|
||||
if s.StartMs != nil {
|
||||
start = time.Duration(*s.StartMs) * time.Millisecond
|
||||
}
|
||||
if s.EndMs != nil {
|
||||
end = time.Duration(*s.EndMs) * time.Millisecond
|
||||
}
|
||||
if requireEnd && s.EndMs == nil {
|
||||
continue
|
||||
}
|
||||
if !requireEnd && s.StartMs == nil {
|
||||
continue
|
||||
}
|
||||
if end <= start {
|
||||
continue
|
||||
}
|
||||
confidence := defaultConfidence
|
||||
if s.Confidence != nil {
|
||||
confidence = *s.Confidence
|
||||
}
|
||||
subs := 0
|
||||
if s.SubmissionCount != nil {
|
||||
subs = *s.SubmissionCount
|
||||
}
|
||||
if !found || subs > bestSubs || (subs == bestSubs && confidence > best.Confidence) {
|
||||
best = markers.Marker{Kind: kind, Start: start, End: end, Confidence: confidence, SubmissionCount: subs}
|
||||
bestSubs = subs
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/markers/introdb/ -run TestProvider -v
|
||||
```
|
||||
|
||||
Expected: PASS (all four `TestProvider*` tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/markers/introdb/provider.go internal/markers/introdb/provider_test.go
|
||||
git commit -m "feat(markers): use real introdb confidence and pick best candidate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Full-package regression + integration build
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Run the entire markers test suite**
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/markers/... -v
|
||||
```
|
||||
|
||||
Expected: PASS — the new `introdb` tests plus the existing `write_test.go` /
|
||||
`types_test.go` (unchanged behavior: `BuildUpdatePayload` still aggregates max confidence,
|
||||
`FetchFirstHit` still uses registration order).
|
||||
|
||||
- [ ] **Step 2: Build the binary to confirm nothing downstream broke**
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: exit 0. (The only callers of the changed client methods are in `provider.go`, updated
|
||||
in Task 2; `cmd/silo/main.go` constructs the provider but never calls the client directly.)
|
||||
|
||||
- [ ] **Step 3: Confirm the working tree is clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: clean (all changes committed in Tasks 1–3; this task adds no files).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Gap coverage:** TVDB lookups (Task 2, `TestFetch*` + `TestProviderResolvesTVDBOnly`), real
|
||||
confidence (Task 3, `TestProviderUsesRealConfidence` + `…DefaultsConfidenceWhenAbsent`),
|
||||
best-candidate (Task 3, `TestProviderPicksMostSubmittedCandidate`). Maps to design gaps #1–#3.
|
||||
- **Containment:** the client signature change touches exactly one production caller
|
||||
(`provider.go`); `markers.Marker` is constructed only in `provider.go`. Verified by grep before
|
||||
planning, re-verified by the Task 4 `go build ./...`.
|
||||
- **No behavior regressions:** the write path is untouched, so `BuildUpdatePayload`'s
|
||||
max-confidence aggregation and its test remain valid; per-segment confidence is deferred to
|
||||
Phase 2 where the merge actually consumes it.
|
||||
- **No placeholders:** every code step shows the complete replacement; every run step shows the
|
||||
command and the expected result. No migration, no frontend, no wiring changes.
|
||||
- **Default confidence:** `defaultConfidence = 0.9` preserves today's effective value for
|
||||
responses that omit confidence, so existing stored markers keep equivalent provenance.
|
||||
@@ -1,482 +0,0 @@
|
||||
# Marker sources & TheIntroDB contribution — implementation plan (all phases)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Implement phases in order; each phase is independently shippable.
|
||||
|
||||
**Goal:** Implement the design in
|
||||
[2026-06-06-marker-sources-and-contribution-design.md](../specs/2026-06-06-marker-sources-and-contribution-design.md):
|
||||
fix the TheIntroDB read-path correctness gaps, formalize a multi-source provider model
|
||||
(query-all / best-wins), and add the ability to contribute Silo's own markers back to
|
||||
TheIntroDB — configured **per provider, off by default**. Server-side API + data model only;
|
||||
web UI is a separate follow-up.
|
||||
|
||||
**Architecture:** Five sequential phases. Phase 1 fixes the existing provider (TVDB, real
|
||||
confidence, best-candidate). Phase 2 adds a per-provider config table, per-segment marker
|
||||
provenance, and `Registry.FetchMerged`. Phase 3 adds the submission client, the contribution
|
||||
audit table, and the `ContributionService` engine. Phase 4 exposes the full admin API (manual
|
||||
markers, provider config, contribute, history). Phase 5 adds the daily auto-contribution task.
|
||||
|
||||
**Tech Stack:** Go (chi handlers, pgx repositories), PostgreSQL (paired numbered migrations),
|
||||
no frontend in this plan. Most touched packages (`internal/markers`, `internal/markers/introdb`,
|
||||
`internal/taskmanager/tasks`) are pure Go; `internal/api/handlers` needs libvips (CGO image
|
||||
deps). Tests run in a throwaway Go container (host has no Go toolchain).
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Phase overview & dependencies
|
||||
|
||||
| Phase | Delivers | Depends on | New migrations |
|
||||
|------|----------|-----------|----------------|
|
||||
| 1 | TheIntroDB read-path correctness (TVDB, real confidence, best-candidate) | — | none |
|
||||
| 2 | `marker_provider_config` + per-segment provenance + `FetchMerged` | 1 (real confidence) | `marker_provider_config` |
|
||||
| 3 | Submission client + `marker_contributions` + `ContributionService` | 2 (`Submitter`, provider config) | `marker_contributions` |
|
||||
| 4 | Admin API: manual markers, provider config, contribute, history | 3 (service) | none |
|
||||
| 5 | Daily auto-contribution task | 3 (service), 4 (optional) | none |
|
||||
|
||||
Phase 1's line-level steps are also captured standalone in
|
||||
[2026-06-06-marker-introdb-readpath-correctness.md](2026-06-06-marker-introdb-readpath-correctness.md);
|
||||
they are summarized here so this document is self-contained.
|
||||
|
||||
---
|
||||
|
||||
## Running tests (host has no Go toolchain)
|
||||
|
||||
Pure-Go packages (`internal/markers`, `internal/markers/introdb`, `internal/taskmanager/...`):
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/markers/... -v
|
||||
```
|
||||
|
||||
`internal/api/handlers` (Phase 4) needs libvips:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
sh -c 'apt-get update >/dev/null && apt-get install -y --no-install-recommends libvips-dev >/dev/null && go test ./internal/api/handlers/ -run TestAdminMarkers -v'
|
||||
```
|
||||
|
||||
Whole-project compile: `go build ./...` (same container).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — TheIntroDB read-path correctness
|
||||
|
||||
**Goal:** Honor TVDB ids, capture/use real per-segment confidence + submission_count, and pick
|
||||
the best candidate among multiple. Contained to `internal/markers/introdb` plus one field on
|
||||
`markers.Marker`. No migration, no write-path change.
|
||||
|
||||
**Files:** `internal/markers/introdb/{types.go,client.go,provider.go}` (modify),
|
||||
`internal/markers/types.go` (modify, add `Marker.SubmissionCount`),
|
||||
`internal/markers/introdb/{client_test.go,provider_test.go}` (create).
|
||||
|
||||
### Task 1.1 — Type plumbing
|
||||
- [ ] Add `Confidence *float64` and `SubmissionCount *int` to `segmentTimestamps`
|
||||
(`introdb/types.go`); add `const defaultConfidence = 0.9`.
|
||||
- [ ] Add `SubmissionCount int` to `markers.Marker` (`markers/types.go`).
|
||||
- [ ] `go build ./internal/markers/...` → exit 0. Commit.
|
||||
|
||||
### Task 1.2 — TVDB lookups (gap #1)
|
||||
- [ ] Create `introdb/client_test.go` (httptest): asserts `tvdb_id` sent when only tvdb given,
|
||||
tmdb preferred over tvdb/imdb, movie tvdb, and cache collapses repeat calls. Run → FAIL (compile).
|
||||
- [ ] `client.go`: `FetchEpisode`/`FetchMovie` gain a `tvdbID` arg; query preference
|
||||
`tmdb → tvdb → imdb` via a `switch`; the empty-id guard includes tvdb; `cacheKeyEpisode`/
|
||||
`cacheKeyMovie` include a `tvdb:` branch.
|
||||
- [ ] `provider.go` `FetchMarkers`: read `req.ExternalIDs[markers.ExternalIDKeyTVDB]`, include it
|
||||
in the empty guard, pass it to both client calls.
|
||||
- [ ] Run client tests → PASS. Commit.
|
||||
|
||||
### Task 1.3 — Real confidence + best-candidate (gaps #2, #3)
|
||||
- [ ] Create `introdb/provider_test.go`: TVDB-only resolves; real confidence used; default when
|
||||
absent; most-submitted candidate wins (ties broken by confidence). Run → FAIL.
|
||||
- [ ] Rewrite `pickMarker` to use real confidence (fallback `defaultConfidence`), set
|
||||
`SubmissionCount`, and select the candidate with the highest `(submission_count, confidence)`.
|
||||
- [ ] Run → PASS. Commit.
|
||||
|
||||
### Task 1.4 — Regression
|
||||
- [ ] `go test ./internal/markers/... && go build ./...` → all green (existing `write_test.go`/
|
||||
`types_test.go` unaffected; write path untouched). Commit if needed.
|
||||
|
||||
(Full code for every step is in the standalone Phase 1 plan linked above.)
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 — Per-provider config, per-segment provenance, FetchMerged
|
||||
|
||||
**Goal:** Add the `marker_provider_config` table that drives which providers are queried (and,
|
||||
later, submitted to); make the marker write path carry **per-segment** provider/confidence/
|
||||
algorithm so a merged multi-provider result writes correct provenance; add
|
||||
`Registry.FetchMerged` (query all enabled providers, keep the best candidate per segment) and
|
||||
switch the lazy-playback path to it. With only TheIntroDB enabled, behavior is unchanged.
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/<next>_marker_provider_config.{up,down}.sql`
|
||||
- Create: `internal/markers/provider_config.go`, `internal/markers/provider_config_test.go`
|
||||
- Modify: `internal/markers/types.go` (`Marker.ProviderID`/`Algorithm`; `Registry.FetchMerged`)
|
||||
- Modify: `internal/markers/write.go` (per-segment provenance in `MarkerUpdatePayload`/`BuildUpdatePayload`)
|
||||
- Modify: `internal/scanner/file_repo.go` (`MarkerUpdate` + `applySegmentPatch` per-segment provenance)
|
||||
- Modify: the `markers.MarkerUpdatePayload` → `scanner.MarkerUpdate` conversion (in the lazy-markers handler/adapter)
|
||||
- Modify: `internal/api/handlers/playback_lazy_markers.go` (call `FetchMerged`)
|
||||
- Modify: `cmd/silo/main.go` (construct the config store; pass to the registry)
|
||||
|
||||
### Task 2.1 — Migration: `marker_provider_config`
|
||||
- [ ] Write `migrations/<next>_marker_provider_config.up.sql` (use the next free number; latest is 180):
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.marker_provider_config (
|
||||
provider text PRIMARY KEY,
|
||||
fetch_enabled boolean NOT NULL DEFAULT true,
|
||||
fetch_priority integer NOT NULL DEFAULT 100,
|
||||
contribute_enabled boolean NOT NULL DEFAULT false,
|
||||
contribute_auto_local boolean NOT NULL DEFAULT false,
|
||||
contribute_min_confidence double precision NOT NULL DEFAULT 0.95,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO public.marker_provider_config (provider, fetch_enabled) VALUES ('introdb', true);
|
||||
```
|
||||
|
||||
- [ ] `.down.sql`: `DROP TABLE public.marker_provider_config;`
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 2.2 — Provider config store
|
||||
- [ ] Create `internal/markers/provider_config.go`:
|
||||
|
||||
```go
|
||||
type ProviderConfig struct {
|
||||
Provider string
|
||||
FetchEnabled bool
|
||||
FetchPriority int
|
||||
ContributeEnabled bool
|
||||
ContributeAutoLocal bool
|
||||
ContributeMinConfidence float64
|
||||
}
|
||||
|
||||
// ProviderConfigStore is a cached read/write façade over marker_provider_config.
|
||||
// Reads serve from an in-memory snapshot; Update writes through and refreshes.
|
||||
type ProviderConfigStore struct { /* pool, mu, cache map[string]ProviderConfig */ }
|
||||
|
||||
func NewProviderConfigStore(pool *pgxpool.Pool) *ProviderConfigStore
|
||||
func (s *ProviderConfigStore) Reload(ctx context.Context) error // load all rows into cache
|
||||
func (s *ProviderConfigStore) List() []ProviderConfig // snapshot copy
|
||||
func (s *ProviderConfigStore) Get(provider string) (ProviderConfig, bool)
|
||||
func (s *ProviderConfigStore) Update(ctx context.Context, p ProviderConfig) error // upsert + refresh
|
||||
// EnabledForFetch returns fetch_enabled providers sorted by (fetch_priority asc, provider asc).
|
||||
func (s *ProviderConfigStore) EnabledForFetch() []ProviderConfig
|
||||
```
|
||||
|
||||
- [ ] Hot-reload: subscribe the store's `Reload` to the existing `cache.EventSettingsChanged`
|
||||
Redis channel (mirror the `introdb.api_key` reload wiring in `cmd/silo/main.go`), or add a
|
||||
dedicated `marker_provider_config_changed` event published by `Update`. Either is acceptable;
|
||||
document which.
|
||||
- [ ] `provider_config_test.go`: with a real/pgxmock pool or an injected fake, assert
|
||||
`EnabledForFetch` filters disabled rows and sorts by priority. Commit.
|
||||
|
||||
### Task 2.3 — Per-segment marker provenance (write path)
|
||||
|
||||
This makes a merged result (intro from provider A, credits from provider B) persist the correct
|
||||
per-segment `*_markers_provider` / `*_markers_confidence` / `*_markers_algorithm`. The DB columns
|
||||
already exist; today `applySegmentPatch` applies one shared triple.
|
||||
|
||||
- [ ] Add to `markers.Marker`: `ProviderID string`, `Algorithm string` (so each segment carries
|
||||
its origin). `provider.go` (Phase 1) sets these from `ProviderID`/`Algorithm` per marker.
|
||||
- [ ] Rework `markers.MarkerUpdatePayload` (`write.go`) from one shared `Confidence`/`Provider`/
|
||||
`Algorithm` to a per-segment struct, e.g.:
|
||||
|
||||
```go
|
||||
type SegmentPayload struct { Start, End *float64; Provider *string; Confidence *float64; Algorithm string }
|
||||
type MarkerUpdatePayload struct {
|
||||
Intro, Credits, Recap, Preview SegmentPayload
|
||||
Source string // shared source class (e.g. "online"); per-kind provider differs
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Update `BuildUpdatePayload` to fill each `SegmentPayload` from the matching `Marker`
|
||||
(provider/confidence/algorithm per segment). Update `write_test.go`
|
||||
(`TestBuildUpdatePayloadAggregatesConfidence` → assert per-segment confidence: intro 0.7,
|
||||
credits 0.9, recap 0.5).
|
||||
- [ ] Read `applySegmentPatch` and the `MarkerUpdatePayload → scanner.MarkerUpdate` conversion
|
||||
first, then extend `scanner.MarkerUpdate` + `applySegmentPatch` so each segment's
|
||||
provider/confidence/algorithm come from that segment (not a shared field). `nextSharedMarkerAttribution`
|
||||
keeps populating the legacy shared `markers_source`/`markers_confidence` columns from the
|
||||
highest-confidence applied segment.
|
||||
- [ ] `go test ./internal/markers/... ./internal/scanner/...` → green. Commit.
|
||||
|
||||
### Task 2.4 — `Registry.FetchMerged`
|
||||
- [ ] Give `Registry` access to the enabled set (constructor takes a `ProviderConfigStore`, or
|
||||
`FetchMerged` takes an `[]ProviderConfig` snapshot). Add:
|
||||
|
||||
```go
|
||||
// FetchMerged queries every fetch-enabled provider concurrently and keeps, per
|
||||
// segment kind, the candidate with the highest (SubmissionCount, Confidence),
|
||||
// breaking ties by the provider's fetch_priority. Per-segment ProviderID/
|
||||
// Algorithm are preserved so the write path records correct provenance.
|
||||
func (r *Registry) FetchMerged(ctx context.Context, req Request) (Result, bool, error)
|
||||
```
|
||||
|
||||
Algorithm: fan out via goroutines (bounded) to enabled providers; collect `(ProviderConfig,
|
||||
Result, error)`; for each `MarkerKind`, choose the best `Marker` across results; assemble a
|
||||
`Result` whose `Markers` carry their winning provider's `ProviderID`/`Algorithm`. Log and skip
|
||||
provider errors (like `FetchFirstHit`). Return `ok=false` if no segment found.
|
||||
|
||||
- [ ] `types_test.go`: add a merge test — two fake providers, one wins intro, the other wins
|
||||
credits by submission_count; assert the merged result has both with correct per-segment
|
||||
ProviderID. Keep the existing `FetchFirstHit` tests. Commit.
|
||||
|
||||
### Task 2.5 — Switch lazy path + wire store
|
||||
- [ ] In `internal/api/handlers/playback_lazy_markers.go`, replace the `FetchFirstHit` call in
|
||||
`fetchOnlineMarkersForPlayback` with `FetchMerged`.
|
||||
- [ ] In `cmd/silo/main.go`, construct `NewProviderConfigStore`, `Reload` it at startup, wire its
|
||||
hot-reload, and pass it to the registry. (TheIntroDB stays registered as today.)
|
||||
- [ ] `go build ./... && go test ./internal/markers/...` → green. With only `introdb` enabled,
|
||||
`FetchMerged` returns the same result `FetchFirstHit` did — verify a provider test asserting
|
||||
single-provider parity. Commit.
|
||||
|
||||
---
|
||||
|
||||
# Phase 3 — Submission client, contribution tracking, service engine
|
||||
|
||||
**Goal:** Build the machinery to submit a marker to a provider, idempotently and audited —
|
||||
without yet wiring a trigger. Adds the `Submitter` capability, the introdb submission/stats
|
||||
client methods, the `marker_contributions` table, and the `ContributionService`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/markers/types.go` (`Submitter`, `SubmissionRequest`/`SubmissionResult`/`UserStats`)
|
||||
- Modify: `internal/markers/introdb/{types.go,client.go,provider.go}` (submit + stats; implement `Submitter`)
|
||||
- Create: `migrations/<next>_marker_contributions.{up,down}.sql`
|
||||
- Create: `internal/markers/contribution_repo.go`, `internal/markers/contribute.go` (+ tests)
|
||||
- Modify: `cmd/silo/main.go` (construct the service)
|
||||
|
||||
### Task 3.1 — `Submitter` capability + DTOs
|
||||
- [ ] Add to `internal/markers/types.go`:
|
||||
|
||||
```go
|
||||
type SubmissionRequest struct {
|
||||
Kind ItemKind
|
||||
ExternalIDs map[string]string // tmdb/imdb (tvdb not accepted by /submit; tmdb required)
|
||||
SeasonNumber int
|
||||
EpisodeNumber int
|
||||
Segment MarkerKind
|
||||
Start, End *time.Duration // nil start ok for intro/recap; nil end ok for credits/preview
|
||||
Duration time.Duration
|
||||
}
|
||||
type SubmissionResult struct { ID string; Status string; Weight float64 } // status: pending|accepted|rejected
|
||||
type UserStats struct { Total, Accepted, Pending, Rejected int; AcceptanceRate float64; CurrentStreak, BestStreak int }
|
||||
|
||||
type Submitter interface {
|
||||
Provider
|
||||
SubmitMarker(ctx context.Context, req SubmissionRequest) (SubmissionResult, error)
|
||||
FetchUserStats(ctx context.Context) (UserStats, error)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Commit (compiles; no implementer yet).
|
||||
|
||||
### Task 3.2 — introdb submission client
|
||||
- [ ] `introdb/types.go`: add the request/response shapes for `POST /v3/submit`
|
||||
(`tmdb_id`,`imdb_id`,`type`,`segment`,`season`,`episode`,`video_duration_ms`,`start_ms`,`end_ms`;
|
||||
response `{submissions:[{id,status,weight,...}]}`) and `GET /v3/user/stats`. Remove the
|
||||
"Submissions are intentionally not supported" package note.
|
||||
- [ ] `introdb/client.go`: add `SubmitSegment(ctx, ...) (*submitResponse, error)` (POST,
|
||||
`Authorization: Bearer` **required** — return an error if `apiKey == ""`; honor
|
||||
`X-UsageLimit-Reset` on 429) and `FetchUserStats(ctx) (*userStats, error)` (GET).
|
||||
- [ ] `introdb/provider.go`: implement `markers.Submitter` — map `SubmissionRequest` to the
|
||||
client call (apply the v3 null rules: intro/recap drop a zero start to `null`; credits/preview
|
||||
send `null` end when absent), translate `SubmissionResult`/`UserStats`.
|
||||
- [ ] `client_test.go`/`provider_test.go`: httptest asserts the submit body shape, bearer header,
|
||||
and that an empty key errors before any HTTP call; stats parse. Commit.
|
||||
|
||||
### Task 3.3 — `marker_contributions` table + repo
|
||||
- [ ] Migration `<next>_marker_contributions.up.sql` (per design §C4):
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.marker_contributions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
media_file_id integer NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
|
||||
provider text NOT NULL,
|
||||
segment_kind text NOT NULL,
|
||||
source text NOT NULL,
|
||||
submitted_start_ms bigint,
|
||||
submitted_end_ms bigint,
|
||||
video_duration_ms bigint,
|
||||
content_hash text NOT NULL,
|
||||
submission_id uuid,
|
||||
status text NOT NULL,
|
||||
http_status integer,
|
||||
error text,
|
||||
submitted_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (media_file_id, provider, segment_kind, content_hash)
|
||||
);
|
||||
CREATE INDEX marker_contributions_file_idx ON public.marker_contributions(media_file_id);
|
||||
```
|
||||
|
||||
- [ ] `.down.sql`: drop the table.
|
||||
- [ ] `internal/markers/contribution_repo.go`: `ContributionStore` with `AlreadySubmitted(ctx,
|
||||
fileID, provider, kind, contentHash) (bool, error)`, `Record(ctx, ContributionRow) error`
|
||||
(upsert on the unique key), `ListByFile(ctx, fileID) ([]ContributionRow, error)`. Add a
|
||||
`ContentHash(kind string, startMs, endMs, durationMs *int64) string` helper (stable SHA-256).
|
||||
- [ ] Repo test (value-hash idempotency, list). Commit.
|
||||
|
||||
### Task 3.4 — `ContributionService`
|
||||
- [ ] `internal/markers/contribute.go`:
|
||||
|
||||
```go
|
||||
type ContributionService struct { /* registry, resolver, cfg *ProviderConfigStore, store *ContributionStore, log */ }
|
||||
|
||||
// ContributeFile submits the file's eligible markers to enabled submitter providers.
|
||||
// opts may scope to one provider and/or specific segment kinds. Auto callers pass
|
||||
// requireAutoLocal=true so only providers with contribute_auto_local participate.
|
||||
func (s *ContributionService) ContributeFile(ctx context.Context, file *models.MediaFile, opts ContributeOptions) ([]ContributionOutcome, error)
|
||||
```
|
||||
|
||||
Per provider (from `cfg.List()` filtered to `Submitter` + `contribute_enabled`, and
|
||||
`contribute_auto_local` when `opts.Auto`):
|
||||
1. **Eligibility (C2):** for each requested segment present on the file, require
|
||||
`source ∈ {scanner, manual}` (skip `online` — circular); resolve external IDs via the existing
|
||||
resolver (skip if none / tmdb required by `/submit`); for auto require `source=scanner`,
|
||||
`kind=intro`, `confidence ≥ contribute_min_confidence`.
|
||||
2. **Idempotency:** compute `ContentHash`; skip if `AlreadySubmitted`.
|
||||
3. **Submit:** `provider.(Submitter).SubmitMarker(...)`.
|
||||
4. **Persist:** `store.Record(...)` with returned status/submission_id (or `status="error"`).
|
||||
|
||||
- [ ] `contribute_test.go` with a fake `Submitter` + in-memory store: asserts online-sourced
|
||||
markers are skipped, sub-threshold auto markers skipped, duplicates skipped, a fresh manual
|
||||
marker submitted and recorded. Commit.
|
||||
|
||||
### Task 3.5 — Wire
|
||||
- [ ] `cmd/silo/main.go`: construct `ContributionService` (registry, resolver, config store,
|
||||
contribution store). Not yet called by any handler/task. `go build ./...` + tests green. Commit.
|
||||
|
||||
---
|
||||
|
||||
# Phase 4 — Admin API (manual markers, provider config, contribute, history)
|
||||
|
||||
**Goal:** Expose the full backend contract under `RequireAdmin`. Mirror the structure of
|
||||
`internal/api/handlers/admin_intro.go` (interface-typed deps, `chi.URLParam`, `writeError`/
|
||||
`writeJSON`, `MarkerUpdateNotifier` after writes).
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/api/handlers/admin_markers.go` (+ test), `internal/api/handlers/admin_marker_providers.go` (+ test)
|
||||
- Modify: `internal/api/router.go` (register routes in the `RequireAdmin` group)
|
||||
- Modify: `cmd/silo/main.go` (construct + inject the handlers)
|
||||
|
||||
### Task 4.1 — Manual marker endpoints (design §C3)
|
||||
- [ ] `admin_markers.go`:
|
||||
- `GET /admin/files/{fileId}/markers` → current per-segment values + provenance
|
||||
(`source`,`provider`,`confidence`,`algorithm`,`detected_at`) for intro/recap/credits/preview.
|
||||
- `PUT /admin/files/{fileId}/markers` → body per segment `{start,end}` (seconds) or `null` to
|
||||
clear; write `source = "manual"` via `scanner.FileRepository.UpsertMarkers` (priority 4);
|
||||
then `MarkerUpdateNotifier.MarkersUpdated(file)`. Validation: `end>start`; intro/recap may
|
||||
omit start; credits/preview may omit end; within `[0, duration]`.
|
||||
- `DELETE /admin/files/{fileId}/markers/{segment}` → clear one segment.
|
||||
- `GET|PUT /admin/items/{id}/markers` → resolve to the item's primary file (convenience).
|
||||
- [ ] Handler test (libvips container): PUT writes manual markers and returns provenance;
|
||||
invalid range → 400; clear nulls the segment. Commit.
|
||||
|
||||
### Task 4.2 — Provider config + validate endpoints (design §C7)
|
||||
- [ ] `admin_marker_providers.go`:
|
||||
- `GET /admin/markers/providers` → for each registered provider: id, `isSubmitter`, its
|
||||
`marker_provider_config` row, and (best-effort cached) `UserStats` for submitters with a key.
|
||||
- `PUT /admin/markers/providers/{provider}` → update the config row (validated; unknown → 404).
|
||||
- `POST /admin/markers/providers/{provider}/validate` → `Submitter.FetchUserStats`; non-submitter → 400.
|
||||
- [ ] Test: list returns introdb with its config; update flips `contribute_enabled`; validate on
|
||||
a non-submitter → 400. Commit.
|
||||
|
||||
### Task 4.3 — Contribution endpoints (design §C7)
|
||||
- [ ] In `admin_markers.go` (or a small `admin_contributions.go`):
|
||||
- `POST /admin/files/{fileId}/contribute` (optional body `{provider?, segments?[]}`) →
|
||||
`ContributionService.ContributeFile(...)`; returns per (provider, segment) outcome.
|
||||
- `GET /admin/files/{fileId}/contributions` → `ContributionStore.ListByFile`.
|
||||
- [ ] Test with a fake service/store. Commit.
|
||||
|
||||
### Task 4.4 — Routing + wiring
|
||||
- [ ] Register all new routes inside the existing `RequireAdmin` group in `internal/api/router.go`
|
||||
(mirror the `redetect-intro` registration). Inject the handlers in `cmd/silo/main.go`.
|
||||
- [ ] `go build ./... && go test ./internal/api/handlers/ -run 'TestAdminMarkers|TestAdminMarkerProviders'`
|
||||
(libvips container) → green. Commit.
|
||||
|
||||
---
|
||||
|
||||
# Phase 5 — Daily auto-contribution task
|
||||
|
||||
**Goal:** A scheduled task that, for each provider with `contribute_enabled &&
|
||||
contribute_auto_local`, submits high-confidence local intro detections that haven't been
|
||||
contributed. Mirror `internal/taskmanager/tasks/detect_intro_markers.go`.
|
||||
|
||||
**Files:** Create `internal/taskmanager/tasks/contribute_markers.go` (+ test); modify
|
||||
`cmd/silo/main.go` (register the task).
|
||||
|
||||
### Task 5.1 — `ContributeMarkersTask`
|
||||
- [ ] Implement the `taskmanager.Task` interface (`Key="contribute_markers"`, `Name`,
|
||||
`Description`, `Category=TaskCategoryLibrary`, `IsHidden() bool`, `DefaultTriggers()` daily e.g.
|
||||
`"04:00"` — after the 03:30 detection task, `Execute`).
|
||||
- [ ] `Execute`: short-circuit if no provider has `contribute_enabled && contribute_auto_local`
|
||||
(read `ProviderConfigStore`). Otherwise page through episode files with
|
||||
`intro_markers_source='scanner'` and `intro_markers_confidence ≥` the provider's
|
||||
`contribute_min_confidence` (a new `ContributionStore`/repo query, batched), and call
|
||||
`ContributionService.ContributeFile(file, ContributeOptions{Auto:true})`. Report progress; obey
|
||||
the introdb usage limit (the client already backs off on `X-UsageLimit-Reset`); idempotency
|
||||
makes interrupted runs resumable.
|
||||
- [ ] Test with fakes: enabled+auto submits eligible files; disabled → no-op; sub-threshold
|
||||
skipped. Commit.
|
||||
|
||||
### Task 5.2 — Register
|
||||
- [ ] Register the task where `DetectIntroMarkersTask` is registered in `cmd/silo/main.go`.
|
||||
- [ ] `go build ./... && go test ./internal/taskmanager/...` → green. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Final integration verification
|
||||
|
||||
**Files:** none (build + runtime).
|
||||
|
||||
- [ ] **Build the image** (compiles backend + frontend):
|
||||
```bash
|
||||
docker build --build-arg BUILD_REVISION=$(git rev-parse --short HEAD) --build-arg BUILD_DIRTY=false -t silo-server:markers-test .
|
||||
```
|
||||
- [ ] **Recreate the container** (applies the new migrations on startup) and wait for health:
|
||||
```bash
|
||||
docker compose up -d silo
|
||||
for i in $(seq 1 24); do s=$(docker inspect -f '{{.State.Health.Status}}' silo-silo-1); echo "$s"; [ "$s" = healthy ] && break; sleep 5; done
|
||||
```
|
||||
- [ ] **Verify migrations applied + tables exist:**
|
||||
```bash
|
||||
docker compose exec -T postgres psql -U silo -d silo -c \
|
||||
"SELECT to_regclass('public.marker_provider_config'), to_regclass('public.marker_contributions');"
|
||||
docker compose exec -T postgres psql -U silo -d silo -c "SELECT * FROM marker_provider_config;"
|
||||
```
|
||||
Expected: both tables present; an `introdb` row with `fetch_enabled=t, contribute_enabled=f`.
|
||||
- [ ] **Smoke the read path (TVDB):** trigger playback (or call the marker fetch) for a
|
||||
TVDB-only-matched episode and confirm markers now populate where they previously did not.
|
||||
- [ ] **Smoke contribution (manual, dry):** enable contribution for introdb against a **test/staging**
|
||||
TheIntroDB key, `PUT` a manual marker on a file, `POST /admin/files/{id}/contribute`, then
|
||||
`GET /admin/files/{id}/contributions` shows a `pending` row; a second contribute is idempotent
|
||||
(no new row). Use a non-production key so test data isn't submitted to the real database.
|
||||
- [ ] `git status` → clean (all changes committed per phase).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Phase independence:** each phase compiles, tests, and ships alone; with only `introdb`
|
||||
enabled and contribution off (defaults), Phases 2–5 are observationally inert until an operator
|
||||
enables a provider — so they can land ahead of any behavior change.
|
||||
- **Per-provider, off by default:** `contribute_enabled`/`contribute_auto_local` default `false`
|
||||
in the migration; the service refuses to submit otherwise; auto additionally gated on
|
||||
`contribute_auto_local`. The `provider` column on `marker_contributions` and the per-provider
|
||||
loop make multi-target submission first-class.
|
||||
- **Circular-contribution guard:** `source='online'` is never contributed (C2 eligibility) — has
|
||||
a dedicated unit test in Task 3.4.
|
||||
- **Idempotency:** value `content_hash` + the unique constraint mean repeat/interrupted runs
|
||||
don't double-submit, while a corrected value (new hash) does submit — matching TheIntroDB's
|
||||
weighted-average model.
|
||||
- **Write-path accuracy:** Task 2.3 requires reading `applySegmentPatch` and the
|
||||
`MarkerUpdatePayload → scanner.MarkerUpdate` conversion before editing; the per-segment columns
|
||||
already exist in `media_files`, so this is a plumbing change, not a schema change.
|
||||
- **Migration numbers:** `marker_provider_config` and `marker_contributions` take the next two
|
||||
free numbers at implementation time (181+; latest on disk is 180). Re-check for collisions
|
||||
before writing.
|
||||
- **Out of scope (tracked in the design doc):** all web UI; per-user TheIntroDB accounts; a
|
||||
`marker_provider.v1` plugin capability. Clean seams left for each.
|
||||
@@ -1,14 +1,13 @@
|
||||
# Audiobooks Absorption — Discovery Findings
|
||||
|
||||
Produced by sub-plan 1, Task 1. Locks data-model and integration
|
||||
decisions for the audiobook foundation migrations and downstream sub-plans.
|
||||
decisions for migrations 139–142 and downstream sub-plans.
|
||||
|
||||
## D1 — Next migration number
|
||||
|
||||
Original snapshot: `138_search_number_word_normalization.up.sql` was the
|
||||
highest existing migration. The landed implementation was renumbered to
|
||||
`147_abs_sessions`, `157_podcast_feeds`, `159_media_folders_kind_noop`, and
|
||||
`160_audiobooks_feature_flag`.
|
||||
Highest existing migration: 138_search_number_word_normalization.up.sql
|
||||
Next available number: 139
|
||||
Sub-plan 1 will use migrations 139 through 142.
|
||||
|
||||
## D2 — `media_libraries` kind/type column
|
||||
|
||||
@@ -17,10 +16,10 @@ The spec refers to `media_libraries` but the actual table is `media_folders`.
|
||||
library content. Current values in production: `movies`, `series`, `mixed`.
|
||||
|
||||
Existing column 'type' (text) discriminates library content on `media_folders`.
|
||||
Task 4 (migration 159) should ADD the value `audiobooks` to the type vocabulary
|
||||
Task 4 (migration 141) should ADD the value `audiobooks` to the type vocabulary
|
||||
rather than add a new column. The audiobook scanner branch will set
|
||||
`media_folders.type = 'audiobooks'` for audiobook libraries. No schema change
|
||||
needed for the column itself; migration 159 becomes a no-op DDL migration that
|
||||
needed for the column itself; migration 141 becomes a no-op DDL migration that
|
||||
documents the new allowed value and adds any supporting indexes if needed.
|
||||
|
||||
No CHECK constraint or enum enforces the `type` column values, so adding
|
||||
|
||||
@@ -41,10 +41,9 @@ Go application. Eliminate it as a plugin. User-visible outcome:
|
||||
### Out
|
||||
|
||||
- Audiobook requests flow (`silo-plugin-audiobook-requests` untouched).
|
||||
- Smart collections, share links, content restrictions, metadata-provider integration.
|
||||
- Smart collections, share links, content restrictions, custom metadata providers.
|
||||
- Embedding-powered "similar books" recommender.
|
||||
- External enrichment through the audiobook metadata plugin/provider — deferred;
|
||||
scanner extracts local tags and identifiers for v1.
|
||||
- External enrichment (OpenLibrary / Google Books) — deferred; file tags only for v1.
|
||||
- Group listening / cowatch parity for audiobooks (silo already has cowatch for
|
||||
video; revisit later if needed).
|
||||
|
||||
@@ -54,7 +53,7 @@ Go application. Eliminate it as a plugin. User-visible outcome:
|
||||
|
||||
New top-level Go package: `internal/audiobooks/`.
|
||||
|
||||
```text
|
||||
```
|
||||
internal/audiobooks/
|
||||
abs/ ← ported from plugin's internal/abs/ (~6.8k LOC)
|
||||
ABS-compatible REST handlers, mounted under /abs/* and the
|
||||
@@ -91,9 +90,7 @@ media-token signing, CDN, streaming, event bus, migrations runner).
|
||||
- ~7.4k LOC ported from the plugin.
|
||||
- ~1.5–2k LOC new TypeScript/TSX in silo's SPA.
|
||||
- ~400 LOC of new Go glue (scanner branches, router wiring, service.go).
|
||||
- Three new SQL migrations in the current stack (`147_abs_sessions`,
|
||||
`157_podcast_feeds`, `160_audiobooks_feature_flag`) plus one documented
|
||||
no-op migration for the already-present media-folder type column.
|
||||
- Two new SQL migrations (134, 135) and at most one column-add migration.
|
||||
|
||||
## Data model
|
||||
|
||||
@@ -114,9 +111,9 @@ media-token signing, CDN, streaming, event bus, migrations runner).
|
||||
|
||||
### New tables (two migrations)
|
||||
|
||||
1. **`abs_sessions`** (migration 147) — parallel to existing `jellycompat_sessions`. Tracks ABS client device, token hash, last-seen so ABS apps reconnect without re-auth. Columns: `id`, `user_id`, `token_hash`, `device_id`, `device_name`, `abs_client_version`, `created_at`, `last_seen_at`. Same shape as `jellycompat_sessions` plus the `abs_client_version` text.
|
||||
1. **`abs_sessions`** (migration 134) — parallel to existing `jellycompat_sessions`. Tracks ABS client device, token, last-seen so ABS apps reconnect without re-auth. Columns: `id`, `user_id`, `token`, `device_id`, `device_name`, `abs_client_version`, `created_at`, `last_seen_at`. Same shape as `jellycompat_sessions` plus the `abs_client_version` text.
|
||||
|
||||
2. **`podcast_feeds`** (migration 157) — one row per subscribed podcast. Columns: `media_item_id` (FK to the podcast `media_items` row), `feed_url`, `etag`, `last_refreshed_at`, `refresh_interval_seconds`. Episode rows live in the reused `episodes` table.
|
||||
2. **`podcast_feeds`** (migration 135) — one row per subscribed podcast. Columns: `media_item_id` (FK to the podcast `media_items` row), `feed_url`, `etag`, `last_refreshed_at`, `refresh_interval_seconds`. Episode rows live in the reused `episodes` table.
|
||||
|
||||
### Schema-touch column add
|
||||
|
||||
@@ -127,8 +124,8 @@ media-token signing, CDN, streaming, event bus, migrations runner).
|
||||
|
||||
### Plugin migrations NOT ported
|
||||
|
||||
Smart collections, share links, embeddings, content restrictions,
|
||||
metadata-provider integration, request provider, file cache, listening stats aggregates,
|
||||
Smart collections, share links, embeddings, content restrictions, custom
|
||||
metadata providers, request provider, file cache, listening stats aggregates,
|
||||
reading goals, notification prefs, standalone-mode tables, recommender
|
||||
embeddings. All represent features dropped from scope.
|
||||
|
||||
@@ -228,9 +225,8 @@ and directors. No new code, just two more role string constants.
|
||||
|
||||
### External enrichment
|
||||
|
||||
Deferred. The scanner extracts local tags and identity hints only. Canonical
|
||||
enrichment remains the responsibility of the audiobook metadata plugin/provider
|
||||
path and is not ported in this foundation pass.
|
||||
Deferred. The plugin's `enrich/` package (OpenLibrary / Google Books lookup)
|
||||
is not ported in v1. File tags only. Add later if needed.
|
||||
|
||||
### Scheduled task
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# Resilient Library Deletion Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make `delete_library` survive concurrent database writers and large libraries. Today it deletes an entire library inside a single multi-minute transaction; on a large library this deadlocks against ordinary background writes and rolls back, making zero progress. Rewrite the deletion to run in small, autocommitted batches with per-batch deadlock retry, so it completes reliably, holds only short locks, and resumes cleanly if interrupted.
|
||||
|
||||
## Background
|
||||
|
||||
A `delete_library` admin job repeatedly failed with:
|
||||
|
||||
```
|
||||
deleting orphaned items: ERROR: deadlock detected (SQLSTATE 40P01)
|
||||
```
|
||||
|
||||
Diagnosis on a library of ~249K items / ~339K media files:
|
||||
|
||||
- The whole deletion runs in one transaction in `FolderRepository.DeleteWithStats` (`internal/catalog/folder_repo.go`): collect orphan IDs, drop the folder (CASCADE), then `DELETE FROM media_items WHERE content_id = ANY($1)`.
|
||||
- `media_items` cascades to 15 child tables, so a bulk delete holds a very large lock set for minutes.
|
||||
- The Postgres deadlock log shows the cycle: the delete cascade into `media_item_provider_ids` versus a concurrent `INSERT INTO media_item_provider_ids` from a routine background task. Each ~7-minute attempt was guaranteed to overlap an ordinary write and lose.
|
||||
- Because it is one transaction, every failure rolls back completely: no progress, not resumable.
|
||||
|
||||
The fix targets the deletion mechanism only. Coordinating or pausing background writers is explicitly out of scope (see Out of Scope) — a batched, retrying delete is robust against *any* concurrent writer, not just today's tasks.
|
||||
|
||||
## Approach
|
||||
|
||||
Replace the single transaction with a sequence of phases. Each phase deletes in bounded batches, and each batch is one autocommit statement wrapped in a deadlock-retry helper. Short lock windows make deadlocks rare; when one occurs, only that small batch retries.
|
||||
|
||||
Scope of the rewrite:
|
||||
|
||||
- Rewrite `FolderRepository.DeleteWithStats` in `internal/catalog/folder_repo.go`.
|
||||
- Add an unexported `retryOnDeadlock` helper in the `catalog` package.
|
||||
- `FolderRepository.Delete` (thin wrapper), the `DeleteFolderStats` struct, and the `delete_library` executor in `internal/adminjob/library_delete.go` keep their current signatures and behavior. S3 image-cleanup queueing and generated-home-section cleanup are untouched.
|
||||
|
||||
## Deletion phases
|
||||
|
||||
`DeleteWithStats(ctx, id, progress)` performs, in order:
|
||||
|
||||
### Phase 0 — preflight (reads only)
|
||||
|
||||
- Load the folder name; return `ErrFolderNotFound` if the row is gone.
|
||||
- `COUNT` media files (`WHERE media_folder_id = $1`), item links (`WHERE media_folder_id = $1`), and orphans, to populate the result payload and the progress denominator.
|
||||
|
||||
### Phase 1 — orphaned `media_items` (batched loop)
|
||||
|
||||
Repeat until no rows are returned:
|
||||
|
||||
```sql
|
||||
SELECT mil.content_id
|
||||
FROM media_item_libraries mil
|
||||
WHERE mil.media_folder_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_item_libraries other
|
||||
WHERE other.content_id = mil.content_id
|
||||
AND other.media_folder_id <> $1
|
||||
)
|
||||
LIMIT $2 -- orphanDeleteBatch
|
||||
```
|
||||
|
||||
For each returned batch: collect S3 image dirs for those IDs (existing `collectImageDirs`, accumulated into a deduped set), then
|
||||
|
||||
```sql
|
||||
DELETE FROM media_items WHERE content_id = ANY($1)
|
||||
```
|
||||
|
||||
The FK cascade removes each item's `media_item_libraries` (including this folder's), `media_item_provider_ids`, episodes, seasons, embeddings, and the rest of the 15 child tables. Deleting orphan items first also clears their junction rows, so the next iteration's detection query naturally advances. Orphan detection must run before Phase 3 touches shared junctions, so the "only in this folder" test stays valid.
|
||||
|
||||
### Phase 2 — folder `media_files` (batched loop)
|
||||
|
||||
`media_files` is folder-tied (`media_folder_id` FK, `ON DELETE CASCADE`), not item-tied, so it is deleted by folder. Repeat until a batch deletes fewer than the batch size:
|
||||
|
||||
```sql
|
||||
DELETE FROM media_files
|
||||
WHERE id IN (
|
||||
SELECT id FROM media_files WHERE media_folder_id = $1 LIMIT $2 -- folderChildDeleteBatch
|
||||
)
|
||||
```
|
||||
|
||||
### Phase 3 — remaining shared-item junctions (batched loop)
|
||||
|
||||
After Phase 1, the only `media_item_libraries` rows left for this folder belong to items that also live in other folders. Delete them in batches (dropping the membership only, never the shared item):
|
||||
|
||||
```sql
|
||||
DELETE FROM media_item_libraries
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2 -- folderChildDeleteBatch
|
||||
)
|
||||
```
|
||||
|
||||
### Phase 4 — folder row
|
||||
|
||||
```sql
|
||||
DELETE FROM media_folders WHERE id = $1
|
||||
```
|
||||
|
||||
Now lightweight. Tolerates 0 rows affected (a resumed re-run may already have removed it) — it does not re-raise `ErrFolderNotFound` here. The folder row is deleted **last**, so a partial deletion leaves the library present-but-disabled and a re-run continues.
|
||||
|
||||
## Deadlock-retry helper
|
||||
|
||||
New unexported helper in the `catalog` package:
|
||||
|
||||
```go
|
||||
func retryOnDeadlock(ctx context.Context, op func() error) error
|
||||
```
|
||||
|
||||
- Retries when the error is a `*pgconn.PgError` with code `40P01` (deadlock) or `40001` (serialization failure).
|
||||
- Up to 5 attempts, exponential backoff starting at 50ms, doubling each retry.
|
||||
- Honors `ctx` cancellation between attempts (returns `ctx.Err()`).
|
||||
- Any non-retryable error returns immediately; the final retryable error is returned after the cap.
|
||||
|
||||
Every batch statement runs through `retryOnDeadlock`. It has a single caller today, so it stays package-local; it can be promoted to a shared DB utility if a second caller appears.
|
||||
|
||||
## Batch sizes
|
||||
|
||||
Named constants in `internal/catalog/folder_repo.go`, tunable:
|
||||
|
||||
- `orphanDeleteBatch = 1000` — small, because each `media_items` row cascades across 15 tables.
|
||||
- `folderChildDeleteBatch = 5000` — for the lighter folder-scoped `media_files` and junction deletes.
|
||||
|
||||
## Transaction model, stats, progress
|
||||
|
||||
- No surrounding transaction. Each batch is a single `pool.Exec` (its own implicit transaction), mirroring the existing batched-cleanup pattern in `internal/opslog/cleanup.go`. This is the core change that eliminates the multi-minute lock window.
|
||||
- Stats: `MediaFiles` and `MediaItemLinks` come from the Phase 0 counts; `OrphanedItems` accumulates across Phase 1 batches; `OrphanedImageDirs` is the accumulated deduped dir set. Image-dir volume for the affected workloads is modest; the deduped set is the bounded-memory point of note.
|
||||
- Progress: the callback reports orphan items processed against the Phase 0 orphan total, plus phase-transition messages. The admin-job runner's existing 10s heartbeat keeps the job alive across the drain.
|
||||
|
||||
## Error handling & resumability
|
||||
|
||||
- Because batches autocommit, a failure or cancellation persists partial progress. There is no rollback of hundreds of thousands of rows and no long lock hold.
|
||||
- The job is resumable by re-running it: the folder still exists (deleted last), and Phase 1 re-detects whatever orphans remain.
|
||||
- New items inserted concurrently during deletion are picked up by the loop's re-detection.
|
||||
|
||||
## Testing
|
||||
|
||||
The `catalog` package uses pure-Go unit tests; there is no Postgres integration harness, so tests avoid a live database.
|
||||
|
||||
- **`retryOnDeadlock` unit test:** a fake `op` returns a `*pgconn.PgError{Code: "40P01"}` N times then `nil` — assert it retries, respects the attempt cap, backs off, and returns immediately on a non-retryable error; a separate case asserts `ctx` cancellation stops retries.
|
||||
- **Batch-loop unit test:** extract the "loop until a batch is under the batch size" iteration behind a small injected exec function so the looping/chunking logic is testable without Postgres.
|
||||
- **Integration verification on dev:** deploy and re-trigger the deletion against the real stuck library; confirm it drains to completion via application logs and database row counts. This is the integration test, given the absence of a catalog DB harness. (Commands assume the repository root is the cwd and follow the deployment-debugging runbook.)
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Pausing or coordinating background writers (e.g., having metadata/scan tasks skip disabled or pending-deletion libraries). The batched, retrying delete is robust against any concurrent writer; writer coordination is a separate potential improvement.
|
||||
- Changing the admin-job runner, the executor's image-cleanup queueing, or home-section cleanup.
|
||||
- Schema or FK changes.
|
||||
@@ -1,141 +0,0 @@
|
||||
# Calendar Presets & Personalized Default — Design
|
||||
|
||||
- **Date:** 2026-05-29
|
||||
- **Status:** Approved (design); ready for implementation planning
|
||||
- **Scope:** `silo-server` (Go backend + web admin UI). Client follow-ups in `silo-android` / `silo-apple` are additive and optional.
|
||||
- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
The calendar works but is not practical. It defaults to *every* upcoming airing in the catalog, so it is noisy and undifferentiated — the user has to mentally filter out shows they don't care about. We want a Simkl-style preset switcher with a personalized default, powered by the server's real watch data rather than a manually curated list.
|
||||
|
||||
## Goals
|
||||
|
||||
- Default the calendar to a personalized **Following** view (shows the profile actually engages with).
|
||||
- Offer a small set of presets, including a **server-wide** popularity view and an **external trending** view.
|
||||
- Keep it cheap under load (hundreds of concurrent users): presets must **filter, not compute**.
|
||||
- Make "what do I still need to watch this week" visible via a watched overlay.
|
||||
- Preserve backward compatibility for existing clients.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No cross-user caching of the windowed airing query yet (phased — add only if metrics show strain).
|
||||
- No pulling in external content that isn't in the library (e.g. a followed show whose next season hasn't been added). The calendar stays catalog-scoped.
|
||||
- No change to the week-view layout, timezone handling, or event ordering (recently tuned; left intact).
|
||||
- No region/country or "For Kids" presets (considered and cut).
|
||||
|
||||
## Presets
|
||||
|
||||
The selector replaces today's `All / Favorites / Watchlist` toggles.
|
||||
|
||||
| Preset | Shows | Source |
|
||||
|---|---|---|
|
||||
| **Following** *(default)* | Upcoming airings for series the profile has watched, favorited, or watchlisted | per-profile: `user_favorites` ∪ `user_watchlist` ∪ watched-series rollup |
|
||||
| **Popular here** | Airings for what is most-watched server-wide | cached `recommendation_cache` (`RecTypePopular`, global key) |
|
||||
| **Trending** | Airings for externally-trending shows that exist in the library | `trending_discover_snapshots` |
|
||||
| **Everything** | All catalog airings (today's behavior) | no restriction |
|
||||
|
||||
Per-profile vs server-wide is split cleanly across two presets: **Following** is personal (this profile), **Popular here** is the server-wide aggregate.
|
||||
|
||||
## Core principle: filter, don't compute
|
||||
|
||||
Every preset resolves to **the windowed airing candidate list ∩ a small id-set**. No preset triggers a fresh aggregation at request time.
|
||||
|
||||
1. **Step 1 — windowed candidate fetch** (unchanged from today): the 3-branch UNION in `internal/catalog/calendar_repo.go` over movies/episodes/season-premieres, scoped only by library access + content rating, across the ±2-day-padded window. Its result depends only on the access signature, not on viewer identity, so it remains cacheable across users later.
|
||||
2. **Step 2 — intersect with one id-set**, chosen by preset:
|
||||
- **Following** → resolve the profile's followed id-set once per request, then filter `itemIDExpr = ANY($set)`.
|
||||
- **Popular / Trending** → read the already-cached global id-set and intersect.
|
||||
- **Everything** → no restriction.
|
||||
3. **Step 3 — group by day, sort by viewer-local wall-clock time, respond** (unchanged).
|
||||
|
||||
The only heavy SQL is Step 1, which already runs today for every calendar load. Presets add a bounded id-set intersection, never a new aggregation.
|
||||
|
||||
### Unify the filter mechanism
|
||||
|
||||
All personal/preset filters resolve to "an id-set, then `itemIDExpr = ANY($set)`". This replaces the special-cased favorites/watchlist `EXISTS` subqueries in `appendPersonalFilterClause` with a single code path (favorites and watchlist become id-sets resolved from their tables — they are subsets of Following). One mechanism is easier to reason about and removes duplicated clause-building.
|
||||
|
||||
`itemIDExpr` continues to be `mi.content_id` for movies and `e.series_id` / `s.series_id` for episodes / season premieres, so a watched/favorited **movie** matches by its own content_id while a followed **series** matches by series id.
|
||||
|
||||
### Following id-set
|
||||
|
||||
Resolved per request as the DISTINCT union of:
|
||||
|
||||
- `SELECT media_item_id FROM user_favorites WHERE user_id = $u AND profile_id = $p`
|
||||
- `SELECT media_item_id FROM user_watchlist WHERE user_id = $u AND profile_id = $p`
|
||||
- `SELECT DISTINCT COALESCE(e.series_id, wp.media_item_id) FROM user_watch_progress wp LEFT JOIN episodes e ON e.content_id = wp.media_item_id WHERE wp.user_id = $u AND wp.profile_id = $p`
|
||||
|
||||
"Engaged" = the presence of a `user_watch_progress` row (you've started it); no completion threshold required for Following. The rollup mirrors the established pattern in `GetPopularItems` (`internal/recommendations/repo.go`) — reuse that rollup expression rather than duplicate it.
|
||||
|
||||
The candidate list (Step 1) is small (one week of airings), so intersecting against a bounded array is cheap. Caveat: a pathologically large followed-set (a profile that has watched thousands of distinct series) makes the array large; if that ever shows up as hot, switch the Following preset to correlated `EXISTS` against the user's tables. Not building that now.
|
||||
|
||||
### Popular / Trending id-sets
|
||||
|
||||
- **Popular**: `GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypePopular, "")` → `[]ScoredItem`; use `MediaItemID` values (already series-level via the `GetPopularItems` rollup). Refreshed by the recommendations worker on a TTL; readers never aggregate live.
|
||||
- **Trending**: `TrendingSnapshotRepository.Get(ctx, source, window)` → `TrendingSnapshot.ContentIDs` (pre-resolved to catalog content_ids, viewer-agnostic). The calendar reads the canonical default snapshot (`tmdb` / `week`). Unioning multiple sources/windows is possible but out of scope.
|
||||
|
||||
**Access is preserved automatically:** Popular/Trending id-sets are viewer-agnostic and may include items a given profile cannot access. Because we intersect them with the access-scoped Step-1 candidates (library scoping + `MaxContentRating` already applied there), inaccessible items drop out naturally. No extra access check needed on the id-sets.
|
||||
|
||||
**Cold/empty cache:** if the Popular cache or Trending snapshot is empty (fresh server, worker hasn't run, provider unconfigured), the id-set is empty and the preset returns no rows. This must render the empty state, not an error.
|
||||
|
||||
## Watched overlay
|
||||
|
||||
After Step 1, decorate the windowed events with per-profile watched status:
|
||||
|
||||
- One bounded lookup: `SELECT media_item_id FROM user_watch_progress WHERE user_id = $u AND profile_id = $p AND completed = true AND media_item_id = ANY($episodeContentIds)` over the window's episode/movie content_ids.
|
||||
- Watched items render dimmed + ✓. This is a per-profile overlay applied after Step 1 (like the Following filter), so it does not affect the shareability of the Step-1 candidate set.
|
||||
- Premiere / finale / new-season badges extend the existing `buildBadges` logic in `internal/api/handlers/calendar.go`.
|
||||
|
||||
## UI / UX
|
||||
|
||||
- **Selector** (`web/src/pages/Calendar.tsx`): segmented pills on desktop, collapsing to a dropdown on narrow viewports — the same responsive swap the week navigator already uses. The library picker stays beside it. Both presentations bind to one selected-preset state.
|
||||
- **Empty state**: when **Following** has no upcoming airings (new or sparse profile), show a nudge with quick switches to Popular / Trending / Everything instead of a blank week.
|
||||
- **Persistence**: remember the profile's last-chosen preset in `localStorage` (keyed by profile id) so the calendar reopens where the user left it. Server-side profile preference is a possible future enhancement.
|
||||
- **Card cues** (`web/src/components/calendar/CalendarEventCard.tsx`, `web/src/lib/upcomingEventPresentation.ts`): badges + watched overlay as above.
|
||||
|
||||
## API contract
|
||||
|
||||
- Extend `/calendar`'s `filter` parameter to accept `following | popular | trending | everything`.
|
||||
- **Keep `all | favorites | watchlist` valid** so existing `silo-android` / `silo-apple` clients keep working unchanged. `all` and `everything` are equivalent. This is purely additive.
|
||||
- Add an additive `watched: bool` field (and any new badge string) to each event item. Older clients ignore unknown fields.
|
||||
- Default when `filter` is omitted: today it is `all`. The **web client** will request `following` explicitly; the server default stays `all` to avoid changing behavior for existing API consumers. (Default-view personalization is a client choice, not a server-contract change.)
|
||||
- **Profile requirement:** the `/calendar` route already runs behind `RequireProfile`, so a profile is always present. `following` (and legacy `favorites` / `watchlist`) plus the watched overlay are per-profile and resolve against it; `popular` / `trending` / `everything` / `all` are profile-agnostic and ignore it.
|
||||
|
||||
### Multi-repo coordination (per CLAUDE.md)
|
||||
|
||||
- `silo-android` / `silo-apple`: may adopt the new presets and the watched cue as independent follow-ups. Nothing here forces a lockstep change because old filter values and response shape remain valid.
|
||||
|
||||
## Implementation surface
|
||||
|
||||
Backend:
|
||||
- `internal/catalog/calendar_repo.go` — add an id-set restriction to `CalendarFilter`; generalize `appendPersonalFilterClause` into a single `ANY($set)` path; keep library/rating clauses intact.
|
||||
- A followed-id-set resolver, `ListFollowedSeriesIDs(ctx, userID, profileID)`, placed in the package that owns the favorites/watchlist/episode join; reuse the `GetPopularItems` rollup expression.
|
||||
- `internal/api/handlers/calendar.go` — validate the expanded `filter` set; orchestrate preset → id-set resolution (Following resolver, Popular cache read, Trending snapshot read); apply the watched overlay; extend `buildBadges`.
|
||||
|
||||
Frontend:
|
||||
- `web/src/pages/Calendar.tsx` — responsive selector + preset persistence + empty state.
|
||||
- `web/src/hooks/queries/calendar.ts` — new `filter` values; `watched` field on the event type.
|
||||
- `web/src/components/calendar/CalendarEventCard.tsx` — watched overlay + badge rendering.
|
||||
- `web/src/lib/upcomingEventPresentation.ts` — badge/treatment helpers.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Cold/empty Popular cache or Trending snapshot → empty preset → empty state, never an error.
|
||||
- Profile with no follows → Following empty → empty state with preset nudges.
|
||||
- Large followed-set → bounded array today; correlated-`EXISTS` fallback noted for later.
|
||||
- Movie vs episode/season id matching (`content_id` vs `series_id`) handled by the existing `itemIDExpr` selection per branch.
|
||||
- Specials (`season_number = 0`) remain excluded.
|
||||
- Access control (`MaxContentRating`, library scoping, disabled libraries) still enforced for every preset via Step 1; viewer-agnostic id-sets cannot leak inaccessible items because of the intersection.
|
||||
- Backward-compat filter values (`all | favorites | watchlist`) still resolve through the unified id-set path with identical results.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Repo** (`internal/catalog/calendar_repo_test.go`): each preset's id-set filter (following / popular / trending / everything), empty id-set → zero rows, access + rating enforcement preserved across presets, movie-by-content_id vs series-by-series_id matching, legacy favorites/watchlist parity.
|
||||
- **Handler** (`internal/api/handlers/calendar_test.go`): filter validation accepts new + legacy values; Popular/Trending cold cache degrades to empty gracefully; `watched` field populated correctly; watched lookup bounded to the window.
|
||||
- **Frontend**: selector responsive swap (pills ↔ dropdown), preset persistence across reloads, watched overlay + badge rendering, Following empty state.
|
||||
|
||||
## Future work
|
||||
|
||||
- Cross-user cache of the Step-1 windowed candidate set keyed by (window, access-signature), if load metrics justify it.
|
||||
- External enrichment of followed shows whose next season is not yet in the library.
|
||||
- Server-side persisted preset preference per profile.
|
||||
- Optional union of multiple Trending sources/windows.
|
||||
@@ -1,208 +0,0 @@
|
||||
# Trending Discover — Persistent Snapshot Design
|
||||
|
||||
Status: Approved (design)
|
||||
Date: 2026-05-29
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
The `trending_discover` home section pulls global trending (TMDB or Trakt),
|
||||
matches it to titles in the viewer's libraries, and renders the result. Today
|
||||
the external fetch + external-ID resolution are memoised in an **in-process
|
||||
map** (`editorialCandidateCache`) keyed by `source|window|fetchLimit` with a
|
||||
1-hour TTL and a `singleflight` group to collapse concurrent misses
|
||||
(`internal/sections/fetcher.go`, `loadTrendingDiscoverContentIDs`).
|
||||
|
||||
That cache has three weaknesses, two of which we explicitly want to fix:
|
||||
|
||||
1. **No resilience to upstream failure.** At TTL expiry a request still blocks
|
||||
on the TMDB/Trakt call, and a slow or down provider degrades the home page.
|
||||
There is no concept of "serve the last good list."
|
||||
2. **No observability.** Nothing records when trending last refreshed, whether
|
||||
it succeeded, or how many trending titles matched the catalog.
|
||||
3. (Secondary) It is per-process and volatile — lost on restart, and each
|
||||
server instance fetches independently.
|
||||
|
||||
## Goals
|
||||
|
||||
- Reads of the trending section **never call the upstream provider** and never
|
||||
block on it. They serve a persisted, last-good list.
|
||||
- A persisted, inspectable record with refresh history (the same "synced
|
||||
record" shape used by library collections).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Reusing the library-collection machinery. `library_collections.library_id` is
|
||||
`NOT NULL` (collections are library-scoped); trending is deliberately
|
||||
library-agnostic and appears once. Making collections library-agnostic is a
|
||||
large, separate change and is out of scope.
|
||||
- An admin UI for the snapshot. The persisted columns make this possible later,
|
||||
but it is not part of this work.
|
||||
- A shared/distributed cache (Redis). The DB is the single source of truth.
|
||||
|
||||
## Approach
|
||||
|
||||
Replace the lazy in-process cache with a **background-refreshed persistent
|
||||
snapshot**. A scheduled task refreshes the trending list on an interval; the
|
||||
read path only ever reads the persisted snapshot. This mirrors the
|
||||
collection-sync *pattern* (`CollectionSyncScheduler` + `SyncCollectionsTask`)
|
||||
without reusing its library-scoped storage.
|
||||
|
||||
The change cleanly separates **read** from **refresh**:
|
||||
|
||||
- The refresh path owns the upstream call and external-ID resolution.
|
||||
- The read path owns only a primary-key snapshot lookup plus the existing
|
||||
per-viewer access filtering.
|
||||
|
||||
## Data model
|
||||
|
||||
New migration `167_trending_discover_snapshots`. (Originally numbered 166, but
|
||||
the shared dev DB had already recorded version 166 from another branch's
|
||||
`166_trending_blend_collection_type`; the migration runner dedupes by integer
|
||||
version, so 166 was silently skipped. Renumbered to 167, the next free version.)
|
||||
|
||||
```
|
||||
trending_discover_snapshots
|
||||
source text NOT NULL -- 'tmdb' | 'trakt'
|
||||
window text NOT NULL -- 'day' | 'week' (trakt canonicalized to 'week')
|
||||
content_ids text[] NOT NULL DEFAULT '{}' -- ordered, resolved to library catalog, capped at 200
|
||||
entry_count int NOT NULL DEFAULT 0 -- raw provider entries fetched (matched-vs-trending observability)
|
||||
refreshed_at timestamptz -- last successful refresh
|
||||
last_attempt_at timestamptz
|
||||
last_status text -- 'ok' | 'empty' | 'error'
|
||||
last_error text
|
||||
PRIMARY KEY (source, window)
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- One row per canonical `(source, window)` — at most a handful of rows.
|
||||
- `content_ids` is catalog-matched but **viewer-agnostic**, exactly like the
|
||||
value the current cache holds. Per-viewer access filtering still happens at
|
||||
read time in `fetchItemsByContentIDs`, so a single row serves all viewers.
|
||||
- The list is stored at the over-fetch cap (200). The read path truncates to the
|
||||
section's `ItemLimit`, decoupling the snapshot from per-section limits (the old
|
||||
`fetchLimit` cache-key dimension goes away).
|
||||
- **Reliability invariant:** a failed refresh updates `last_attempt_at`,
|
||||
`last_status`, and `last_error` but **never clears `content_ids`**. The last
|
||||
good list keeps serving through an upstream outage.
|
||||
|
||||
The paired `.down.sql` drops the table.
|
||||
|
||||
## Components
|
||||
|
||||
All new server code lives in `internal/`.
|
||||
|
||||
### `TrendingSnapshotRepository` (new, `internal/sections/`)
|
||||
|
||||
Thin repository over the new table:
|
||||
|
||||
- `Get(ctx, source, window) (TrendingSnapshot, error)` — PK lookup.
|
||||
- `Upsert(ctx, snapshot) error` — insert/update on `(source, window)`.
|
||||
- `ListAll(ctx) ([]TrendingSnapshot, error)` — for future admin/inspection use
|
||||
and tests.
|
||||
|
||||
`Upsert` distinguishes a successful refresh (writes `content_ids`,
|
||||
`refreshed_at`, `entry_count`, `last_status='ok'|'empty'`) from a failed one
|
||||
(touches only `last_attempt_at`/`last_status='error'`/`last_error`, leaving
|
||||
`content_ids` intact).
|
||||
|
||||
### `TrendingRefresher` (new, `internal/sections/trending_refresher.go`)
|
||||
|
||||
Owns the upstream call. Holds `TMDBTrending`, `TraktTrending`, `ItemRepo`, the
|
||||
section repository (to enumerate used combos), and the snapshot repository. The
|
||||
existing `fetchTrendingDiscoverEntries` and `resolveTrendingDiscoverIDs` logic
|
||||
**moves here** from `fetcher.go`.
|
||||
|
||||
`RunOnce(ctx) (json.RawMessage, error)`:
|
||||
|
||||
1. Enumerate the distinct canonical `(source, window)` pairs from **enabled**
|
||||
`trending_discover` sections across all scopes (parse each section's
|
||||
`TrendingDiscoverParams`, apply the same source/window normalization the
|
||||
section uses, collapse Trakt to `week`).
|
||||
2. For each pair: fetch the cap-200 list from the provider, resolve external IDs
|
||||
to library content IDs, and `Upsert`.
|
||||
3. Return a JSON summary (e.g. `{combos, refreshed, empty, failed}`), mirroring
|
||||
`CollectionSyncResult`.
|
||||
|
||||
No used combos → no upstream calls (a dormant feature costs nothing). A
|
||||
configured-but-empty provider yields `last_status='empty'` (no error noise). A
|
||||
per-pair failure is recorded and does not abort the other pairs.
|
||||
|
||||
### `RefreshTrendingDiscoverTask` (new, `internal/taskmanager/tasks/`)
|
||||
|
||||
Mirrors `SyncCollectionsTask`: delegates to `TrendingRefresher.RunOnce` and
|
||||
reports progress/result data. Default trigger: hourly interval (matches the
|
||||
recipe's `DefaultCacheTTL` of 1h), plus a one-shot run at startup so the first
|
||||
snapshot lands quickly rather than after a full interval.
|
||||
|
||||
### `Fetcher` (modified, `internal/sections/fetcher.go`)
|
||||
|
||||
- `loadTrendingDiscoverContentIDs` now reads the snapshot row via the snapshot
|
||||
repository and returns its `content_ids`. It drops the upstream fetch, the
|
||||
`singleflight` group, and the `editorialCandidateCache` usage **for trending**
|
||||
(the editorial-candidate cache remains for the editorial sections that still
|
||||
use it).
|
||||
- `fetchTrendingDiscover` keeps its read-time responsibilities: access
|
||||
filtering, re-ordering to trending rank, truncation to `ItemLimit`. It no
|
||||
longer computes a `fetchLimit` for the read path.
|
||||
- The `TMDBTrending`, `TraktTrending`, and trending-only use of `ItemRepo`
|
||||
migrate out of the read path into the refresher. The fetcher gains the
|
||||
snapshot repository dependency.
|
||||
|
||||
### Wiring (`cmd/silo/main.go`, `internal/api/router.go`)
|
||||
|
||||
- Construct `TrendingSnapshotRepository` and `TrendingRefresher` (wired with the
|
||||
TMDB/Trakt fetchers, `ItemRepo`, section repo, snapshot repo).
|
||||
- Register `RefreshTrendingDiscoverTask` alongside the other task registrations.
|
||||
- Hand the snapshot repository to the section `Fetcher`; move the upstream
|
||||
fetchers from the fetcher wiring to the refresher wiring.
|
||||
|
||||
## Data flow
|
||||
|
||||
**Refresh (background, hourly + startup):**
|
||||
task → `Refresher.RunOnce` → enumerate distinct canonical `(source, window)`
|
||||
from enabled `trending_discover` sections → per pair: fetch cap-200 → resolve
|
||||
external IDs → `Upsert` snapshot.
|
||||
|
||||
**Read (per home render):**
|
||||
`fetchTrendingDiscover` → `loadTrendingDiscoverContentIDs` reads the snapshot
|
||||
row → `fetchItemsByContentIDs` applies the viewer's access filter → re-order to
|
||||
trending rank → truncate to `ItemLimit`. No upstream call. A single PK lookup on
|
||||
a ≤6-row table — negligible next to the queries `FetchAll` already runs, so no
|
||||
in-process read cache is added (it would re-muddy the single-source-of-truth and
|
||||
is premature).
|
||||
|
||||
## Edge behavior
|
||||
|
||||
- **Before first sync:** snapshot absent → section renders empty (no blocking,
|
||||
no upstream call). The startup task run closes this gap to roughly one fetch
|
||||
duration.
|
||||
- **Provider unconfigured but a section exists:** `last_status='empty'`, no
|
||||
error.
|
||||
- **Upstream failure on a pair that already has a snapshot:** prior
|
||||
`content_ids` keep serving; only the attempt/status/error columns update.
|
||||
- **Trakt `day` vs `week`:** collapse to a single `week` row (Trakt ignores the
|
||||
window).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Repository round-trip:** upsert then get; `content_ids` ordering preserved;
|
||||
success vs failure upsert paths.
|
||||
- **Refresher `RunOnce`:** fake `TMDBTrending`/`TraktTrending`, fake section
|
||||
enumerator, in-memory `ItemRepo`. Assert ordered IDs upserted; `ok`/`empty`/
|
||||
`error` transitions; and the reliability invariant — a failed refresh
|
||||
preserves prior `content_ids`.
|
||||
- **Read path:** inject nil upstream fetchers into the fetcher and assert the
|
||||
trending read still works from the snapshot (proves reads never call upstream).
|
||||
- **Canonicalization:** Trakt `day` and `week` resolve to one row.
|
||||
|
||||
## Risks / follow-ups
|
||||
|
||||
- The data captured (`refreshed_at`, `last_status`, `last_error`, `entry_count`)
|
||||
enables a future admin/inspection surface; not built here.
|
||||
- If multiple sections request very different `ItemLimit`s, all share the cap-200
|
||||
list and truncate — intended, and strictly more data than the old per-limit
|
||||
cache held.
|
||||
```
|
||||
@@ -1,353 +0,0 @@
|
||||
# Multi-Instance Sonarr/Radarr Routing for Requests (Seerr-style)
|
||||
|
||||
**Status:** Design approved, ready for implementation plan
|
||||
**Date:** 2026-06-01
|
||||
**Area:** `internal/requests`, `migrations/`, `web/src` (admin settings + request queue)
|
||||
|
||||
## Summary
|
||||
|
||||
Silo's request system today fulfills every approved request through a single
|
||||
Radarr (movies) or single Sonarr (series) instance, at whatever quality that
|
||||
one instance's profile is configured for. There is no 4K concept, no anime
|
||||
handling, and exactly one instance per kind (`request_integrations.kind` is the
|
||||
primary key).
|
||||
|
||||
This work replicates Seerr's Sonarr/Radarr management model *inside* Silo's own
|
||||
request system (no external Seerr dependency):
|
||||
|
||||
- **Many instances per kind** — N Radarr and N Sonarr servers, each with its own
|
||||
connection, root folder, quality profile, and tags.
|
||||
- **Default HD + Default 4K routing** — one HD-default and one 4K-default
|
||||
instance per kind; requests route to the matching default(s).
|
||||
- **Per-server anime overrides** (Overseerr-style) — each instance carries an
|
||||
optional anime profile / root folder / tags applied when a title is detected
|
||||
as anime.
|
||||
- **Entitlement-driven dual-quality fan-out** — a single user request can be
|
||||
fulfilled in **both** 1080p and 2160p when the requester's `MaxPlaybackQuality`
|
||||
allows 4K, or when an admin global toggle forces it for everyone.
|
||||
|
||||
The end-user request action is unchanged: quality is derived from the user's
|
||||
existing entitlement, not a per-request choice.
|
||||
|
||||
## Goals
|
||||
|
||||
- Support multiple Radarr/Sonarr instances per kind.
|
||||
- Route by quality (HD vs 4K) to per-kind default instances.
|
||||
- Fan a single request out to both 1080p and 2160p targets based on the
|
||||
requester's `MaxPlaybackQuality` entitlement, with an admin override that
|
||||
forces dual-quality regardless of role.
|
||||
- Apply per-instance anime overrides using Seerr-exact anime detection.
|
||||
- Keep the request lifecycle (quotas, approval, reconcile) coherent under
|
||||
one-to-many fulfillment.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No integration *with* a Seerr instance (an earlier idea, dropped). Silo
|
||||
remains the system of record.
|
||||
- No per-request manual server/profile picker (Overseerr's advanced UI). Routing
|
||||
is automatic from defaults + entitlement.
|
||||
- No separate 1080p/4K *availability* tracking. The pre-request presence check
|
||||
stays binary ("is this title in the Silo library at all").
|
||||
- No admin-configurable anime keyword list (the heuristic is isolated so one can
|
||||
be added later).
|
||||
- No dedicated-anime-instance flag — anime is handled by per-instance overrides
|
||||
(decided during design).
|
||||
|
||||
## Background: current flow
|
||||
|
||||
When a user requests a movie today (`internal/requests/service.go`):
|
||||
|
||||
1. **Browse/discover** via TMDB; each result carries a `RequestState`.
|
||||
2. `CreateRequest` validates: requests enabled, not already available
|
||||
(presence), not already actively requested (dedup by `tmdb_id`), computes the
|
||||
requester's `EffectivePolicy` (quota, blocked, auto-approve), enforces quota.
|
||||
3. Auto-approve users with a configured integration → row created `approved`,
|
||||
else `pending` for the admin queue.
|
||||
4. `submitApprovedRequest` → `integrationForMediaType(mediaType)` finds the
|
||||
single instance by kind → `radarr.Client.SubmitMovie` POSTs to Radarr using
|
||||
that instance's root folder / quality profile / tags → request goes `queued`.
|
||||
5. `ReconcileRequests` polls the instance: `queued → downloading → completed`
|
||||
(or `failed`).
|
||||
|
||||
The single insertion point that changes is step 4's instance selection.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Data model
|
||||
|
||||
**`request_integrations` — reworked from one-row-per-kind to many instances:**
|
||||
|
||||
| column | purpose |
|
||||
|---|---|
|
||||
| `id` (text PK, via `idgen`) | replaces `kind` as PK |
|
||||
| `kind` (text, `radarr`\|`sonarr`) | now a plain column, keeps its CHECK |
|
||||
| `name` (text) | admin-facing label |
|
||||
| `enabled` (bool) | |
|
||||
| `base_url`, `api_key_ref` | connection; API key stays a Fernet secret ref, never plaintext |
|
||||
| `root_folder`, `quality_profile_id`, `tags[]` | standard (non-anime) defaults |
|
||||
| `is_4k` (bool) | marks this as a 4K server |
|
||||
| `is_default` (bool) | the HD default for its kind |
|
||||
| `is_default_4k` (bool) | the 4K default for its kind |
|
||||
| `anime_enabled` (bool) | apply anime overrides on this instance |
|
||||
| `anime_quality_profile_id`, `anime_root_folder`, `anime_tags[]` | anime overrides |
|
||||
| `options` (jsonb) | kind-specific extras (Radarr min-availability, Sonarr season-folders/series-type) |
|
||||
| `last_check_*`, `created_at`, `updated_at` | unchanged |
|
||||
|
||||
**Invariants** (partial unique indexes + service validation):
|
||||
|
||||
- At most one `is_default` per kind; at most one `is_default_4k` per kind.
|
||||
- `is_default ⇒ NOT is_4k` and `is_default_4k ⇒ is_4k`. The HD default is an HD
|
||||
server; the 4K default is a 4K server. HD and 4K defaults are therefore always
|
||||
distinct instances.
|
||||
|
||||
**New `media_request_targets`:**
|
||||
|
||||
| column | purpose |
|
||||
|---|---|
|
||||
| `id` (bigint identity PK) | |
|
||||
| `request_id` (text → `media_requests` ON DELETE CASCADE) | |
|
||||
| `integration_id` (text → `request_integrations` ON DELETE SET NULL) | |
|
||||
| `integration_kind` (text snapshot) | survives instance deletion for history |
|
||||
| `quality` (text, `1080p`\|`2160p`) | reuses `internal/access` presets |
|
||||
| `is_anime` (bool) | which profile set was applied |
|
||||
| `external_id`, `external_status`, `status`, `last_error` | per-target Radarr/Sonarr lifecycle |
|
||||
| `created_at`, `updated_at` | |
|
||||
| UNIQUE `(request_id, quality)` | one target per quality per request |
|
||||
|
||||
**`media_requests` changes:** the per-fulfillment columns
|
||||
(`integration_kind`, `external_id`, `external_status`) move out to
|
||||
`media_request_targets`. Add `is_anime` (bool). The request's `status` becomes an
|
||||
**aggregate** over its targets (see §3). `idx_media_requests_active_tmdb` stays —
|
||||
dedup is still one active request per title regardless of quality, and a
|
||||
dual-quality request counts as **one** row against the user's quota.
|
||||
|
||||
### 2. Routing engine
|
||||
|
||||
A pure function `routeTargets(req, entitlement, settings, instances) -> []plannedTarget`
|
||||
replaces `integrationForMediaType` as the selection point in
|
||||
`submitApprovedRequest`.
|
||||
|
||||
**Inputs:** the request (incl. detected `is_anime`), the requester's
|
||||
`MaxPlaybackQuality`, the global `force_dual_quality` setting, the configured
|
||||
instances for the kind.
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. **Kind** = `movie → radarr`, `series → sonarr`.
|
||||
2. **Desired qualities:**
|
||||
- `1080p` is always desired (baseline for everyone).
|
||||
- Add `2160p` if `access.QualityAllowed("2160p", user.MaxPlaybackQuality)`
|
||||
**OR** `force_dual_quality` is on (the toggle overrides role).
|
||||
3. **Pick the default instance per desired quality:** `1080p → is_default`,
|
||||
`2160p → is_default_4k` (for that kind).
|
||||
4. **Profile selection per chosen instance:** if `req.is_anime` **and** the
|
||||
instance has `anime_enabled` → use its anime profile / root folder / tags
|
||||
(and, for Sonarr, `seriesType: anime`); otherwise the standard set.
|
||||
5. Emit one `plannedTarget{instance, quality, isAnime, profile, rootFolder, tags}`
|
||||
per resolved quality.
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- A desired quality with **no default instance assigned** is silently skipped
|
||||
(cannot route 4K without a 4K default — matches "when both are assigned").
|
||||
- **Zero** resolved targets (e.g. no HD default configured): the request stays
|
||||
`approved` but unfulfilled, with `last_error` = "no Radarr/Sonarr instance
|
||||
configured for this quality" so it surfaces in the admin queue.
|
||||
- No dedup needed: the `is_default`/`is_default_4k` invariants guarantee HD and
|
||||
4K defaults are distinct instances.
|
||||
|
||||
### 3. Fulfillment & reconcile lifecycle
|
||||
|
||||
The adapter interfaces (`SubmitMovie`/`SubmitSeries`,
|
||||
`CheckMovieStatus`/`CheckSeriesStatus`) are **unchanged** — they already take
|
||||
`(req, integration)`. We call them **once per target**, passing a *resolved*
|
||||
`Integration` whose `RootFolder`/`QualityProfileID`/`Tags` are filled from the
|
||||
standard or anime block. Radarr/Sonarr's notion of "4K" is just that instance's
|
||||
quality profile + root folder, so adapters stay quality-agnostic. (The Sonarr
|
||||
adapter additionally sets `seriesType: anime` for anime targets.)
|
||||
|
||||
**On approval (`submitApprovedRequest`):**
|
||||
|
||||
1. `routeTargets(...)` → planned targets.
|
||||
2. For each: insert a `media_request_targets` row, then call the matching
|
||||
adapter with the resolved instance + decrypted key.
|
||||
3. On success → target `queued` with its `external_id`/`external_status`; on
|
||||
error → target `failed` + `last_error`.
|
||||
4. **Recompute the request aggregate** in the same transaction as the target
|
||||
writes.
|
||||
|
||||
**Reconcile loop (`ReconcileRequests`):** for each candidate request, iterate its
|
||||
**non-terminal targets**, load each target's instance by `integration_id`, call
|
||||
the status adapter, update the target, then recompute the aggregate. Targets
|
||||
reconcile independently.
|
||||
|
||||
**Aggregate recompute (single source of truth):**
|
||||
|
||||
- all targets `completed` → request `completed` (+ `completed_at`)
|
||||
- any target `downloading` → `downloading`; else any `queued` → `queued`
|
||||
- all targets `failed` → `outcome = failed`; **some** failed while others active
|
||||
→ request stays active, `last_error` set from the failed target(s)
|
||||
|
||||
**Retry** is target-aware: an admin retry re-submits only the `failed` targets
|
||||
(re-routing if instance config changed), leaving healthy targets alone.
|
||||
|
||||
### 4. Anime detection (Seerr-exact)
|
||||
|
||||
Detection matches upstream Seerr exactly — a single TMDB keyword id, no genre or
|
||||
language fallback:
|
||||
|
||||
- `server/api/themoviedb/constants.ts`: `ANIME_KEYWORD_ID = 210024`
|
||||
- `server/entity/MediaRequest.ts`: anime ⇔ `keywords.results` contains that id.
|
||||
|
||||
Implementation:
|
||||
|
||||
- A named constant `animeKeywordID = 210024`.
|
||||
- `detectAnime(detail) bool` → true iff the TMDB keyword **ids** contain
|
||||
`210024`. Matching by id (not the name string) avoids false positives from
|
||||
neighboring keywords like "based on anime".
|
||||
- Detected once at `CreateRequest` time and stored on `media_requests.is_anime`,
|
||||
so routing, reconcile, and retries stay consistent.
|
||||
- Applies to both movies and series; ignored if no instance for that kind has
|
||||
`anime_enabled`.
|
||||
|
||||
**Supporting TMDB-client change:** extend `tmdb.MediaDetail` to carry keyword
|
||||
**ids** (add `append_to_response=keywords` to the detail fetch). `GetMediaDetail`
|
||||
is already called in the request path, so no extra round-trip beyond the append.
|
||||
(`OriginalLanguage` is not needed under the parity-only decision but may be
|
||||
mapped opportunistically since it is already in the response.)
|
||||
|
||||
The heuristic is isolated in one function so an admin-configurable keyword list
|
||||
can be added later without touching routing.
|
||||
|
||||
### 5. Admin UI
|
||||
|
||||
`web/src/pages/admin-settings/IntegrationsSettings.tsx` and the setup wizard
|
||||
`web/src/pages/setup-wizard/steps/IntegrationsStep.tsx` move from a single
|
||||
Radarr/Sonarr form to an **instance-list manager** per kind:
|
||||
|
||||
**Per kind — list of instance cards** (add / edit / delete):
|
||||
|
||||
- Connection: name, base URL, API key (write-only; shows "configured" once set,
|
||||
never echoes the secret), **Test connection** → `LoadIntegrationOptions`
|
||||
populates root-folder and quality-profile **dropdowns**.
|
||||
- Standard block: root folder, quality profile, tags.
|
||||
- Quality role: `is_4k` switch, **Default (HD)** toggle, **Default 4K** toggle.
|
||||
The form enforces invariants client-side (no Default-HD on a 4K server;
|
||||
selecting a new default clears the prior one visually); server is source of
|
||||
truth.
|
||||
- Anime block (collapsible, `anime_enabled`): anime quality profile, anime root
|
||||
folder, anime tags — dropdowns from the same test-connection options.
|
||||
|
||||
**Request settings panel** gains the **"Always fulfill in both 1080p and 4K"**
|
||||
toggle (`force_dual_quality`) with helper text: applies to all requests when both
|
||||
a Default HD and Default 4K instance exist, regardless of user role.
|
||||
|
||||
**`web/src/pages/AdminRequests.tsx`** (queue): each request row expands to show
|
||||
its **targets** — quality badge (1080p/2160p), instance name, per-target status,
|
||||
and per-target **retry** on failed ones. The aggregate status stays the headline.
|
||||
|
||||
**Setup wizard** stays minimal: add one Radarr + one Sonarr, auto-marked Default
|
||||
HD. Advanced multi-instance/4K/anime config lives in the full settings page.
|
||||
|
||||
### 6. API contract & multi-repo coordination
|
||||
|
||||
**End-user request flow is unchanged** — quality is derived from
|
||||
`MaxPlaybackQuality`, so the request action and payload are identical (no 4K
|
||||
toggle, no new permission).
|
||||
|
||||
**`Request` response model** gains `is_anime` and a `targets[]` array
|
||||
(`quality`, `instance_name`, `status`, `external_status`, `last_error`); the
|
||||
top-level `integration_kind`/`external_id`/`external_status` are removed in favor
|
||||
of the aggregate `status` + `targets`.
|
||||
|
||||
- Per `CLAUDE.md`'s multi-repo rule: end-user `silo-android` / `silo-apple`
|
||||
clients show a single aggregate status, so they likely need **no change** —
|
||||
but this is flagged as explicit Apple/Android follow-up to verify the request
|
||||
model tolerates the removed fields and ignores/parses the new `targets`.
|
||||
|
||||
**Admin endpoints** change from "upsert by kind" to instance CRUD:
|
||||
|
||||
- `GET/POST/PUT/DELETE` request integrations **by `id`**; `LoadIntegrationOptions`
|
||||
(test-connection) stays, keyed by instance.
|
||||
- Default-toggle handled server-side transactionally (a new HD/4K default clears
|
||||
the prior one for that kind).
|
||||
- `request_settings` gains `force_dual_quality`.
|
||||
- Admin-web-only; no mobile client surface.
|
||||
|
||||
### 7. Migration & backfill
|
||||
|
||||
Next migration number: **169** (`169_request_multi_instance.{up,down}.sql`).
|
||||
|
||||
**Up:**
|
||||
|
||||
1. `ALTER request_integrations`: add `id`, `name`, `is_4k`, `is_default`,
|
||||
`is_default_4k`, `anime_enabled`, `anime_quality_profile_id`,
|
||||
`anime_root_folder`, `anime_tags[]`. Backfill existing rows: `id = gen`,
|
||||
`name = initcap(kind)`, `is_default = enabled` (the lone existing instance
|
||||
becomes that kind's HD default), everything else default/false. Swap the PK
|
||||
from `kind` to `id`; keep `kind` as a plain column with its CHECK.
|
||||
2. Partial unique indexes: one `is_default` per kind, one `is_default_4k` per
|
||||
kind.
|
||||
3. `CREATE media_request_targets` (per §1).
|
||||
4. Backfill targets: for any `media_requests` row with an `external_id`, insert a
|
||||
target `(quality='1080p', is_anime=false, integration_id = that kind's
|
||||
instance, integration_kind, external_id, external_status, status = request's
|
||||
current status)`. Rows never submitted get no target yet.
|
||||
5. `ALTER media_requests`: add `is_anime bool DEFAULT false`; drop
|
||||
`integration_kind`, `external_id`, `external_status`.
|
||||
6. `idx_media_requests_active_tmdb` stays unchanged.
|
||||
|
||||
**Down:** re-add the three columns to `media_requests` and copy back the **1080p**
|
||||
target's external fields; drop `media_request_targets`; drop `is_anime`; collapse
|
||||
`request_integrations` to `kind`-PK by keeping each kind's `is_default` instance
|
||||
and discarding extras; drop the new columns/indexes.
|
||||
|
||||
**Caveat (explicit):** the down migration is **lossy** — rollback discards any
|
||||
additional instances (beyond one default per kind) and any 4K/anime target
|
||||
history. This is the unavoidable cost of collapsing a one-to-many back to
|
||||
one-to-one and is acceptable for a down migration.
|
||||
|
||||
**Go-side data access** (implementation, not SQL): `Integration` struct +
|
||||
`scanIntegration` gain the new fields; the `Store` interface gains target CRUD +
|
||||
`ListInstances(kind)`; `scanRequest` drops the external fields; `UpsertIntegration`
|
||||
becomes id-based CRUD with default-toggle handling in-transaction.
|
||||
|
||||
### 8. Testing
|
||||
|
||||
- **`routeTargets` — table-driven (core of the feature):** matrix of
|
||||
{1080p vs 2160p entitlement} × {`force_dual_quality` on/off} × {which defaults
|
||||
exist} × {anime vs not} → expected target set.
|
||||
- **`detectAnime`:** keyword `210024` present/absent; no name-substring false
|
||||
positives.
|
||||
- **Aggregate recompute:** all-complete, partial-failure (request stays active +
|
||||
`last_error`), all-failed (`outcome=failed`), mixed downloading/queued.
|
||||
- **Repository:** target CRUD; single-default-per-kind invariant rejects a second
|
||||
default; migration backfill yields exactly one 1080p target per previously
|
||||
submitted request.
|
||||
- **Adapter:** Sonarr `SubmitSeries` sets `seriesType=anime` only for anime
|
||||
targets on anime-enabled instances.
|
||||
- **Service:** extend `service_test.go` fakes — a 4K-entitled user's single
|
||||
request fans out to two adapter submissions with the right resolved instances;
|
||||
target-scoped retry re-submits only the failed target.
|
||||
- **Frontend:** instance-form default-invariant enforcement; queue renders
|
||||
per-target rows + retry.
|
||||
|
||||
## Risks & open considerations
|
||||
|
||||
- **Reconcile cost** scales with targets, not requests (≤2× today). Bounded and
|
||||
acceptable; the reconcile loop already batches.
|
||||
- **Anime keyword imperfection** is inherited from Seerr by design (parity
|
||||
choice). Mitigated by isolating `detectAnime` for future tuning.
|
||||
- **Client model drift**: removing top-level external fields from `Request` must
|
||||
be verified against `silo-android` / `silo-apple` deserialization before
|
||||
release (flagged in §6).
|
||||
|
||||
## References
|
||||
|
||||
- Seerr upstream: https://github.com/seerr-team/seerr
|
||||
(`server/api/themoviedb/constants.ts`, `server/entity/MediaRequest.ts`)
|
||||
- Overseerr anime discussions: #2876 (Sonarr for anime only), #3777 (configurable
|
||||
series type)
|
||||
- Existing code: `internal/requests/{service,repository,types,store}.go`,
|
||||
`internal/requests/{radarr,sonarr,arrclient}`, `internal/access/quality.go`,
|
||||
`migrations/139_media_requests.up.sql`
|
||||
@@ -1,222 +0,0 @@
|
||||
# Autoscan: Polling Radarr/Sonarr to Trigger Targeted Library Scans
|
||||
|
||||
**Status:** Design approved, ready for implementation plan
|
||||
**Date:** 2026-06-02
|
||||
**Area:** new `internal/autoscan`, `internal/taskmanager/tasks`, `migrations/`, `internal/api`, `web/src/pages/AdminRequests.tsx`
|
||||
|
||||
## Summary
|
||||
|
||||
Silo currently learns about new media via scheduled/manual library scans. When
|
||||
Radarr/Sonarr import a download, the new file only appears in Silo on the next
|
||||
scan. This adds an **autoscan poller**: a periodic background task that polls the
|
||||
configured Radarr/Sonarr instances for newly-imported files and enqueues
|
||||
**targeted folder scans** into Silo's existing scan queue, so requested (and
|
||||
non-requested) content shows up promptly.
|
||||
|
||||
The design follows the established autoscan pattern — poll arr import history,
|
||||
translate the imported paths, and trigger targeted scans — but kept deliberately
|
||||
simple. That pattern is normally built to orchestrate scans across many separate
|
||||
media-server boxes, which needs a bounded fan-out guard and a retry queue. Silo
|
||||
is a single service scanning *itself*, so the cross-node fan-out machinery is
|
||||
unnecessary — imported paths become folder jobs in Silo's own worker-bounded
|
||||
`scanqueue`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Poll autoscan-enabled Radarr/Sonarr instances on an interval for import events.
|
||||
- Translate arr import paths into Silo media folders and enqueue targeted scans.
|
||||
- Reuse the Radarr/Sonarr instances already configured for requests
|
||||
(`request_integrations`) — no duplicate URLs/keys.
|
||||
- Coalesce bursts (a season's episodes) into minimal redundant scans.
|
||||
- Admin-configurable: global enable, interval, debounce, per-instance toggle,
|
||||
optional path rewrites, on-demand "poll now".
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No cross-node scan fan-out or bounded-concurrency semaphore (single service;
|
||||
the `scanqueue` workers are the bound).
|
||||
- No separate retry queue (a failed source poll simply does not advance its
|
||||
high-water mark, so the next cycle retries the window).
|
||||
- No arr webhook/"Connect" push integration (polling only, as requested; webhook
|
||||
push is a possible future add-on).
|
||||
- No separate autoscan source credential store — sources are existing
|
||||
`request_integrations`.
|
||||
|
||||
## Background
|
||||
|
||||
- **`internal/scantrigger`** already maps a filesystem path to a Silo
|
||||
`MediaFolder` and scan path: `Resolver.Resolve(Request{Path}) -> Target{Folder,
|
||||
Mode, Path}` (via `LongestMatchingRoot` / `MatchFolderForPath`).
|
||||
- **`internal/scanqueue`** executes scans through a worker-bounded queue:
|
||||
`Queuer.EnqueueScan(ctx, folderID, mode, path, trigger)` /
|
||||
`EnqueueScans(targets)`.
|
||||
- **`internal/requests/arrclient`** is a thin Radarr/Sonarr HTTP client
|
||||
(`New(baseURL, apiKey, httpClient)`, `GetJSON`, `DoJSON`) — reused for the new
|
||||
history call.
|
||||
- **`request_integrations`** holds each instance's `id`, `kind`, `base_url`, and
|
||||
Fernet-encrypted `api_key_ref`, resolved via the same secret resolver the
|
||||
request service uses.
|
||||
- The **task manager** runs periodic tasks via the `taskmanager.Task` interface
|
||||
with interval `DefaultTriggers()` (e.g. `ReconcileRequestsTask`).
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Data model & config
|
||||
|
||||
A new `internal/autoscan` package owns its own schema; `request_integrations`
|
||||
stays focused on fulfillment.
|
||||
|
||||
**`autoscan_settings`** (singleton, mirrors `request_settings`):
|
||||
|
||||
| column | type / default | purpose |
|
||||
|---|---|---|
|
||||
| `id` | bool PK default true, singleton CHECK | |
|
||||
| `enabled` | bool, default false | master on/off |
|
||||
| `poll_interval_minutes` | int, default 10 | poll task interval |
|
||||
| `debounce_seconds` | int, default 60 | folder re-scan suppression window |
|
||||
| `created_at`, `updated_at` | timestamptz | |
|
||||
|
||||
**`autoscan_sources`** (one row per autoscan-enabled instance):
|
||||
|
||||
| column | type | purpose |
|
||||
|---|---|---|
|
||||
| `integration_id` | text PK → `request_integrations(id)` ON DELETE CASCADE | the source instance |
|
||||
| `enabled` | bool, default false | per-instance autoscan toggle |
|
||||
| `path_rewrites` | jsonb, default `[]` | `[{from, to}]` prefix overrides |
|
||||
| `last_poll_at` | timestamptz, null | `history/since` high-water mark |
|
||||
| `created_at`, `updated_at` | timestamptz | |
|
||||
|
||||
**Reused, not duplicated:** `base_url` / `api_key_ref` come from
|
||||
`request_integrations`. Debounce/suppression state lives in **Redis**
|
||||
(`autoscan:scanned:{folder_id}`, TTL = `debounce_seconds`), not Postgres.
|
||||
|
||||
### 2. Poll cycle
|
||||
|
||||
`autoscan.Service.PollOnce(ctx)` (invoked by the periodic task):
|
||||
|
||||
1. Load `autoscan_settings`; if `!enabled`, return immediately (no-op).
|
||||
2. Load `autoscan_sources WHERE enabled`, joined to `request_integrations` for
|
||||
`kind` / `base_url` / `api_key_ref`.
|
||||
3. For each source (sequential — a handful of instances):
|
||||
1. Decrypt the API key via the secret resolver.
|
||||
2. Record `cycleStart := now`.
|
||||
3. `arrclient.GetJSON("/api/v3/history/since?date={last_poll_at}&eventType=downloadFolderImported")`
|
||||
— both Radarr and Sonarr expose this. `last_poll_at` null → a sane floor
|
||||
(do not scan all history on first enable; floor at `cycleStart`).
|
||||
4. Extract the imported **file path** per event (Radarr: movie file path;
|
||||
Sonarr: episode file `outputPath`); take its **parent directory**.
|
||||
5. On success, advance that source's `last_poll_at = cycleStart`. On failure
|
||||
(arr unreachable, decode error), log and **do not** advance — next cycle
|
||||
retries the window.
|
||||
4. For each extracted directory: apply path rewrites (§3), resolve via
|
||||
`scantrigger.Resolver` → `Target{Folder, Mode, Path}`. Unresolvable paths
|
||||
(outside Silo's media folders) are logged and skipped.
|
||||
5. **Dedupe to unique folders** for this cycle; for each, check the Redis
|
||||
suppression key — if absent, `EnqueueScan(..., trigger="autoscan")` and set
|
||||
the key with TTL `debounce_seconds`; if present, skip (already scanned this
|
||||
window).
|
||||
|
||||
**Debounce rationale:** because the poller batches a whole interval of imports
|
||||
per `history/since` call, within-cycle folder dedup does most of the work. The
|
||||
Redis suppression key only prevents re-enqueuing the same folder on back-to-back
|
||||
cycles. Silo's scanner is idempotent, so a season importing across two cycles
|
||||
simply gets a second (cheap) scan that picks up the rest. This is simpler than a
|
||||
"collect-expired" debounce plus a retry queue, which the polling + idempotent
|
||||
re-scan model makes unnecessary.
|
||||
|
||||
### 3. Path resolution & scan enqueue
|
||||
|
||||
A small pipeline in front of the existing scan plumbing — no new scan logic:
|
||||
|
||||
1. **Rewrite** (per source, optional): apply the first matching `path_rewrites`
|
||||
entry as a prefix replacement (e.g. `/data/media -> /mnt/media`). No rewrites
|
||||
→ path used as-is (shared-mount common case).
|
||||
2. **Resolve** via `scantrigger.Resolver.Resolve(Request{Path: rewrittenDir})`
|
||||
→ `Target{Folder, Mode, Path}`. No matching media folder → log + skip.
|
||||
3. **Enqueue** deduped targets via `scanqueue` `EnqueueScans` with
|
||||
`trigger="autoscan"` (attributable in scan history, flows through the same
|
||||
worker-bounded queue).
|
||||
|
||||
### 4. Scheduling & concurrency
|
||||
|
||||
- **`internal/taskmanager/tasks/autoscan_poll.go`** implements `taskmanager.Task`
|
||||
(`Key: "autoscan_poll"`, name/description/category, `DefaultTriggers` = interval
|
||||
trigger seeded from `poll_interval_minutes`). Its run body calls
|
||||
`autoscan.Service.PollOnce(ctx)`.
|
||||
- **Disabled = cheap no-op:** the task stays registered; `PollOnce` returns early
|
||||
when `enabled` is false. Toggling autoscan never (de)registers tasks.
|
||||
- **Interval changes:** updating `poll_interval_minutes` reconfigures the task's
|
||||
interval trigger via the task manager's existing trigger-config path; if a live
|
||||
update is unavailable it applies on next start.
|
||||
- **No overlap, no semaphore:** the task manager serializes a task's executions;
|
||||
sources are polled sequentially; scan execution is bounded by `scanqueue`. The
|
||||
single-service simplification: no bounded-fan-out semaphore is needed.
|
||||
- **Primary/integrated server only** (same as the request reconcile task), not on
|
||||
standalone transcode/proxy worker nodes.
|
||||
|
||||
### 5. Admin API & UI
|
||||
|
||||
**API** (admin-gated, `/api/v1/admin/autoscan`, mirroring the request-settings
|
||||
handlers; never echoes API keys):
|
||||
|
||||
- `GET/PUT /autoscan/settings` — `{enabled, poll_interval_minutes, debounce_seconds}`
|
||||
- `GET /autoscan/sources` — per autoscan-eligible instance (joined from
|
||||
`request_integrations`): `{integration_id, name, kind, enabled, path_rewrites,
|
||||
last_poll_at}`
|
||||
- `PUT /autoscan/sources/{integration_id}` — set `{enabled, path_rewrites}`
|
||||
- `POST /autoscan/trigger` — run `PollOnce` once on demand; tight per-admin rate
|
||||
limit so it cannot hammer the arr instances
|
||||
- `GET /autoscan/status` — `{enabled, per-source last_poll_at, last scan summary}`
|
||||
|
||||
**UI:** a new **"Autoscan" tab** in `web/src/pages/AdminRequests.tsx` (alongside
|
||||
Queue / Settings / Integrations / User Overrides):
|
||||
|
||||
- Global controls: enable switch, poll interval, debounce window.
|
||||
- Per-source list — one row per Radarr/Sonarr instance with an autoscan toggle,
|
||||
an optional `from → to` path-rewrite editor, and a read-only "last polled".
|
||||
- A **"Poll now"** button → `POST /autoscan/trigger`.
|
||||
|
||||
Rate limits and admin-auth follow Silo's existing request-admin conventions.
|
||||
|
||||
### 6. Testing
|
||||
|
||||
- **Path rewrite:** prefix replacement, first-match ordering, no-match
|
||||
passthrough, trailing-slash handling.
|
||||
- **arr history client:** table-driven `httptest` fixtures for both Radarr and
|
||||
Sonarr import-event JSON shapes; asserts the correct file path is extracted and
|
||||
its parent folder taken.
|
||||
- **Dedupe + suppression:** many paths in one folder → one target; folder with a
|
||||
live suppression key skipped; absent/expired key enqueues.
|
||||
- **`PollOnce` (integration seam):** fakes for source store, arr history client,
|
||||
`scantrigger.Resolver`, `scanqueue.Queuer`, Redis — asserts deduped folder
|
||||
scans enqueued with `trigger="autoscan"`, `last_poll_at` advanced on success,
|
||||
**not** advanced on per-source failure, clean no-op when disabled.
|
||||
- **Unresolvable path:** logged + skipped, never enqueued or fatal.
|
||||
- **Repository / migration:** `autoscan_settings` singleton + `autoscan_sources`
|
||||
CRUD and the `request_integrations` join; migration applies + rolls back
|
||||
(sidecar table, `ON DELETE CASCADE`).
|
||||
- **Frontend:** Autoscan tab renders, per-source toggle / path-rewrite editor
|
||||
round-trips, "Poll now" calls the trigger endpoint.
|
||||
|
||||
## Risks & open considerations
|
||||
|
||||
- **First-enable floor:** `last_poll_at` null must floor at "now" (not epoch) so
|
||||
enabling autoscan doesn't enqueue a scan for every historical import.
|
||||
- **history/since shape differences:** Radarr vs Sonarr import-event payloads
|
||||
differ; the path extraction is per-kind and covered by fixtures.
|
||||
- **Path rewrites vs scantrigger roots:** if neither rewrites nor Silo's media
|
||||
folders match the arr path, the import is silently skipped (logged) — this is
|
||||
the expected "content outside Silo's libraries" behavior, but a misconfigured
|
||||
rewrite will look like "autoscan does nothing"; the `last_poll_at` + status
|
||||
endpoint and skip logs are the debugging surface.
|
||||
- **Idempotent re-scan:** relies on Silo's scanner being safe to run repeatedly
|
||||
on a folder (it is — re-scan finds new files).
|
||||
|
||||
## References
|
||||
|
||||
- Existing Silo plumbing reused by this design: `internal/scantrigger/scantrigger.go`,
|
||||
`internal/scanqueue/`, `internal/requests/arrclient/`,
|
||||
`internal/taskmanager/tasks/reconcile_requests.go`
|
||||
- Prior art: the general autoscan pattern (poll arr import history → translate
|
||||
paths → trigger targeted scans). Silo's variant drops the cross-node fan-out
|
||||
guard and retry queue, which only a multi-box orchestrator needs.
|
||||
@@ -1,262 +0,0 @@
|
||||
# Autoscan as a Pluggable Scan-Source Category — Design Spec
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Status:** Approved design, pre-implementation
|
||||
**Goal:** Turn autoscan from a Requests-coupled, arr-only feature into a standalone **Autoscan** category whose change-detection providers are out-of-process Silo plugins, with Sonarr/Radarr as the first provider.
|
||||
|
||||
**Architecture (one sentence):** Silo's host keeps a thin, provider-agnostic engine that resolves changed paths to library folders and enqueues rescans; each *way of noticing change* (arr, later inotify/ceph) is an installable plugin that Silo pulls on a timer through a new additive `scan_source.v1` capability.
|
||||
|
||||
**Tech stack:** Go (silo-server host + plugins), `silo-plugin-sdk` (protobuf capability contract, consumed as a tagged module), existing `internal/plugins` + `internal/pluginhost` runtime, existing `scantrigger`/`scanqueue` scan pipeline, React/TS admin UI.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background & motivation
|
||||
|
||||
The current autoscan implementation (delivered in the now-closed PR #43, still on local `main`) is in-process Go that:
|
||||
|
||||
- polls Sonarr/Radarr `/api/v3/history/since` on a host timer,
|
||||
- rewrites arr-side paths to Silo paths, dedups to parent dirs,
|
||||
- resolves each to a `MediaFolder` and enqueues a targeted scan,
|
||||
- is configured against `request_integrations` rows (hard FK, `ON DELETE CASCADE`), living as a tab inside the **Requests** admin page.
|
||||
|
||||
Two structural problems:
|
||||
|
||||
1. **It is arr-only and not extensible.** Adding inotify, Ceph notifications, or any other change source means more bespoke host code.
|
||||
2. **It is subordinate to Requests.** A source cannot exist without a Requests integration row; deleting the row destroys the source.
|
||||
|
||||
Silo already ships a full out-of-process plugin runtime (`internal/plugins`, `internal/pluginhost`): manifests, an installer, typed capability descriptors (`metadata_provider.v1`, `request_router.v1`, `media_analyzer.v1`, `scheduled_task.v1`, `event_consumer.v1`, …), a host-driven scheduled-task mechanism, and a rate-limited plugin→host event path. This design uses that runtime to make change-detection pluggable.
|
||||
|
||||
## 2. Goals / non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- A new **Autoscan** admin category, independent of Requests.
|
||||
- A new additive `scan_source.v1` plugin capability — **breaks no existing plugin**.
|
||||
- A **client-pull** contract: Silo asks each source "what changed since `<marker>`?" on a timer.
|
||||
- Sonarr/Radarr re-implemented as the first scan-source plugin; the closed PR's arr logic relocates into it.
|
||||
- A provider-agnostic host engine reusable by every future scan source.
|
||||
- Optional reuse of Requests' arr connection details, *or* standalone connection entry.
|
||||
|
||||
**Non-goals (this design)**
|
||||
|
||||
- inotify / Ceph / other providers (the contract must *accommodate* them; we do not build them).
|
||||
- A push (plugin-initiated) delivery mode. Pull only; push sources buffer internally and answer on pull (see §6).
|
||||
- Refactoring the Requests subsystem itself.
|
||||
|
||||
## 3. Architecture overview
|
||||
|
||||
```
|
||||
┌──────────────────────────── Silo (host) ────────────────────────────┐
|
||||
│ Autoscan category (new; not under Requests) │
|
||||
│ • Sources: which scan_source plugin instance, on/off, interval │
|
||||
│ • Connections: reuse a Requests arr server, or enter own │
|
||||
│ • Per-source state: opaque marker, last-run status │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ AutoscanStore — generic engine (provider-agnostic) │
|
||||
│ per tick, per enabled source: │
|
||||
│ call plugin PollChanges(marker) │
|
||||
│ → resolve each Silo-native path → MediaFolder │
|
||||
│ → suppress duplicates → enqueue scan (existing scanner) │
|
||||
│ → store returned next_marker │
|
||||
│ │ scan_source.v1 (pull) │
|
||||
└──────────────────────────────┼───────────────────────────────────────┘
|
||||
▼
|
||||
┌──────── Sonarr/Radarr scan-source plugin ────────┐
|
||||
│ (new installable repo, like silo-plugin-tmdb) │
|
||||
│ receives resolved arr address + key │
|
||||
│ polls /history (imports + renames) │
|
||||
│ returns RAW source paths + next marker │
|
||||
│ (host applies the source's rewrites) │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Five components**
|
||||
|
||||
1. **`scan_source.v1`** (in `silo-plugin-sdk`) — the pull contract. §5.
|
||||
2. **AutoscanStore** (host) — generic resolve→suppress→enqueue→marker engine. §7.
|
||||
3. **Connection resolution** (host) — reference-or-inline → concrete credentials. §8.
|
||||
4. **Autoscan category + data model** (host) — sources, connections, decoupled from Requests. §9.
|
||||
5. **arr scan-source plugin** (new repo) — first provider. §10.
|
||||
|
||||
## 4. Responsibility split (the dividing line)
|
||||
|
||||
| Concern | Owner | Rationale |
|
||||
|---|---|---|
|
||||
| Talk to arr; poll `/history`; imports + renames; bounded window; first-run | **Plugin** | Provider-specific I/O. |
|
||||
| **Path rewrites** (per-source config + application) | **Host** | _(Revised post-merge: the SDK landed host-owned rewrites.)_ The plugin returns RAW source-namespace paths in `source_paths`; the host applies each source's rewrite rules (longest-`From`-first) before resolving. Keeps every provider dumb and rewrite behavior uniform. Rewrites are edited per-source in the Autoscan UI and stored in `autoscan_sources.path_rewrites`. |
|
||||
| Resolve Silo-native path → `MediaFolder`; dedupe; **enqueue rescan** | **Host** | Touches Silo internals (scan queue, DB, library map) that plugins cannot and must not reach. |
|
||||
| Opaque per-source marker; poll interval; enabled; last-run status | **Host** | Scheduling and bookkeeping are the engine's job. |
|
||||
| Connection selection (reuse-from-Requests vs own) and credential resolution | **Host** | Only the host knows about Requests; the plugin receives resolved creds. |
|
||||
|
||||
The plugin's responsibility ends at **"here are the changed paths, in Silo's terms."** Everything past that is the host engine, which is provider-agnostic.
|
||||
|
||||
## 5. The `scan_source.v1` contract (centerpiece)
|
||||
|
||||
A new, additive capability in `silo-plugin-sdk`. Existing plugins never declare it and are unaffected (§11).
|
||||
|
||||
### Protobuf (as implemented in silo-plugin-sdk)
|
||||
|
||||
```proto
|
||||
// scan_source.v1 — host pulls changed paths from a provider on a timer.
|
||||
service ScanSource {
|
||||
// Host calls this on each poll tick for a configured scan_source capability.
|
||||
rpc PollChanges(PollChangesRequest) returns (PollChangesResponse);
|
||||
}
|
||||
|
||||
message PollChangesRequest {
|
||||
string capability_id = 1; // which configured scan_source instance
|
||||
string marker = 2; // opaque; empty string on first run
|
||||
ResolvedConnection connection = 3; // resolved upstream credentials (see §8)
|
||||
}
|
||||
|
||||
// Concrete upstream credentials the host resolves and hands to the plugin each
|
||||
// poll, so the plugin stays credential-source-agnostic and stores no secrets.
|
||||
message ResolvedConnection {
|
||||
string base_url = 1;
|
||||
string api_key = 2;
|
||||
}
|
||||
|
||||
message PollChangesResponse {
|
||||
// Absolute changed paths in the provider/source namespace (RAW — NOT yet
|
||||
// rewritten). Files or directories. The host applies the source's rewrite
|
||||
// rules and validates before enqueueing.
|
||||
repeated string source_paths = 1;
|
||||
// Opaque continuation token. Host stores verbatim and echoes it back
|
||||
// on the next PollChanges. The plugin never assumes the host parses it.
|
||||
string next_marker = 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Contract rules
|
||||
|
||||
- **Pull only.** The host owns the timer and calls `PollChanges`. The plugin never initiates.
|
||||
- **Opaque marker.** The host stores and echoes `next_marker` without interpreting it. arr uses an RFC3339 timestamp; a future provider may use a sequence number or event id — no host change required.
|
||||
- **First run.** `marker == ""` ⇒ the plugin starts from "now" (do not replay full history).
|
||||
- **Idempotent, at-least-once.** The host may re-issue the same `marker` if it failed to persist `next_marker`; providers must tolerate returning overlapping paths. The host's suppression layer (§7) absorbs duplicates.
|
||||
- **Error semantics.** A failed `PollChanges` (e.g. arr unreachable) ⇒ the host keeps the *old* marker and retries next tick; nothing is skipped or advanced.
|
||||
- **Raw source paths.** `source_paths` are in the provider's namespace, NOT rewritten. The host applies each source's path-rewrite rules (host-owned, per-source) before resolving to a library folder.
|
||||
- **Credentials per call.** The host resolves the source's connection (§8) and passes the concrete `{base_url, api_key}` in `PollChangesRequest.connection` on every poll. The plugin reads them from the request — it stores no credentials and never knows whether they came from its own config or a reused Requests link. Delivered over the local host↔plugin gRPC channel only.
|
||||
|
||||
The capability needs **only** `PollChanges` for v1. A `TestConnection`/validate RPC is explicitly deferred (YAGNI; connection validity surfaces as a failed poll).
|
||||
|
||||
## 6. Marker & delivery semantics
|
||||
|
||||
- One opaque marker stored per source, host-side.
|
||||
- Pull-only does **not** exclude push-style providers: an inotify/Ceph plugin maintains its own internal buffer of observed changes and flushes it (and advances its marker) on each `PollChanges`. Cost: up to one poll-interval of latency. This keeps a single uniform contract.
|
||||
|
||||
## 7. Host: AutoscanStore engine
|
||||
|
||||
Provider-agnostic. Per enabled source, per tick:
|
||||
|
||||
1. `paths, next := plugin.PollChanges(marker)`; on error, log + keep marker + continue.
|
||||
2. For each `dir` in `uniqueParentDirs(paths)`:
|
||||
- `target := scantrigger.Resolve(dir)`. Unresolvable (outside Silo's media folders) ⇒ quiet skip.
|
||||
- Suppression key `fmt.Sprintf("%d|%s", folderID, target.Path)` with debounce TTL; already-claimed ⇒ skip.
|
||||
- Accumulate `target`; record claimed key.
|
||||
3. `scanqueue.EnqueueScans(targets)`. On failure, release claims and **do not** advance the marker.
|
||||
4. On success, persist `next_marker`.
|
||||
|
||||
This is the salvaged core of PR #43 (`internal/autoscan/service.go` resolve/suppress/enqueue, `dedupe.go`, `suppress.go`), generalized so its input is "paths from a provider" rather than "paths from arr." Path-rewrite helpers (`rewrite.go`, `suggest.go`, `history.go`, the arr history client) leave the host and move into the plugin (§10).
|
||||
|
||||
## 8. Host: Connections (reuse or own)
|
||||
|
||||
A **connection** is "an arr server Silo can reach," created two ways:
|
||||
|
||||
- **Own:** name + base URL + API key, entered in the Autoscan category; the key is Fernet-encrypted at rest (same mechanism as existing service credentials — never plaintext in JSONB).
|
||||
- **Linked (reuse):** a live reference to an existing Requests `request_integrations` arr server. Resolved at use-time, so edits in Requests propagate.
|
||||
|
||||
**Resolution:** on each poll the host resolves the chosen connection (own or linked) into concrete `{base_url, api_key}` and passes them in `PollChangesRequest.connection` (§5). The plugin is credential-source-agnostic — it only ever sees resolved values, fresh each poll, so a change to a reused Requests credential propagates immediately with no re-push. This "reuse or own" behaviour is therefore free for every future provider. (Resolving per-call rather than caching in plugin config also means no decrypted secret is ever stored at rest in the plugin.)
|
||||
|
||||
**Decoupling from Requests:** the link is **soft and optional** — `SET NULL` / surfaced-as-"needs attention", never cascading. Requests has no knowledge of Autoscan. Autoscan and Requests are peers; Autoscan merely *offers* to borrow a Requests connection.
|
||||
|
||||
## 9. Host: Autoscan category & data model
|
||||
|
||||
New top-level **Autoscan** admin section (its own nav entry, not a Requests tab). Manages **Connections** (§8) and **Sources**.
|
||||
|
||||
A **source** corresponds to one configured `scan_source.v1` capability instance and carries:
|
||||
|
||||
- **Host-owned per-source config:** path rewrites (`autoscan_sources.path_rewrites`, edited in the Autoscan Sources UI) and any provider-specific settings. _(Revised post-merge: rewrites moved host-side; the plugin has no config.)_
|
||||
- **Host-owned per-source state:** the chosen connection, enabled on/off, poll interval (per-source, not one global timer), the opaque marker, and last-run status/error.
|
||||
|
||||
**Install flow + source auto-discovery (resolves §14 risk #1).** A scan-source plugin (Sonarr/Radarr, etc.) is installed through the **normal `/admin/plugins`** page — there is no separate install path; the installer doesn't special-case the capability type. Source rows are then never created by hand: the host enumerates every installed `scan_source.v1` capability (via the plugin installation store) and *seeds a disabled, connection-less row* per capability (`INSERT … ON CONFLICT (installation_id, capability_id) DO NOTHING`, never disturbing existing config). Discovery runs **both on each poll cycle AND when the Autoscan sources list is viewed** (so a freshly-installed plugin appears in Autoscan immediately, even when autoscan is disabled and nothing is polling). The operator then binds a connection and enables it. Consequently **`connection_id` is nullable**: a freshly-discovered source has none, and the engine skips any enabled source with no connection (recording a "no connection bound" status). The admin API rejects enabling a source that has no effective connection.
|
||||
|
||||
**Per-source interval** is honoured as a floor: the global poll task runs at `default_poll_interval_seconds`, and within a cycle the engine skips a source whose `last_run_at` is newer than its own `poll_interval_seconds` (falling back to the default). A source therefore polls *at most* every N seconds, never more often than the global cadence.
|
||||
|
||||
### Schema changes (host)
|
||||
|
||||
Rework the PR #43 tables; **no FK into `request_integrations`**:
|
||||
|
||||
- `autoscan_connections` — `id`, `name`, `kind` (e.g. `sonarr`/`radarr`), and *either* own credentials (`base_url`, `api_key_ref` encrypted) *or* a nullable soft link `request_integration_id` (`ON DELETE SET NULL`).
|
||||
- `autoscan_sources` — `id`, `(installation_id, capability_id)` (unique; the discovered identity), **nullable** `connection_id → autoscan_connections` (`ON DELETE RESTRICT`; null until the operator binds one), `enabled`, `poll_interval_seconds` (nullable; null = use default), `marker` (opaque text, nullable), `last_run_at`, `last_error`. Creation-time validity for connections (own creds OR a link) is enforced at the application layer (the DB CHECK would conflict with `ON DELETE SET NULL` on the reuse link).
|
||||
- `autoscan_settings` (optional) — retain a global enable + default interval; per-source interval overrides it.
|
||||
|
||||
A migration supersedes PR #43's `171_autoscan` schema (which is not yet upstream, so this is a forward redefinition, not a production migration of live data on `origin/main`).
|
||||
|
||||
## 10. Plugin: Sonarr/Radarr scan-source
|
||||
|
||||
A new installable repo structured like `silo-plugin-tmdb` (standalone Go module, `manifest.json` declaring `scan_source.v1`, gRPC `main.go`, depends on the new tagged `silo-plugin-sdk`). Receives resolved arr `{base_url, api_key}` as config. Implements `PollChanges`:
|
||||
|
||||
1. Treat `marker` as the "since" timestamp (empty ⇒ now).
|
||||
2. Call arr `/api/v3/history/since`, capped by a bounded window (24h max-lookback floor + overlap buffer — relocated from PR #43) so a long outage cannot pull an oversized response.
|
||||
3. Extract paths: **imports** (`downloadFolderImported.importedPath`) and **renames** (`episodeFileRenamed`/`movieFileRenamed`: both new `path` and old `sourcePath`). Deletes ignored (upgrade-deletes are covered by the paired import; standalone deletes carry no path).
|
||||
4. Return `{source_paths = the RAW arr paths, next_marker = newest history timestamp}`. The plugin does NOT rewrite — the host normalizes separators and applies the source's rewrite rules. _(Revised post-merge.)_
|
||||
|
||||
**Rewrite config + "Sync from arr":** the rewrite list and the suffix-match suggester (which matches arr root folders to Silo media folders) live in the plugin. The suggester needs Silo's media-folder list, obtained via the existing host library-listing service (`internal/pluginhost` library lister) exposed to plugins. The relocated logic is PR #43's `suggest.go`/`suggest_deps.go`/`rewrite.go`/`history.go`.
|
||||
|
||||
## 11. Backward compatibility (hard requirement)
|
||||
|
||||
Adding `scan_source.v1` **must not break existing plugins.** It doesn't, by construction:
|
||||
|
||||
- Capabilities are independent opt-in declarations. Each host subsystem **filters for its own type and ignores the rest** (verified: `metadata_provider.v1`, `scheduled_task.v1`, `event_consumer.v1`, … each use `if type != mine { skip }`). There is **no global allowlist that rejects unknown capability types.**
|
||||
- **Additive-only rule:** the SDK change *adds* new proto messages/service + a new capability string. It never modifies the shape of an existing capability. (Mutating an existing capability is the only thing that would break plugins; we forbid it.)
|
||||
- **No forced upgrades:** existing plugin repos stay pinned to their current SDK version and build unchanged. Only a plugin that *wants* to be a scan source adopts the new SDK version.
|
||||
|
||||
## 12. Build order & repo decomposition
|
||||
|
||||
One shared contract, three repos (+ catalog), in dependency order:
|
||||
|
||||
1. **`silo-plugin-sdk`** (long pole): define `scan_source.v1` (proto + generated code + manifest helper + runtime server scaffold), tag a new version (e.g. `v0.5.0`). The tagged release is a hard serialization point — host and plugin cannot build against the contract until it exists.
|
||||
2. **silo-server (host)** — bump SDK dep; build AutoscanStore (generalized), the `scan_source.v1` caller (timer → `PollChanges`), connections + resolution, the Autoscan category UI, retire the in-process arr autoscan, add the reworked migration. *Parallelizable with (3) once (1) is tagged.*
|
||||
3. **arr plugin (new repo)** — depends on SDK `v0.5.0`; implement `PollChanges`, rewrites, "Sync from arr", bounded window.
|
||||
4. **`silo-plugins`** (catalog) — add the entry so the arr plugin is installable.
|
||||
|
||||
Each repo gets its **own implementation plan** (separate `writing-plans` pass); this single spec defines the shared contract and per-side responsibilities they all build against.
|
||||
|
||||
**Local logistics:** only `silo-server` and `silo-plugin-tmdb` are currently checked out. Implementation will require `silo-plugin-sdk` and a new arr-plugin repo (and later `silo-plugins`) available locally.
|
||||
|
||||
## 13. Salvage map (from closed PR #43)
|
||||
|
||||
| PR #43 code | Destination |
|
||||
|---|---|
|
||||
| arr `/history` client, imports + renames, bounded window (`history.go`) | arr plugin |
|
||||
| path rewrite apply (`rewrite.go`) + "Sync from arr" suggester (`suggest.go`, `suggest_deps.go`) | arr plugin |
|
||||
| `last_poll_at` cursor | host, generalized to opaque per-source `marker` |
|
||||
| resolve + dedupe + suppress + enqueue (`service.go`, `dedupe.go`, `suppress.go`) | host AutoscanStore (generic) |
|
||||
| `171_autoscan` schema (FK → `request_integrations`) | host, reworked: `autoscan_connections` + `autoscan_sources`, no requests FK |
|
||||
| Autoscan admin tab under Requests (`AdminRequests.tsx`) | host, as its own Autoscan category |
|
||||
|
||||
## 14. Risks & open questions
|
||||
|
||||
1. **Plugin multiplicity for multiple arr servers.** Does the runtime support multiple configured instances of one plugin (one per arr server), or is config per-installation only? If the latter, "two Sonarrs + one Radarr" implies either multiple installations or a plugin that manages several connections internally. *Resolve before the host/plugin plans by reading `internal/plugins` installation + capability-instance handling.*
|
||||
2. **Plugin config UI richness.** The rewrite table + a "Sync from arr" action are richer than typical key-value plugin config. Verify the plugin config-schema/admin-config UI can host a structured list and an action button; if not, design a minimal affordance (e.g. suggester runs on first configure, or a dedicated plugin HTTP route via `http_routes.v1`).
|
||||
3. **Library-folder list exposure to plugins.** Confirm the `internal/pluginhost` library lister is reachable by a `scan_source` plugin for the suggester. If not, add a host service method.
|
||||
4. **Marker persistence vs at-least-once.** Ensure marker write happens only after successful enqueue, matching PR #43's "don't advance on failure" guarantee.
|
||||
5. **Closed PR disposition.** The in-process autoscan on local `main` (34 commits) should be removed/replaced rather than shipped; sequence the host plan to retire it cleanly.
|
||||
|
||||
## 15. Testing strategy
|
||||
|
||||
- **SDK:** proto compiles; manifest helper round-trips `scan_source.v1`; runtime scaffold serves `PollChanges`.
|
||||
- **Host engine:** table-driven tests for resolve→suppress→enqueue→marker (port PR #43's `service_test.go`, generalized to a fake `ScanSource` client): dedupe, distinct-paths-same-folder, disabled no-op, poll failure keeps marker, enqueue failure releases claims + keeps marker, opaque-marker pass-through. Run in the libvips-equipped environment (host handler tests link libvips via CGO — see workspace note).
|
||||
- **Connections:** resolution of own vs linked; linked falls back gracefully when the Requests row is deleted (`SET NULL`).
|
||||
- **arr plugin:** history parsing (imports + renames, ignores unrelated events), rewrite application, bounded window, first-run "from now"; suffix-match suggester cases (unique/ambiguous/covered/no-op/normalization) ported from PR #43.
|
||||
- **Compat:** an existing-style plugin manifest with no `scan_source.v1` is unaffected by host scan-source dispatch.
|
||||
|
||||
## 16. Out of scope / future
|
||||
|
||||
- inotify and Ceph-notify scan-source plugins (the contract accommodates them via the buffer-and-flush pattern of §6).
|
||||
- A push/event delivery mode, if pull latency ever proves insufficient.
|
||||
- A `TestConnection` RPC on the capability.
|
||||
- Migrating Requests onto a shared connection abstraction (today's reuse is a one-directional soft link only).
|
||||
@@ -1,173 +0,0 @@
|
||||
# Autoscan: Sync Path Rewrites from Arr Root Folders
|
||||
|
||||
**Status:** Design approved, ready for implementation plan
|
||||
**Date:** 2026-06-02
|
||||
**Area:** `internal/autoscan`, `internal/api`, `web/src/pages/AdminRequests.tsx`
|
||||
**Extends:** the autoscan feature (`docs/superpowers/specs/2026-06-02-autoscan-arr-polling-design.md`)
|
||||
|
||||
## Summary
|
||||
|
||||
Autoscan resolves an imported file path against Silo's media folders, but
|
||||
Radarr/Sonarr often report files under paths that differ from how Silo mounts
|
||||
the same content — so operators must hand-author per-root `path_rewrites`. For a
|
||||
multi-instance, many-root deployment this can be dozens of rows.
|
||||
|
||||
This adds a **"Sync rewrites from arr"** convenience: for one instance, query its
|
||||
root folders, match each root to a Silo media folder by **shared trailing path
|
||||
segments**, and present the resulting `{from, to}` rewrites as a **preview** the
|
||||
admin reviews and commits. Manual rewrites remain the source of truth; sync only
|
||||
*fills the editor* — nothing persists until the admin saves.
|
||||
|
||||
The matcher is purely structural (no assumptions about any particular path
|
||||
layout) so it helps any deployment whose arr and Silo share folder names at some
|
||||
depth, and degrades gracefully (no shared names → "unmatched" → manual entry)
|
||||
when they do not.
|
||||
|
||||
## Goals
|
||||
|
||||
- One-click suggestion of `path_rewrites` for an instance from its arr root folders.
|
||||
- Correct disambiguation when multiple Silo folders share a leaf name.
|
||||
- Preview-then-commit: the admin sees proposed/unmatched/ambiguous and saves
|
||||
through the existing per-source save. Manual rows are never removed or overwritten.
|
||||
- Deployment-agnostic: no hard-coded paths; works for any structure; surfaces
|
||||
match confidence so weak matches can be rejected.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No automatic/background writing of rewrites — the admin always commits.
|
||||
- No new persistence path — apply is the existing `PUT /sources/{id}`.
|
||||
- No case-insensitive matching (case-sensitive segments; correct on Linux). A
|
||||
toggle can be added later if needed.
|
||||
- No "sync all instances at once" — per-instance, matching the editor UI.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Matching function (core, pure)
|
||||
|
||||
A single side-effect-free function in `internal/autoscan`:
|
||||
|
||||
```go
|
||||
func suggestRewrites(arrRoots []string, siloFolderPaths []string, existing []PathRewrite) RewriteSuggestions
|
||||
```
|
||||
|
||||
```go
|
||||
type RewriteSuggestions struct {
|
||||
Proposed []ProposedRewrite `json:"proposed"`
|
||||
Unmatched []string `json:"unmatched"`
|
||||
Ambiguous []AmbiguousRoot `json:"ambiguous"`
|
||||
Covered []string `json:"covered"`
|
||||
}
|
||||
type ProposedRewrite struct {
|
||||
From string `json:"from"` // arr root
|
||||
To string `json:"to"` // matched Silo folder path
|
||||
MatchDepth int `json:"match_depth"` // trailing segments shared (confidence)
|
||||
}
|
||||
type AmbiguousRoot struct {
|
||||
Root string `json:"root"`
|
||||
Candidates []string `json:"candidates"`
|
||||
}
|
||||
```
|
||||
|
||||
**Normalization** (applied to every path before comparison): backslashes → `/`
|
||||
(reuse `normalizeSeparators`), strip a trailing `/`, collapse duplicate slashes.
|
||||
This makes any path format (POSIX, Windows, single- or many-root) comparable.
|
||||
|
||||
**Per arr root:**
|
||||
1. **Covered-first:** if any `existing` rewrite's `From` already matches the root
|
||||
(exact, or root starts with `From + "/"`, using the same boundary rule as
|
||||
`applyRewrites`), classify as **Covered** and skip — so sync never re-proposes
|
||||
what a manual or broad rule already handles.
|
||||
2. Split both the root and each Silo folder path into segments; compute the
|
||||
**longest common trailing-segment suffix** (full-segment equality, so `4ktv7`
|
||||
does not match `4ktv70`).
|
||||
3. Among Silo folders, take the maximum suffix length `> 0` and the folders that
|
||||
achieve it:
|
||||
- exactly one → **Proposed** `{From: root, To: folder, MatchDepth: n}`
|
||||
- more than one → **Ambiguous** `{root, candidates}`
|
||||
- zero → **Unmatched**
|
||||
|
||||
No deployment constants appear in the function; it operates only on the string
|
||||
slices it is given.
|
||||
|
||||
### 2. Preview endpoint & service
|
||||
|
||||
**Endpoint:** `GET /api/v1/admin/autoscan/sources/{id}/rewrite-suggestions` →
|
||||
`RewriteSuggestions` JSON (admin-gated, like the other autoscan routes).
|
||||
|
||||
**Service method** `Service.SuggestRewrites(ctx, integrationID) (RewriteSuggestions, error)`:
|
||||
1. `Repository.GetSource(id)` — a new single-source variant of the existing join
|
||||
that returns `kind`/`base_url`/`api_key_ref`/`path_rewrites` even when the
|
||||
source is disabled. Not found → `ErrIntegrationNotFound`.
|
||||
2. Decrypt the API key via the existing `SecretResolver`.
|
||||
3. `RootFolderClient.RootFolders(ctx, baseURL, apiKey)` → arr root paths.
|
||||
4. `FolderLister.ListFolderPaths(ctx)` → all Silo media-folder paths.
|
||||
5. Return `suggestRewrites(arrRoots, siloPaths, source.PathRewrites)`.
|
||||
|
||||
**Two new narrow `Service` dependencies** (injected like `history`/`resolver`):
|
||||
- `RootFolderClient.RootFolders(ctx, baseURL, apiKey string) ([]string, error)` —
|
||||
an `arrclient`-backed impl reusing `arrclient.ListRootFolders` (returns paths only).
|
||||
- `FolderLister.ListFolderPaths(ctx) ([]string, error)` — a thin adapter over
|
||||
`catalog.FolderRepository.List()` flattening each folder's `Paths`.
|
||||
|
||||
**Wiring:** both constructed where the autoscan handler is built in `router.go`
|
||||
(`deps.FolderRepo` already in scope; the arr client is trivial). The handler's
|
||||
service interface gains `SuggestRewrites`.
|
||||
|
||||
**Errors:** instance not found → 404; arr unreachable / bad key → 502 with the
|
||||
arr error surfaced; Silo folder-list failure → 500.
|
||||
|
||||
No new write path — the frontend merges `proposed` into the editor and the admin
|
||||
saves via the existing `PUT /sources/{id}`.
|
||||
|
||||
### 3. Frontend (preview UI)
|
||||
|
||||
In `AutoscanSourceEditor` (`web/src/pages/AdminRequests.tsx`), add a **"Sync from
|
||||
arr"** button beside Save. On click, a new hook
|
||||
(`useAutoscanRewriteSuggestions`) calls the endpoint and opens a preview panel:
|
||||
|
||||
- **Proposed** — each row: `From → To` + a **confidence badge** from `match_depth`
|
||||
(e.g. "3 segments" strong, "1 segment — weak"), with a checkbox (default checked).
|
||||
- **Unmatched** — info-only list of arr roots with no Silo match ("add manually").
|
||||
- **Ambiguous** — arr root + candidate Silo paths (info-only; admin adds a manual row).
|
||||
|
||||
**"Add selected to rewrites"** merges the checked proposals into the editor's
|
||||
existing rewrite rows, **deduped by `from`, never removing manual rows**. The
|
||||
admin then clicks the normal **Save**. New types in `web/src/api/types.ts`
|
||||
(`AutoscanRewriteSuggestions`, `ProposedRewrite`, `AmbiguousRoot`) and hook in
|
||||
`web/src/hooks/queries/useAutoscan.ts`. Reuse existing
|
||||
`Button`/`Badge`/`Dialog`/`Field` primitives.
|
||||
|
||||
### 4. Testing
|
||||
|
||||
- **`suggestRewrites`** — table-driven, fixtures spanning layouts: multi-segment
|
||||
match, single-segment unique, longest-suffix disambiguation (`Anime/Subs` vs
|
||||
`Anime2/Subs`), unmatched (no shared segment), already-covered via a broad
|
||||
prefix rule, an ambiguous tie, plus **unlike-this-deployment** cases
|
||||
(`/data/Movies` arr vs `/library/Films` Silo → unmatched; `/srv/tv/Show` vs
|
||||
`/tank/television/Show` → leaf match) and normalization (trailing slash,
|
||||
backslashes, duplicate slashes).
|
||||
- **`Service.SuggestRewrites`** — fakes for source store / `RootFolderClient` /
|
||||
`FolderLister` / secrets: asserts proposed/unmatched/ambiguous; arr-unreachable
|
||||
surfaces the error.
|
||||
- **Handler** — returns the JSON; 404 for missing instance; 502 when the arr is
|
||||
unreachable.
|
||||
- **Frontend** — the button hits the endpoint; the preview renders all three
|
||||
buckets with confidence badges; "Add selected" merges deduped rows; Save
|
||||
persists via the existing mutation.
|
||||
|
||||
## Risks & open considerations
|
||||
|
||||
- **Weak (1-segment) matches** can be coincidental across very different layouts;
|
||||
mitigated by surfacing `match_depth` so the admin rejects them before saving.
|
||||
- **Case-sensitivity** means case-mismatched mounts produce Unmatched rather than
|
||||
a wrong auto-match — a deliberate safe default; revisit only if requested.
|
||||
- **Arr reachability** is required for sync (unlike the rest of autoscan which
|
||||
polls on a schedule); a 502 with the arr error tells the admin to check the
|
||||
instance, and manual entry still works.
|
||||
|
||||
## References
|
||||
|
||||
- Reused: `internal/requests/arrclient.ListRootFolders`,
|
||||
`internal/catalog/FolderRepository.List`, `internal/autoscan` (`PathRewrite`,
|
||||
`normalizeSeparators`, `Repository`, `Service`, the existing
|
||||
`PUT /autoscan/sources/{id}` save).
|
||||
@@ -1,161 +0,0 @@
|
||||
# Autoscan source labels — design
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved, pending implementation plan
|
||||
**Scope:** Admin UI labeling of autoscan scan sources, generic across plugins, plus an operator-editable per-source label.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
Autoscan sources are labeled in the admin UI (Sources tab and Activity tab) by their
|
||||
`capability_id` plus `plugin #<installation_id>`. The `arr` scan-source plugin fans out
|
||||
one source per connection (Sonarr, Sonarr4k, Radarr, Radarr4k) under a single generic
|
||||
`arr` capability, so every row rendered an identical "arr (plugin #4)" label. A prior
|
||||
change made the Sources/Activity labels lead with the bound connection name, which fixes
|
||||
`arr`, but the fallback for any other plugin is still the cryptic `capability_id` +
|
||||
`plugin #N`.
|
||||
|
||||
Two gaps remain:
|
||||
|
||||
1. **Other plugins look cryptic.** A connectionless plugin (e.g. cephfs) or any future
|
||||
plugin still falls back to the raw `capability_id`, ignoring the human-friendly
|
||||
`display_name` the plugin already declares in its manifest.
|
||||
2. **No operator control.** An operator cannot name a source themselves. This is the only
|
||||
fully-general answer for plugin shapes the automatic chain cannot disambiguate (e.g. a
|
||||
single plugin installation creating several connectionless sources distinguished only
|
||||
by their `source_config`).
|
||||
|
||||
## Goals
|
||||
|
||||
- Generic, plugin-agnostic labels driven by metadata that already exists
|
||||
(manifest `display_name`, connection `name`), so future plugins look good with **no
|
||||
host code per plugin**.
|
||||
- An optional operator-editable label per source that overrides the automatic chain.
|
||||
- A single shared label-resolution helper, consumed by both the Sources and Activity
|
||||
panels, so the logic is not duplicated.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Plugin-declared label templates in the manifest/SDK (the "option C" from brainstorming).
|
||||
Deferred: it requires a cross-repo SDK/manifest contract change. The chain below leaves a
|
||||
clean seam for it.
|
||||
- Backend-side label resolution returned on API payloads. The resolution stays in the
|
||||
frontend helper for now; promote it to the backend only if the mobile clients need the
|
||||
same labels.
|
||||
|
||||
## Resolution chain
|
||||
|
||||
A source's display label resolves through four rungs, most-specific first:
|
||||
|
||||
```
|
||||
operator label -> connection name -> manifest display_name -> capability_id
|
||||
```
|
||||
|
||||
- **operator label** — the optional per-source text the admin sets (this design's new field).
|
||||
- **connection name** — the operator-named `autoscan_connections.name` bound to the source.
|
||||
- **manifest display_name** — from the plugin manifest, surfaced by the
|
||||
`scan-source-plugins` endpoint (`AutoscanAvailableSource.display_name`).
|
||||
- **capability_id** — last-resort raw identifier; never worse than today.
|
||||
|
||||
The chosen rung is the **title**. The remaining identity (plugin display name / capability +
|
||||
`plugin #<installation_id>`) renders as a **subtitle**, so no information is lost when a
|
||||
higher rung wins. Example: operator label `4K Movies` as title, `Radarr4k · plugin #4` as
|
||||
subtitle.
|
||||
|
||||
### Per-plugin behavior
|
||||
|
||||
| Source shape | Title | Subtitle |
|
||||
| --- | --- | --- |
|
||||
| arr, connection bound, no operator label | `Radarr4k` (connection) | `arr · plugin #4` |
|
||||
| arr, operator label set | `4K Movies` (operator) | `Radarr4k · plugin #4` |
|
||||
| cephfs, no connection | `CephFS Watcher` (manifest display_name) | `plugin #5` |
|
||||
| future plugin, nothing declared | `capability_id` | `plugin #N` |
|
||||
| Activity event whose source was deleted (`source_id` null) | manifest display_name or `capability_id` from the event's stored fields | `plugin #N` |
|
||||
|
||||
## Components
|
||||
|
||||
### Shared helper — `web/src/lib/autoscanLabels.ts` (new)
|
||||
|
||||
Pure, React-free, unit-tested.
|
||||
|
||||
- `composeSourceLabel({ operatorLabel, connectionName, displayName, capabilityId, installationId })`
|
||||
`=> { name: string; detail: string }` — implements the four-rung chain and the
|
||||
subtitle composition.
|
||||
- `pluginDisplayNameKey(installationId, capabilityId) => string` — stable map key.
|
||||
- `buildPluginDisplayNames(available: AutoscanAvailableSource[]) => Map<string, string>` —
|
||||
builds the `installation_id + capability_id -> display_name` lookup both panels use.
|
||||
|
||||
Future seam: `composeSourceLabel` may later accept an optional `configLabel` rung (for the
|
||||
deferred plugin-template option) without changing either panel's call shape.
|
||||
|
||||
### Migration — `migrations/174_autoscan_source_label.{up,down}.sql` (new)
|
||||
|
||||
```sql
|
||||
-- up
|
||||
ALTER TABLE public.autoscan_sources ADD COLUMN label text NOT NULL DEFAULT '';
|
||||
-- down
|
||||
ALTER TABLE public.autoscan_sources DROP COLUMN label;
|
||||
```
|
||||
|
||||
`label` empty string = unset; the resolution chain falls through to the automatic rungs.
|
||||
174 is a fresh, unused migration number (no collision with the reused-172 history).
|
||||
|
||||
### Backend (Go)
|
||||
|
||||
- `internal/autoscan/types.go` — add `Label string` to the `Source` struct and the source
|
||||
input type.
|
||||
- `internal/autoscan/repository.go` — add `label` to `sourceColumns`, `scanSource`, and the
|
||||
create/update statements.
|
||||
- `internal/api/handlers/autoscan.go` — accept `label` on source update; include it in the
|
||||
`autoscanStatusSource` and source responses. Trim whitespace and cap length at 120
|
||||
characters server-side.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `web/src/api/types.ts` — add `label` to `AutoscanSource` and `AutoscanSourceInput`.
|
||||
- `web/src/pages/admin/autoscan/SourcesPanel.tsx` — replace the inline `sourceIdentity`
|
||||
block with a call to `composeSourceLabel`. Add an optional "Label" text input to the
|
||||
source row, saved on blur via the existing `fullBody` PATCH path (mirrors the interval
|
||||
field's `handleIntervalBlur` auto-save). Build the display-name map from the existing
|
||||
`useAvailableScanSources()` query.
|
||||
- `web/src/pages/admin/autoscan/ActivityPanel.tsx` — add `useAvailableScanSources()`, build
|
||||
the display-name map, map `source_id -> source` to obtain the operator label and
|
||||
connection, and route `pollSourceName` / `scanSourceName` through `composeSourceLabel`,
|
||||
returning `.name`. Preserve the existing `"Autoscan"` fallback for scans with no
|
||||
capability.
|
||||
|
||||
## Edit UX
|
||||
|
||||
Inline per-row, reusing the existing on-blur auto-save pattern (no modal, no new
|
||||
interaction model). The label input is blank by default with placeholder text indicating
|
||||
it is optional. Saving sends the full row state through the same source-update mutation
|
||||
used for connection/interval/rewrites.
|
||||
|
||||
## Error handling and edge cases
|
||||
|
||||
- **Deleted source on an Activity event** — `autoscan_events.source_id` is
|
||||
`ON DELETE SET NULL`; with a null `source_id` there is no operator label or connection,
|
||||
so the chain resolves from the event row's own stored `capability_id` / `installation_id`
|
||||
(plus manifest display_name if the plugin is still installed).
|
||||
- **Uninstalled plugin** — `display_name` does not resolve; the chain falls to
|
||||
`capability_id`.
|
||||
- **Bound connection** — `autoscan_sources.connection_id` is `ON DELETE RESTRICT`, so a
|
||||
bound connection always resolves to a name.
|
||||
- **Length / whitespace** — the operator label is trimmed and capped at 120 characters
|
||||
server-side; an all-whitespace label normalizes to empty (unset).
|
||||
|
||||
## Testing
|
||||
|
||||
- `web/src/lib/autoscanLabels.test.ts` — covers each rung of the chain: operator label
|
||||
wins, connection name wins, manifest display_name wins, capability fallback, and the
|
||||
subtitle composition.
|
||||
- Extend the existing autoscan repository/handler Go tests to cover the `label` column
|
||||
round-trip (create/update/read) and the server-side trim + length cap.
|
||||
|
||||
## Scope summary
|
||||
|
||||
One migration pair, three Go files, four frontend files (one new). No SDK changes, no
|
||||
multi-repo coordination. Operator-editable labels (this design) and the automatic chain
|
||||
share the same helper, so the deferred plugin-template option layers on later with no
|
||||
rework.
|
||||
@@ -1,472 +0,0 @@
|
||||
# Marker sources & TheIntroDB contribution — design
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Draft (rev 2), pending review
|
||||
**Scope:** Review the current TheIntroDB marker integration, formalize a multi-source
|
||||
marker-provider model (query-all / best-wins), and add the ability to contribute Silo's own
|
||||
markers back to TheIntroDB. This round designs the **server-side API and data model only** —
|
||||
web-admin and mobile surfaces are deferred to a UI-only follow-up. No client (Android/Apple)
|
||||
changes required.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
**Rev 2 changes:** contribution is configured **per provider** (off by default); all new web
|
||||
UI is **deferred** and the backend API is specified in full instead.
|
||||
|
||||
## Decisions locked for this design
|
||||
|
||||
These were chosen up front and constrain everything below:
|
||||
|
||||
1. **Deliverable now:** design/plan only. No source changes land from this document; it is
|
||||
the blueprint a follow-up implementation plan expands.
|
||||
2. **What we contribute:** *both* local auto-detected markers *and* manual corrections. Local
|
||||
detection only produces **intro markers for episodes**, so credits/recap/preview and all
|
||||
movies can only be contributed via manual correction — this asymmetry drives the design.
|
||||
3. **Account model:** a single server-wide TheIntroDB account, reusing the existing
|
||||
`introdb.api_key` server setting. Per-user accounts are a noted future seam, not built now.
|
||||
4. **Multi-source dispatch:** query all enabled online sources and keep the highest-quality
|
||||
result per segment ("best wins"). This makes *real per-segment confidence* (gap #2 below)
|
||||
a hard prerequisite, not an optional nicety.
|
||||
5. **Contribution is configured per provider, off by default.** Every contribution-capable
|
||||
provider has its own enable switch and submit settings, so when more providers exist an
|
||||
operator chooses *which* ones to submit to. Both the master contribute switch and the
|
||||
auto-submit-local-detections switch default to off; a single setting flips each on.
|
||||
6. **API-first, UI deferred.** This round fully specifies the backend (manual marker
|
||||
read/write/clear, contribution trigger + status, per-provider provider config, key
|
||||
validation and stats) so any future surface — web admin or mobile — builds on a complete
|
||||
contract. No new web UI is designed here beyond noting where it will eventually attach.
|
||||
|
||||
## Problem
|
||||
|
||||
Silo currently detects intro/recap/credits/preview markers from two places — a local
|
||||
chromaprint/chapter analyzer (`internal/intromarkers`) and the online TheIntroDB provider
|
||||
(`internal/markers/introdb`) — reconciled by a source-priority ladder at write time. The
|
||||
online side is cleanly abstracted behind a `markers.Provider` interface and a
|
||||
`markers.Registry`, which is good, but three things are missing:
|
||||
|
||||
1. The current TheIntroDB provider has **correctness gaps** measured against the real v3 API
|
||||
contract (TVDB lookups dropped, real confidence discarded — see "Review findings").
|
||||
2. There is **no path to contribute** Silo's markers back to TheIntroDB, even though the v3
|
||||
API exposes a submission endpoint and the package was deliberately built read-only.
|
||||
3. The provider abstraction is **fetch-only and first-hit**, so it does not yet support
|
||||
"several online sources, keep the best answer" or "this source can also accept
|
||||
contributions, and I choose which sources to submit to."
|
||||
|
||||
## Review findings — current implementation
|
||||
|
||||
### What is already right (keep it)
|
||||
|
||||
- **Clean source abstraction.** `markers.Provider` (`ID()`, `FetchMarkers()`) + `markers.Registry`
|
||||
with `FetchFirstHit` (`internal/markers/types.go`). TheIntroDB is *not* hardcoded; a second
|
||||
provider is one `Register()` call (`cmd/silo/main.go`).
|
||||
- **Multi-source-aware schema.** `media_files` carries per-segment provenance for all four
|
||||
kinds: `{intro,credits,recap,preview}_markers_{source,provider,confidence,algorithm,detected_at}`
|
||||
(migrations 108/109/137).
|
||||
- **Write-time reconciliation across source classes.** `CanWriteMarker`
|
||||
(`internal/markers/write.go`) + `MarkerSourcePriority` (`internal/models/marker_source.go`):
|
||||
`manual(4) > online/plugin(3) > s3(2) > scanner(1)`, confidence breaks ties. Local detection
|
||||
uses its own priority-gated path (`intromarkers.Repository.PatchIntroMarker`).
|
||||
- **Solid HTTP hygiene** in `internal/markers/introdb/client.go`: conservative rate limiter
|
||||
(2 req/s, burst 5; documented limit is 30 req/10s), retry with exponential backoff,
|
||||
`Retry-After` handling, 24h TTL cache with negative caching, hot-reloadable API key over
|
||||
Redis pub/sub.
|
||||
|
||||
### Gaps (measured against TheIntroDB's own v3 client + Jellyfin plugin)
|
||||
|
||||
| # | Gap | Evidence | Impact |
|
||||
|---|-----|----------|--------|
|
||||
| 1 | **TVDB lookups silently dropped.** Provider reads only TMDB+IMDB; client can't send `tvdb_id`. | `internal/markers/introdb/provider.go` (reads `ExternalIDKeyTMDB`/`IMDB` only); `client.go` `FetchEpisode/FetchMovie` build `tmdb_id` else `imdb_id`. | Titles matched only by TVDB (very common for anime / TheTVDB-first libraries) get **zero** markers, even though the resolver already populates `TvdbID` and the v3 API + both official clients support `tvdb_id`. Correctness bug. |
|
||||
| 2 | **Real confidence + submission_count discarded; hardcoded 0.9.** | `internal/markers/introdb/types.go` `segmentTimestamps` has only `start_ms`/`end_ms`; `pickMarker` sets `Confidence: 0.9`. | The `/media` response carries per-segment `confidence` and `submission_count`. Without them, "best wins" merging can't rank sources, and write-time confidence tie-breaks are meaningless. Hard blocker for the chosen dispatch model. |
|
||||
| 3 | **Naive multi-candidate selection.** `pickMarker` takes the *first* usable entry. | `internal/markers/introdb/provider.go`. | When several candidates remain (e.g. no duration match), first-usable may not be the most-submitted/highest-confidence. Minor today (duration narrows the set), but compounds once we rank by confidence. |
|
||||
| 4 | **No contribution path** (by design). | `internal/markers/introdb/types.go` package doc: "Submissions are intentionally not supported." | The whole point of this effort. |
|
||||
| 5 | **Provider is fetch-only.** No submit capability in the interface. | `markers.Provider` in `internal/markers/types.go`. | Contribution needs an optional `Submitter` capability so non-contributing sources don't have to implement it, and so submission is per provider. |
|
||||
| 6 | **No key validation / contribution stats.** | `IntroDBCredentialCard` in `web/src/pages/admin-settings/IntegrationsSettings.tsx` only sets the key. | v3 exposes `GET /v3/user/stats` to validate a key and show contribution totals/streaks. Useful "connect & verify" probe, currently absent from the API. |
|
||||
| 7 | **Single collapsed confidence in the write payload.** `BuildUpdatePayload` promotes one max confidence across all segments. | `internal/markers/write.go`. | The DB has *per-segment* confidence columns, but a merged result (intro from source A, credits from source B) can't populate them independently until per-segment confidence is threaded through the payload. Coupled to gap #2. |
|
||||
|
||||
## Authoritative API contract (TheIntroDB v3)
|
||||
|
||||
Confirmed from TheIntroDB's own `theintrodb` npm client (`src/funcs.ts`, `src/types.ts`) and
|
||||
the official Jellyfin plugin (`TheIntroDbClient.cs`). Base URL: `https://api.theintrodb.org/v3`.
|
||||
|
||||
### `GET /media` — read (public; optional key)
|
||||
|
||||
Query: one of `tmdb_id` / `tvdb_id` / `imdb_id` (preference order tmdb → tvdb → imdb),
|
||||
plus `season` + `episode` together for TV, plus optional `duration_ms` (selects the closest
|
||||
release version). Optional `Authorization: Bearer <key>` additionally includes that user's
|
||||
*pending* submissions.
|
||||
|
||||
Response — each segment kind is an array of:
|
||||
```jsonc
|
||||
{ "start_ms": 30000|null, "end_ms": 90000|null, "confidence": 0.93|null, "submission_count": 7|null }
|
||||
```
|
||||
`start_ms:null` = "from the beginning" (intro/recap); `end_ms:null` = "to the end"
|
||||
(credits/preview). Arrays may hold multiple entries (multiple release versions) — pick by
|
||||
duration match, then by `submission_count`/`confidence`.
|
||||
|
||||
### `POST /submit` — contribute (requires key)
|
||||
|
||||
`Authorization: Bearer <user key>` required; submissions are credited to that account.
|
||||
Body (one segment per call):
|
||||
```jsonc
|
||||
{
|
||||
"tmdb_id": 1234, // required
|
||||
"imdb_id": "tt0903747", // optional
|
||||
"type": "tv" | "movie",
|
||||
"segment": "intro" | "recap" | "credits" | "preview",
|
||||
"season": 1, // TV only (number, or "1,2,3" multi-select)
|
||||
"episode": 1, // TV only
|
||||
"video_duration_ms": 2760000, // optional, strongly recommended (release-version match)
|
||||
"start_ms": 0|null, // intro/recap may be null (= beginning)
|
||||
"end_ms": 90000|null // credits/preview may be null (= end)
|
||||
}
|
||||
```
|
||||
Response: `{ "submissions": [ { "id": uuid, "status": "pending"|"accepted"|"rejected",
|
||||
"weight": number, ... } ] }`. Submissions enter as `pending`; the backend weight-averages
|
||||
timestamps across submissions and moderates to accepted/rejected.
|
||||
|
||||
Validation rules to mirror client-side: intro/recap require an **end**; credits/preview
|
||||
require a **start**; TV requires season+episode; movies omit them; max timestamp 6h
|
||||
(21,600,000 ms); `video_duration_ms` is 0 (unknown) or ≥ 300,000 ms.
|
||||
|
||||
### `GET /user/stats` — validate key + contribution stats
|
||||
|
||||
`Authorization: Bearer <key>`. Returns `{ total, accepted, pending, rejected,
|
||||
acceptance_rate, current_streak, best_streak, total_time_saved_ms, top_media }`. A 2xx with no
|
||||
`error` means the key is valid. This is both our key-validation probe and the stats display.
|
||||
|
||||
### Rate / usage limits
|
||||
|
||||
429 responses carry `X-RateLimit-{Limit,Remaining,Reset}` and `X-UsageLimit-{Limit,Remaining,Reset}`.
|
||||
Reads count against the rate limit; **submissions count against the usage limit**. The
|
||||
contribution pipeline must honor `X-UsageLimit-Reset` and back off — reuse the existing
|
||||
limiter and add usage-limit awareness.
|
||||
|
||||
## Target architecture
|
||||
|
||||
Three layers, each independently shippable. Layer A is pure correctness and unblocks B and C.
|
||||
|
||||
### Layer A — Correctness foundation (gaps #1, #2, #3, #7)
|
||||
|
||||
- **TVDB in the client + provider.** Add `tvdb_id` to the query builder in
|
||||
`internal/markers/introdb/client.go` (`FetchEpisode`/`FetchMovie` gain a `tvdbID` arg,
|
||||
preference tmdb → tvdb → imdb, cache keys extended). Provider reads
|
||||
`req.ExternalIDs[markers.ExternalIDKeyTVDB]`. Resolver already supplies it — no resolver
|
||||
change.
|
||||
- **Capture confidence + submission_count.** Extend `segmentTimestamps` with
|
||||
`Confidence *float64` and `SubmissionCount *int`; add `SubmissionCount int` to
|
||||
`markers.Marker`; `pickMarker` uses the real confidence (fallback to a named default
|
||||
constant, e.g. `0.9`, only when the field is null) instead of hardcoding.
|
||||
- **Best-candidate selection.** `pickMarker` ranks candidates by `(submission_count desc,
|
||||
confidence desc)` after duration filtering, instead of first-usable.
|
||||
- **Per-segment confidence in the payload.** Replace the single `Confidence *float64` on
|
||||
`markers.MarkerUpdatePayload` with per-segment confidence (e.g. `IntroConfidence`,
|
||||
`CreditsConfidence`, …) so a merged multi-source result writes the correct
|
||||
`{kind}_markers_confidence` per kind. Update `BuildUpdatePayload` and the
|
||||
`scanner.FileRepository.UpsertMarkers` column mapping accordingly.
|
||||
|
||||
### Layer B — Multi-source dispatch ("query all, best wins")
|
||||
|
||||
- **`Submitter` capability (optional interface).**
|
||||
```go
|
||||
// internal/markers/types.go
|
||||
type Submitter interface {
|
||||
Provider
|
||||
SubmitMarker(ctx context.Context, req SubmissionRequest) (SubmissionResult, error)
|
||||
// FetchUserStats validates the configured key and returns contribution stats.
|
||||
FetchUserStats(ctx context.Context) (UserStats, error)
|
||||
}
|
||||
```
|
||||
Only `introdb.Provider` implements it; the contribution service type-asserts
|
||||
`provider.(markers.Submitter)`. This is also what makes contribution per-provider: the
|
||||
service only ever submits to registered providers that satisfy `Submitter` *and* are enabled
|
||||
in `marker_provider_config` (C6).
|
||||
|
||||
- **Merge dispatch.** Add `Registry.FetchMerged(ctx, req) (Result, bool, error)` that fans out
|
||||
to all *fetch-enabled* providers concurrently (bounded), then merges **per segment kind**
|
||||
keeping the candidate with the highest `(submission_count, confidence)`. `FetchFirstHit`
|
||||
stays as the trivial single-provider path / fallback. With only TheIntroDB enabled,
|
||||
`FetchMerged` behaves identically to today — zero behavior change until a second source
|
||||
exists.
|
||||
|
||||
- **Cross-source-class reconciliation is unchanged.** `FetchMerged` reconciles *within* the
|
||||
online class; `CanWriteMarker` still arbitrates online vs scanner vs manual at write time.
|
||||
No double logic.
|
||||
|
||||
- **Provider enable/order config.** Driven by the `marker_provider_config` table (C6):
|
||||
`fetch_enabled` selects which providers are queried, `fetch_priority` is the merge
|
||||
tiebreaker. The metadata `library_provider_chains` DB pattern (`internal/metadata/chain.go`)
|
||||
is the upgrade path *if* per-library control is ever needed — noted, not built.
|
||||
|
||||
- **Future plugin sources (deferred seam).** A `marker_provider.v1` plugin capability would let
|
||||
third-party sources plug in. The clean seam is a `PluginMarkerProvider` adapter implementing
|
||||
`markers.Provider` (mirroring `internal/metadata/plugin_provider.go`), so the registry never
|
||||
changes. Cross-repo (silo-plugin-sdk) — explicitly out of scope here.
|
||||
|
||||
### Layer C — Contribution to TheIntroDB
|
||||
|
||||
#### C1. Submission client
|
||||
|
||||
Add to `internal/markers/introdb/client.go`:
|
||||
- `SubmitSegment(ctx, SubmissionRequest) (SubmissionResult, error)` → `POST /v3/submit`,
|
||||
Bearer key **required** (error early if `introdb.api_key` is empty), usage-limit aware.
|
||||
- `FetchUserStats(ctx) (UserStats, error)` → `GET /v3/user/stats`.
|
||||
`introdb.Provider` implements `markers.Submitter` by delegating to these.
|
||||
|
||||
#### C2. What is eligible to contribute
|
||||
|
||||
A marker is contributable iff:
|
||||
- the target provider is a registered `Submitter` with `contribute_enabled = true` (C6), and
|
||||
- its segment source ∈ `{scanner, manual}` (**never** `online` — that came *from* an online
|
||||
provider; contributing it back is circular), and
|
||||
- the file resolves to a usable external ID (tmdb/tvdb/imdb) via the existing resolver, and
|
||||
- for **auto** contribution: provider `contribute_auto_local = true`, source = `scanner`,
|
||||
kind = `intro`, confidence ≥ that provider's `contribute_min_confidence`. (Local detection is
|
||||
intro-only/episodes-only.)
|
||||
- for **manual** contribution: any kind, episodes or movies, source = `manual`.
|
||||
|
||||
#### C3. Manual marker API (write path; UI deferred)
|
||||
|
||||
Markers live on `media_files`, and an item can have several versions, so the manual API is
|
||||
**file-scoped** for precision, with an item-level convenience resolver. All under the existing
|
||||
`RequireAdmin` group (`internal/api/middleware/auth.go`). New `internal/api/handlers/admin_markers.go`:
|
||||
|
||||
- `GET /admin/files/{fileId}/markers` — current per-segment values **plus full provenance**
|
||||
(`source`, `provider`, `confidence`, `algorithm`, `detected_at`) for each of
|
||||
intro/recap/credits/preview. The read side an editor (or a script) needs.
|
||||
- `PUT /admin/files/{fileId}/markers` — upsert the **manual** layer. Body carries, per segment,
|
||||
either `{start, end}` (seconds) or `null` to clear that segment's manual value. Writes
|
||||
`source = manual` (priority 4, beats every other source) through the existing priority-gated
|
||||
`scanner.FileRepository.UpsertMarkers` path, then notifies live sessions via
|
||||
`playback.MarkerUpdateNotifier.MarkersUpdated` (mirrors `admin_intro.go`).
|
||||
- `DELETE /admin/files/{fileId}/markers/{segment}` — explicit single-segment clear.
|
||||
- `GET|PUT /admin/items/{id}/markers` — item-level convenience that resolves to the primary
|
||||
file (and reports the per-version set), for callers that don't track file IDs.
|
||||
|
||||
**Clear semantics.** Clearing a manual segment nulls that segment's value and provenance;
|
||||
`markers_source` drops to the next-highest segment still present. A subsequent local
|
||||
re-detection or online lazy-fetch can repopulate it. (We can't "un-overwrite" a value a manual
|
||||
edit replaced, so clearing means "remove the manual value," not "restore the previous source.")
|
||||
|
||||
**Validation** mirrors the contribution rules so a manual edit can always be contributed:
|
||||
`end > start`; intro/recap may omit start (= 0); credits/preview may omit end (= file
|
||||
duration); values within [0, file duration].
|
||||
|
||||
A web editor (player-chrome control with a "use current time" helper) attaches to this API
|
||||
later; its UX is intentionally **out of scope** for this round.
|
||||
|
||||
#### C4. Contribution tracking (idempotency + audit)
|
||||
|
||||
The audit log that makes submission idempotent and lets us report status. Per-provider by
|
||||
construction (the `provider` column), so the same marker can be tracked/submitted to several
|
||||
providers independently.
|
||||
|
||||
```sql
|
||||
-- migrations/<next>_marker_contributions.up.sql (next free number at implementation time)
|
||||
CREATE TABLE public.marker_contributions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
media_file_id integer NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
|
||||
provider text NOT NULL, -- 'introdb'
|
||||
segment_kind text NOT NULL, -- intro|recap|credits|preview
|
||||
source text NOT NULL, -- what we contributed: 'scanner'|'manual'
|
||||
submitted_start_ms bigint,
|
||||
submitted_end_ms bigint,
|
||||
video_duration_ms bigint,
|
||||
content_hash text NOT NULL, -- hash(segment_kind,start_ms,end_ms,duration_ms)
|
||||
submission_id uuid, -- returned by /submit
|
||||
status text NOT NULL, -- pending|accepted|rejected|error
|
||||
http_status integer,
|
||||
error text,
|
||||
submitted_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (media_file_id, provider, segment_kind, content_hash)
|
||||
);
|
||||
CREATE INDEX marker_contributions_file_idx ON public.marker_contributions(media_file_id);
|
||||
```
|
||||
|
||||
`content_hash` over the *value* gives idempotency: the same times are never resubmitted to the
|
||||
same provider, but a manual correction (different times → different hash) submits the new
|
||||
value — which is exactly how TheIntroDB's weighted averaging expects corrections to arrive.
|
||||
|
||||
#### C5. Contribution service + triggers
|
||||
|
||||
A single `internal/markers/contribute.go` `ContributionService` funnels both triggers:
|
||||
`resolve enabled submitter providers (C6) → per provider: eligibility (C2) → idempotency check
|
||||
(hash vs marker_contributions) → Submitter.SubmitMarker → persist result`. Triggers:
|
||||
- **Manual / on-demand:** `POST /admin/files/{fileId}/contribute` (C7) submits a file's eligible
|
||||
markers to enabled providers (optionally scoped to one provider/segments).
|
||||
- **Auto local detections:** a daily `ContributeMarkersTask` (`internal/taskmanager/tasks/`)
|
||||
that, for each provider with `contribute_enabled && contribute_auto_local`, scans for episode
|
||||
files with `intro_markers_source = 'scanner'` and `intro_markers_confidence ≥` that provider's
|
||||
`contribute_min_confidence` lacking a current-hash `marker_contributions` row, and submits
|
||||
them rate/usage-limited. Runs after the existing 03:30 detection task.
|
||||
|
||||
#### C6. Per-provider configuration
|
||||
|
||||
Contribution and fetch behavior is **per provider**, so a single global toggle won't do.
|
||||
Credentials stay in `server_settings` (sensitive handling: `introdb.api_key`); behavioral
|
||||
config moves to a new table keyed by provider id:
|
||||
|
||||
```sql
|
||||
-- migrations/<next>_marker_provider_config.up.sql
|
||||
CREATE TABLE public.marker_provider_config (
|
||||
provider text PRIMARY KEY, -- 'introdb'
|
||||
fetch_enabled boolean NOT NULL DEFAULT true, -- queried during reads (Layer B)
|
||||
fetch_priority integer NOT NULL DEFAULT 100, -- merge tiebreaker
|
||||
contribute_enabled boolean NOT NULL DEFAULT false, -- master submit switch (off)
|
||||
contribute_auto_local boolean NOT NULL DEFAULT false, -- auto-submit local detections (off)
|
||||
contribute_min_confidence double precision NOT NULL DEFAULT 0.95,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO public.marker_provider_config (provider, fetch_enabled) VALUES ('introdb', true);
|
||||
```
|
||||
|
||||
- A provider's submissions require `contribute_enabled = true`. With it on, **manual**
|
||||
corrections submit on demand; **auto** submission of local detections additionally requires
|
||||
`contribute_auto_local = true`. Both contribute flags default off — "easily enabled with a
|
||||
setting," exactly one row per provider.
|
||||
- This same table carries the Layer B fetch enable/order, so "which providers do we read from"
|
||||
and "which do we submit to" live in one place. New providers insert a row (contribute off).
|
||||
- Surfaced and edited through the provider API (C7), so the future admin surface lists
|
||||
providers generically rather than hard-coding `introdb`.
|
||||
|
||||
The `markers.mode` / `markers.lazy_playback` globals and the `introdb.api_key` credential are
|
||||
unchanged.
|
||||
|
||||
#### C7. Admin provider + contribution API (fully fledged)
|
||||
|
||||
The complete backend surface, all under `RequireAdmin`, so a UI is purely additive later:
|
||||
|
||||
- `GET /admin/markers/providers` — list every registered provider: id, whether it is a
|
||||
`Submitter`, its `marker_provider_config` row, and (best-effort, cached) live `/user/stats`
|
||||
for submitter providers that have a key.
|
||||
- `PUT /admin/markers/providers/{provider}` — update that provider's config row
|
||||
(`fetch_enabled`, `fetch_priority`, `contribute_enabled`, `contribute_auto_local`,
|
||||
`contribute_min_confidence`). Validated; unknown provider → 404.
|
||||
- `POST /admin/markers/providers/{provider}/validate` — validate the configured key and return
|
||||
fresh `UserStats` (calls `Submitter.FetchUserStats`). Non-submitter → 400.
|
||||
- `POST /admin/files/{fileId}/contribute` — submit this file's eligible markers (C2) to enabled
|
||||
submitter providers; optional body `{provider?, segments?[]}` to scope to one provider /
|
||||
specific kinds. Returns per (provider, segment) `SubmissionResult`. Honors idempotency (C4):
|
||||
unchanged values are skipped, not resubmitted.
|
||||
- `GET /admin/files/{fileId}/contributions` — contribution history for the file from
|
||||
`marker_contributions` (per provider/segment: status, submitted_at, error).
|
||||
|
||||
Plus the manual marker endpoints from C3. Together these cover read, edit, submit, and audit
|
||||
without any web code.
|
||||
|
||||
## Data model summary
|
||||
|
||||
- **New table `marker_contributions`** (C4) — idempotent submission audit, keyed per
|
||||
(file, provider, segment, value-hash).
|
||||
- **New table `marker_provider_config`** (C6) — per-provider fetch + contribute behavior,
|
||||
seeded with `introdb` (fetch on, contribute off).
|
||||
- **No new flat server settings.** `introdb.api_key`, `markers.mode`, `markers.lazy_playback`
|
||||
are unchanged; behavioral provider config lives in `marker_provider_config`, not string keys.
|
||||
- **No new marker columns.** Per-segment confidence columns already exist; Layer A only changes
|
||||
how they're *populated* (per-segment instead of collapsed). `submission_count` stays
|
||||
transient (merge-ranking only) unless a later need appears.
|
||||
|
||||
## Client / cross-repo impact
|
||||
|
||||
- **No Android/Apple changes required.** This adds no new marker *kinds* and no new
|
||||
client-facing read contract: markers still flow to clients via the existing `WatchDetail`
|
||||
fields, the jellycompat `GET /MediaSegments` endpoint, and the `markers_updated` realtime
|
||||
event. Manual editing and contribution are server-side (admin API); their eventual UI is
|
||||
deferred web-admin work.
|
||||
- **Plugin SDK:** untouched. `marker_provider.v1` is a deferred future capability (Layer B
|
||||
seam), not part of this work.
|
||||
- A future *per-user* contribution model (each user links their own TheIntroDB account) *would*
|
||||
be client work (a "Connect TheIntroDB" screen) — explicitly deferred.
|
||||
|
||||
## Build sequence
|
||||
|
||||
Each phase is shippable on its own; later phases depend only on earlier ones. Every phase is
|
||||
**backend-only** — no web UI.
|
||||
|
||||
1. **Phase 1 — Correctness foundation (Layer A).** TVDB lookups; capture
|
||||
confidence/submission_count; best-candidate selection; per-segment confidence in the
|
||||
payload. *Directly answers "are we doing this properly," low risk, no new surface.*
|
||||
2. **Phase 2 — Multi-source dispatch + provider config (Layer B + C6).** `Submitter` interface;
|
||||
`FetchMerged` query-all/best-wins; `marker_provider_config` table + repo driving fetch
|
||||
enable/order. *No behavior change with only TheIntroDB enabled.*
|
||||
3. **Phase 3 — Submission client + tracking + service (C1, C4, C5 core).** `SubmitSegment` /
|
||||
`FetchUserStats`; `marker_contributions` migration + repo; `ContributionService` (eligibility,
|
||||
idempotency, per-provider gating).
|
||||
4. **Phase 4 — Manual marker API (C3) + provider/contribution API (C7).** File- and item-level
|
||||
manual read/write/clear; provider list/config/validate; per-file contribute + history. The
|
||||
full admin contract, no UI.
|
||||
5. **Phase 5 — Auto-contribution (C5 task).** `ContributeMarkersTask`: per provider, when
|
||||
`contribute_enabled && contribute_auto_local`, submit episode intro markers above the
|
||||
provider's `contribute_min_confidence`, usage-limit aware, idempotent.
|
||||
|
||||
Deferred (separate UI-only follow-up): web admin surfaces over C3/C6/C7 — a marker editor in
|
||||
the player chrome and a provider/contribution panel in admin settings.
|
||||
|
||||
## Risks & trade-offs
|
||||
|
||||
- **Data-quality / pollution.** Auto-submitting chromaprint detections (confidence 0.65–0.90)
|
||||
risks polluting a provider's data. Mitigation: `contribute_enabled` and `contribute_auto_local`
|
||||
default **off** per provider, and the default `contribute_min_confidence` of 0.95 admits only
|
||||
chapter / chapter+silence detections even once enabled. Manual, high-trust corrections are the
|
||||
intended first contribution. Revisit chromaprint auto-submission after watching acceptance
|
||||
rates via `/user/stats`.
|
||||
- **Single-account weighting.** All contributions credit one server account; TheIntroDB's
|
||||
weighting may treat a single account's mass submissions differently than many users'. Note
|
||||
the per-user seam; acceptable for v1.
|
||||
- **Circular contribution.** Never contribute `source = online`. Enforced in C2 eligibility and
|
||||
worth a unit test.
|
||||
- **Usage limits.** Bulk auto-submission can hit the submission usage limit; the task must obey
|
||||
`X-UsageLimit-Reset` and resume next run. Idempotency means an interrupted run safely resumes.
|
||||
- **"Query all" cost.** `FetchMerged` issues one lookup per fetch-enabled provider; with only
|
||||
TheIntroDB it's identical to today, and per-provider caching bounds the cost as sources are
|
||||
added.
|
||||
- **Movies & non-intro segments** have no local detector, so their only contribution route is
|
||||
manual correction — acceptable and explicit.
|
||||
|
||||
## Open questions for review
|
||||
|
||||
1. **Contribute to all enabled providers, or require explicit per-call selection?** The design
|
||||
defaults `POST /admin/files/{id}/contribute` to "every enabled submitter," with an optional
|
||||
`provider` scope. Acceptable, or should submission always name its target provider?
|
||||
2. **Manual-clear behavior.** Clearing a manual segment removes the manual value and lets a
|
||||
later detection/fetch repopulate it (we can't restore the exact pre-edit value). Is
|
||||
"remove, then re-detect" the right contract, or do you want a marker history/undo stack?
|
||||
3. **Per-provider config home.** This design uses a `marker_provider_config` table (clean for N
|
||||
providers, future per-library override) rather than flat `server_settings` keys. Confirm the
|
||||
table is the direction you want.
|
||||
|
||||
## File-level blueprint
|
||||
|
||||
**Backend (Go) — Layer A**
|
||||
- `internal/markers/introdb/client.go` — *modify*: `tvdb_id` query support; cache-key extension.
|
||||
- `internal/markers/introdb/types.go` — *modify*: `confidence`/`submission_count` fields.
|
||||
- `internal/markers/introdb/provider.go` — *modify*: read TVDB; real confidence; best-candidate pick.
|
||||
- `internal/markers/types.go` — *modify*: per-segment confidence in `Marker`/Result plumbing.
|
||||
- `internal/markers/write.go` — *modify*: per-segment confidence in `MarkerUpdatePayload` + `BuildUpdatePayload`.
|
||||
- `internal/scanner/file_repo.go` — *modify*: map per-segment confidence in `UpsertMarkers`.
|
||||
|
||||
**Backend (Go) — Layer B + per-provider config**
|
||||
- `internal/markers/types.go` — *modify*: `Submitter` interface; `SubmissionRequest`/`Result`, `UserStats`; `Registry.FetchMerged`.
|
||||
- `internal/markers/provider_config.go` — *create*: `marker_provider_config` repo (load/update; drives fetch enable/order + contribute gating).
|
||||
- `migrations/<next>_marker_provider_config.{up,down}.sql` — *create*.
|
||||
|
||||
**Backend (Go) — Layer C**
|
||||
- `internal/markers/introdb/client.go` — *modify*: `SubmitSegment` (`POST /v3/submit`), `FetchUserStats` (`GET /v3/user/stats`), usage-limit handling.
|
||||
- `internal/markers/introdb/provider.go` — *modify*: implement `markers.Submitter`; drop the "submissions not supported" note in `types.go`.
|
||||
- `internal/markers/contribute.go` — *create*: `ContributionService` (eligibility, idempotency, per-provider gating, submit, persist).
|
||||
- `internal/markers/contribution_repo.go` — *create*: `marker_contributions` CRUD + value-hash check.
|
||||
- `migrations/<next>_marker_contributions.{up,down}.sql` — *create*.
|
||||
- `internal/api/handlers/admin_markers.go` — *create*: manual marker read/write/clear (C3) + per-file contribute + history (C7 file endpoints).
|
||||
- `internal/api/handlers/admin_marker_providers.go` — *create*: provider list/config/validate (C7 provider endpoints).
|
||||
- `internal/api/router.go` — *modify*: register the new `RequireAdmin` routes.
|
||||
- `internal/taskmanager/tasks/contribute_markers.go` — *create*: daily auto-contribution task.
|
||||
- `cmd/silo/main.go` — *modify*: wire `ContributionService`, provider-config repo, the task, and the `Submitter`.
|
||||
|
||||
**Frontend (React/TS) — deferred (not this round)**
|
||||
- Marker editor over `PUT /admin/files/{id}/markers`; provider/contribution panel over the C7
|
||||
endpoints; `IntroDBCredentialCard` "validate key & show stats". Listed for traceability only.
|
||||
|
||||
**Reference (read-only, not in this repo)**
|
||||
- TheIntroDB v3 contract verified against `github.com/TheIntroDB/theintrodb-npm`
|
||||
(`src/funcs.ts`, `src/types.ts`, `docs/`) and `github.com/TheIntroDB/jellyfin-plugin`
|
||||
(`TheIntroDB/Api/*.cs`).
|
||||
@@ -29,28 +29,24 @@ require (
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/pgvector/pgvector-go v0.3.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/zishang520/socket.io/v2 v2.5.0
|
||||
go.n16f.net/thumbhash v1.1.0
|
||||
golang.org/x/image v0.39.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -59,7 +55,6 @@ require (
|
||||
github.com/quic-go/quic-go v0.53.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||
@@ -69,14 +64,14 @@ require (
|
||||
github.com/zishang520/webtransport-go v0.9.1 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.4.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
@@ -95,14 +90,15 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sony/sonyflake v1.3.0
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
@@ -2,12 +2,12 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.4.0 h1:DJkRROQfr/kfwnF5dUdkhmbCso1KDLZc7uK7YfJnaO0=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.4.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
@@ -44,7 +44,6 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -52,8 +51,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
@@ -111,8 +108,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -128,12 +125,10 @@ github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZb
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
||||
@@ -145,8 +140,6 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
||||
@@ -156,32 +149,26 @@ github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2
|
||||
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
|
||||
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sony/sonyflake v1.3.0 h1:tiB4Dlp0lnmKp/h6BLXA14P8Qi+LYS9+0QRpcrKHvg4=
|
||||
github.com/sony/sonyflake v1.3.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -228,24 +215,22 @@ go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
|
||||
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
@@ -266,6 +251,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -277,12 +263,12 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -299,11 +285,3 @@ gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo=
|
||||
mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=
|
||||
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package adminjob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CancelRegistry bridges HTTP cancellation requests to in-process job contexts.
|
||||
// Queued jobs are cancelled in the repository; running jobs need their active
|
||||
// context cancelled so executors stop doing work before the row is finalized.
|
||||
type CancelRegistry struct {
|
||||
mu sync.Mutex
|
||||
cancels map[string]cancelEntry
|
||||
}
|
||||
|
||||
type cancelEntry struct {
|
||||
cancel context.CancelFunc
|
||||
token *struct{}
|
||||
}
|
||||
|
||||
func NewCancelRegistry() *CancelRegistry {
|
||||
return &CancelRegistry{cancels: make(map[string]cancelEntry)}
|
||||
}
|
||||
|
||||
func (r *CancelRegistry) Register(jobID string, cancel context.CancelFunc) func() {
|
||||
if r == nil || jobID == "" || cancel == nil {
|
||||
return func() {}
|
||||
}
|
||||
token := &struct{}{}
|
||||
r.mu.Lock()
|
||||
r.cancels[jobID] = cancelEntry{cancel: cancel, token: token}
|
||||
r.mu.Unlock()
|
||||
|
||||
return func() {
|
||||
r.mu.Lock()
|
||||
if entry := r.cancels[jobID]; entry.token == token {
|
||||
delete(r.cancels, jobID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *CancelRegistry) Cancel(jobID string) bool {
|
||||
if r == nil || jobID == "" {
|
||||
return false
|
||||
}
|
||||
r.mu.Lock()
|
||||
cancel := r.cancels[jobID].cancel
|
||||
r.mu.Unlock()
|
||||
if cancel == nil {
|
||||
return false
|
||||
}
|
||||
cancel()
|
||||
return true
|
||||
}
|
||||
@@ -542,19 +542,17 @@ func (e *ItemRefreshExecutor) Execute(ctx context.Context, req ItemRefreshReques
|
||||
e.publish(cache.EventMetadataUpdated, refreshContentID)
|
||||
if e.realtimeHub != nil {
|
||||
if scanResult != nil && (scanResult.New > 0 || scanResult.Updated > 0 || scanResult.Missing > 0 || matched > 0) {
|
||||
_ = e.realtimeHub.PublishCatalogLibraryChanged(ctx, notifications.LibraryChangeEvent{
|
||||
_ = e.realtimeHub.PublishLibraryChanged(ctx, notifications.LibraryChangeEvent{
|
||||
LibraryID: req.ScanFolderID,
|
||||
Reason: "item_refresh",
|
||||
New: scanResult.New,
|
||||
Updated: scanResult.Updated,
|
||||
Missing: scanResult.Missing,
|
||||
MatchedFiles: matched,
|
||||
})
|
||||
}
|
||||
_ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
||||
_ = e.realtimeHub.PublishMetadataUpdated(ctx, notifications.MetadataUpdateEvent{
|
||||
LibraryID: req.ScanFolderID,
|
||||
ContentID: refreshContentID,
|
||||
Change: "metadata_updated",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -251,9 +251,8 @@ func (e *LibraryRefreshExecutor) Execute(
|
||||
if result.RefreshedOK > 0 || result.PipelineOK > 0 {
|
||||
e.publish(cache.EventMetadataUpdated, strconv.Itoa(req.LibraryID))
|
||||
if e.realtimeHub != nil {
|
||||
_ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
||||
_ = e.realtimeHub.PublishMetadataUpdated(ctx, notifications.MetadataUpdateEvent{
|
||||
LibraryID: req.LibraryID,
|
||||
Change: "metadata_updated",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -322,7 +321,6 @@ func (e *LibraryRefreshExecutor) refreshItemsWithIDs(
|
||||
result.RefreshedFailed++
|
||||
} else {
|
||||
result.RefreshedOK++
|
||||
e.publishCatalogItemChanged(ctx, result.LibraryID, itemResult.contentID)
|
||||
}
|
||||
advance("Refreshing items with external IDs")
|
||||
}
|
||||
@@ -388,7 +386,6 @@ func (e *LibraryRefreshExecutor) refreshUnmatchedItem(ctx context.Context, libra
|
||||
if err := e.refresher.RefreshItemForLibrary(ctx, req.RefreshContentID, req.ScanFolderID); err != nil {
|
||||
return fmt.Errorf("refresh metadata: %w", err)
|
||||
}
|
||||
e.publishCatalogItemChanged(ctx, libraryID, req.RefreshContentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -402,17 +399,6 @@ func (e *LibraryRefreshExecutor) publish(eventType, payload string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (e *LibraryRefreshExecutor) publishCatalogItemChanged(ctx context.Context, libraryID int, contentID string) {
|
||||
if e == nil || e.realtimeHub == nil || contentID == "" {
|
||||
return
|
||||
}
|
||||
_ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
||||
LibraryID: libraryID,
|
||||
ContentID: contentID,
|
||||
Change: "metadata_updated",
|
||||
})
|
||||
}
|
||||
|
||||
func decodeLibraryRefreshRequest(data json.RawMessage) (LibraryRefreshRequest, error) {
|
||||
var req LibraryRefreshRequest
|
||||
if len(data) == 0 {
|
||||
|
||||
@@ -24,13 +24,11 @@ const (
|
||||
StatusRunning = "running"
|
||||
StatusCompleted = "completed"
|
||||
StatusFailed = "failed"
|
||||
StatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrJobNotFound = errors.New("admin job not found")
|
||||
ErrActiveJobConflict = errors.New("admin job already active for type")
|
||||
ErrJobNotCancellable = errors.New("admin job is not cancellable")
|
||||
)
|
||||
|
||||
type ActiveJobConflictError struct {
|
||||
@@ -473,83 +471,6 @@ func (r *Repository) Fail(ctx context.Context, id string, input FailJobInput) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) Cancel(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error) {
|
||||
if message == "" {
|
||||
message = "Admin job cancelled"
|
||||
}
|
||||
job, err := scanAdminJob(r.pool.QueryRow(ctx, `
|
||||
UPDATE admin_jobs
|
||||
SET status = $2,
|
||||
message = $3,
|
||||
error_message = '',
|
||||
completed_at = NOW(),
|
||||
heartbeat_at = NOW(),
|
||||
expires_at = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status IN ($5, $6)
|
||||
RETURNING `+adminJobColumns,
|
||||
id,
|
||||
StatusCancelled,
|
||||
message,
|
||||
expiresAt,
|
||||
StatusQueued,
|
||||
StatusRunning,
|
||||
))
|
||||
if err == nil {
|
||||
return job, nil
|
||||
}
|
||||
if !errors.Is(err, ErrJobNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
existing, lookupErr := r.GetByID(ctx, id)
|
||||
if lookupErr != nil {
|
||||
return nil, lookupErr
|
||||
}
|
||||
if existing.Status != StatusQueued && existing.Status != StatusRunning {
|
||||
return nil, ErrJobNotCancellable
|
||||
}
|
||||
return nil, ErrJobNotFound
|
||||
}
|
||||
|
||||
func (r *Repository) CancelQueued(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error) {
|
||||
if message == "" {
|
||||
message = "Admin job cancelled"
|
||||
}
|
||||
job, err := scanAdminJob(r.pool.QueryRow(ctx, `
|
||||
UPDATE admin_jobs
|
||||
SET status = $2,
|
||||
message = $3,
|
||||
error_message = '',
|
||||
completed_at = NOW(),
|
||||
heartbeat_at = NOW(),
|
||||
expires_at = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status = $5
|
||||
RETURNING `+adminJobColumns,
|
||||
id,
|
||||
StatusCancelled,
|
||||
message,
|
||||
expiresAt,
|
||||
StatusQueued,
|
||||
))
|
||||
if err == nil {
|
||||
return job, nil
|
||||
}
|
||||
if !errors.Is(err, ErrJobNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
existing, lookupErr := r.GetByID(ctx, id)
|
||||
if lookupErr != nil {
|
||||
return nil, lookupErr
|
||||
}
|
||||
if existing.Status != StatusQueued {
|
||||
return nil, ErrJobNotCancellable
|
||||
}
|
||||
return nil, ErrJobNotFound
|
||||
}
|
||||
|
||||
func (r *Repository) RequeueStaleRunning(ctx context.Context, before time.Time) (int, error) {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE admin_jobs
|
||||
|
||||
@@ -57,7 +57,6 @@ type Runner struct {
|
||||
heartbeatInterval time.Duration
|
||||
staleAfter time.Duration
|
||||
retention time.Duration
|
||||
cancelRegistry *CancelRegistry
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
@@ -100,17 +99,10 @@ func NewRunner(
|
||||
heartbeatInterval: 10 * time.Second,
|
||||
staleAfter: 2 * time.Minute,
|
||||
retention: 7 * 24 * time.Hour,
|
||||
cancelRegistry: NewCancelRegistry(),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) SetCancelRegistry(registry *CancelRegistry) {
|
||||
if registry != nil {
|
||||
r.cancelRegistry = registry
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) Start() {
|
||||
go func() {
|
||||
r.requeueStaleJobs()
|
||||
@@ -337,8 +329,6 @@ func (r *Runner) executeLibraryRefresh(job *models.AdminJob) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), libraryRefreshTimeout)
|
||||
defer cancel()
|
||||
unregisterCancel := r.cancelRegistry.Register(job.ID, cancel)
|
||||
defer unregisterCancel()
|
||||
|
||||
heartbeatStop := make(chan struct{})
|
||||
go r.heartbeatLoop(ctx, job.ID, heartbeatStop)
|
||||
@@ -367,10 +357,6 @@ func (r *Runner) executeLibraryRefresh(job *models.AdminJob) {
|
||||
r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID)
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
r.cancelJob(job.ID, current, total, "Library metadata refresh cancelled")
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
if ctx.Err() != nil {
|
||||
msg = fmt.Sprintf("timed out after %s: %s", libraryRefreshTimeout, msg)
|
||||
@@ -860,21 +846,3 @@ func (r *Runner) failJob(id string, current, total int, message, errorMessage st
|
||||
}
|
||||
r.publishJobByID(ctx, notifications.TypeJobFailed, id)
|
||||
}
|
||||
|
||||
func (r *Runner) cancelJob(id string, current, total int, message string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := r.repo.UpdateProgress(ctx, id, current, total, message); err != nil {
|
||||
slog.Warn("admin jobs: failed to update cancellation progress", "job_id", id, "error", err)
|
||||
}
|
||||
job, err := r.repo.Cancel(ctx, id, message, time.Now().UTC().Add(r.retention))
|
||||
if err != nil {
|
||||
slog.Warn("admin jobs: failed to mark job cancelled", "job_id", id, "error", err)
|
||||
return
|
||||
}
|
||||
if r.realtimeHub != nil {
|
||||
if err := r.realtimeHub.PublishJob(ctx, notifications.TypeJobCancelled, job); err != nil {
|
||||
slog.Warn("admin jobs: failed to publish job cancellation", "job_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/autoscan"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
||||
)
|
||||
|
||||
// autoscanQueuer is the scan-enqueue surface BuildAutoscanService needs; it is
|
||||
// satisfied by *scanqueue.Service (autoscan.Queuer's concrete production impl).
|
||||
type autoscanQueuer = autoscan.Queuer
|
||||
|
||||
// RequestIntegrationLookup adapts the Requests repository to the autoscan
|
||||
// connection resolver's RequestIntegrationLookup: it resolves a soft-linked
|
||||
// Requests integration to its base URL and api key (the repo decrypts the key
|
||||
// on read, so this returns plaintext).
|
||||
type RequestIntegrationLookup struct {
|
||||
Repo *mediarequests.Repository
|
||||
}
|
||||
|
||||
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error) {
|
||||
integration, err := l.Repo.GetIntegration(ctx, integrationID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// A reused Requests connection must honor the integration's live state. The
|
||||
// v1 poll gated on `WHERE ri.enabled = true`; here we surface a disabled or
|
||||
// unconfigured (blank base_url) integration as an error so the engine turns
|
||||
// it into a logged skip / RecordError rather than polling an unusable target.
|
||||
if err := checkRequestIntegrationUsable(integrationID, integration.Enabled, integration.BaseURL); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return integration.BaseURL, integration.APIKeyRef, nil
|
||||
}
|
||||
|
||||
// checkRequestIntegrationUsable returns a non-nil error when a linked Requests
|
||||
// integration cannot be polled: it is disabled, or it has no base_url. Extracted
|
||||
// as a pure function so the gating is unit-testable without a DB-backed repo.
|
||||
func checkRequestIntegrationUsable(integrationID string, enabled bool, baseURL string) error {
|
||||
if !enabled {
|
||||
return fmt.Errorf("linked requests integration %q is disabled", integrationID)
|
||||
}
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
return fmt.Errorf("linked requests integration %q has no base_url configured", integrationID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PluginScanSourceAdapter adapts plugins.Service to autoscan.ScanSourceResolver.
|
||||
// plugins.Service.ScanSourceClient returns the concrete
|
||||
// *pluginhost.ScanSourceClient; that concrete type satisfies
|
||||
// autoscan.PollChangesClient (it has the matching PollChanges method), so the
|
||||
// adapter declares the exported interface as its return type and returns the
|
||||
// concrete value (Go has no return-type covariance, so the method signature must
|
||||
// name the interface exactly to satisfy ScanSourceResolver).
|
||||
type PluginScanSourceAdapter struct {
|
||||
Svc *plugins.Service
|
||||
}
|
||||
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
|
||||
}
|
||||
|
||||
// scanSourceCapabilityType is the plugin capability type autoscan discovery
|
||||
// enumerates.
|
||||
const scanSourceCapabilityType = "scan_source.v1"
|
||||
|
||||
// PluginScanSourceLister adapts the plugin installation store to
|
||||
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
|
||||
// capability across ALL installed plugins, regardless of enabled state.
|
||||
type PluginScanSourceLister struct {
|
||||
Store *plugins.InstallationStore
|
||||
}
|
||||
|
||||
func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan.DiscoveredSource, error) {
|
||||
installations, err := l.Store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []autoscan.DiscoveredSource
|
||||
for _, inst := range installations {
|
||||
caps, err := l.Store.ListCapabilities(ctx, inst.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range caps {
|
||||
if c == nil || c.Type != scanSourceCapabilityType {
|
||||
continue
|
||||
}
|
||||
out = append(out, autoscan.DiscoveredSource{
|
||||
PluginID: inst.PluginID,
|
||||
CapabilityID: c.ID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanSourceDisplayName derives a human-friendly label for a scan_source
|
||||
// capability: the capability manifest's display_name when present, else the
|
||||
// plugin id (with the capability id appended when it adds information).
|
||||
func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
|
||||
if c != nil && c.Metadata != nil {
|
||||
if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case pluginID != "" && c != nil && c.ID != "":
|
||||
return pluginID + " / " + c.ID
|
||||
case pluginID != "":
|
||||
return pluginID
|
||||
case c != nil:
|
||||
return c.ID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// BuildAutoscanService wires the v2 autoscan engine from its concrete
|
||||
// dependencies. Both the HTTP router (manual trigger) and the background poll
|
||||
// task share this constructor so the adapter wiring lives in exactly one place.
|
||||
// Credentials are now decrypted inline by the autoscan/requests repos, so there
|
||||
// is no separate secret resolver to thread.
|
||||
func BuildAutoscanService(
|
||||
repo *autoscan.Repository,
|
||||
pluginService *plugins.Service,
|
||||
installationStore *plugins.InstallationStore,
|
||||
requestsRepo *mediarequests.Repository,
|
||||
folderRepo *catalog.FolderRepository,
|
||||
queue autoscanQueuer,
|
||||
redisClient *redis.Client,
|
||||
) *autoscan.Service {
|
||||
provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService})
|
||||
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo})
|
||||
svc := autoscan.NewService(
|
||||
repo,
|
||||
provider,
|
||||
connRes,
|
||||
scantrigger.NewResolver(folderRepo),
|
||||
queue,
|
||||
autoscan.NewRedisSuppressor(redisClient),
|
||||
PluginScanSourceLister{installationStore},
|
||||
)
|
||||
// Wire the connection-test + rewrite-suggester deps: a (long-timeout)
|
||||
// arr root-folder/status client and a Silo media-folder lister.
|
||||
svc.SetSuggesterDeps(
|
||||
autoscan.NewArrRootFolderClient(nil),
|
||||
autoscan.NewCatalogFolderLister(folderRepo),
|
||||
)
|
||||
return svc
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
|
||||
// connection is rejected when the integration is disabled or has no base_url, so
|
||||
// the autoscan engine skips it instead of polling an unusable target.
|
||||
func TestCheckRequestIntegrationUsable(t *testing.T) {
|
||||
t.Run("enabled with base_url is usable", func(t *testing.T) {
|
||||
if err := checkRequestIntegrationUsable("req-1", true, "http://radarr:7878"); err != nil {
|
||||
t.Fatalf("expected usable, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("disabled is rejected", func(t *testing.T) {
|
||||
if err := checkRequestIntegrationUsable("req-1", false, "http://radarr:7878"); err == nil {
|
||||
t.Fatal("expected error for disabled integration")
|
||||
}
|
||||
})
|
||||
t.Run("blank base_url is rejected", func(t *testing.T) {
|
||||
if err := checkRequestIntegrationUsable("req-1", true, " "); err == nil {
|
||||
t.Fatal("expected error for blank base_url")
|
||||
}
|
||||
})
|
||||
}
|
||||
+89
-262
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -113,34 +112,20 @@ func NewAdminHandler(
|
||||
|
||||
// createUserRequest represents the JSON body for POST /admin/users.
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Permissions createStringSliceField `json:"permissions"`
|
||||
CreateDefaultProfile bool `json:"create_default_profile"`
|
||||
DefaultProfileName string `json:"default_profile_name,omitempty"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
type createStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *createStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
CreateDefaultProfile bool `json:"create_default_profile"`
|
||||
DefaultProfileName string `json:"default_profile_name,omitempty"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
type updateLibraryIDsField struct {
|
||||
@@ -388,9 +373,6 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
req.Username = auth.NormalizeUsername(req.Username)
|
||||
req.Email = auth.NormalizeEmail(req.Email)
|
||||
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" || req.Role == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and role are required")
|
||||
return
|
||||
@@ -405,11 +387,7 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
|
||||
return
|
||||
}
|
||||
permissions := auth.DefaultUserPermissions()
|
||||
if req.Permissions.Set {
|
||||
permissions = req.Permissions.Value
|
||||
}
|
||||
permissions, err := auth.NormalizePermissions(permissions)
|
||||
permissions, err := auth.NormalizePermissions(req.Permissions)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
@@ -482,7 +460,7 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
permissions = &normalized
|
||||
}
|
||||
|
||||
updateInput := models.UpdateUserInput{
|
||||
err = h.userRepo.Update(r.Context(), id, models.UpdateUserInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
@@ -496,31 +474,12 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
}
|
||||
|
||||
var currentUser *models.User
|
||||
if updateMayRequireSessionRevocation(updateInput) {
|
||||
currentUser, err = h.userRepo.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if auth.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch user")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = h.userRepo.Update(r.Context(), id, updateInput)
|
||||
})
|
||||
if err != nil {
|
||||
if auth.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user")
|
||||
return
|
||||
}
|
||||
if updateRequiresSessionRevocation(currentUser, updateInput) {
|
||||
if updateRequiresSessionRevocation(req) {
|
||||
if err := h.revokeUserSessions(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke updated user sessions")
|
||||
return
|
||||
@@ -788,35 +747,13 @@ func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Req
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func updateMayRequireSessionRevocation(input models.UpdateUserInput) bool {
|
||||
return input.Password != nil ||
|
||||
input.Role != nil ||
|
||||
input.Enabled != nil ||
|
||||
input.Permissions != nil ||
|
||||
input.MaxPlaybackQuality != nil
|
||||
}
|
||||
|
||||
func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool {
|
||||
if input.Password != nil {
|
||||
return true
|
||||
}
|
||||
if current == nil {
|
||||
return updateMayRequireSessionRevocation(input)
|
||||
}
|
||||
if input.Role != nil && *input.Role != current.Role {
|
||||
return true
|
||||
}
|
||||
if input.Enabled != nil && *input.Enabled != current.Enabled {
|
||||
return true
|
||||
}
|
||||
if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) {
|
||||
return true
|
||||
}
|
||||
if input.MaxPlaybackQuality != nil &&
|
||||
access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
func updateRequiresSessionRevocation(req updateUserRequest) bool {
|
||||
return req.Password != nil ||
|
||||
req.Role != nil ||
|
||||
req.Enabled != nil ||
|
||||
req.LibraryIDs.Set ||
|
||||
req.Permissions.Set ||
|
||||
req.MaxPlaybackQuality != nil
|
||||
}
|
||||
|
||||
func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error {
|
||||
@@ -893,9 +830,6 @@ func (h *AdminHandler) HandleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
var resp AdminStats
|
||||
|
||||
if h.StatsSource != nil {
|
||||
if isTruthyQuery(r.URL.Query().Get("refresh")) {
|
||||
h.StatsSource.Invalidate()
|
||||
}
|
||||
stats, err := h.StatsSource.Get(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get stats")
|
||||
@@ -922,15 +856,6 @@ func (h *AdminHandler) HandleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func isTruthyQuery(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) invalidateStats(ctx context.Context, channel, eventType, payload string) {
|
||||
if h.StatsSource != nil {
|
||||
h.StatsSource.Invalidate()
|
||||
@@ -1041,7 +966,6 @@ type UpdateItemMetadataRequest struct {
|
||||
FirstAirDate *string `json:"first_air_date"`
|
||||
LastAirDate *string `json:"last_air_date"`
|
||||
AirTime *string `json:"air_time"`
|
||||
AirTimezone *string `json:"air_timezone"`
|
||||
AirDate *string `json:"air_date"`
|
||||
Status *string `json:"status"`
|
||||
RatingIMDB *float64 `json:"rating_imdb"`
|
||||
@@ -1069,14 +993,6 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.AirTimezone != nil {
|
||||
trimmed := strings.TrimSpace(*req.AirTimezone)
|
||||
req.AirTimezone = &trimmed
|
||||
if !catalog.ValidateAirTimezone(trimmed) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "air_timezone must be a valid IANA timezone")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
upd := catalog.MetadataUpdate{
|
||||
Title: req.Title, SortTitle: req.SortTitle, OriginalTitle: req.OriginalTitle,
|
||||
@@ -1084,7 +1000,7 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
Year: req.Year, Runtime: req.Runtime,
|
||||
Genres: req.Genres, Studios: req.Studios, Networks: req.Networks, Countries: req.Countries,
|
||||
ReleaseDate: req.ReleaseDate, FirstAirDate: req.FirstAirDate, LastAirDate: req.LastAirDate,
|
||||
AirTime: req.AirTime, AirTimezone: req.AirTimezone,
|
||||
AirTime: req.AirTime,
|
||||
AirDate: req.AirDate, Status: req.Status,
|
||||
RatingIMDB: req.RatingIMDB, RatingTMDB: req.RatingTMDB,
|
||||
RatingRTCritic: req.RatingRTCritic, RatingRTAudience: req.RatingRTAudience,
|
||||
@@ -1137,10 +1053,28 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
|
||||
// --- Server Settings endpoints ---
|
||||
|
||||
// sensitiveSettingKeys is the audited allowlist of secret-bearing settings
|
||||
// keys, shared with the at-rest encryption decorator so redaction and
|
||||
// encryption can never drift apart. See catalog.SensitiveSettingKeys.
|
||||
var sensitiveSettingKeys = catalog.SensitiveSettingKeys
|
||||
var sensitiveSettingKeys = map[string]bool{
|
||||
"auth.jwt_secret": true,
|
||||
"s3.public_access_key": true,
|
||||
"s3.public_secret_key": true,
|
||||
"s3.public_token_secret": true,
|
||||
"s3.private_access_key": true,
|
||||
"s3.private_secret_key": true,
|
||||
"s3.user_db_access_key": true,
|
||||
"s3.user_db_secret_key": true,
|
||||
"redis.url": true,
|
||||
"recommendations.openai_api_key": true,
|
||||
"recommendations.embedding_auth_token": true,
|
||||
"tmdb.api_key": true,
|
||||
"introdb.api_key": true,
|
||||
"mdblist.api_key": true,
|
||||
"requests.radarr.api_key": true,
|
||||
"requests.sonarr.api_key": true,
|
||||
"watchsync.trakt.client_id": true,
|
||||
"watchsync.trakt.client_secret": true,
|
||||
"watchsync.simkl.client_id": true,
|
||||
"watchsync.simkl.client_secret": true,
|
||||
}
|
||||
|
||||
// HandleGetSettings handles GET /admin/settings.
|
||||
func (h *AdminHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1268,9 +1202,6 @@ type adminDeviceDetailResponse struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DevicePlatform string `json:"device_platform"`
|
||||
OverrideCount int `json:"override_count"`
|
||||
ProfileCount int `json:"profile_count"`
|
||||
Profiles []adminDeviceProfileSummary `json:"profiles"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
Settings []adminDeviceSettingResponse `json:"settings"`
|
||||
}
|
||||
@@ -1513,12 +1444,6 @@ func (h *AdminHandler) HandleUpdateUserDeviceSetting(w http.ResponseWriter, r *h
|
||||
if existing != nil {
|
||||
entry.DeviceName = existing.DeviceName
|
||||
entry.DevicePlatform = existing.DevicePlatform
|
||||
} else if registered, err := registeredDeviceForProfile(r.Context(), store, profileID, deviceID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
return
|
||||
} else if registered != nil {
|
||||
entry.DeviceName = registered.DeviceName
|
||||
entry.DevicePlatform = registered.DevicePlatform
|
||||
}
|
||||
if err := store.SetDeviceSetting(r.Context(), entry); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device setting")
|
||||
@@ -1641,10 +1566,6 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list device settings: %w", err)
|
||||
}
|
||||
devices, err := listRegisteredDevices(gctx, store)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list devices: %w", err)
|
||||
}
|
||||
profileNames, err := listProfileNamesByID(gctx, store)
|
||||
if err != nil {
|
||||
slog.Warn("admin list devices profile lookup failed",
|
||||
@@ -1653,14 +1574,7 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
|
||||
)
|
||||
profileNames = map[string]string{}
|
||||
}
|
||||
perUser[i] = buildAdminDeviceSummaries(
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Email,
|
||||
entries,
|
||||
devices,
|
||||
profileNames,
|
||||
)
|
||||
perUser[i] = buildAdminDeviceSummaries(user.ID, user.Username, user.Email, entries, profileNames)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1724,11 +1638,6 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
return
|
||||
}
|
||||
registeredDevices, err := listRegisteredDevices(r.Context(), store)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
return
|
||||
}
|
||||
profileNames, err := listProfileNamesByID(r.Context(), store)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
|
||||
@@ -1741,26 +1650,12 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceEntries = append(deviceEntries, entry)
|
||||
}
|
||||
}
|
||||
deviceRegistrations := make([]userstore.DeviceEntry, 0)
|
||||
for _, entry := range registeredDevices {
|
||||
if entry.DeviceID == deviceID {
|
||||
deviceRegistrations = append(deviceRegistrations, entry)
|
||||
}
|
||||
}
|
||||
summaries := buildAdminDeviceSummaries(
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Email,
|
||||
deviceEntries,
|
||||
deviceRegistrations,
|
||||
profileNames,
|
||||
)
|
||||
if len(summaries) == 0 {
|
||||
if len(deviceEntries) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Device not found")
|
||||
return
|
||||
}
|
||||
|
||||
summary := summaries[0]
|
||||
summary := buildAdminDeviceSummaries(user.ID, user.Username, user.Email, deviceEntries, profileNames)[0]
|
||||
writeJSON(w, http.StatusOK, adminDeviceDetailResponse{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
@@ -1768,41 +1663,11 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
DeviceID: summary.DeviceID,
|
||||
DeviceName: summary.DeviceName,
|
||||
DevicePlatform: summary.DevicePlatform,
|
||||
OverrideCount: summary.OverrideCount,
|
||||
ProfileCount: summary.ProfileCount,
|
||||
Profiles: summary.Profiles,
|
||||
LastUpdated: summary.LastUpdated,
|
||||
Settings: buildAdminDeviceSettingsResponse(user.ID, profileNames, deviceEntries).Settings,
|
||||
})
|
||||
}
|
||||
|
||||
func listRegisteredDevices(ctx context.Context, store userstore.UserStore) ([]userstore.DeviceEntry, error) {
|
||||
registry, ok := store.(userstore.DeviceRegistry)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return registry.ListDevices(ctx)
|
||||
}
|
||||
|
||||
func registeredDeviceForProfile(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
deviceID string,
|
||||
) (*userstore.DeviceEntry, error) {
|
||||
devices, err := listRegisteredDevices(ctx, store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, device := range devices {
|
||||
if device.ProfileID == profileID && device.DeviceID == deviceID {
|
||||
matched := device
|
||||
return &matched, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func buildAdminDeviceSettingsResponse(userID int, profileNames map[string]string, entries []userstore.DeviceSettingEntry) adminDeviceSettingsListResponse {
|
||||
resp := adminDeviceSettingsListResponse{
|
||||
Settings: make([]adminDeviceSettingResponse, 0, len(entries)),
|
||||
@@ -1828,7 +1693,6 @@ func buildAdminDeviceSummaries(
|
||||
username string,
|
||||
email string,
|
||||
entries []userstore.DeviceSettingEntry,
|
||||
registeredDevices []userstore.DeviceEntry,
|
||||
profileNames map[string]string,
|
||||
) []adminDeviceSummaryResponse {
|
||||
type profileAccumulator struct {
|
||||
@@ -1842,10 +1706,10 @@ func buildAdminDeviceSummaries(
|
||||
}
|
||||
|
||||
byDevice := make(map[string]*summary)
|
||||
|
||||
ensureDevice := func(deviceID, deviceName, devicePlatform, lastUpdated string) *summary {
|
||||
for _, entry := range entries {
|
||||
deviceID := strings.TrimSpace(entry.DeviceID)
|
||||
if deviceID == "" {
|
||||
return nil
|
||||
continue
|
||||
}
|
||||
current, ok := byDevice[deviceID]
|
||||
if !ok {
|
||||
@@ -1855,86 +1719,49 @@ func buildAdminDeviceSummaries(
|
||||
Username: username,
|
||||
Email: email,
|
||||
DeviceID: deviceID,
|
||||
DeviceName: deviceName,
|
||||
DevicePlatform: devicePlatform,
|
||||
LastUpdated: lastUpdated,
|
||||
DeviceName: entry.DeviceName,
|
||||
DevicePlatform: entry.DevicePlatform,
|
||||
LastUpdated: entry.UpdatedAt,
|
||||
},
|
||||
keys: make(map[string]struct{}),
|
||||
profiles: make(map[string]*profileAccumulator),
|
||||
}
|
||||
byDevice[deviceID] = current
|
||||
}
|
||||
if current.device.DeviceName == "" && deviceName != "" {
|
||||
current.device.DeviceName = deviceName
|
||||
}
|
||||
if current.device.DevicePlatform == "" && devicePlatform != "" {
|
||||
current.device.DevicePlatform = devicePlatform
|
||||
}
|
||||
if lastUpdated > current.device.LastUpdated {
|
||||
current.device.LastUpdated = lastUpdated
|
||||
if deviceName != "" {
|
||||
current.device.DeviceName = deviceName
|
||||
}
|
||||
if devicePlatform != "" {
|
||||
current.device.DevicePlatform = devicePlatform
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
ensureProfile := func(current *summary, profileID, lastUpdated string) *profileAccumulator {
|
||||
if current == nil || profileID == "" {
|
||||
return nil
|
||||
}
|
||||
profile, exists := current.profiles[profileID]
|
||||
if !exists {
|
||||
profile = &profileAccumulator{
|
||||
summary: adminDeviceProfileSummary{
|
||||
ProfileID: profileID,
|
||||
ProfileName: profileNames[profileID],
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
keys: make(map[string]struct{}),
|
||||
}
|
||||
current.profiles[profileID] = profile
|
||||
return profile
|
||||
}
|
||||
if lastUpdated > profile.summary.LastUpdated {
|
||||
profile.summary.LastUpdated = lastUpdated
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
for _, device := range registeredDevices {
|
||||
deviceID := strings.TrimSpace(device.DeviceID)
|
||||
profileID := strings.TrimSpace(device.ProfileID)
|
||||
current := ensureDevice(
|
||||
deviceID,
|
||||
device.DeviceName,
|
||||
device.DevicePlatform,
|
||||
device.LastSeenAt,
|
||||
)
|
||||
ensureProfile(current, profileID, device.LastSeenAt)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
deviceID := strings.TrimSpace(entry.DeviceID)
|
||||
current.keys[entry.ProfileID+":"+entry.Key] = struct{}{}
|
||||
profileID := strings.TrimSpace(entry.ProfileID)
|
||||
current := ensureDevice(
|
||||
deviceID,
|
||||
entry.DeviceName,
|
||||
entry.DevicePlatform,
|
||||
entry.UpdatedAt,
|
||||
)
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
if profileID != "" && entry.Key != "" {
|
||||
current.keys[profileID+":"+entry.Key] = struct{}{}
|
||||
}
|
||||
profile := ensureProfile(current, profileID, entry.UpdatedAt)
|
||||
if profile != nil && entry.Key != "" {
|
||||
if profileID != "" {
|
||||
profile, exists := current.profiles[profileID]
|
||||
if !exists {
|
||||
profile = &profileAccumulator{
|
||||
summary: adminDeviceProfileSummary{
|
||||
ProfileID: profileID,
|
||||
ProfileName: profileNames[profileID],
|
||||
LastUpdated: entry.UpdatedAt,
|
||||
},
|
||||
keys: make(map[string]struct{}),
|
||||
}
|
||||
current.profiles[profileID] = profile
|
||||
}
|
||||
profile.keys[entry.Key] = struct{}{}
|
||||
if entry.UpdatedAt > profile.summary.LastUpdated {
|
||||
profile.summary.LastUpdated = entry.UpdatedAt
|
||||
}
|
||||
}
|
||||
if current.device.DeviceName == "" && entry.DeviceName != "" {
|
||||
current.device.DeviceName = entry.DeviceName
|
||||
}
|
||||
if current.device.DevicePlatform == "" && entry.DevicePlatform != "" {
|
||||
current.device.DevicePlatform = entry.DevicePlatform
|
||||
}
|
||||
if entry.UpdatedAt > current.device.LastUpdated {
|
||||
current.device.LastUpdated = entry.UpdatedAt
|
||||
if entry.DeviceName != "" {
|
||||
current.device.DeviceName = entry.DeviceName
|
||||
}
|
||||
if entry.DevicePlatform != "" {
|
||||
current.device.DevicePlatform = entry.DevicePlatform
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
const adminJobDownloadExpiry = 15 * time.Minute
|
||||
@@ -24,22 +23,12 @@ type AdminJobArtifactStore interface {
|
||||
PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error)
|
||||
}
|
||||
|
||||
type adminJobRepository interface {
|
||||
List(ctx context.Context, opts adminjob.ListJobsOptions) ([]*models.AdminJob, error)
|
||||
GetByID(ctx context.Context, id string) (*models.AdminJob, error)
|
||||
Cancel(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error)
|
||||
CancelQueued(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error)
|
||||
UpdateProgress(ctx context.Context, id string, current, total int, message string) error
|
||||
}
|
||||
|
||||
type AdminJobsHandler struct {
|
||||
repo adminJobRepository
|
||||
store AdminJobArtifactStore
|
||||
CancelRegistry *adminjob.CancelRegistry
|
||||
RealtimeHub *notifications.Hub
|
||||
repo *adminjob.Repository
|
||||
store AdminJobArtifactStore
|
||||
}
|
||||
|
||||
func NewAdminJobsHandler(repo adminJobRepository, store AdminJobArtifactStore) *AdminJobsHandler {
|
||||
func NewAdminJobsHandler(repo *adminjob.Repository, store AdminJobArtifactStore) *AdminJobsHandler {
|
||||
return &AdminJobsHandler{repo: repo, store: store}
|
||||
}
|
||||
|
||||
@@ -131,105 +120,6 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
|
||||
}
|
||||
|
||||
func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.repo == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured", "Admin jobs are not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Job ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
job, err := h.repo.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, adminjob.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job")
|
||||
return
|
||||
}
|
||||
|
||||
if job.JobType != adminjob.JobTypeLibraryRefresh {
|
||||
writeError(w, http.StatusConflict, "not_cancellable", "Only library metadata refresh jobs can be cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
switch job.Status {
|
||||
case adminjob.StatusQueued:
|
||||
cancelled, cancelErr := h.repo.CancelQueued(
|
||||
r.Context(),
|
||||
id,
|
||||
"Library metadata refresh cancelled",
|
||||
time.Now().UTC().Add(7*24*time.Hour),
|
||||
)
|
||||
if cancelErr != nil {
|
||||
if errors.Is(cancelErr, adminjob.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(cancelErr, adminjob.ErrJobNotCancellable) {
|
||||
advanced, loadErr := h.repo.GetByID(r.Context(), id)
|
||||
if loadErr != nil {
|
||||
if errors.Is(loadErr, adminjob.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job")
|
||||
return
|
||||
}
|
||||
if advanced.Status == adminjob.StatusRunning {
|
||||
h.requestRunningCancellation(w, r, advanced)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusConflict, "not_cancellable", "Job is no longer cancellable")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel job")
|
||||
return
|
||||
}
|
||||
if h.RealtimeHub != nil {
|
||||
publishEventJob(
|
||||
r.Context(),
|
||||
h.RealtimeHub.EventsHub(),
|
||||
string(notifications.TypeJobCancelled),
|
||||
cancelled,
|
||||
)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminJobToResponse(r, cancelled, h.store))
|
||||
case adminjob.StatusRunning:
|
||||
h.requestRunningCancellation(w, r, job)
|
||||
default:
|
||||
writeError(w, http.StatusConflict, "not_cancellable", "Job is no longer cancellable")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminJobsHandler) requestRunningCancellation(w http.ResponseWriter, r *http.Request, job *models.AdminJob) {
|
||||
if h.CancelRegistry == nil || !h.CancelRegistry.Cancel(job.ID) {
|
||||
writeError(w, http.StatusConflict, "not_cancellable", "Running job is not cancellable on this node")
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpdateProgress(
|
||||
r.Context(),
|
||||
job.ID,
|
||||
job.ProgressCurrent,
|
||||
job.ProgressTotal,
|
||||
"Cancellation requested",
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request cancellation")
|
||||
return
|
||||
}
|
||||
updated, err := h.repo.GetByID(r.Context(), job.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponse(r, updated, h.store))
|
||||
}
|
||||
|
||||
func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArtifactStore) adminJobResponse {
|
||||
resp := adminJobResponse{
|
||||
ID: job.ID,
|
||||
|
||||
@@ -2,21 +2,12 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
|
||||
@@ -88,195 +79,3 @@ func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *tes
|
||||
t.Fatalf("ErrorMessage = %q, want stripped for non-admin", resp.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsHandleCancel_RunningRegistryMissDoesNotUpdateProgress(t *testing.T) {
|
||||
repo := &fakeAdminJobRepository{
|
||||
job: &models.AdminJob{
|
||||
ID: "job-1",
|
||||
JobType: adminjob.JobTypeLibraryRefresh,
|
||||
Status: adminjob.StatusRunning,
|
||||
CreatedByUserID: 1,
|
||||
Message: "Running",
|
||||
RequestedAt: time.Now().UTC(),
|
||||
},
|
||||
}
|
||||
handler := NewAdminJobsHandler(repo, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleCancel(rec, adminJobCancelRequest("job-1"))
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
|
||||
}
|
||||
if repo.updateProgressCalls != 0 {
|
||||
t.Fatalf("UpdateProgress called %d times, want 0", repo.updateProgressCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsHandleCancel_QueuedPublishesCancelledEvent(t *testing.T) {
|
||||
repo := &fakeAdminJobRepository{
|
||||
job: &models.AdminJob{
|
||||
ID: "job-1",
|
||||
JobType: adminjob.JobTypeLibraryRefresh,
|
||||
Status: adminjob.StatusQueued,
|
||||
CreatedByUserID: 1,
|
||||
Message: "Queued",
|
||||
RequestedAt: time.Now().UTC(),
|
||||
},
|
||||
}
|
||||
hub := notifications.NewHub("test", &cache.NoopEventBus{})
|
||||
events, unsubscribe := hub.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
handler := NewAdminJobsHandler(repo, nil)
|
||||
handler.RealtimeHub = hub
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleCancel(rec, adminJobCancelRequest("job-1"))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type != notifications.TypeJobCancelled {
|
||||
t.Fatalf("event.Type = %q, want %q", event.Type, notifications.TypeJobCancelled)
|
||||
}
|
||||
if event.Job == nil || event.Job.ID != "job-1" {
|
||||
t.Fatalf("event.Job = %#v, want job-1", event.Job)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for job.cancelled event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsHandleCancel_QueuedSnapshotFallsBackToRunningCancellation(t *testing.T) {
|
||||
repo := &fakeAdminJobRepository{
|
||||
job: &models.AdminJob{
|
||||
ID: "job-1",
|
||||
JobType: adminjob.JobTypeLibraryRefresh,
|
||||
Status: adminjob.StatusQueued,
|
||||
CreatedByUserID: 1,
|
||||
Message: "Queued",
|
||||
RequestedAt: time.Now().UTC(),
|
||||
ProgressCurrent: 3,
|
||||
ProgressTotal: 10,
|
||||
},
|
||||
promoteToRunningOnCancelQueued: true,
|
||||
}
|
||||
cancelled := false
|
||||
registry := adminjob.NewCancelRegistry()
|
||||
unregister := registry.Register("job-1", func() {
|
||||
cancelled = true
|
||||
})
|
||||
defer unregister()
|
||||
|
||||
handler := NewAdminJobsHandler(repo, nil)
|
||||
handler.CancelRegistry = registry
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleCancel(rec, adminJobCancelRequest("job-1"))
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String())
|
||||
}
|
||||
if !cancelled {
|
||||
t.Fatal("expected running job cancel registry to be invoked")
|
||||
}
|
||||
if repo.cancelQueuedCalls != 1 {
|
||||
t.Fatalf("CancelQueued called %d times, want 1", repo.cancelQueuedCalls)
|
||||
}
|
||||
if repo.cancelCalls != 0 {
|
||||
t.Fatalf("Cancel called %d times, want 0", repo.cancelCalls)
|
||||
}
|
||||
if repo.updateProgressCalls != 1 {
|
||||
t.Fatalf("UpdateProgress called %d times, want 1", repo.updateProgressCalls)
|
||||
}
|
||||
if repo.job.Status != adminjob.StatusRunning {
|
||||
t.Fatalf("job status = %q, want %q", repo.job.Status, adminjob.StatusRunning)
|
||||
}
|
||||
if repo.job.Message != "Cancellation requested" {
|
||||
t.Fatalf("job message = %q, want cancellation request", repo.job.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func adminJobCancelRequest(id string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/jobs/"+id+"/cancel", nil)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", id)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
type fakeAdminJobRepository struct {
|
||||
job *models.AdminJob
|
||||
updateProgressCalls int
|
||||
cancelCalls int
|
||||
cancelQueuedCalls int
|
||||
promoteToRunningOnCancelQueued bool
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) List(context.Context, adminjob.ListJobsOptions) ([]*models.AdminJob, error) {
|
||||
if r.job == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []*models.AdminJob{r.job}, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) GetByID(_ context.Context, id string) (*models.AdminJob, error) {
|
||||
if r.job == nil || r.job.ID != id {
|
||||
return nil, adminjob.ErrJobNotFound
|
||||
}
|
||||
cp := *r.job
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) Cancel(_ context.Context, id, message string, _ time.Time) (*models.AdminJob, error) {
|
||||
r.cancelCalls++
|
||||
if r.job == nil || r.job.ID != id {
|
||||
return nil, adminjob.ErrJobNotFound
|
||||
}
|
||||
if r.job.Status != adminjob.StatusQueued && r.job.Status != adminjob.StatusRunning {
|
||||
return nil, adminjob.ErrJobNotCancellable
|
||||
}
|
||||
cp := *r.job
|
||||
cp.Status = adminjob.StatusCancelled
|
||||
cp.Message = message
|
||||
r.job = &cp
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) CancelQueued(_ context.Context, id, message string, _ time.Time) (*models.AdminJob, error) {
|
||||
r.cancelQueuedCalls++
|
||||
if r.job == nil || r.job.ID != id {
|
||||
return nil, adminjob.ErrJobNotFound
|
||||
}
|
||||
if r.promoteToRunningOnCancelQueued {
|
||||
cp := *r.job
|
||||
cp.Status = adminjob.StatusRunning
|
||||
r.job = &cp
|
||||
return nil, adminjob.ErrJobNotCancellable
|
||||
}
|
||||
if r.job.Status != adminjob.StatusQueued {
|
||||
return nil, adminjob.ErrJobNotCancellable
|
||||
}
|
||||
cp := *r.job
|
||||
cp.Status = adminjob.StatusCancelled
|
||||
cp.Message = message
|
||||
r.job = &cp
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) UpdateProgress(_ context.Context, id string, current, total int, message string) error {
|
||||
r.updateProgressCalls++
|
||||
if r.job == nil || r.job.ID != id {
|
||||
return adminjob.ErrJobNotFound
|
||||
}
|
||||
if message == "" {
|
||||
return errors.New("message is required")
|
||||
}
|
||||
r.job.ProgressCurrent = current
|
||||
r.job.ProgressTotal = total
|
||||
r.job.Message = message
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
)
|
||||
|
||||
// AdminMarkerProvidersHandler serves the per-provider marker config + key
|
||||
// validation API under the RequireAdmin group.
|
||||
type AdminMarkerProvidersHandler struct {
|
||||
Registry *markers.Registry
|
||||
Config *markers.ProviderConfigStore
|
||||
EventBus cache.EventBus
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewAdminMarkerProvidersHandler constructs the handler.
|
||||
func NewAdminMarkerProvidersHandler(registry *markers.Registry, config *markers.ProviderConfigStore, eventBus cache.EventBus, logger *slog.Logger) *AdminMarkerProvidersHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &AdminMarkerProvidersHandler{Registry: registry, Config: config, EventBus: eventBus, logger: logger}
|
||||
}
|
||||
|
||||
type providerConfigResponse struct {
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
SourceType string `json:"source_type,omitempty"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
PluginInstallationID int `json:"plugin_installation_id,omitempty"`
|
||||
CapabilityID string `json:"capability_id,omitempty"`
|
||||
IsSubmitter bool `json:"is_submitter"`
|
||||
FetchEnabled bool `json:"fetch_enabled"`
|
||||
FetchPriority int `json:"fetch_priority"`
|
||||
ContributeEnabled bool `json:"contribute_enabled"`
|
||||
ContributeAutoLocal bool `json:"contribute_auto_local"`
|
||||
ContributeMinConfidence float64 `json:"contribute_min_confidence"`
|
||||
}
|
||||
|
||||
type markerUserStatsResponse struct {
|
||||
Total int `json:"total"`
|
||||
Accepted int `json:"accepted"`
|
||||
Pending int `json:"pending"`
|
||||
Rejected int `json:"rejected"`
|
||||
AcceptanceRate float64 `json:"acceptance_rate"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
BestStreak int `json:"best_streak"`
|
||||
}
|
||||
|
||||
func (h *AdminMarkerProvidersHandler) submitterIDs() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if h.Registry == nil {
|
||||
return out
|
||||
}
|
||||
for _, p := range h.Registry.Providers() {
|
||||
if _, ok := p.(markers.Submitter); ok {
|
||||
out[p.ID()] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *AdminMarkerProvidersHandler) providerDescriptions() map[string]markers.ProviderDescriptor {
|
||||
out := map[string]markers.ProviderDescriptor{}
|
||||
if h.Registry == nil {
|
||||
return out
|
||||
}
|
||||
for _, p := range h.Registry.Providers() {
|
||||
desc := markers.ProviderDescriptor{ID: p.ID()}
|
||||
if described, ok := p.(markers.DescribedProvider); ok {
|
||||
desc = described.ProviderDescription()
|
||||
}
|
||||
if desc.ID == "" {
|
||||
desc.ID = p.ID()
|
||||
}
|
||||
out[p.ID()] = desc
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HandleListProviders lists registered providers with their config + capability.
|
||||
func (h *AdminMarkerProvidersHandler) HandleListProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.Config == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
submitters := h.submitterIDs()
|
||||
descriptions := h.providerDescriptions()
|
||||
out := []providerConfigResponse{}
|
||||
if h.Registry != nil {
|
||||
for _, provider := range h.Registry.Providers() {
|
||||
c, ok := h.Config.Get(provider.ID())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, toProviderConfigResponse(c, submitters[c.Provider], descriptions[c.Provider]))
|
||||
}
|
||||
} else {
|
||||
for _, c := range h.Config.List() {
|
||||
out = append(out, toProviderConfigResponse(c, submitters[c.Provider], descriptions[c.Provider]))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].FetchPriority != out[j].FetchPriority {
|
||||
return out[i].FetchPriority < out[j].FetchPriority
|
||||
}
|
||||
return out[i].Provider < out[j].Provider
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"providers": out})
|
||||
}
|
||||
|
||||
// HandleUpdateProvider updates a provider's config row.
|
||||
func (h *AdminMarkerProvidersHandler) HandleUpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.Config == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
existing, ok := h.Config.Get(provider)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Unknown marker provider")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
FetchEnabled *bool `json:"fetch_enabled"`
|
||||
FetchPriority *int `json:"fetch_priority"`
|
||||
ContributeEnabled *bool `json:"contribute_enabled"`
|
||||
ContributeAutoLocal *bool `json:"contribute_auto_local"`
|
||||
ContributeMinConfidence *float64 `json:"contribute_min_confidence"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if body.FetchEnabled != nil {
|
||||
existing.FetchEnabled = *body.FetchEnabled
|
||||
}
|
||||
if body.FetchPriority != nil {
|
||||
existing.FetchPriority = *body.FetchPriority
|
||||
}
|
||||
if body.ContributeEnabled != nil {
|
||||
existing.ContributeEnabled = *body.ContributeEnabled
|
||||
}
|
||||
if body.ContributeAutoLocal != nil {
|
||||
existing.ContributeAutoLocal = *body.ContributeAutoLocal
|
||||
}
|
||||
if body.ContributeMinConfidence != nil {
|
||||
v := *body.ContributeMinConfidence
|
||||
if v < 0 || v > 1 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "contribute_min_confidence must be between 0 and 1")
|
||||
return
|
||||
}
|
||||
existing.ContributeMinConfidence = v
|
||||
}
|
||||
if err := h.Config.Update(r.Context(), existing); err != nil {
|
||||
h.logger.Error("admin markers: update provider config failed", "provider", provider, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update provider")
|
||||
return
|
||||
}
|
||||
if h.EventBus != nil {
|
||||
_ = h.EventBus.Publish(r.Context(), cache.ChannelAdmin, cache.Event{
|
||||
Type: cache.EventMarkerProviderConfigChanged,
|
||||
Payload: provider,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toProviderConfigResponse(existing, h.submitterIDs()[provider], h.providerDescriptions()[provider]))
|
||||
}
|
||||
|
||||
// HandleValidateProvider validates the provider's configured key and returns stats.
|
||||
func (h *AdminMarkerProvidersHandler) HandleValidateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.Registry == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
var submitter markers.Submitter
|
||||
for _, p := range h.Registry.Providers() {
|
||||
if p.ID() == provider {
|
||||
if s, ok := p.(markers.Submitter); ok {
|
||||
submitter = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if submitter == nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Provider does not support contribution")
|
||||
return
|
||||
}
|
||||
stats, err := submitter.FetchUserStats(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": true, "stats": toMarkerUserStatsResponse(stats)})
|
||||
}
|
||||
|
||||
func toProviderConfigResponse(c markers.ProviderConfig, isSubmitter bool, desc markers.ProviderDescriptor) providerConfigResponse {
|
||||
return providerConfigResponse{
|
||||
Provider: c.Provider,
|
||||
DisplayName: desc.DisplayName,
|
||||
SourceType: desc.SourceType,
|
||||
PluginID: desc.PluginID,
|
||||
PluginInstallationID: desc.PluginInstallationID,
|
||||
CapabilityID: desc.CapabilityID,
|
||||
IsSubmitter: isSubmitter,
|
||||
FetchEnabled: c.FetchEnabled,
|
||||
FetchPriority: c.FetchPriority,
|
||||
ContributeEnabled: c.ContributeEnabled,
|
||||
ContributeAutoLocal: c.ContributeAutoLocal,
|
||||
ContributeMinConfidence: c.ContributeMinConfidence,
|
||||
}
|
||||
}
|
||||
|
||||
func toMarkerUserStatsResponse(s markers.UserStats) markerUserStatsResponse {
|
||||
return markerUserStatsResponse{
|
||||
Total: s.Total,
|
||||
Accepted: s.Accepted,
|
||||
Pending: s.Pending,
|
||||
Rejected: s.Rejected,
|
||||
AcceptanceRate: s.AcceptanceRate,
|
||||
CurrentStreak: s.CurrentStreak,
|
||||
BestStreak: s.BestStreak,
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
)
|
||||
|
||||
type fakeMarkerStatsSubmitter struct{}
|
||||
|
||||
func (fakeMarkerStatsSubmitter) ID() string { return "introdb" }
|
||||
func (fakeMarkerStatsSubmitter) FetchMarkers(context.Context, markers.Request) (markers.Result, error) {
|
||||
return markers.Result{}, nil
|
||||
}
|
||||
func (fakeMarkerStatsSubmitter) SubmitMarker(context.Context, markers.SubmissionRequest) (markers.SubmissionResult, error) {
|
||||
return markers.SubmissionResult{}, nil
|
||||
}
|
||||
func (fakeMarkerStatsSubmitter) FetchUserStats(context.Context) (markers.UserStats, error) {
|
||||
return markers.UserStats{Total: 10, Accepted: 7, Pending: 2, Rejected: 1, AcceptanceRate: 0.7, CurrentStreak: 3, BestStreak: 5}, nil
|
||||
}
|
||||
|
||||
func TestValidateMarkerProviderUsesSnakeCaseStats(t *testing.T) {
|
||||
reg := markers.NewRegistry(nil)
|
||||
if err := reg.Register(fakeMarkerStatsSubmitter{}); err != nil {
|
||||
t.Fatalf("register provider: %v", err)
|
||||
}
|
||||
h := NewAdminMarkerProvidersHandler(reg, nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/markers/providers/introdb/validate", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("provider", "introdb")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleValidateProvider(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"acceptance_rate":0.7`) || strings.Contains(body, "AcceptanceRate") {
|
||||
t.Fatalf("unexpected stats response shape: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerProviderResponseIncludesPluginMetadata(t *testing.T) {
|
||||
resp := toProviderConfigResponse(
|
||||
markers.ProviderConfig{Provider: "plugin:4:markers", FetchEnabled: true, FetchPriority: 25},
|
||||
true,
|
||||
markers.ProviderDescriptor{
|
||||
DisplayName: "Plugin Markers",
|
||||
SourceType: markers.ProviderSourcePlugin,
|
||||
PluginID: "silo.plugin.markers",
|
||||
PluginInstallationID: 4,
|
||||
CapabilityID: "markers",
|
||||
},
|
||||
)
|
||||
if resp.DisplayName != "Plugin Markers" || resp.SourceType != markers.ProviderSourcePlugin {
|
||||
t.Fatalf("plugin metadata fields missing: %+v", resp)
|
||||
}
|
||||
if resp.PluginID != "silo.plugin.markers" ||
|
||||
resp.PluginInstallationID != 4 ||
|
||||
resp.CapabilityID != "markers" ||
|
||||
!resp.IsSubmitter {
|
||||
t.Fatalf("plugin identity fields missing: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,7 @@ type AdminStats struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
TotalUsers int `json:"total_users"`
|
||||
TotalMovies int `json:"total_movies"`
|
||||
TotalMovieFiles int `json:"total_movie_files"`
|
||||
TotalShows int `json:"total_shows"`
|
||||
TotalShowFiles int `json:"total_show_files"`
|
||||
ActiveStreams int `json:"active_streams"`
|
||||
TotalStorageBytes int64 `json:"total_storage_bytes"`
|
||||
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
|
||||
@@ -129,15 +127,13 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
}
|
||||
|
||||
var (
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalMovieFiles int64
|
||||
totalShows int64
|
||||
totalShowFiles int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalShows int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
)
|
||||
|
||||
row := pool.QueryRow(ctx, `
|
||||
@@ -155,34 +151,8 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
file_stats AS (
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'movie')::bigint AS total_movie_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'series')::bigint AS total_show_files,
|
||||
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
|
||||
FROM (
|
||||
SELECT
|
||||
media_files.file_size,
|
||||
CASE
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'movie'
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'series'
|
||||
THEN 'series'
|
||||
WHEN episodes.content_id IS NOT NULL
|
||||
THEN 'series'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
WHEN lower(trim(media_folders.type)) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(media_folders.type)) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
ELSE ''
|
||||
END AS file_kind
|
||||
FROM media_files
|
||||
JOIN media_folders ON media_folders.id = media_files.media_folder_id
|
||||
LEFT JOIN media_items ON media_items.content_id = media_files.content_id
|
||||
LEFT JOIN episodes ON episodes.content_id = media_files.episode_id
|
||||
) classified_files
|
||||
FROM media_files
|
||||
),
|
||||
session_stats AS (
|
||||
SELECT COUNT(*)::bigint AS active_streams
|
||||
@@ -193,9 +163,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
item_stats.total_items,
|
||||
file_stats.total_files,
|
||||
item_stats.total_movies,
|
||||
file_stats.total_movie_files,
|
||||
item_stats.total_shows,
|
||||
file_stats.total_show_files,
|
||||
session_stats.active_streams,
|
||||
file_stats.total_storage_bytes
|
||||
FROM user_stats
|
||||
@@ -208,9 +176,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
&totalItems,
|
||||
&totalFiles,
|
||||
&totalMovies,
|
||||
&totalMovieFiles,
|
||||
&totalShows,
|
||||
&totalShowFiles,
|
||||
&activeStreams,
|
||||
&totalStorage,
|
||||
); err != nil {
|
||||
@@ -228,9 +194,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
TotalItems: int(totalItems),
|
||||
TotalFiles: int(totalFiles),
|
||||
TotalMovies: int(totalMovies),
|
||||
TotalMovieFiles: int(totalMovieFiles),
|
||||
TotalShows: int(totalShows),
|
||||
TotalShowFiles: int(totalShowFiles),
|
||||
ActiveStreams: int(activeStreams),
|
||||
TotalStorageBytes: totalStorage,
|
||||
WatchProviderActivity: activity,
|
||||
|
||||
@@ -1,132 +1,73 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
role := "admin"
|
||||
sameRole := "user"
|
||||
enabled := true
|
||||
disabled := false
|
||||
libraryIDs := []int{1, 2}
|
||||
sameLibraryIDs := []int{1}
|
||||
emptyLibraryIDs := []int{}
|
||||
var allLibraryIDs []int
|
||||
maxPlaybackQuality := "1080p"
|
||||
sameMaxPlaybackQuality := "original"
|
||||
password := "new-password"
|
||||
username := "renamed"
|
||||
maxStreams := 4
|
||||
permissions := []string{"metadata_curation"}
|
||||
samePermissions := []string{"download"}
|
||||
|
||||
current := &models.User{
|
||||
Role: "user",
|
||||
Permissions: []string{"download"},
|
||||
Enabled: false,
|
||||
LibraryIDs: []int{1},
|
||||
MaxPlaybackQuality: "original",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in models.UpdateUserInput
|
||||
req updateUserRequest
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "permissions set",
|
||||
in: models.UpdateUserInput{Permissions: &permissions},
|
||||
req: updateUserRequest{Permissions: updateStringSliceField{Set: true, Value: []string{"metadata_curation"}}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "permissions unchanged",
|
||||
in: models.UpdateUserInput{Permissions: &samePermissions},
|
||||
name: "permissions unset",
|
||||
req: updateUserRequest{Permissions: updateStringSliceField{Set: false, Value: []string{"metadata_curation"}}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
in: models.UpdateUserInput{Role: &role},
|
||||
req: updateUserRequest{Role: &role},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "role unchanged",
|
||||
in: models.UpdateUserInput{Role: &sameRole},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "enabled",
|
||||
in: models.UpdateUserInput{Enabled: &enabled},
|
||||
req: updateUserRequest{Enabled: &enabled},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "enabled unchanged",
|
||||
in: models.UpdateUserInput{Enabled: &disabled},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "library ids does not revoke session",
|
||||
in: models.UpdateUserInput{LibraryIDs: &libraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "library ids unchanged",
|
||||
in: models.UpdateUserInput{LibraryIDs: &sameLibraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "library ids nil does not revoke session",
|
||||
in: models.UpdateUserInput{LibraryIDs: &allLibraryIDs},
|
||||
want: false,
|
||||
name: "library ids",
|
||||
req: updateUserRequest{LibraryIDs: updateLibraryIDsField{Set: true, Value: libraryIDs}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "max playback quality",
|
||||
in: models.UpdateUserInput{MaxPlaybackQuality: &maxPlaybackQuality},
|
||||
req: updateUserRequest{MaxPlaybackQuality: &maxPlaybackQuality},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "max playback quality unchanged",
|
||||
in: models.UpdateUserInput{MaxPlaybackQuality: &sameMaxPlaybackQuality},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
in: models.UpdateUserInput{Password: &password},
|
||||
req: updateUserRequest{Password: &password},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non access fields",
|
||||
in: models.UpdateUserInput{Username: &username, MaxStreams: &maxStreams},
|
||||
req: updateUserRequest{Username: &username, MaxStreams: &maxStreams},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty update",
|
||||
in: models.UpdateUserInput{},
|
||||
req: updateUserRequest{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(current, tt.in); got != tt.want {
|
||||
if got := updateRequiresSessionRevocation(tt.req); got != tt.want {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unrestrictedCurrent := *current
|
||||
unrestrictedCurrent.LibraryIDs = nil
|
||||
t.Run("library ids empty does not revoke session", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("library ids nil unchanged", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &allLibraryIDs}); got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,757 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
const audioPlaybackSessionTTL = 12 * time.Hour
|
||||
|
||||
type AudioDetailService interface {
|
||||
GetItemDetail(ctx context.Context, contentID string, filter catalog.AccessFilter) (*catalog.ItemDetail, error)
|
||||
}
|
||||
|
||||
type AudioFileResolver interface {
|
||||
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
|
||||
}
|
||||
|
||||
type AudioHandler struct {
|
||||
detailSvc AudioDetailService
|
||||
files AudioFileResolver
|
||||
storeProvider userstore.UserStoreProvider
|
||||
bookmarkStore *PGAudioBookmarkStore
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]audioPlaybackSession
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAudioHandler(
|
||||
detailSvc AudioDetailService,
|
||||
files AudioFileResolver,
|
||||
storeProvider userstore.UserStoreProvider,
|
||||
bookmarkStore *PGAudioBookmarkStore,
|
||||
) *AudioHandler {
|
||||
return &AudioHandler{
|
||||
detailSvc: detailSvc,
|
||||
files: files,
|
||||
storeProvider: storeProvider,
|
||||
bookmarkStore: bookmarkStore,
|
||||
sessions: make(map[string]audioPlaybackSession),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
type audioPlaybackSession struct {
|
||||
ID string
|
||||
UserID int
|
||||
ProfileID string
|
||||
ContentID string
|
||||
TotalSeconds float64
|
||||
Files []audioSessionFile
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type audioSessionFile struct {
|
||||
FileID int
|
||||
FilePath string
|
||||
Start float64
|
||||
Duration float64
|
||||
Mode string
|
||||
TrackIdx int
|
||||
}
|
||||
|
||||
type audioStartPlaybackRequest struct {
|
||||
ContentID string `json:"content_id"`
|
||||
StartPosition *float64 `json:"start_position,omitempty"`
|
||||
Restart bool `json:"restart,omitempty"`
|
||||
}
|
||||
|
||||
type audioProgressRequest struct {
|
||||
Position float64 `json:"position"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
}
|
||||
|
||||
type audioPlaybackResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ContentID string `json:"content_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
TotalDurationSeconds float64 `json:"total_duration_seconds"`
|
||||
ResumePositionSeconds float64 `json:"resume_position_seconds"`
|
||||
Tracks []audioTrackResponse `json:"tracks"`
|
||||
Chapters []audioChapter `json:"chapters"`
|
||||
Bookmarks []audioBookmark `json:"bookmarks"`
|
||||
}
|
||||
|
||||
type audioTrackResponse struct {
|
||||
Index int `json:"index"`
|
||||
FileID int `json:"file_id"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
StartOffsetSeconds float64 `json:"start_offset_seconds"`
|
||||
StreamURL string `json:"stream_url"`
|
||||
StreamType string `json:"stream_type"`
|
||||
PlayMethod string `json:"play_method"`
|
||||
Codec string `json:"codec,omitempty"`
|
||||
Container string `json:"container,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
Chapters []audioChapter `json:"chapters"`
|
||||
}
|
||||
|
||||
type audioChapter struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title,omitempty"`
|
||||
StartSeconds float64 `json:"start_seconds"`
|
||||
EndSeconds float64 `json:"end_seconds,omitempty"`
|
||||
TrackIndex int `json:"track_index,omitempty"`
|
||||
}
|
||||
|
||||
type audioBookmark struct {
|
||||
ID string `json:"id"`
|
||||
ContentID string `json:"content_id"`
|
||||
TimeSeconds float64 `json:"time_seconds"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type audioBookmarkRequest struct {
|
||||
TimeSeconds *float64 `json:"time_seconds"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.detailSvc == nil || h.files == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Audio playback is not configured")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 || profileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Profile is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req audioStartPlaybackRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
req.ContentID = strings.TrimSpace(req.ContentID)
|
||||
if req.ContentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
filter := requestAccessFilter(r)
|
||||
detail, err := h.detailSvc.GetItemDetail(r.Context(), req.ContentID, filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item detail")
|
||||
return
|
||||
}
|
||||
if detail == nil || detail.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.files.GetByContentID(r.Context(), req.ContentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load audio files")
|
||||
return
|
||||
}
|
||||
files = catalog.FilterMediaFilesByAccess(files, filter)
|
||||
sortAudioFiles(files)
|
||||
if len(files) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No playable audio files found")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := ulid.Make().String()
|
||||
tracks, sessionFiles, chapters, totalDuration := buildAudioTrackResponses(sessionID, files)
|
||||
if detail.Audiobook != nil && detail.Audiobook.TotalDurationSeconds > 0 {
|
||||
totalDuration = float64(detail.Audiobook.TotalDurationSeconds)
|
||||
}
|
||||
|
||||
resume := resolveAudioResumePosition(r.Context(), h.storeProvider, userID, profileID, req.ContentID)
|
||||
if req.Restart {
|
||||
resume = 0
|
||||
} else if req.StartPosition != nil && isFiniteNonNegative(*req.StartPosition) {
|
||||
resume = clampFloat(*req.StartPosition, 0, totalDuration)
|
||||
}
|
||||
|
||||
bookmarks, _ := h.listBookmarks(r.Context(), userID, profileID, req.ContentID)
|
||||
session := audioPlaybackSession{
|
||||
ID: sessionID,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: req.ContentID,
|
||||
TotalSeconds: totalDuration,
|
||||
Files: sessionFiles,
|
||||
CreatedAt: h.now(),
|
||||
LastSeenAt: h.now(),
|
||||
ExpiresAt: h.now().Add(audioPlaybackSessionTTL),
|
||||
}
|
||||
h.putSession(session)
|
||||
|
||||
writeJSON(w, http.StatusOK, audioPlaybackResponse{
|
||||
SessionID: sessionID,
|
||||
ContentID: detail.ContentID,
|
||||
Type: detail.Type,
|
||||
Title: detail.Title,
|
||||
Subtitle: audioSubtitle(detail),
|
||||
PosterURL: detail.PosterURL,
|
||||
BackdropURL: detail.BackdropURL,
|
||||
TotalDurationSeconds: totalDuration,
|
||||
ResumePositionSeconds: resume,
|
||||
Tracks: tracks,
|
||||
Chapters: chapters,
|
||||
Bookmarks: bookmarks,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleSyncPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req audioProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
duration := req.Duration
|
||||
if duration <= 0 {
|
||||
duration = session.TotalSeconds
|
||||
}
|
||||
position := clampFloat(req.Position, 0, duration)
|
||||
if err := h.persistProgress(r.Context(), session.UserID, session.ProfileID, session.ContentID, position, duration); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync audio progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"session_id": session.ID,
|
||||
"position": position,
|
||||
"duration": duration,
|
||||
"is_paused": req.IsPaused,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleClosePlayback(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req audioProgressRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if isFiniteNonNegative(req.Position) {
|
||||
duration := req.Duration
|
||||
if duration <= 0 {
|
||||
duration = session.TotalSeconds
|
||||
}
|
||||
position := clampFloat(req.Position, 0, duration)
|
||||
_ = h.persistProgress(r.Context(), session.UserID, session.ProfileID, session.ContentID, position, duration)
|
||||
}
|
||||
h.deleteSession(session.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleStreamTrack(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rawIndex := chi.URLParam(r, "track_index")
|
||||
index, err := strconv.Atoi(rawIndex)
|
||||
if err != nil || index < 0 || index >= len(session.Files) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid track index")
|
||||
return
|
||||
}
|
||||
file := session.Files[index]
|
||||
mode := r.URL.Query().Get("mode")
|
||||
if mode == "" {
|
||||
mode = file.Mode
|
||||
}
|
||||
if mode == "aac" {
|
||||
seek := 0.0
|
||||
if raw := r.URL.Query().Get("seek"); raw != "" {
|
||||
if parsed, err := strconv.ParseFloat(raw, 64); err == nil && parsed >= 0 {
|
||||
seek = parsed
|
||||
}
|
||||
}
|
||||
if err := playback.ServeRemux(w, r, file.FilePath, "mp4", seek, true, 0); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = playback.ServeDirectPlay(w, r, file.FilePath)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleListBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
bookmarks, err := h.listBookmarks(r.Context(), userID, profileID, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list bookmarks")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"bookmarks": bookmarks})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleCreateBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.bookmarkStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Bookmarks are not configured")
|
||||
return
|
||||
}
|
||||
var req audioBookmarkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TimeSeconds == nil || !isFiniteNonNegative(*req.TimeSeconds) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "time_seconds is required")
|
||||
return
|
||||
}
|
||||
filter := requestAccessFilter(r)
|
||||
detail, err := h.detailSvc.GetItemDetail(r.Context(), contentID, filter)
|
||||
if err != nil || detail == nil || detail.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audiobook not found")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
bookmark, err := h.bookmarkStore.Upsert(r.Context(), userID, profileID, contentID, *req.TimeSeconds, req.Title)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save bookmark")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bookmark)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleDeleteBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.bookmarkStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Bookmarks are not configured")
|
||||
return
|
||||
}
|
||||
rawTime := chi.URLParam(r, "time_seconds")
|
||||
timeSeconds, err := strconv.ParseFloat(rawTime, 64)
|
||||
if err != nil || !isFiniteNonNegative(timeSeconds) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid bookmark time")
|
||||
return
|
||||
}
|
||||
if err := h.bookmarkStore.Delete(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), contentID, timeSeconds); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete bookmark")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) contentIDParam(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return "", false
|
||||
}
|
||||
return contentID, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) sessionForRequest(w http.ResponseWriter, r *http.Request) (audioPlaybackSession, bool) {
|
||||
sessionID := chi.URLParam(r, "session_id")
|
||||
if sessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
session, ok := h.getSession(sessionID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audio playback session not found")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
if session.UserID != apimw.GetUserID(r.Context()) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID != "" && session.ProfileID != profileID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another profile")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) putSession(session audioPlaybackSession) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.pruneExpiredLocked(h.now())
|
||||
h.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (h *AudioHandler) getSession(sessionID string) (audioPlaybackSession, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := h.now()
|
||||
h.pruneExpiredLocked(now)
|
||||
session, ok := h.sessions[sessionID]
|
||||
if !ok {
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(h.sessions, sessionID)
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
session.LastSeenAt = now
|
||||
h.sessions[sessionID] = session
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) deleteSession(sessionID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.sessions, sessionID)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) pruneExpiredLocked(now time.Time) {
|
||||
for id, session := range h.sessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(h.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AudioHandler) persistProgress(ctx context.Context, userID int, profileID, contentID string, position, duration float64) error {
|
||||
if h.storeProvider == nil {
|
||||
return nil
|
||||
}
|
||||
store, err := h.storeProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.SetProgress(ctx, profileID, contentID, position, duration, userstore.ProgressThresholds{})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) listBookmarks(ctx context.Context, userID int, profileID, contentID string) ([]audioBookmark, error) {
|
||||
if h.bookmarkStore == nil {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
return h.bookmarkStore.List(ctx, userID, profileID, contentID)
|
||||
}
|
||||
|
||||
func buildAudioTrackResponses(sessionID string, files []*models.MediaFile) ([]audioTrackResponse, []audioSessionFile, []audioChapter, float64) {
|
||||
tracks := make([]audioTrackResponse, 0, len(files))
|
||||
sessionFiles := make([]audioSessionFile, 0, len(files))
|
||||
allChapters := []audioChapter{}
|
||||
offset := 0.0
|
||||
for index, file := range files {
|
||||
duration := float64(file.Duration)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
mode := audioDeliveryMode(file)
|
||||
streamURL := fmt.Sprintf("/api/v1/audio/playback/%s/tracks/%d", sessionID, index)
|
||||
if mode == "aac" {
|
||||
streamURL += "?mode=aac"
|
||||
}
|
||||
chapters := audioChaptersForTrack(index, offset, file.Chapters)
|
||||
allChapters = append(allChapters, chapters...)
|
||||
tracks = append(tracks, audioTrackResponse{
|
||||
Index: index,
|
||||
FileID: file.ID,
|
||||
FileName: filepath.Base(file.FilePath),
|
||||
DurationSeconds: duration,
|
||||
StartOffsetSeconds: offset,
|
||||
StreamURL: streamURL,
|
||||
StreamType: "progressive",
|
||||
PlayMethod: mode,
|
||||
Codec: file.CodecAudio,
|
||||
Container: file.Container,
|
||||
Bitrate: file.Bitrate,
|
||||
Chapters: chapters,
|
||||
})
|
||||
sessionFiles = append(sessionFiles, audioSessionFile{
|
||||
FileID: file.ID,
|
||||
FilePath: file.FilePath,
|
||||
Start: offset,
|
||||
Duration: duration,
|
||||
Mode: mode,
|
||||
TrackIdx: index,
|
||||
})
|
||||
offset += duration
|
||||
}
|
||||
return tracks, sessionFiles, allChapters, offset
|
||||
}
|
||||
|
||||
func audioChaptersForTrack(trackIndex int, offset float64, chapters []models.MediaChapter) []audioChapter {
|
||||
out := make([]audioChapter, 0, len(chapters))
|
||||
for _, chapter := range chapters {
|
||||
start := offset + maxFloat(0, chapter.StartSeconds)
|
||||
end := 0.0
|
||||
if chapter.EndSeconds > 0 {
|
||||
end = offset + chapter.EndSeconds
|
||||
}
|
||||
out = append(out, audioChapter{
|
||||
Index: len(out),
|
||||
Title: chapter.Title,
|
||||
StartSeconds: start,
|
||||
EndSeconds: end,
|
||||
TrackIndex: trackIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortAudioFiles(files []*models.MediaFile) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
left, right := files[i], files[j]
|
||||
if left.PresentationPartIndex != right.PresentationPartIndex {
|
||||
if left.PresentationPartIndex == 0 {
|
||||
return false
|
||||
}
|
||||
if right.PresentationPartIndex == 0 {
|
||||
return true
|
||||
}
|
||||
return left.PresentationPartIndex < right.PresentationPartIndex
|
||||
}
|
||||
if left.FilePath != right.FilePath {
|
||||
return left.FilePath < right.FilePath
|
||||
}
|
||||
return left.ID < right.ID
|
||||
})
|
||||
}
|
||||
|
||||
func audioDeliveryMode(file *models.MediaFile) string {
|
||||
if file == nil {
|
||||
return "direct"
|
||||
}
|
||||
codec := strings.ToLower(strings.TrimSpace(file.CodecAudio))
|
||||
container := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(file.Container), "."))
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.FilePath), "."))
|
||||
if container == "" {
|
||||
container = ext
|
||||
}
|
||||
switch codec {
|
||||
case "aac", "mp3", "alac", "flac":
|
||||
return "direct"
|
||||
case "":
|
||||
switch container {
|
||||
case "mp3", "m4a", "m4b", "mp4", "aac", "flac", "wav", "aiff", "aif":
|
||||
return "direct"
|
||||
}
|
||||
}
|
||||
return "aac"
|
||||
}
|
||||
|
||||
func audioSubtitle(detail *catalog.ItemDetail) string {
|
||||
if detail == nil || detail.Audiobook == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
if len(detail.Audiobook.Authors) > 0 {
|
||||
names := make([]string, 0, len(detail.Audiobook.Authors))
|
||||
for _, author := range detail.Audiobook.Authors {
|
||||
if strings.TrimSpace(author.Name) != "" {
|
||||
names = append(names, author.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
parts = append(parts, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
if len(detail.Audiobook.Narrators) > 0 {
|
||||
names := make([]string, 0, len(detail.Audiobook.Narrators))
|
||||
for _, narrator := range detail.Audiobook.Narrators {
|
||||
if strings.TrimSpace(narrator.Name) != "" {
|
||||
names = append(names, narrator.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
parts = append(parts, "Narrated by "+strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " • ")
|
||||
}
|
||||
|
||||
func resolveAudioResumePosition(ctx context.Context, provider userstore.UserStoreProvider, userID int, profileID, contentID string) float64 {
|
||||
if provider == nil || userID == 0 || profileID == "" {
|
||||
return 0
|
||||
}
|
||||
store, err := provider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
progress, err := store.GetProgress(ctx, profileID, contentID)
|
||||
if err != nil || progress == nil || progress.Completed {
|
||||
return 0
|
||||
}
|
||||
if progress.PositionSeconds < 30 {
|
||||
return 0
|
||||
}
|
||||
if progress.DurationSeconds > 0 && progress.PositionSeconds >= progress.DurationSeconds-5 {
|
||||
return 0
|
||||
}
|
||||
return progress.PositionSeconds
|
||||
}
|
||||
|
||||
func isFiniteNonNegative(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0
|
||||
}
|
||||
|
||||
func clampFloat(value, lower, upper float64) float64 {
|
||||
if !isFiniteNonNegative(value) {
|
||||
return lower
|
||||
}
|
||||
if value < lower {
|
||||
return lower
|
||||
}
|
||||
if upper > lower && value > upper {
|
||||
return upper
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func maxFloat(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type PGAudioBookmarkStore struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGAudioBookmarkStore(pool *pgxpool.Pool) *PGAudioBookmarkStore {
|
||||
if pool == nil {
|
||||
return nil
|
||||
}
|
||||
return &PGAudioBookmarkStore{Pool: pool}
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) List(ctx context.Context, userID int, profileID, contentID string) ([]audioBookmark, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, library_item_id, time_seconds, title, created_at, updated_at
|
||||
FROM abs_bookmarks
|
||||
WHERE user_id = $1
|
||||
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
AND library_item_id = $3
|
||||
ORDER BY time_seconds ASC`,
|
||||
userID, profileArg(profileID), contentID,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
bookmarks := []audioBookmark{}
|
||||
for rows.Next() {
|
||||
var createdAt, updatedAt time.Time
|
||||
var bookmark audioBookmark
|
||||
if err := rows.Scan(&bookmark.ID, &bookmark.ContentID, &bookmark.TimeSeconds, &bookmark.Title, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bookmark.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
bookmark.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
|
||||
bookmarks = append(bookmarks, bookmark)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bookmarks, nil
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) Upsert(ctx context.Context, userID int, profileID, contentID string, timeSeconds float64, title string) (audioBookmark, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return audioBookmark{}, fmt.Errorf("bookmark store unavailable")
|
||||
}
|
||||
id := ulid.Make().String()
|
||||
var createdAt, updatedAt time.Time
|
||||
var bookmark audioBookmark
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO abs_bookmarks
|
||||
(id, user_id, profile_id, library_item_id, time_seconds, title)
|
||||
VALUES ($1, $2, $3::uuid, $4, $5, $6)
|
||||
ON CONFLICT (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
library_item_id,
|
||||
time_seconds
|
||||
) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
updated_at = now()
|
||||
RETURNING id, library_item_id, time_seconds, title, created_at, updated_at`,
|
||||
id, userID, profileArg(profileID), contentID, timeSeconds, strings.TrimSpace(title),
|
||||
).Scan(&bookmark.ID, &bookmark.ContentID, &bookmark.TimeSeconds, &bookmark.Title, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return audioBookmark{}, err
|
||||
}
|
||||
bookmark.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
bookmark.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
|
||||
return bookmark, nil
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) Delete(ctx context.Context, userID int, profileID, contentID string, timeSeconds float64) error {
|
||||
if s == nil || s.Pool == nil {
|
||||
return fmt.Errorf("bookmark store unavailable")
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM abs_bookmarks
|
||||
WHERE user_id = $1
|
||||
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
AND library_item_id = $3
|
||||
AND time_seconds = $4`,
|
||||
userID, profileArg(profileID), contentID, timeSeconds,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func profileArg(profileID string) any {
|
||||
if strings.TrimSpace(profileID) == "" {
|
||||
return nil
|
||||
}
|
||||
return profileID
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type stubAudioDetailService struct {
|
||||
detail *catalog.ItemDetail
|
||||
}
|
||||
|
||||
func (s stubAudioDetailService) GetItemDetail(context.Context, string, catalog.AccessFilter) (*catalog.ItemDetail, error) {
|
||||
return s.detail, nil
|
||||
}
|
||||
|
||||
type stubAudioFiles struct {
|
||||
files []*models.MediaFile
|
||||
}
|
||||
|
||||
func (s stubAudioFiles) GetByContentID(context.Context, string) ([]*models.MediaFile, error) {
|
||||
return append([]*models.MediaFile(nil), s.files...), nil
|
||||
}
|
||||
|
||||
func (s stubAudioFiles) GetByID(_ context.Context, id int) (*models.MediaFile, error) {
|
||||
for _, file := range s.files {
|
||||
if file.ID == id {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestAudioStartPlaybackOrdersTracksAndBuildsGlobalChapters(t *testing.T) {
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{
|
||||
testAudioFile(3, "part-03.mp3", 30, 30),
|
||||
testAudioFile(1, "part-01.mp3", 10, 10),
|
||||
testAudioFile(2, "part-02.mp3", 20, 20),
|
||||
}},
|
||||
testUserStoreProvider{store: newPlaybackTestStore(t)},
|
||||
nil,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleStartPlayback(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp audioPlaybackResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Tracks) != 3 {
|
||||
t.Fatalf("tracks = %d, want 3", len(resp.Tracks))
|
||||
}
|
||||
if resp.Tracks[0].FileID != 1 || resp.Tracks[1].FileID != 2 || resp.Tracks[2].FileID != 3 {
|
||||
t.Fatalf("track order = [%d %d %d], want [1 2 3]", resp.Tracks[0].FileID, resp.Tracks[1].FileID, resp.Tracks[2].FileID)
|
||||
}
|
||||
if resp.Tracks[1].StartOffsetSeconds != 10 || resp.Tracks[2].StartOffsetSeconds != 30 {
|
||||
t.Fatalf("offsets = %.0f %.0f, want 10 30", resp.Tracks[1].StartOffsetSeconds, resp.Tracks[2].StartOffsetSeconds)
|
||||
}
|
||||
if len(resp.Chapters) != 3 || resp.Chapters[2].StartSeconds != 30 {
|
||||
t.Fatalf("global chapters = %+v, want third chapter at 30s", resp.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioSyncPersistsCumulativeProgressAndEnforcesOwner(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{
|
||||
testAudioFile(1, "part-01.mp3", 10, 10),
|
||||
testAudioFile(2, "part-02.mp3", 20, 20),
|
||||
}},
|
||||
testUserStoreProvider{store: store},
|
||||
nil,
|
||||
)
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
startRec := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRec, startReq)
|
||||
if startRec.Code != http.StatusOK {
|
||||
t.Fatalf("start status = %d body=%s", startRec.Code, startRec.Body.String())
|
||||
}
|
||||
var start audioPlaybackResponse
|
||||
if err := json.Unmarshal(startRec.Body.Bytes(), &start); err != nil {
|
||||
t.Fatalf("decode start: %v", err)
|
||||
}
|
||||
|
||||
otherCtx := apimw.SetClaims(context.Background(), &auth.Claims{UserID: 2, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
otherCtx = apimw.SetProfileID(otherCtx, "profile-1")
|
||||
denied := httptest.NewRequest(http.MethodPatch, "/api/v1/audio/playback/"+start.SessionID+"/sync", strings.NewReader(`{"position":12,"duration":30}`))
|
||||
denied = withAudioRouteParam(denied.WithContext(otherCtx), "session_id", start.SessionID)
|
||||
deniedRec := httptest.NewRecorder()
|
||||
handler.HandleSyncPlayback(deniedRec, denied)
|
||||
if deniedRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign sync status = %d, want 403", deniedRec.Code)
|
||||
}
|
||||
|
||||
syncReq := httptest.NewRequest(http.MethodPatch, "/api/v1/audio/playback/"+start.SessionID+"/sync", strings.NewReader(`{"position":18,"duration":30}`))
|
||||
syncReq = withAudioRouteParam(syncReq.WithContext(newAuthorizedPlaybackContext()), "session_id", start.SessionID)
|
||||
syncRec := httptest.NewRecorder()
|
||||
handler.HandleSyncPlayback(syncRec, syncReq)
|
||||
if syncRec.Code != http.StatusOK {
|
||||
t.Fatalf("sync status = %d body=%s", syncRec.Code, syncRec.Body.String())
|
||||
}
|
||||
|
||||
progress, err := store.GetProgress(context.Background(), "profile-1", "book-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get progress: %v", err)
|
||||
}
|
||||
if progress == nil || progress.PositionSeconds != 18 || progress.DurationSeconds != 30 {
|
||||
t.Fatalf("progress = %+v, want 18/30", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioSessionExpires(t *testing.T) {
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{testAudioFile(1, "part-01.mp3", 10, 10)}},
|
||||
testUserStoreProvider{store: newPlaybackTestStore(t)},
|
||||
nil,
|
||||
)
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler.now = func() time.Time { return now }
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
startRec := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRec, startReq)
|
||||
var start audioPlaybackResponse
|
||||
if err := json.Unmarshal(startRec.Body.Bytes(), &start); err != nil {
|
||||
t.Fatalf("decode start: %v", err)
|
||||
}
|
||||
|
||||
now = now.Add(audioPlaybackSessionTTL + time.Second)
|
||||
streamReq := httptest.NewRequest(http.MethodHead, "/api/v1/audio/playback/"+start.SessionID+"/tracks/0", nil)
|
||||
streamReq = withAudioRouteParam(streamReq.WithContext(newAuthorizedPlaybackContext()), "session_id", start.SessionID)
|
||||
streamReq = withAudioRouteParam(streamReq, "track_index", "0")
|
||||
streamRec := httptest.NewRecorder()
|
||||
handler.HandleStreamTrack(streamRec, streamReq)
|
||||
if streamRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expired stream status = %d, want 404", streamRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func testAudiobookDetail() *catalog.ItemDetail {
|
||||
return &catalog.ItemDetail{
|
||||
ContentID: "book-1",
|
||||
Type: "audiobook",
|
||||
Title: "Book One",
|
||||
Audiobook: &catalog.AudiobookDetailExtension{
|
||||
Authors: []catalog.AudiobookPerson{{Name: "Author One"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testAudioFile(id int, name string, partIndex int, duration int) *models.MediaFile {
|
||||
return &models.MediaFile{
|
||||
ID: id,
|
||||
ContentID: "book-1",
|
||||
FilePath: "/media/" + name,
|
||||
CodecAudio: "mp3",
|
||||
Container: "mp3",
|
||||
Duration: duration,
|
||||
PresentationPartIndex: partIndex,
|
||||
Chapters: []models.MediaChapter{{
|
||||
Index: 0,
|
||||
Title: name,
|
||||
StartSeconds: 0,
|
||||
EndSeconds: float64(duration),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func withAudioRouteParam(req *http.Request, key, value string) *http.Request {
|
||||
routeCtx := chi.RouteContext(req.Context())
|
||||
if routeCtx == nil {
|
||||
routeCtx = chi.NewRouteContext()
|
||||
}
|
||||
routeCtx.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// HandleGetAudiobook serves GET /api/v1/audiobooks/{id}. Returns the
|
||||
// audiobook media_item, its media_files (with embedded chapters),
|
||||
// author/narrator from item_people (kinds 7/8), and the caller's
|
||||
// per-profile listening progress.
|
||||
func (h *AudiobookHandler) HandleGetAudiobook(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.Items.GetByID(r.Context(), contentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load audiobook failed")
|
||||
return
|
||||
}
|
||||
if item == nil || item.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.Files.GetByContentID(r.Context(), contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
|
||||
return
|
||||
}
|
||||
|
||||
author, narrator, err := h.fetchAuthorNarrator(r, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load people failed")
|
||||
return
|
||||
}
|
||||
|
||||
progress := h.fetchProgress(r, contentID)
|
||||
|
||||
alsoByAuthor := h.fetchAlsoByAuthor(r.Context(), contentID)
|
||||
inSeries := h.fetchInSeries(r.Context(), contentID)
|
||||
similar := h.fetchSimilar(r.Context(), contentID)
|
||||
otherNarrations := h.fetchOtherNarrations(r.Context(), contentID)
|
||||
|
||||
resp := audiobookDetailResponse{
|
||||
Audiobook: audiobookDetailItem{
|
||||
ContentID: item.ContentID,
|
||||
Title: item.Title,
|
||||
Year: item.Year,
|
||||
Overview: item.Overview,
|
||||
PosterURL: h.presignPoster(r.Context(), item.PosterPath),
|
||||
Publisher: pickFirstString(item.Studios),
|
||||
Genres: append([]string(nil), item.Genres...),
|
||||
},
|
||||
Author: author,
|
||||
Narrator: narrator,
|
||||
Files: audiobookDetailFiles(files),
|
||||
Progress: progress,
|
||||
AlsoByAuthor: alsoByAuthor,
|
||||
InSeries: inSeries,
|
||||
SimilarAudiobooks: similar,
|
||||
OtherNarrations: otherNarrations,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// fetchAuthorNarrator retrieves the first author (kind=7) and narrator (kind=8)
|
||||
// name from item_people for the given content ID.
|
||||
func (h *AudiobookHandler) fetchAuthorNarrator(r *http.Request, contentID string) (author, narrator string, err error) {
|
||||
people, err := h.Items.GetPeople(r.Context(), contentID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, p := range people {
|
||||
switch p.Kind {
|
||||
case models.PersonKindAuthor:
|
||||
if author == "" {
|
||||
author = p.Name
|
||||
}
|
||||
case models.PersonKindNarrator:
|
||||
if narrator == "" {
|
||||
narrator = p.Name
|
||||
}
|
||||
}
|
||||
if author != "" && narrator != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return author, narrator, nil
|
||||
}
|
||||
|
||||
// fetchAlsoByAuthor returns other audiobooks sharing any author (item_people
|
||||
// kind=PersonKindAuthor) with the given book. Capped at 12 to keep the rail
|
||||
// snappy on the client. Returns an empty slice when there is no author or
|
||||
// no other matching titles.
|
||||
func (h *AudiobookHandler) fetchAlsoByAuthor(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
const q = `
|
||||
SELECT m2.content_id, m2.title, COALESCE(m2.year, 0), COALESCE(m2.poster_path, '')
|
||||
FROM item_people ip1
|
||||
JOIN item_people ip2
|
||||
ON ip2.person_id = ip1.person_id
|
||||
AND ip2.kind = ip1.kind
|
||||
AND ip2.content_id <> ip1.content_id
|
||||
JOIN media_items m2
|
||||
ON m2.content_id = ip2.content_id
|
||||
AND m2.type = 'audiobook'
|
||||
WHERE ip1.content_id = $1
|
||||
AND ip1.kind = $2
|
||||
ORDER BY m2.year DESC NULLS LAST, LOWER(m2.sort_title)
|
||||
LIMIT 12
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]audiobookRelatedItem, 0, 12)
|
||||
seen := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var it audiobookRelatedItem
|
||||
var poster string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, dup := seen[it.ContentID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[it.ContentID] = struct{}{}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchSimilar returns the "You might also like" rail. Prefers embedding-based
|
||||
// nearest-neighbor search when the source book has an embedding; falls back to
|
||||
// shared-genre matching otherwise (book is brand new, embedding job hasn't run,
|
||||
// or the embedding lookup errored). The genre fallback excludes same-author
|
||||
// books so the rail complements rather than duplicates also_by_author.
|
||||
func (h *AudiobookHandler) fetchSimilar(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Recs != nil {
|
||||
if items := h.fetchSimilarByEmbedding(ctx, contentID); items != nil {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return h.fetchSimilarByGenres(ctx, contentID)
|
||||
}
|
||||
|
||||
// fetchSimilarByEmbedding returns nearest-neighbor audiobooks by cosine
|
||||
// similarity on the gemini embedding vector. Returns nil (not an empty slice)
|
||||
// when the source book has no embedding yet, so the caller can fall back to
|
||||
// the genre-based recommender.
|
||||
func (h *AudiobookHandler) fetchSimilarByEmbedding(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Recs == nil || h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
emb, err := h.Recs.GetEmbedding(ctx, contentID)
|
||||
if err != nil || emb == nil {
|
||||
return nil
|
||||
}
|
||||
// Pull extra candidates so we can drop the source book's own author from
|
||||
// the rail (they belong in also_by_author).
|
||||
scored, err := h.Recs.FindSimilar(ctx, emb, []string{contentID}, "audiobook", 36)
|
||||
if err != nil || len(scored) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(scored))
|
||||
for _, s := range scored {
|
||||
ids = append(ids, s.MediaItemID)
|
||||
}
|
||||
const q = `
|
||||
WITH this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
||||
)
|
||||
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
|
||||
FROM media_items m
|
||||
WHERE m.content_id = ANY($3)
|
||||
AND m.type = 'audiobook'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $2
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor, ids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
byID := make(map[string]audiobookRelatedItem, len(scored))
|
||||
for rows.Next() {
|
||||
var it audiobookRelatedItem
|
||||
var poster string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
|
||||
return nil
|
||||
}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
byID[it.ContentID] = it
|
||||
}
|
||||
out := make([]audiobookRelatedItem, 0, 12)
|
||||
for _, s := range scored {
|
||||
if it, ok := byID[s.MediaItemID]; ok {
|
||||
out = append(out, it)
|
||||
if len(out) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchOtherNarrations returns sibling audiobook items that share the same
|
||||
// "core" title (after stripping the common "read by X" / "(Read by X)" /
|
||||
// "(UK Version: Read by X)" suffixes) and the same author. Powers the
|
||||
// narrator picker dropdown on the detail page when a single book has
|
||||
// multiple narration editions stored as separate items in the catalog.
|
||||
func (h *AudiobookHandler) fetchOtherNarrations(ctx context.Context, contentID string) []audiobookNarration {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
// The regex strips a trailing block of the form "[ separator] [( ]read by ...[) ]".
|
||||
// Tested against patterns in the corpus: "Foo Read By X",
|
||||
// "Foo - read by X", "Foo (Read by X)", "Foo (UK Version: Read by X)".
|
||||
const stripRE = `\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*[Rr]ead [Bb]y [^()]+\)?\s*$`
|
||||
const q = `
|
||||
WITH this_book AS (
|
||||
SELECT trim(regexp_replace(title, $1, '', 'g')) AS norm_title
|
||||
FROM media_items WHERE content_id = $2
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $2 AND kind = $3
|
||||
)
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(MAX(p.name), '') AS narrator,
|
||||
COALESCE(m.year, 0)
|
||||
FROM media_items m
|
||||
LEFT JOIN item_people ipn ON ipn.content_id = m.content_id AND ipn.kind = $4
|
||||
LEFT JOIN people p ON p.id = ipn.person_id
|
||||
WHERE m.type = 'audiobook'
|
||||
AND m.content_id <> $2
|
||||
AND trim(regexp_replace(m.title, $1, '', 'g')) = (SELECT norm_title FROM this_book)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $3
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
GROUP BY m.content_id, m.title, m.year
|
||||
ORDER BY m.year DESC NULLS LAST, m.title
|
||||
LIMIT 8
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q,
|
||||
stripRE, contentID, models.PersonKindAuthor, models.PersonKindNarrator)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]audiobookNarration, 0, 4)
|
||||
for rows.Next() {
|
||||
var n audiobookNarration
|
||||
if err := rows.Scan(&n.ContentID, &n.Title, &n.Narrator, &n.Year); err != nil {
|
||||
return nil
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchSimilarByGenres returns up to 12 audiobooks sharing genres with the
|
||||
// given book, excluding books by the same author so the rail complements
|
||||
// rather than duplicates also_by_author. Used as a fallback when no
|
||||
// embedding exists for the source yet.
|
||||
func (h *AudiobookHandler) fetchSimilarByGenres(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
const q = `
|
||||
WITH this_genres AS (
|
||||
SELECT unnest(genres) AS g FROM media_items WHERE content_id = $1
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
||||
)
|
||||
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
|
||||
FROM media_items m
|
||||
WHERE m.type = 'audiobook'
|
||||
AND m.content_id <> $1
|
||||
AND m.genres && (SELECT array_agg(g) FROM this_genres)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $2
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
ORDER BY
|
||||
cardinality(ARRAY(SELECT unnest(m.genres) INTERSECT SELECT g FROM this_genres)) DESC,
|
||||
COALESCE(m.year, 0) DESC,
|
||||
LOWER(m.sort_title)
|
||||
LIMIT 12
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]audiobookRelatedItem, 0, 12)
|
||||
for rows.Next() {
|
||||
var it audiobookRelatedItem
|
||||
var poster string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
|
||||
return nil
|
||||
}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchInSeries returns the series the audiobook belongs to (if any) along
|
||||
// with the ordered list of sibling entries. Driven by the audiobook_series
|
||||
// table populated by either the scanner (tag-derived) or migration 145's
|
||||
// best-effort title-pattern backfill.
|
||||
func (h *AudiobookHandler) fetchInSeries(ctx context.Context, contentID string) *audiobookSeriesGroup {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
const q = `
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(m.year, 0),
|
||||
COALESCE(m.poster_path, ''),
|
||||
s.series_name,
|
||||
s.series_index
|
||||
FROM audiobook_series root
|
||||
JOIN audiobook_series s ON LOWER(s.series_name) = LOWER(root.series_name)
|
||||
JOIN media_items m ON m.content_id = s.content_id AND m.type = 'audiobook'
|
||||
WHERE root.content_id = $1
|
||||
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
|
||||
LIMIT 30
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var seriesName string
|
||||
entries := make([]audiobookRelatedItem, 0, 16)
|
||||
for rows.Next() {
|
||||
var (
|
||||
it audiobookRelatedItem
|
||||
poster string
|
||||
thisName string
|
||||
idxNum *float64
|
||||
)
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster, &thisName, &idxNum); err != nil {
|
||||
return nil
|
||||
}
|
||||
if seriesName == "" {
|
||||
seriesName = thisName
|
||||
}
|
||||
if idxNum != nil {
|
||||
n := int(*idxNum)
|
||||
if float64(n) == *idxNum {
|
||||
it.SeriesIndex = &n
|
||||
}
|
||||
}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
entries = append(entries, it)
|
||||
}
|
||||
// A series with only this book is not useful as a rail.
|
||||
if len(entries) < 2 {
|
||||
return nil
|
||||
}
|
||||
return &audiobookSeriesGroup{Name: seriesName, Entries: entries}
|
||||
}
|
||||
|
||||
// fetchProgress returns the caller's listening progress for this audiobook, or
|
||||
// nil when no progress exists or the caller is not authenticated with a profile.
|
||||
func (h *AudiobookHandler) fetchProgress(r *http.Request, contentID string) *audiobookDetailProgress {
|
||||
if h.StoreProvider == nil {
|
||||
return nil
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if userID == 0 {
|
||||
return nil
|
||||
}
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if profileID == "" {
|
||||
return nil
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
||||
if err != nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
wp, err := store.GetProgress(r.Context(), profileID, contentID)
|
||||
if err != nil || wp == nil {
|
||||
return nil
|
||||
}
|
||||
return &audiobookDetailProgress{
|
||||
PositionSeconds: wp.PositionSeconds,
|
||||
Completed: wp.Completed,
|
||||
UpdatedAt: wp.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type audiobookDetailResponse struct {
|
||||
Audiobook audiobookDetailItem `json:"audiobook"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
Files []audiobookDetailFile `json:"files"`
|
||||
Progress *audiobookDetailProgress `json:"progress,omitempty"`
|
||||
AlsoByAuthor []audiobookRelatedItem `json:"also_by_author,omitempty"`
|
||||
InSeries *audiobookSeriesGroup `json:"in_series,omitempty"`
|
||||
SimilarAudiobooks []audiobookRelatedItem `json:"similar_audiobooks,omitempty"`
|
||||
OtherNarrations []audiobookNarration `json:"other_narrations,omitempty"`
|
||||
}
|
||||
|
||||
// audiobookNarration is a sibling narration (same book, different
|
||||
// narrator/edition). Returned alongside the primary item so the UI can
|
||||
// surface a narrator picker.
|
||||
type audiobookNarration struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookRelatedItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
SeriesIndex *int `json:"series_index,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookSeriesGroup struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Entries []audiobookRelatedItem `json:"entries"`
|
||||
}
|
||||
|
||||
type audiobookDetailItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) presignPoster(ctx context.Context, path string) string {
|
||||
if path == "" || h.Detail == nil {
|
||||
return path
|
||||
}
|
||||
return h.Detail.PresignURL(ctx, path, "featured")
|
||||
}
|
||||
|
||||
func pickFirstString(values []string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type audiobookDetailFile struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Container string `json:"container,omitempty"`
|
||||
CodecAudio string `json:"codec_audio,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
AudioChannels int `json:"audio_channels,omitempty"`
|
||||
Chapters []models.MediaChapter `json:"chapters,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookDetailProgress struct {
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
Completed bool `json:"completed"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func audiobookDetailFiles(files []*models.MediaFile) []audiobookDetailFile {
|
||||
out := make([]audiobookDetailFile, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, audiobookDetailFile{
|
||||
ID: f.ID,
|
||||
Path: f.FilePath,
|
||||
DurationSeconds: f.Duration,
|
||||
Container: f.Container,
|
||||
CodecAudio: f.CodecAudio,
|
||||
Bitrate: f.Bitrate,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Chapters: f.Chapters,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// AudiobookHandler serves silo-native audiobook endpoints under
|
||||
// /api/v1/audiobooks/*. Sub-plan 3 implements list/detail/progress;
|
||||
// sub-plan 4's ABS-compat layer is separate.
|
||||
type AudiobookHandler struct {
|
||||
Items *catalog.ItemRepository
|
||||
Files *scanner.FileRepository
|
||||
StoreProvider userstore.UserStoreProvider
|
||||
Detail *catalog.DetailService
|
||||
// Recs is optional. When set, the detail handler uses embedding-based
|
||||
// nearest-neighbor search for the "You might also like" rail and falls
|
||||
// back to shared-genre matching only when no embedding exists for the
|
||||
// source book yet.
|
||||
Recs *recommendations.Repo
|
||||
}
|
||||
|
||||
// HandleListAudiobooks serves GET /api/v1/audiobooks?limit=&offset=.
|
||||
// Paginated catalog of media_items with type='audiobook'.
|
||||
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
genre := r.URL.Query().Get("genre")
|
||||
|
||||
pool := h.Files.Pool()
|
||||
ctx := r.Context()
|
||||
var total int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM media_items
|
||||
WHERE type='audiobook' AND ($1 = '' OR $1 = ANY(genres))
|
||||
`, genre).Scan(&total); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks count failed")
|
||||
return
|
||||
}
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT
|
||||
mi.content_id,
|
||||
mi.title,
|
||||
COALESCE(mi.year, 0),
|
||||
COALESCE(mi.poster_path, ''),
|
||||
COALESCE(SUM(mf.duration), 0)::int AS duration_seconds,
|
||||
COALESCE(MIN(mf.container), '') AS container,
|
||||
COALESCE(MIN(mf.codec_audio), '') AS codec_audio,
|
||||
COUNT(mf.id) AS file_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN media_files mf ON mf.content_id = mi.content_id
|
||||
WHERE mi.type='audiobook'
|
||||
AND ($3 = '' OR $3 = ANY(mi.genres))
|
||||
GROUP BY mi.content_id, mi.title, mi.year, mi.poster_path, mi.sort_title
|
||||
ORDER BY LOWER(mi.sort_title), LOWER(mi.title)
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset, genre)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]audiobookListItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var it audiobookListItem
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterURL, &it.DurationSeconds, &it.Container, &it.CodecAudio, &it.FileCount); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "scan audiobook row failed")
|
||||
return
|
||||
}
|
||||
if h.Detail != nil && it.PosterURL != "" {
|
||||
it.PosterURL = h.Detail.PresignURL(ctx, it.PosterURL, "card")
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "iterate audiobook rows failed")
|
||||
return
|
||||
}
|
||||
resp := struct {
|
||||
Items []audiobookListItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}{Items: items, Total: total, Limit: limit, Offset: offset}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
type audiobookListItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Container string `json:"container,omitempty"`
|
||||
CodecAudio string `json:"codec_audio,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
type audiobookProgressRequest struct {
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
MediaFileID int `json:"media_file_id,omitempty"`
|
||||
}
|
||||
|
||||
// HandleReportAudiobookProgress serves POST /api/v1/audiobooks/{id}/progress.
|
||||
// UPSERTs into user_watch_progress for the caller's (user_id, profile_id, content_id).
|
||||
// Duration is unknown at the audiobook level (it lives on the media_file), so we
|
||||
// pass 0 and let the store compute completion via thresholds against position only.
|
||||
func (h *AudiobookHandler) HandleReportAudiobookProgress(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req audiobookProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.PositionSeconds < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "position_seconds must be >= 0")
|
||||
return
|
||||
}
|
||||
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if userID == 0 || profileID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if h.StoreProvider == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
|
||||
return
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
||||
if err != nil || store == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
// Duration is not carried in the progress body; pass 0 so the store
|
||||
// uses position-only heuristics. ProgressThresholds zero value means
|
||||
// "use defaults" (90% watched, 5% min-resume).
|
||||
if err := store.SetProgress(r.Context(), profileID, contentID, req.PositionSeconds, 0, userstore.ProgressThresholds{}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "save progress failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -17,10 +17,9 @@ import (
|
||||
|
||||
// AuthHandler handles authentication-related HTTP endpoints.
|
||||
type AuthHandler struct {
|
||||
service *auth.Service
|
||||
jwt *auth.JWTService
|
||||
device *auth.DeviceLoginService
|
||||
oauthRoutesAvailable bool
|
||||
service *auth.Service
|
||||
jwt *auth.JWTService
|
||||
device *auth.DeviceLoginService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new AuthHandler backed by the given auth, JWT,
|
||||
@@ -33,13 +32,6 @@ func NewAuthHandler(service *auth.Service, jwt *auth.JWTService, device *auth.De
|
||||
}
|
||||
}
|
||||
|
||||
// SetOAuthRoutesAvailable controls whether OAuth login providers are
|
||||
// advertised by /auth/providers. The router only mounts OAuth routes when the
|
||||
// server has enough configuration to complete the flow.
|
||||
func (h *AuthHandler) SetOAuthRoutesAvailable(available bool) {
|
||||
h.oauthRoutesAvailable = available
|
||||
}
|
||||
|
||||
// --- Request/Response types ---
|
||||
|
||||
// loginRequest represents the JSON body of a POST /auth/login request.
|
||||
@@ -189,9 +181,6 @@ func (h *AuthHandler) HandleProviders(w http.ResponseWriter, r *http.Request) {
|
||||
providers := h.service.ListProviders()
|
||||
response := make([]authProviderResponse, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider.Mode == "oauth" && !h.oauthRoutesAvailable {
|
||||
continue
|
||||
}
|
||||
response = append(response, authProviderResponse{
|
||||
ID: provider.ID,
|
||||
DisplayName: provider.DisplayName,
|
||||
@@ -225,9 +214,6 @@ func (h *AuthHandler) HandleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
req.Username = auth.NormalizeUsername(req.Username)
|
||||
req.Email = auth.NormalizeEmail(req.Email)
|
||||
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required")
|
||||
return
|
||||
@@ -461,9 +447,6 @@ func (h *AuthHandler) HandleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
req.Username = auth.NormalizeUsername(req.Username)
|
||||
req.Email = auth.NormalizeEmail(req.Email)
|
||||
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" || req.InviteCode == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and invite code are required")
|
||||
return
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type stubLoginProvider struct{}
|
||||
|
||||
func (stubLoginProvider) Authenticate(context.Context, auth.Credentials) (*models.User, error) {
|
||||
return nil, auth.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
func (stubLoginProvider) ValidateSession(context.Context, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func newAuthProviderHandlerForTest(oauthRoutesAvailable bool) *AuthHandler {
|
||||
service := auth.NewService(nil, nil, nil, nil, nil, nil, nil)
|
||||
provider := stubLoginProvider{}
|
||||
|
||||
service.RegisterProvider(auth.LoginProviderInfo{
|
||||
ID: "local",
|
||||
DisplayName: "Local",
|
||||
Mode: "credentials",
|
||||
Default: true,
|
||||
}, provider)
|
||||
service.RegisterProvider(auth.LoginProviderInfo{
|
||||
ID: "plugin:41:oidc",
|
||||
DisplayName: "OIDC",
|
||||
Mode: "oauth",
|
||||
InstallationID: 41,
|
||||
}, provider)
|
||||
|
||||
handler := NewAuthHandler(service, nil, nil)
|
||||
handler.SetOAuthRoutesAvailable(oauthRoutesAvailable)
|
||||
return handler
|
||||
}
|
||||
|
||||
func readProviderResponse(t *testing.T, handler *AuthHandler) []authProviderResponse {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/providers", nil)
|
||||
handler.HandleProviders(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var providers []authProviderResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&providers); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func TestAuthProvidersHideOAuthWhenOAuthRoutesUnavailable(t *testing.T) {
|
||||
providers := readProviderResponse(t, newAuthProviderHandlerForTest(false))
|
||||
|
||||
if len(providers) != 1 {
|
||||
t.Fatalf("provider count = %d, want 1: %#v", len(providers), providers)
|
||||
}
|
||||
if providers[0].ID != "local" {
|
||||
t.Fatalf("provider ID = %q, want local", providers[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthProvidersIncludeOAuthWhenOAuthRoutesAvailable(t *testing.T) {
|
||||
providers := readProviderResponse(t, newAuthProviderHandlerForTest(true))
|
||||
|
||||
if len(providers) != 2 {
|
||||
t.Fatalf("provider count = %d, want 2: %#v", len(providers), providers)
|
||||
}
|
||||
|
||||
var foundOAuth bool
|
||||
for _, provider := range providers {
|
||||
if provider.ID == "plugin:41:oidc" {
|
||||
foundOAuth = true
|
||||
if provider.Mode != "oauth" {
|
||||
t.Fatalf("OAuth provider mode = %q, want oauth", provider.Mode)
|
||||
}
|
||||
if provider.InstallationID != 41 {
|
||||
t.Fatalf("OAuth provider installation ID = %d, want 41", provider.InstallationID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundOAuth {
|
||||
t.Fatalf("OAuth provider missing from response: %#v", providers)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,69 +2,26 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
)
|
||||
|
||||
type calendarRepository interface {
|
||||
ListEvents(ctx context.Context, f catalog.CalendarFilter) ([]catalog.CalendarEvent, error)
|
||||
}
|
||||
|
||||
// calendarPersonalRepo resolves per-profile id-sets and watched status.
|
||||
type calendarPersonalRepo interface {
|
||||
ListFollowedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error)
|
||||
ListFavoriteItemIDs(ctx context.Context, userID int, profileID string) ([]string, error)
|
||||
ListWatchlistItemIDs(ctx context.Context, userID int, profileID string) ([]string, error)
|
||||
ListWatchedItemIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]bool, error)
|
||||
}
|
||||
|
||||
// calendarPopularSource reads the cached server-wide popular id-set.
|
||||
type calendarPopularSource interface {
|
||||
GetRecommendationCache(ctx context.Context, userID int, profileID, recType, sourceItemID string) ([]recommendations.ScoredItem, error)
|
||||
}
|
||||
|
||||
// calendarTrendingSource reads the external-trending snapshot.
|
||||
type calendarTrendingSource interface {
|
||||
Get(ctx context.Context, source, window string) (sections.TrendingSnapshot, bool, error)
|
||||
}
|
||||
|
||||
const (
|
||||
calendarFilterAll = "all"
|
||||
calendarFilterEverything = "everything"
|
||||
calendarFilterFollowing = "following"
|
||||
calendarFilterFavorites = "favorites"
|
||||
calendarFilterWatchlist = "watchlist"
|
||||
calendarFilterPopular = "popular"
|
||||
calendarFilterTrending = "trending"
|
||||
)
|
||||
|
||||
// The Trending preset reads this canonical external-trending snapshot
|
||||
// (see internal/sections trending snapshots).
|
||||
const (
|
||||
calendarTrendingSnapshotSource = "tmdb"
|
||||
calendarTrendingSnapshotWindow = "week"
|
||||
)
|
||||
|
||||
// CalendarHandler handles the calendar endpoint.
|
||||
type CalendarHandler struct {
|
||||
repo calendarRepository
|
||||
detailSvc *catalog.DetailService
|
||||
personal calendarPersonalRepo // nil-tolerant (per-profile presets degrade to empty)
|
||||
popular calendarPopularSource // nil when recommendations disabled
|
||||
trending calendarTrendingSource // nil when trending disabled
|
||||
}
|
||||
|
||||
// NewCalendarHandler creates a new CalendarHandler. The repo doubles as the
|
||||
// per-profile resolver since *catalog.CalendarRepository implements both.
|
||||
func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService, popular calendarPopularSource, trending calendarTrendingSource) *CalendarHandler {
|
||||
return &CalendarHandler{repo: repo, detailSvc: detailSvc, personal: repo, popular: popular, trending: trending}
|
||||
// NewCalendarHandler creates a new CalendarHandler.
|
||||
func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService) *CalendarHandler {
|
||||
return &CalendarHandler{repo: repo, detailSvc: detailSvc}
|
||||
}
|
||||
|
||||
// --- Response types ---
|
||||
@@ -79,12 +36,8 @@ type calendarEventResponse struct {
|
||||
EpisodeNumber *int `json:"episode_number,omitempty"`
|
||||
AirDate string `json:"air_date"`
|
||||
AirTime *string `json:"air_time,omitempty"`
|
||||
AirAt *string `json:"air_at,omitempty"`
|
||||
AirTimezone *string `json:"air_timezone,omitempty"`
|
||||
LocalAirDate string `json:"local_air_date"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
|
||||
Watched bool `json:"watched"`
|
||||
Badges []string `json:"badges"`
|
||||
}
|
||||
|
||||
@@ -122,39 +75,31 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
filter := q.Get("filter")
|
||||
if filter == "" {
|
||||
filter = calendarFilterAll
|
||||
filter = "all"
|
||||
}
|
||||
switch filter {
|
||||
case calendarFilterAll, calendarFilterEverything, calendarFilterFollowing,
|
||||
calendarFilterFavorites, calendarFilterWatchlist, calendarFilterPopular, calendarFilterTrending:
|
||||
case "all", "favorites", "watchlist":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid filter")
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "filter must be all, favorites, or watchlist")
|
||||
return
|
||||
}
|
||||
|
||||
af := requestAccessFilter(r)
|
||||
|
||||
viewerLocation := catalog.CalendarLocation(q.Get("timezone"))
|
||||
|
||||
restrict, ids, err := h.resolveCalendarRestriction(r.Context(), filter, af)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to resolve calendar filter")
|
||||
return
|
||||
}
|
||||
// A restricting preset with no ids can never match — skip the windowed query.
|
||||
if restrict && len(ids) == 0 {
|
||||
writeJSON(w, http.StatusOK, calendarResponse{Events: []calendarDayResponse{}})
|
||||
if (filter == "favorites" || filter == "watchlist") && af.ProfileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "profile required for favorites/watchlist filter")
|
||||
return
|
||||
}
|
||||
|
||||
cf := catalog.CalendarFilter{
|
||||
Start: start.AddDate(0, 0, -2),
|
||||
End: end.AddDate(0, 0, 2),
|
||||
Start: start,
|
||||
End: end,
|
||||
Filter: filter,
|
||||
AllowedLibraryIDs: af.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: af.DisabledLibraryIDs,
|
||||
MaxContentRating: af.MaxContentRating,
|
||||
RestrictByIDs: restrict,
|
||||
RestrictToIDs: ids,
|
||||
UserID: af.UserID,
|
||||
ProfileID: af.ProfileID,
|
||||
}
|
||||
|
||||
if v := q.Get("library_id"); v != "" {
|
||||
@@ -172,82 +117,12 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
watched := h.resolveWatched(r.Context(), af, events)
|
||||
|
||||
// Group events by date and build response.
|
||||
days := groupEventsByDate(events, r, h.detailSvc, start, end, viewerLocation, watched)
|
||||
days := groupEventsByDate(events, r, h.detailSvc)
|
||||
writeJSON(w, http.StatusOK, calendarResponse{Events: days})
|
||||
}
|
||||
|
||||
// resolveCalendarRestriction maps a preset to an id-set restriction. restrict=false
|
||||
// means no restriction (everything/all). A nil/missing source degrades that preset
|
||||
// to an empty id-set (which the caller renders as an empty calendar).
|
||||
func (h *CalendarHandler) resolveCalendarRestriction(ctx context.Context, filter string, af catalog.AccessFilter) (restrict bool, ids []string, err error) {
|
||||
switch filter {
|
||||
case calendarFilterAll, calendarFilterEverything:
|
||||
return false, nil, nil
|
||||
case calendarFilterFollowing, calendarFilterFavorites, calendarFilterWatchlist:
|
||||
if h.personal == nil {
|
||||
return true, nil, nil
|
||||
}
|
||||
switch filter {
|
||||
case calendarFilterFavorites:
|
||||
ids, err = h.personal.ListFavoriteItemIDs(ctx, af.UserID, af.ProfileID)
|
||||
case calendarFilterWatchlist:
|
||||
ids, err = h.personal.ListWatchlistItemIDs(ctx, af.UserID, af.ProfileID)
|
||||
default: // following
|
||||
ids, err = h.personal.ListFollowedItemIDs(ctx, af.UserID, af.ProfileID)
|
||||
}
|
||||
return true, ids, err
|
||||
case calendarFilterPopular:
|
||||
if h.popular == nil {
|
||||
return true, nil, nil
|
||||
}
|
||||
items, err := h.popular.GetRecommendationCache(ctx, recommendations.GlobalCacheUserID, recommendations.GlobalCacheProfileID, recommendations.RecTypePopular, "")
|
||||
if err != nil {
|
||||
return true, nil, err
|
||||
}
|
||||
ids = make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
ids = append(ids, it.MediaItemID)
|
||||
}
|
||||
return true, ids, nil
|
||||
case calendarFilterTrending:
|
||||
if h.trending == nil {
|
||||
return true, nil, nil
|
||||
}
|
||||
snap, ok, err := h.trending.Get(ctx, calendarTrendingSnapshotSource, calendarTrendingSnapshotWindow)
|
||||
if err != nil {
|
||||
return true, nil, err
|
||||
}
|
||||
if !ok {
|
||||
return true, nil, nil
|
||||
}
|
||||
return true, snap.ContentIDs, nil
|
||||
default:
|
||||
return false, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// resolveWatched decorates events with the profile's completed status. Best-effort:
|
||||
// a lookup failure logs and returns no watched marks rather than failing the page.
|
||||
func (h *CalendarHandler) resolveWatched(ctx context.Context, af catalog.AccessFilter, events []catalog.CalendarEvent) map[string]bool {
|
||||
if h.personal == nil || len(events) == 0 {
|
||||
return map[string]bool{}
|
||||
}
|
||||
ids := make([]string, 0, len(events))
|
||||
for _, ev := range events {
|
||||
ids = append(ids, ev.ContentID)
|
||||
}
|
||||
watched, err := h.personal.ListWatchedItemIDs(ctx, af.UserID, af.ProfileID, ids)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "calendar watched overlay failed", "error", err)
|
||||
return map[string]bool{}
|
||||
}
|
||||
return watched
|
||||
}
|
||||
|
||||
func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService, start, end time.Time, viewerLocation *time.Location, watched map[string]bool) []calendarDayResponse {
|
||||
func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService) []calendarDayResponse {
|
||||
if len(events) == 0 {
|
||||
return []calendarDayResponse{}
|
||||
}
|
||||
@@ -269,78 +144,17 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv
|
||||
posterURLs = detailSvc.PresignImageURLs(r.Context(), posterPaths, "poster", "small")
|
||||
}
|
||||
|
||||
type preparedCalendarEvent struct {
|
||||
event catalog.CalendarEvent
|
||||
localDate string
|
||||
sourceDate string
|
||||
localTime time.Time
|
||||
hasTime bool
|
||||
airAtString *string
|
||||
}
|
||||
|
||||
startDate := start.Format("2006-01-02")
|
||||
endDate := end.Format("2006-01-02")
|
||||
|
||||
prepared := make([]preparedCalendarEvent, 0, len(events))
|
||||
for _, ev := range events {
|
||||
localTime, hasTime := catalog.CalendarEventLocalTime(ev.AirDate, ev.AirTime, ev.AirTimezone, viewerLocation)
|
||||
localDate := localTime.Format("2006-01-02")
|
||||
if localDate < startDate || localDate > endDate {
|
||||
continue
|
||||
}
|
||||
var airAtString *string
|
||||
if airAt := catalog.CalendarEventAirAt(ev.AirDate, ev.AirTime, ev.AirTimezone); airAt != nil {
|
||||
formatted := airAt.Format(time.RFC3339)
|
||||
airAtString = &formatted
|
||||
}
|
||||
prepared = append(prepared, preparedCalendarEvent{
|
||||
event: ev,
|
||||
localDate: localDate,
|
||||
sourceDate: ev.AirDate.Format("2006-01-02"),
|
||||
localTime: localTime,
|
||||
hasTime: hasTime,
|
||||
airAtString: airAtString,
|
||||
})
|
||||
}
|
||||
if len(prepared) == 0 {
|
||||
return []calendarDayResponse{}
|
||||
}
|
||||
|
||||
// Order each local day by the wall-clock time the viewer actually sees,
|
||||
// then place date-only entries (no air_time) after timed entries.
|
||||
sort.SliceStable(prepared, func(i, j int) bool {
|
||||
left, right := prepared[i], prepared[j]
|
||||
if left.localDate != right.localDate {
|
||||
return left.localDate < right.localDate
|
||||
}
|
||||
if left.hasTime != right.hasTime {
|
||||
return left.hasTime
|
||||
}
|
||||
if left.hasTime && !left.localTime.Equal(right.localTime) {
|
||||
return left.localTime.Before(right.localTime)
|
||||
}
|
||||
if left.event.Title != right.event.Title {
|
||||
return left.event.Title < right.event.Title
|
||||
}
|
||||
if left.event.SeasonNumber != nil && right.event.SeasonNumber != nil && *left.event.SeasonNumber != *right.event.SeasonNumber {
|
||||
return *left.event.SeasonNumber < *right.event.SeasonNumber
|
||||
}
|
||||
if left.event.EpisodeNumber != nil && right.event.EpisodeNumber != nil && *left.event.EpisodeNumber != *right.event.EpisodeNumber {
|
||||
return *left.event.EpisodeNumber < *right.event.EpisodeNumber
|
||||
}
|
||||
return left.event.ContentID < right.event.ContentID
|
||||
})
|
||||
|
||||
var days []calendarDayResponse
|
||||
var currentDay *calendarDayResponse
|
||||
|
||||
for _, item := range prepared {
|
||||
ev := item.event
|
||||
if currentDay == nil || currentDay.Date != item.localDate {
|
||||
for _, ev := range events {
|
||||
dateStr := ev.AirDate.Format("2006-01-02")
|
||||
|
||||
if currentDay == nil || currentDay.Date != dateStr {
|
||||
if currentDay != nil {
|
||||
days = append(days, *currentDay)
|
||||
}
|
||||
currentDay = &calendarDayResponse{Date: item.localDate}
|
||||
currentDay = &calendarDayResponse{Date: dateStr}
|
||||
}
|
||||
|
||||
badges := buildBadges(ev)
|
||||
@@ -353,14 +167,10 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv
|
||||
SeriesID: ev.SeriesID,
|
||||
SeasonNumber: ev.SeasonNumber,
|
||||
EpisodeNumber: ev.EpisodeNumber,
|
||||
AirDate: item.sourceDate,
|
||||
AirDate: dateStr,
|
||||
AirTime: ev.AirTime,
|
||||
AirAt: item.airAtString,
|
||||
AirTimezone: ev.AirTimezone,
|
||||
LocalAirDate: item.localDate,
|
||||
PosterURL: posterURLs[ev.PosterPath],
|
||||
PosterThumbhash: ev.PosterThumbhash,
|
||||
Watched: watched[ev.ContentID],
|
||||
Badges: badges,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
)
|
||||
|
||||
type stubCalendarRepo struct {
|
||||
@@ -112,62 +110,6 @@ func TestHandleGetCalendar_ReturnsEmptyEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_ExpandsRepoRangeAndGroupsByViewerLocalDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
airTime := "00:30"
|
||||
airTimezone := "Asia/Tokyo"
|
||||
repo := &stubCalendarRepo{
|
||||
events: []catalog.CalendarEvent{
|
||||
{
|
||||
ContentID: "episode-1",
|
||||
Type: "episode",
|
||||
Title: "Series",
|
||||
SeriesID: ptrString("series-1"),
|
||||
AirDate: time.Date(2026, time.January, 2, 0, 0, 0, 0, time.UTC),
|
||||
AirTime: &airTime,
|
||||
AirTimezone: &airTimezone,
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := &CalendarHandler{repo: repo}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-01-01&end=2026-01-01&timezone=America/New_York", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := repo.last.Start.Format("2006-01-02"); got != "2025-12-30" {
|
||||
t.Fatalf("repo start = %s, want expanded 2025-12-30", got)
|
||||
}
|
||||
if got := repo.last.End.Format("2006-01-02"); got != "2026-01-03" {
|
||||
t.Fatalf("repo end = %s, want expanded 2026-01-03", got)
|
||||
}
|
||||
|
||||
var resp calendarResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Events) != 1 {
|
||||
t.Fatalf("days len = %d, want 1", len(resp.Events))
|
||||
}
|
||||
if resp.Events[0].Date != "2026-01-01" {
|
||||
t.Fatalf("day = %q, want viewer-local 2026-01-01", resp.Events[0].Date)
|
||||
}
|
||||
item := resp.Events[0].Items[0]
|
||||
if item.AirDate != "2026-01-02" {
|
||||
t.Fatalf("air_date = %q, want source date 2026-01-02", item.AirDate)
|
||||
}
|
||||
if item.LocalAirDate != "2026-01-01" {
|
||||
t.Fatalf("local_air_date = %q, want 2026-01-01", item.LocalAirDate)
|
||||
}
|
||||
if item.AirAt == nil || *item.AirAt != "2026-01-01T15:30:00Z" {
|
||||
t.Fatalf("air_at = %v, want 2026-01-01T15:30:00Z", item.AirAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_GroupsEventsAndBatchResolvesCardPosters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -247,176 +189,6 @@ func TestHandleGetCalendar_GroupsEventsAndBatchResolvesCardPosters(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_OrdersEventsWithoutTimezoneByWallClock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
late := "22:00"
|
||||
early := "10:00"
|
||||
repo := &stubCalendarRepo{
|
||||
events: []catalog.CalendarEvent{
|
||||
{
|
||||
ContentID: "ep-late",
|
||||
Type: "episode",
|
||||
Title: "Aaa Show", // alphabetically first, but airs late
|
||||
SeriesID: ptrString("series-late"),
|
||||
AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
AirTime: &late,
|
||||
// no AirTimezone -> air_at is nil
|
||||
},
|
||||
{
|
||||
ContentID: "ep-early",
|
||||
Type: "episode",
|
||||
Title: "Zzz Show", // alphabetically last, but airs early
|
||||
SeriesID: ptrString("series-early"),
|
||||
AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
AirTime: &early,
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := &CalendarHandler{repo: repo}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-01-01&end=2026-01-01&timezone=America/New_York", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp calendarResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Events) != 1 {
|
||||
t.Fatalf("days len = %d, want 1", len(resp.Events))
|
||||
}
|
||||
items := resp.Events[0].Items
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items len = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].ContentID != "ep-early" || items[1].ContentID != "ep-late" {
|
||||
t.Fatalf("order = [%s, %s], want [ep-early, ep-late] (ascending by air_time)",
|
||||
items[0].ContentID, items[1].ContentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_InterleavesZonedAndUnzonedByViewerLocalTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
eightPM := "20:00"
|
||||
sixPM := "18:00"
|
||||
londonNine := "21:00"
|
||||
london := "Europe/London"
|
||||
repo := &stubCalendarRepo{
|
||||
events: []catalog.CalendarEvent{
|
||||
{
|
||||
ContentID: "alpha-8pm",
|
||||
Type: "episode",
|
||||
Title: "Alpha", // would sort first alphabetically
|
||||
SeriesID: ptrString("series-alpha"),
|
||||
AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
AirTime: &eightPM, // displays 8:00 PM ET
|
||||
},
|
||||
{
|
||||
ContentID: "bravo-4pm",
|
||||
Type: "episode",
|
||||
Title: "Bravo",
|
||||
SeriesID: ptrString("series-bravo"),
|
||||
AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
AirTime: &londonNine, // 21:00 GMT -> 16:00 ET (4:00 PM)
|
||||
AirTimezone: &london,
|
||||
},
|
||||
{
|
||||
ContentID: "charlie-6pm",
|
||||
Type: "episode",
|
||||
Title: "Charlie",
|
||||
SeriesID: ptrString("series-charlie"),
|
||||
AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
AirTime: &sixPM, // displays 6:00 PM ET
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := &CalendarHandler{repo: repo}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-01-01&end=2026-01-01&timezone=America/New_York", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp calendarResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Events) != 1 {
|
||||
t.Fatalf("days len = %d, want 1", len(resp.Events))
|
||||
}
|
||||
got := make([]string, 0, len(resp.Events[0].Items))
|
||||
for _, item := range resp.Events[0].Items {
|
||||
got = append(got, item.ContentID)
|
||||
}
|
||||
want := []string{"bravo-4pm", "charlie-6pm", "alpha-8pm"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("items = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v (ascending by viewer-local time)", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_DateOnlyEntriesSortAfterTimedEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
noon := "12:00"
|
||||
repo := &stubCalendarRepo{
|
||||
events: []catalog.CalendarEvent{
|
||||
{
|
||||
ContentID: "movie-1",
|
||||
Type: "movie",
|
||||
Title: "Aaa Movie", // alphabetically first, but has no air_time
|
||||
AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
ContentID: "ep-noon",
|
||||
Type: "episode",
|
||||
Title: "Zzz Show",
|
||||
SeriesID: ptrString("series-z"),
|
||||
AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
AirTime: &noon,
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := &CalendarHandler{repo: repo}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-01-01&end=2026-01-01&timezone=America/New_York", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp calendarResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Events) != 1 {
|
||||
t.Fatalf("days len = %d, want 1", len(resp.Events))
|
||||
}
|
||||
items := resp.Events[0].Items
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items len = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].ContentID != "ep-noon" || items[1].ContentID != "movie-1" {
|
||||
t.Fatalf("order = [%s, %s], want [ep-noon, movie-1] (timed before date-only)",
|
||||
items[0].ContentID, items[1].ContentID)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
@@ -424,162 +196,3 @@ func ptrString(value string) *string {
|
||||
func ptrInt(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
type stubCalendarPersonal struct {
|
||||
followed []string
|
||||
favorites []string
|
||||
watchlist []string
|
||||
watched map[string]bool
|
||||
lastWatchedIDs []string
|
||||
}
|
||||
|
||||
func (s *stubCalendarPersonal) ListFollowedItemIDs(_ context.Context, _ int, _ string) ([]string, error) {
|
||||
return s.followed, nil
|
||||
}
|
||||
func (s *stubCalendarPersonal) ListFavoriteItemIDs(_ context.Context, _ int, _ string) ([]string, error) {
|
||||
return s.favorites, nil
|
||||
}
|
||||
func (s *stubCalendarPersonal) ListWatchlistItemIDs(_ context.Context, _ int, _ string) ([]string, error) {
|
||||
return s.watchlist, nil
|
||||
}
|
||||
func (s *stubCalendarPersonal) ListWatchedItemIDs(_ context.Context, _ int, _ string, ids []string) (map[string]bool, error) {
|
||||
s.lastWatchedIDs = ids
|
||||
return s.watched, nil
|
||||
}
|
||||
|
||||
type stubPopularSource struct{ items []recommendations.ScoredItem }
|
||||
|
||||
func (s *stubPopularSource) GetRecommendationCache(_ context.Context, _ int, _, _, _ string) ([]recommendations.ScoredItem, error) {
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
type stubTrendingSource struct {
|
||||
snap sections.TrendingSnapshot
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (s *stubTrendingSource) Get(_ context.Context, _, _ string) (sections.TrendingSnapshot, bool, error) {
|
||||
return s.snap, s.ok, nil
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_FollowingPassesFollowedIDs(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{followed: []string{"s1", "s2"}}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=following", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !repo.last.RestrictByIDs {
|
||||
t.Fatalf("expected RestrictByIDs true")
|
||||
}
|
||||
if got := repo.last.RestrictToIDs; len(got) != 2 || got[0] != "s1" || got[1] != "s2" {
|
||||
t.Fatalf("RestrictToIDs = %v, want [s1 s2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_PopularReadsCache(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{
|
||||
repo: repo,
|
||||
popular: &stubPopularSource{items: []recommendations.ScoredItem{{MediaItemID: "p1"}, {MediaItemID: "p2"}}},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=popular", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if !repo.last.RestrictByIDs || len(repo.last.RestrictToIDs) != 2 || repo.last.RestrictToIDs[0] != "p1" {
|
||||
t.Fatalf("RestrictToIDs = %v, want [p1 p2]", repo.last.RestrictToIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_TrendingReadsSnapshot(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{
|
||||
repo: repo,
|
||||
trending: &stubTrendingSource{ok: true, snap: sections.TrendingSnapshot{ContentIDs: []string{"t1"}}},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=trending", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if !repo.last.RestrictByIDs || len(repo.last.RestrictToIDs) != 1 || repo.last.RestrictToIDs[0] != "t1" {
|
||||
t.Fatalf("RestrictToIDs = %v, want [t1]", repo.last.RestrictToIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_EverythingHasNoRestriction(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{repo: repo}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=everything", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if repo.last.RestrictByIDs {
|
||||
t.Fatalf("expected no restriction for everything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_RejectsUnknownFilter(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{repo: repo}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=bogus", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if repo.calls != 0 {
|
||||
t.Fatalf("expected repo not called, got %d", repo.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_EmptyFollowedShortCircuits(t *testing.T) {
|
||||
repo := &stubCalendarRepo{}
|
||||
handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{followed: nil}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=following", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if repo.calls != 0 {
|
||||
t.Fatalf("expected ListEvents skipped for empty followed set, got %d calls", repo.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetCalendar_MarksWatchedItems(t *testing.T) {
|
||||
repo := &stubCalendarRepo{events: []catalog.CalendarEvent{
|
||||
{ContentID: "ep-1", Type: "episode", Title: "Show", AirDate: time.Date(2026, time.April, 8, 0, 0, 0, 0, time.UTC)},
|
||||
{ContentID: "ep-2", Type: "episode", Title: "Show", AirDate: time.Date(2026, time.April, 8, 0, 0, 0, 0, time.UTC)},
|
||||
}}
|
||||
handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{watched: map[string]bool{"ep-1": true}}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=everything", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleGetCalendar(rec, req)
|
||||
|
||||
var resp calendarResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
watched := map[string]bool{}
|
||||
for _, day := range resp.Events {
|
||||
for _, item := range day.Items {
|
||||
watched[item.ContentID] = item.Watched
|
||||
}
|
||||
}
|
||||
if !watched["ep-1"] || watched["ep-2"] {
|
||||
t.Fatalf("watched flags = %v, want ep-1 true / ep-2 false", watched)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,17 +80,6 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
|
||||
overlaySummaries := h.itemsH.listOverlaySummaries(r.Context(), result.Items, h.itemsH.accessFilter(r))
|
||||
userStates := h.itemsH.listItemUserStates(r, result.Items)
|
||||
episodeMetadata := h.itemsH.listEpisodeBrowseMetadata(r.Context(), result.Items)
|
||||
sortField := catalog.NormalizeQuerySort(req.Query.Sort).Field
|
||||
store, profileID, _ := h.itemsH.userStoreForRequest(r)
|
||||
sortMetrics := h.itemsH.listSortMetrics(
|
||||
r.Context(),
|
||||
result.Items,
|
||||
sortField,
|
||||
h.itemsH.accessFilter(r),
|
||||
overlaySummaries,
|
||||
store,
|
||||
profileID,
|
||||
)
|
||||
items := make([]itemListResponse, 0, len(result.Items))
|
||||
for _, item := range result.Items {
|
||||
resp := h.itemsH.toItemListResponseWithOverlay(
|
||||
@@ -104,7 +93,6 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
|
||||
resp.SeasonNumber = meta.SeasonNumber
|
||||
resp.EpisodeNumber = meta.EpisodeNumber
|
||||
}
|
||||
resp.SortMetrics = sortMetrics[item.ContentID]
|
||||
items = append(items, resp)
|
||||
}
|
||||
|
||||
@@ -221,9 +209,6 @@ func (h *CatalogHandler) HandleGetCatalogFacetSearch(w http.ResponseWriter, r *h
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
||||
return
|
||||
}
|
||||
if n > 100 {
|
||||
n = 100
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
|
||||
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
|
||||
}
|
||||
}
|
||||
case "movie", "episode", "audiobook":
|
||||
case "movie", "episode":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
|
||||
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
@@ -23,10 +23,9 @@ func publishEventMetadataUpdate(ctx context.Context, hub *evt.Hub, libraryID int
|
||||
if hub == nil {
|
||||
return
|
||||
}
|
||||
if err := hub.PublishJSON(ctx, evt.ChannelCatalog, "catalog.item.changed", map[string]any{
|
||||
if err := hub.PublishJSON(ctx, evt.ChannelCatalog, "metadata.updated", map[string]any{
|
||||
"library_id": libraryID,
|
||||
"content_id": contentID,
|
||||
"change": "metadata_updated",
|
||||
}, evt.PublishOptions{}); err != nil {
|
||||
slog.Warn("events: failed to publish metadata update", "library_id", libraryID, "content_id", contentID, "error", err)
|
||||
}
|
||||
|
||||
@@ -391,17 +391,13 @@ func (h *EventsHandler) writeSnapshotFrame(
|
||||
) error {
|
||||
data, err := h.snapshotForChannel(r, claims, channel)
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"events: failed to build initial snapshot",
|
||||
"channel",
|
||||
channel,
|
||||
"user_id",
|
||||
claims.UserID,
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
writeWebSocketError(conn, "internal_error", "Failed to load snapshot")
|
||||
return nil
|
||||
_ = writeWebSocketControl(
|
||||
conn,
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "snapshot failed"),
|
||||
)
|
||||
return err
|
||||
}
|
||||
return writeWebSocketJSON(conn, evt.EventsSnapshotMessage{
|
||||
Type: "snapshot",
|
||||
|
||||
@@ -216,9 +216,7 @@ func (h *PersonalDataHandler) HandleAddFavorite(w http.ResponseWriter, r *http.R
|
||||
|
||||
h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventAdded, userID, profileID, itemID)
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", userStateEventState{
|
||||
IsFavorite: boolPtr(true),
|
||||
})
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", "favorite.updated")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -246,9 +244,7 @@ func (h *PersonalDataHandler) HandleRemoveFavorite(w http.ResponseWriter, r *htt
|
||||
|
||||
h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventRemoved, userID, profileID, itemID)
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", userStateEventState{
|
||||
IsFavorite: boolPtr(false),
|
||||
})
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", "favorite.updated")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -370,9 +366,7 @@ func (h *PersonalDataHandler) HandleAddToWatchlist(w http.ResponseWriter, r *htt
|
||||
}
|
||||
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", userStateEventState{
|
||||
InWatchlist: boolPtr(true),
|
||||
})
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", "watchlist.updated")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -399,9 +393,7 @@ func (h *PersonalDataHandler) HandleRemoveFromWatchlist(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", userStateEventState{
|
||||
InWatchlist: boolPtr(false),
|
||||
})
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", "watchlist.updated")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -500,7 +492,7 @@ func (h *PersonalDataHandler) HandleRemoveHistory(w http.ResponseWriter, r *http
|
||||
mediaItemID,
|
||||
"",
|
||||
"history",
|
||||
userStateEventState{},
|
||||
"history.updated",
|
||||
)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -8,13 +8,11 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
type HomeDismissalHandler struct {
|
||||
storeProvider userstore.UserStoreProvider
|
||||
EventsHub *evt.Hub
|
||||
}
|
||||
|
||||
type upsertHomeDismissalRequest struct {
|
||||
@@ -78,16 +76,6 @@ func (h *HomeDismissalHandler) HandleUpsertDismissal(w http.ResponseWriter, r *h
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save dismissal")
|
||||
return
|
||||
}
|
||||
publishUserStateEvent(
|
||||
r.Context(),
|
||||
h.EventsHub,
|
||||
userID,
|
||||
profileID,
|
||||
itemID,
|
||||
req.SeriesID,
|
||||
"home_dismissal",
|
||||
userStateEventState{},
|
||||
)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -116,16 +104,6 @@ func (h *HomeDismissalHandler) HandleDeleteDismissal(w http.ResponseWriter, r *h
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete dismissal")
|
||||
return
|
||||
}
|
||||
publishUserStateEvent(
|
||||
r.Context(),
|
||||
h.EventsHub,
|
||||
userID,
|
||||
profileID,
|
||||
itemID,
|
||||
"",
|
||||
"home_dismissal",
|
||||
userStateEventState{},
|
||||
)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
+23
-255
@@ -34,10 +34,6 @@ type EpisodeFileProvider interface {
|
||||
ListByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error)
|
||||
}
|
||||
|
||||
type batchEpisodeFileProvider interface {
|
||||
ListByEpisodeIDs(ctx context.Context, episodeIDs []string) (map[string][]*models.MediaFile, error)
|
||||
}
|
||||
|
||||
type MetadataRefreshRequester interface {
|
||||
RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error
|
||||
}
|
||||
@@ -150,7 +146,7 @@ func (h *ItemsHandler) maybeRequestStaleEpisodeMetadataRefresh(ctx context.Conte
|
||||
if h == nil || h.metadataRefreshRequester == nil || episode == nil {
|
||||
return
|
||||
}
|
||||
if metadata.EpisodeHasActionableMetadataDebt(episode, time.Now()) {
|
||||
if metadata.EpisodeHasIncompleteMetadata(episode, time.Now()) {
|
||||
h.requestStaleMetadataRefresh(ctx, "episode", episode.ContentID)
|
||||
}
|
||||
}
|
||||
@@ -161,7 +157,7 @@ func (h *ItemsHandler) maybeRequestStaleSeasonMetadataRefresh(ctx context.Contex
|
||||
}
|
||||
now := time.Now()
|
||||
for _, episode := range episodes {
|
||||
if metadata.EpisodeHasActionableMetadataDebt(episode, now) {
|
||||
if metadata.EpisodeHasIncompleteMetadata(episode, now) {
|
||||
h.requestStaleMetadataRefresh(ctx, "season", seasonID)
|
||||
return
|
||||
}
|
||||
@@ -213,20 +209,9 @@ type itemListResponse struct {
|
||||
LastAirDate *string `json:"last_air_date,omitempty"`
|
||||
AddedAt *time.Time `json:"added_at,omitempty"`
|
||||
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
|
||||
SortMetrics *sortMetricsResponse `json:"sort_metrics,omitempty"`
|
||||
UserState *itemUserStateResponse `json:"user_state,omitempty"`
|
||||
}
|
||||
|
||||
type sortMetricsResponse struct {
|
||||
ReleaseDate *string `json:"release_date,omitempty"`
|
||||
RuntimeMinutes *int `json:"runtime_minutes,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
BitrateKbps *int `json:"bitrate_kbps,omitempty"`
|
||||
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
|
||||
ViewedAt string `json:"viewed_at,omitempty"`
|
||||
PlayCount *int `json:"play_count,omitempty"`
|
||||
}
|
||||
|
||||
// browseResponse is the paginated response for the /items endpoint.
|
||||
type browseResponse struct {
|
||||
Total int `json:"total"`
|
||||
@@ -476,9 +461,7 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", userStateEventState{
|
||||
Played: boolPtr(played),
|
||||
})
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", "watched.updated")
|
||||
|
||||
writeJSON(w, http.StatusOK, watchedStateResponse{
|
||||
ContentID: id,
|
||||
@@ -834,147 +817,6 @@ func (h *ItemsHandler) listOverlaySummaries(ctx context.Context, items []*models
|
||||
return summaries
|
||||
}
|
||||
|
||||
groupedFiles := h.listBrowseItemFiles(ctx, items, filter)
|
||||
for contentID, files := range groupedFiles {
|
||||
if summary := overlays.BuildSummary(files); summary != nil {
|
||||
summaries[contentID] = summary
|
||||
}
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
filter catalog.AccessFilter,
|
||||
overlaySummaries map[string]*models.OverlaySummary,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
) map[string]*sortMetricsResponse {
|
||||
metrics := make(map[string]*sortMetricsResponse, len(items))
|
||||
switch sortField {
|
||||
case "release_date":
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
releaseDate := firstNonBlankPtr(item.ReleaseDate, item.FirstAirDate)
|
||||
if releaseDate != nil {
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ReleaseDate: releaseDate}
|
||||
}
|
||||
}
|
||||
case "runtime":
|
||||
for _, item := range items {
|
||||
if item == nil || item.Runtime <= 0 {
|
||||
continue
|
||||
}
|
||||
runtimeMinutes := item.Runtime
|
||||
metrics[item.ContentID] = &sortMetricsResponse{RuntimeMinutes: &runtimeMinutes}
|
||||
}
|
||||
case "resolution":
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if summary := overlaySummaries[item.ContentID]; summary != nil && strings.TrimSpace(summary.Resolution) != "" {
|
||||
metrics[item.ContentID] = &sortMetricsResponse{Resolution: summary.Resolution}
|
||||
}
|
||||
}
|
||||
case "bitrate":
|
||||
groupedFiles := h.listBrowseItemFiles(ctx, items, filter)
|
||||
for contentID, files := range groupedFiles {
|
||||
if bitrate := maxFileBitrate(files); bitrate > 0 {
|
||||
value := bitrate
|
||||
metrics[contentID] = &sortMetricsResponse{BitrateKbps: &value}
|
||||
}
|
||||
}
|
||||
case "progress", "date_viewed", "plays":
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listUserSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
if store == nil || profileID == "" || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
contentIDs := uniqueItemContentIDs(items)
|
||||
if len(contentIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
progressMap, err := store.ListProgressByMediaItems(ctx, profileID, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch sortField {
|
||||
case "progress":
|
||||
for _, item := range items {
|
||||
if item == nil || item.ContentID == "" {
|
||||
continue
|
||||
}
|
||||
progress, ok := progressMap[item.ContentID]
|
||||
if !ok || progress.Completed || progress.PositionSeconds <= 0 || progress.DurationSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
ratio := progress.PositionSeconds / progress.DurationSeconds
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
}
|
||||
case "date_viewed", "plays":
|
||||
history, err := listCompletedHistoryForItems(ctx, store, profileID, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
historyCounts := make(map[string]int, len(contentIDs))
|
||||
historyViewedAt := make(map[string]string, len(contentIDs))
|
||||
for _, entry := range history {
|
||||
if entry.MediaItemID == "" {
|
||||
continue
|
||||
}
|
||||
historyCounts[entry.MediaItemID]++
|
||||
if entry.WatchedAt > historyViewedAt[entry.MediaItemID] {
|
||||
historyViewedAt[entry.MediaItemID] = entry.WatchedAt
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
if item == nil || item.ContentID == "" {
|
||||
continue
|
||||
}
|
||||
resp := &sortMetricsResponse{}
|
||||
if sortField == "date_viewed" {
|
||||
viewedAt := historyViewedAt[item.ContentID]
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt {
|
||||
viewedAt = progress.UpdatedAt
|
||||
}
|
||||
if viewedAt == "" {
|
||||
continue
|
||||
}
|
||||
resp.ViewedAt = viewedAt
|
||||
} else {
|
||||
playCount := historyCounts[item.ContentID]
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if playCount <= 0 {
|
||||
continue
|
||||
}
|
||||
resp.PlayCount = &playCount
|
||||
}
|
||||
metrics[item.ContentID] = resp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueItemContentIDs(items []*models.MediaItem) []string {
|
||||
contentIDs := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
@@ -987,100 +829,21 @@ func uniqueItemContentIDs(items []*models.MediaItem) []string {
|
||||
seen[item.ContentID] = struct{}{}
|
||||
contentIDs = append(contentIDs, item.ContentID)
|
||||
}
|
||||
return contentIDs
|
||||
}
|
||||
|
||||
func listCompletedHistoryForItems(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
contentIDs []string,
|
||||
) ([]userstore.WatchHistoryEntry, error) {
|
||||
const pageSize = 500
|
||||
var all []userstore.WatchHistoryEntry
|
||||
for offset := 0; ; offset += pageSize {
|
||||
page, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{
|
||||
ProfileID: profileID,
|
||||
MediaItemIDs: contentIDs,
|
||||
Limit: pageSize,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page...)
|
||||
if len(page) < pageSize {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listBrowseItemFiles(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string][]*models.MediaFile {
|
||||
grouped := make(map[string][]*models.MediaFile, len(items))
|
||||
if h.fileRepo == nil || len(items) == 0 {
|
||||
return grouped
|
||||
if len(contentIDs) == 0 {
|
||||
return summaries
|
||||
}
|
||||
|
||||
contentIDs := make([]string, 0, len(items))
|
||||
episodeIDs := make([]string, 0)
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil || item.ContentID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.ContentID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.ContentID] = struct{}{}
|
||||
if item.Type == "episode" {
|
||||
episodeIDs = append(episodeIDs, item.ContentID)
|
||||
} else {
|
||||
contentIDs = append(contentIDs, item.ContentID)
|
||||
groupedFiles, err := h.fileRepo.ListByContentIDs(ctx, contentIDs)
|
||||
if err != nil {
|
||||
return summaries
|
||||
}
|
||||
for contentID, files := range groupedFiles {
|
||||
files = catalog.FilterMediaFilesByAccess(files, filter)
|
||||
if summary := overlays.BuildSummary(files); summary != nil {
|
||||
summaries[contentID] = summary
|
||||
}
|
||||
}
|
||||
|
||||
if len(contentIDs) > 0 {
|
||||
contentFiles, err := h.fileRepo.ListByContentIDs(ctx, contentIDs)
|
||||
if err == nil {
|
||||
for contentID, files := range contentFiles {
|
||||
grouped[contentID] = catalog.FilterMediaFilesByAccess(files, filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(episodeIDs) > 0 {
|
||||
if batchProvider, ok := h.fileRepo.(batchEpisodeFileProvider); ok {
|
||||
episodeFiles, err := batchProvider.ListByEpisodeIDs(ctx, episodeIDs)
|
||||
if err == nil {
|
||||
for episodeID, files := range episodeFiles {
|
||||
grouped[episodeID] = catalog.FilterMediaFilesByAccess(files, filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
func firstNonBlankPtr(values ...*string) *string {
|
||||
for _, value := range values {
|
||||
if value == nil || strings.TrimSpace(*value) == "" {
|
||||
continue
|
||||
}
|
||||
copyValue := *value
|
||||
return ©Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maxFileBitrate(files []*models.MediaFile) int {
|
||||
maxBitrate := 0
|
||||
for _, file := range files {
|
||||
if file != nil && file.Bitrate > maxBitrate {
|
||||
maxBitrate = file.Bitrate
|
||||
}
|
||||
}
|
||||
return maxBitrate
|
||||
return summaries
|
||||
}
|
||||
|
||||
// toSeasonResponse converts a Season model to an API response.
|
||||
@@ -1233,11 +996,16 @@ func featuredPosterPath(path string) string {
|
||||
}
|
||||
|
||||
// featuredBackdropPath converts an S3 backdrop path from original to w1920 for
|
||||
// featured/hero contexts (displayed at full viewport width). Episode stills
|
||||
// used as backdrops lack a w1920 variant and clamp to their largest cached
|
||||
// size. Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is.
|
||||
// featured/hero contexts (displayed at full viewport width).
|
||||
// Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is.
|
||||
func featuredBackdropPath(path string) string {
|
||||
return catalog.BackdropVariantPath(path, "w1920")
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
if strings.Contains(path, "://") {
|
||||
return path
|
||||
}
|
||||
return strings.Replace(path, "/original.", "/w1920.", 1)
|
||||
}
|
||||
|
||||
// presignURL resolves an image path to a usable URL, delegating to the
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
func TestListSortMetricsIncludesUserStateSorts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "movie-progress", Type: "movie", Title: "Progress"},
|
||||
{ContentID: "movie-viewed", Type: "movie", Title: "Viewed"},
|
||||
{ContentID: "movie-plays", Type: "movie", Title: "Plays"},
|
||||
}
|
||||
handler := &ItemsHandler{}
|
||||
|
||||
if err := store.SetProgress(ctx, "profile-1", "movie-progress", 900, 3600, userstore.ProgressThresholds{}); err != nil {
|
||||
t.Fatalf("SetProgress(progress): %v", err)
|
||||
}
|
||||
completedAt := time.Date(2026, 5, 29, 18, 30, 0, 0, time.UTC)
|
||||
if err := store.SetProgressAt(ctx, "profile-1", "movie-viewed", 3600, 3600, true, completedAt); err != nil {
|
||||
t.Fatalf("SetProgressAt(viewed): %v", err)
|
||||
}
|
||||
for _, watchedAt := range []string{"2026-05-27T10:00:00Z", "2026-05-28T10:00:00Z"} {
|
||||
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
||||
ProfileID: "profile-1",
|
||||
MediaItemID: "movie-plays",
|
||||
WatchedAt: watchedAt,
|
||||
DurationSeconds: 3600,
|
||||
Completed: true,
|
||||
Source: userstore.WatchHistorySourcePlayback,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddHistory(%s): %v", watchedAt, err)
|
||||
}
|
||||
}
|
||||
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
progress := progressMetrics["movie-progress"]
|
||||
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.25 {
|
||||
t.Fatalf("progress metrics = %#v", progress)
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
if got := viewedMetrics["movie-viewed"]; got == nil || got.ViewedAt != "2026-05-29T18:30:00Z" {
|
||||
t.Fatalf("viewed metrics = %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
plays := playsMetrics["movie-plays"]
|
||||
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 2 {
|
||||
t.Fatalf("plays metrics = %#v", plays)
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,6 @@ type LibraryHandler struct {
|
||||
PluginInstallations pluginInstallationLister
|
||||
SkippedRootRepo *metadata.SkippedRootRepository
|
||||
StaleIDRepo *metadata.StaleMediaIDRepository
|
||||
MovieMatchQueueRepo libraryMovieMatchQueue
|
||||
SeriesMatchQueueRepo librarySeriesMatchQueue
|
||||
RawMatchBacklogRepo libraryRawMatchBacklog
|
||||
TVSeriesRootQueue bool
|
||||
ScannedGroupRepo *scanner.ScannedGroupRepository
|
||||
GroupOverrideRepo *scanner.MediaGroupOverrideRepository
|
||||
ObservedLocationRepo *scanner.ObservedLocationRepository
|
||||
@@ -98,27 +94,6 @@ type libraryScanQueuer interface {
|
||||
CancelByLibrary(ctx context.Context, libraryID int) (int, error)
|
||||
}
|
||||
|
||||
type libraryMovieMatchQueue interface {
|
||||
SyncForFolder(ctx context.Context, folderID int) error
|
||||
DeleteByFolder(ctx context.Context, folderID int) (int, error)
|
||||
CountByFolder(ctx context.Context, folderID int) (int, error)
|
||||
ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.MovieMatchQueueEntry, int, error)
|
||||
}
|
||||
|
||||
type librarySeriesMatchQueue interface {
|
||||
SyncForFolder(ctx context.Context, folderID int) error
|
||||
DeleteByFolder(ctx context.Context, folderID int) (int, error)
|
||||
CountByFolder(ctx context.Context, folderID int) (int, error)
|
||||
ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error)
|
||||
}
|
||||
|
||||
type libraryRawMatchBacklog interface {
|
||||
CountUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error)
|
||||
ListUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode, limit int, offset int) ([]*models.MediaFile, int, error)
|
||||
SuppressUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error)
|
||||
RetryUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error)
|
||||
}
|
||||
|
||||
// NewLibraryHandler creates a new LibraryHandler backed by the given folder
|
||||
// repository and ingest executor. The ingester may be nil if scan endpoints are not needed.
|
||||
func NewLibraryHandler(
|
||||
@@ -1086,9 +1061,6 @@ func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.T
|
||||
Trigger: target.Trigger,
|
||||
Status: "accepted",
|
||||
})
|
||||
if run, ok := h.ScanRegistry.Get(scanID); ok {
|
||||
h.publishScanEvent(context.Background(), "scan.accepted", run)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) markScanRunning(scanID string) {
|
||||
@@ -1307,371 +1279,6 @@ type refreshLibraryMetadataRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type libraryMetadataMatchQueueStatusResponse struct {
|
||||
LibraryID int `json:"library_id"`
|
||||
MovieCount int `json:"movie_count"`
|
||||
SeriesCount int `json:"series_count"`
|
||||
RawFileCount int `json:"raw_file_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
type libraryMetadataMatchQueueActionResponse struct {
|
||||
Status string `json:"status"`
|
||||
LibraryID int `json:"library_id"`
|
||||
MovieCancelled int `json:"movie_cancelled,omitempty"`
|
||||
SeriesCancelled int `json:"series_cancelled,omitempty"`
|
||||
RawFileCancelled int `json:"raw_file_cancelled,omitempty"`
|
||||
RawFileRetried int `json:"raw_file_retried,omitempty"`
|
||||
TotalCancelled int `json:"total_cancelled,omitempty"`
|
||||
Queue libraryMetadataMatchQueueStatusResponse `json:"queue"`
|
||||
}
|
||||
|
||||
type libraryMetadataMatchQueueDetailResponse struct {
|
||||
libraryMetadataMatchQueueStatusResponse
|
||||
Movies []libraryMovieMatchQueueEntryResponse `json:"movies"`
|
||||
Series []librarySeriesMatchQueueEntryResponse `json:"series"`
|
||||
RawFiles []libraryRawMatchBacklogEntryResponse `json:"raw_files"`
|
||||
}
|
||||
|
||||
type libraryMovieMatchQueueEntryResponse struct {
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
MediaFolderID int `json:"media_folder_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
FirstQueuedAt time.Time `json:"first_queued_at"`
|
||||
AvailableAt time.Time `json:"available_at"`
|
||||
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type librarySeriesMatchQueueEntryResponse struct {
|
||||
MediaFolderID int `json:"media_folder_id"`
|
||||
ObservedRootPath string `json:"observed_root_path"`
|
||||
FirstQueuedAt time.Time `json:"first_queued_at"`
|
||||
AvailableAt time.Time `json:"available_at"`
|
||||
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type libraryRawMatchBacklogEntryResponse struct {
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
MediaFolderID int `json:"media_folder_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
BaseTitle string `json:"base_title,omitempty"`
|
||||
BaseYear int `json:"base_year,omitempty"`
|
||||
BaseType string `json:"base_type,omitempty"`
|
||||
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) HandleListMetadataMatchQueues(w http.ResponseWriter, r *http.Request) {
|
||||
if h.folderRepo == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Library repository is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
folders, err := h.folderRepo.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to list libraries", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queues")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]libraryMetadataMatchQueueStatusResponse, 0, len(folders))
|
||||
for _, folder := range folders {
|
||||
if folder == nil {
|
||||
continue
|
||||
}
|
||||
status, err := h.metadataMatchQueueStatus(r.Context(), folder.ID)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to load queue status", "library_id", folder.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
||||
return
|
||||
}
|
||||
resp = append(resp, status)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.metadataMatchBacklogConfigured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := parseIDParam(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
||||
return
|
||||
}
|
||||
if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, catalog.ErrFolderNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 10
|
||||
if value := strings.TrimSpace(r.URL.Query().Get("limit")); value != "" {
|
||||
if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 && parsed <= 50 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
status, err := h.metadataMatchQueueStatus(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to load queue status", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
||||
return
|
||||
}
|
||||
|
||||
resp := libraryMetadataMatchQueueDetailResponse{
|
||||
libraryMetadataMatchQueueStatusResponse: status,
|
||||
Movies: []libraryMovieMatchQueueEntryResponse{},
|
||||
Series: []librarySeriesMatchQueueEntryResponse{},
|
||||
RawFiles: []libraryRawMatchBacklogEntryResponse{},
|
||||
}
|
||||
if h.MovieMatchQueueRepo != nil {
|
||||
movies, _, err := h.MovieMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to list movie queue", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue")
|
||||
return
|
||||
}
|
||||
for _, entry := range movies {
|
||||
resp.Movies = append(resp.Movies, libraryMovieMatchQueueEntryResponse{
|
||||
MediaFileID: entry.MediaFileID,
|
||||
MediaFolderID: entry.MediaFolderID,
|
||||
FilePath: entry.FilePath,
|
||||
FirstQueuedAt: entry.FirstQueuedAt,
|
||||
AvailableAt: entry.AvailableAt,
|
||||
LastAttemptedAt: entry.LastAttemptedAt,
|
||||
AttemptCount: entry.AttemptCount,
|
||||
LastError: entry.LastError,
|
||||
UpdatedAt: entry.UpdatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
if h.SeriesMatchQueueRepo != nil {
|
||||
series, _, err := h.SeriesMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to list series queue", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue")
|
||||
return
|
||||
}
|
||||
for _, entry := range series {
|
||||
resp.Series = append(resp.Series, librarySeriesMatchQueueEntryResponse{
|
||||
MediaFolderID: entry.MediaFolderID,
|
||||
ObservedRootPath: entry.ObservedRootPath,
|
||||
FirstQueuedAt: entry.FirstQueuedAt,
|
||||
AvailableAt: entry.AvailableAt,
|
||||
LastAttemptedAt: entry.LastAttemptedAt,
|
||||
AttemptCount: entry.AttemptCount,
|
||||
LastError: entry.LastError,
|
||||
UpdatedAt: entry.UpdatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
if h.RawMatchBacklogRepo != nil {
|
||||
rawFiles, _, err := h.RawMatchBacklogRepo.ListUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode(), limit, 0)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to list raw backlog", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher backlog")
|
||||
return
|
||||
}
|
||||
for _, file := range rawFiles {
|
||||
if file == nil {
|
||||
continue
|
||||
}
|
||||
resp.RawFiles = append(resp.RawFiles, libraryRawMatchBacklogEntryResponse{
|
||||
MediaFileID: file.ID,
|
||||
MediaFolderID: file.MediaFolderID,
|
||||
FilePath: file.FilePath,
|
||||
BaseTitle: file.BaseTitle,
|
||||
BaseYear: file.BaseYear,
|
||||
BaseType: file.BaseType,
|
||||
LastAttemptedAt: file.MatchAttemptedAt,
|
||||
CreatedAt: file.CreatedAt,
|
||||
UpdatedAt: file.UpdatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.metadataMatchBacklogConfigured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := parseIDParam(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
||||
return
|
||||
}
|
||||
if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, catalog.ErrFolderNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
||||
return
|
||||
}
|
||||
|
||||
if h.SeriesMatchQueueRepo != nil {
|
||||
if err := h.SeriesMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil {
|
||||
slog.Error("metadata queue: failed to retry series queue", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
if h.MovieMatchQueueRepo != nil {
|
||||
if err := h.MovieMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil {
|
||||
slog.Error("metadata queue: failed to retry movie queue", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
rawFileRetried := 0
|
||||
if h.RawMatchBacklogRepo != nil {
|
||||
rawFileRetried, err = h.RawMatchBacklogRepo.RetryUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode())
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to retry raw backlog", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
status, err := h.metadataMatchQueueStatus(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to load retried queue status", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, libraryMetadataMatchQueueActionResponse{
|
||||
Status: "queued",
|
||||
LibraryID: id,
|
||||
RawFileRetried: rawFileRetried,
|
||||
Queue: status,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.metadataMatchBacklogConfigured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := parseIDParam(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
||||
return
|
||||
}
|
||||
if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, catalog.ErrFolderNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
||||
return
|
||||
}
|
||||
|
||||
seriesCancelled := 0
|
||||
if h.SeriesMatchQueueRepo != nil {
|
||||
seriesCancelled, err = h.SeriesMatchQueueRepo.DeleteByFolder(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to cancel series queue", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
movieCancelled := 0
|
||||
if h.MovieMatchQueueRepo != nil {
|
||||
movieCancelled, err = h.MovieMatchQueueRepo.DeleteByFolder(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to cancel movie queue", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
rawFileCancelled := 0
|
||||
if h.RawMatchBacklogRepo != nil {
|
||||
rawFileCancelled, err = h.RawMatchBacklogRepo.SuppressUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode())
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to suppress raw backlog", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
status, err := h.metadataMatchQueueStatus(r.Context(), id)
|
||||
if err != nil {
|
||||
slog.Error("metadata queue: failed to load cancelled queue status", "library_id", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, libraryMetadataMatchQueueActionResponse{
|
||||
Status: "cancelled",
|
||||
LibraryID: id,
|
||||
MovieCancelled: movieCancelled,
|
||||
SeriesCancelled: seriesCancelled,
|
||||
RawFileCancelled: rawFileCancelled,
|
||||
TotalCancelled: movieCancelled + seriesCancelled + rawFileCancelled,
|
||||
Queue: status,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) metadataMatchBacklogConfigured() bool {
|
||||
return h.folderRepo != nil &&
|
||||
(h.MovieMatchQueueRepo != nil || h.SeriesMatchQueueRepo != nil || h.RawMatchBacklogRepo != nil)
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) metadataMatchQueueStatus(ctx context.Context, libraryID int) (libraryMetadataMatchQueueStatusResponse, error) {
|
||||
resp := libraryMetadataMatchQueueStatusResponse{LibraryID: libraryID}
|
||||
if h.MovieMatchQueueRepo != nil {
|
||||
count, err := h.MovieMatchQueueRepo.CountByFolder(ctx, libraryID)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
resp.MovieCount = count
|
||||
}
|
||||
if h.SeriesMatchQueueRepo != nil {
|
||||
count, err := h.SeriesMatchQueueRepo.CountByFolder(ctx, libraryID)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
resp.SeriesCount = count
|
||||
}
|
||||
if h.RawMatchBacklogRepo != nil {
|
||||
count, err := h.RawMatchBacklogRepo.CountUnmatchedMatchBacklogByFolder(ctx, libraryID, h.rawMatchBacklogMode())
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
resp.RawFileCount = count
|
||||
}
|
||||
resp.TotalCount = resp.MovieCount + resp.SeriesCount + resp.RawFileCount
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (h *LibraryHandler) rawMatchBacklogMode() scanner.RawMatchBacklogMode {
|
||||
if h.TVSeriesRootQueue && h.MovieMatchQueueRepo != nil {
|
||||
return scanner.RawMatchBacklogMixed
|
||||
}
|
||||
if h.TVSeriesRootQueue {
|
||||
return scanner.RawMatchBacklogNonSeries
|
||||
}
|
||||
return scanner.RawMatchBacklogGeneric
|
||||
}
|
||||
|
||||
// HandleRefreshLibraryMetadata handles POST /libraries/{id}/refresh-metadata.
|
||||
// It queues a background admin job to refresh metadata for items in the
|
||||
// specified library. Quick mode is the default.
|
||||
@@ -2036,10 +1643,8 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
levels = []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
levels = []string{"movie"}
|
||||
case "audiobooks", "audiobook":
|
||||
levels = []string{"audiobook"}
|
||||
case "mixed":
|
||||
levels = []string{"movie", "series", "season", "episode", "audiobook"}
|
||||
levels = []string{"movie", "series", "season", "episode"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -2453,45 +2058,16 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
|
||||
}
|
||||
}
|
||||
|
||||
// Optional case-insensitive search across title, library name, type, and
|
||||
// status. Applied server-side so it spans the whole table, not just the
|
||||
// current page. The displayed folder still comes from the lateral join below,
|
||||
// but the search predicate checks every membership so multi-library items are
|
||||
// found when any linked library name matches.
|
||||
search := strings.TrimSpace(q.Get("q"))
|
||||
filter := ""
|
||||
filterArgs := []any{}
|
||||
if search != "" {
|
||||
filterArgs = append(filterArgs, "%"+search+"%")
|
||||
filter = ` AND (
|
||||
mi.title ILIKE $1
|
||||
OR mi.type ILIKE $1
|
||||
OR mi.status ILIKE $1
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM media_item_libraries search_mil
|
||||
JOIN media_folders search_f ON search_f.id = search_mil.media_folder_id
|
||||
WHERE search_mil.content_id = mi.content_id
|
||||
AND search_f.name ILIKE $1
|
||||
)
|
||||
)`
|
||||
}
|
||||
|
||||
countSQL := `
|
||||
SELECT COUNT(*)
|
||||
FROM media_items mi
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')`
|
||||
countSQL += filter
|
||||
|
||||
var total int
|
||||
if err := h.pool.QueryRow(r.Context(), countSQL, filterArgs...).Scan(&total); err != nil {
|
||||
if err := h.pool.QueryRow(r.Context(),
|
||||
`SELECT COUNT(*) FROM media_items WHERE status IN ('unmatched', 'pending', 'ambiguous')`,
|
||||
).Scan(&total); err != nil {
|
||||
slog.Error("counting unmatched items", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unmatched items")
|
||||
return
|
||||
}
|
||||
|
||||
listArgs := append(append([]any{}, filterArgs...), limit, offset)
|
||||
listSQL := fmt.Sprintf(`
|
||||
rows, err := h.pool.Query(r.Context(), `
|
||||
SELECT mi.content_id, mi.title, mi.year, mi.type, mi.status,
|
||||
COALESCE(lib.folder_id, 0),
|
||||
COALESCE(lib.folder_name, '')
|
||||
@@ -2503,12 +2079,10 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
|
||||
WHERE mil.content_id = mi.content_id
|
||||
LIMIT 1
|
||||
) lib ON true
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s
|
||||
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')
|
||||
ORDER BY mi.title ASC, mi.content_id ASC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, filter, len(filterArgs)+1, len(filterArgs)+2)
|
||||
|
||||
rows, err := h.pool.Query(r.Context(), listSQL, listArgs...)
|
||||
LIMIT $1 OFFSET $2
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
slog.Error("listing unmatched items", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched items")
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// TestLibraryMatchUnmatchedItems_NilPool verifies that the unmatched-items
|
||||
@@ -169,79 +166,6 @@ func TestLibraryMatchRematch_DeprecatedStillRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryMetadataMatchQueueHandlers_NilFolderRepo(t *testing.T) {
|
||||
h := &LibraryHandler{MovieMatchQueueRepo: noopMovieMatchQueue{}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
handler http.HandlerFunc
|
||||
}{
|
||||
{
|
||||
name: "get",
|
||||
method: http.MethodGet,
|
||||
path: "/libraries/1/metadata-match-queue",
|
||||
handler: h.HandleGetMetadataMatchQueue,
|
||||
},
|
||||
{
|
||||
name: "retry",
|
||||
method: http.MethodPost,
|
||||
path: "/libraries/1/metadata-match-queue/retry",
|
||||
handler: h.HandleRetryMetadataMatchQueue,
|
||||
},
|
||||
{
|
||||
name: "cancel",
|
||||
method: http.MethodDelete,
|
||||
path: "/libraries/1/metadata-match-queue",
|
||||
handler: h.HandleCancelMetadataMatchQueue,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Method(tt.method, "/libraries/{id}/metadata-match-queue", tt.handler)
|
||||
r.Method(tt.method, "/libraries/{id}/metadata-match-queue/retry", tt.handler)
|
||||
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 for nil folderRepo, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp errorResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decoding error response: %v", err)
|
||||
}
|
||||
if resp.Error != "unavailable" {
|
||||
t.Errorf("expected error code 'unavailable', got %q", resp.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type noopMovieMatchQueue struct{}
|
||||
|
||||
func (noopMovieMatchQueue) SyncForFolder(context.Context, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopMovieMatchQueue) DeleteByFolder(context.Context, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (noopMovieMatchQueue) CountByFolder(context.Context, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (noopMovieMatchQueue) ListByFolder(context.Context, int, int, int) ([]models.MovieMatchQueueEntry, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func keys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
|
||||
@@ -1,849 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
)
|
||||
|
||||
// MarkerFileResolver loads a media file by id for the manual-marker API.
|
||||
type MarkerFileResolver interface {
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
|
||||
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
|
||||
GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error)
|
||||
}
|
||||
|
||||
// ManualMarkerWriter persists/clears manual markers.
|
||||
type ManualMarkerWriter interface {
|
||||
UpsertMarkers(ctx context.Context, fileID int, update scanner.MarkerUpdate) (bool, error)
|
||||
ClearMarkers(ctx context.Context, fileID int, segments []string) (bool, error)
|
||||
UpsertAndClearMarkers(ctx context.Context, fileID int, update scanner.MarkerUpdate, clearSegments []string) (bool, error)
|
||||
}
|
||||
|
||||
// MarkerContributor submits a file's eligible markers to enabled providers.
|
||||
type MarkerContributor interface {
|
||||
ContributeFile(ctx context.Context, file *models.MediaFile, opts markers.ContributeOptions) ([]markers.ContributionOutcome, error)
|
||||
}
|
||||
|
||||
// MarkerContributionLister reads contribution history for a file.
|
||||
type MarkerContributionLister interface {
|
||||
ListByFile(ctx context.Context, fileID int) ([]markers.ContributionRow, error)
|
||||
}
|
||||
|
||||
type MarkerAuditLister interface {
|
||||
ListMarkerEditAudit(ctx context.Context, fileIDs []int, limit int) ([]scanner.MarkerEditAuditRow, error)
|
||||
ListAllMarkerEditAudit(ctx context.Context, limit int) ([]scanner.MarkerEditAuditRow, error)
|
||||
}
|
||||
|
||||
type MarkerPermissionUserLoader interface {
|
||||
GetByID(ctx context.Context, id int) (*models.User, error)
|
||||
}
|
||||
|
||||
// MarkersHandler serves the manual-marker + contribution API. The marker
|
||||
// read/write/clear routes are mounted for any authenticated viewer (users fix
|
||||
// and create markers from the player); the contribution + history routes stay
|
||||
// admin-only. A successful manual write fires a background contribution run
|
||||
// (see maybeContribute) so corrected markers reach enabled providers.
|
||||
type MarkersHandler struct {
|
||||
Files MarkerFileResolver
|
||||
Writer ManualMarkerWriter
|
||||
Contributor MarkerContributor
|
||||
Contributions MarkerContributionLister
|
||||
AuditHistory MarkerAuditLister
|
||||
Notifier PlaybackMarkerUpdateNotifier
|
||||
Users MarkerPermissionUserLoader
|
||||
// Authorizer enforces per-item access on file lookups so a viewer can only
|
||||
// edit markers for content they can actually watch. When nil (tests) the
|
||||
// handler falls back to an unchecked lookup.
|
||||
Authorizer *MediaFileAuthorizer
|
||||
// BaseContext is the lifetime context for detached background work (set from
|
||||
// the app context) so contribution goroutines cancel on shutdown.
|
||||
BaseContext context.Context
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewMarkersHandler constructs the handler.
|
||||
func NewMarkersHandler(files MarkerFileResolver, writer ManualMarkerWriter, contributor MarkerContributor, contributions MarkerContributionLister, notifier PlaybackMarkerUpdateNotifier, logger *slog.Logger) *MarkersHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &MarkersHandler{Files: files, Writer: writer, Contributor: contributor, Contributions: contributions, Notifier: notifier, logger: logger}
|
||||
}
|
||||
|
||||
var manualMarkerConfidence = 1.0
|
||||
|
||||
const manualMarkerAlgorithm = "manual:v1"
|
||||
|
||||
// markerContributeTimeout bounds the detached contribution run kicked off after
|
||||
// a manual save so a slow or rate-limited provider can't leak goroutines.
|
||||
const markerContributeTimeout = 30 * time.Second
|
||||
|
||||
var markerSegmentNames = []string{"intro", "credits", "recap", "preview"}
|
||||
|
||||
type segmentInput struct {
|
||||
Start *float64 `json:"start"`
|
||||
End *float64 `json:"end"`
|
||||
}
|
||||
|
||||
type segmentMarker struct {
|
||||
Start *float64 `json:"start"`
|
||||
End *float64 `json:"end"`
|
||||
Source *string `json:"source"`
|
||||
Provider *string `json:"provider"`
|
||||
Confidence *float64 `json:"confidence"`
|
||||
Algorithm *string `json:"algorithm"`
|
||||
DetectedAt *time.Time `json:"detected_at"`
|
||||
}
|
||||
|
||||
type fileMarkersResponse struct {
|
||||
FileID int `json:"file_id"`
|
||||
Intro segmentMarker `json:"intro"`
|
||||
Credits segmentMarker `json:"credits"`
|
||||
Recap segmentMarker `json:"recap"`
|
||||
Preview segmentMarker `json:"preview"`
|
||||
}
|
||||
|
||||
type contributionOutcomeResponse struct {
|
||||
Provider string `json:"provider"`
|
||||
Segment string `json:"segment"`
|
||||
Status string `json:"status"`
|
||||
SubmissionID string `json:"submission_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type contributionRowResponse struct {
|
||||
ID string `json:"id"`
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
Provider string `json:"provider"`
|
||||
Segment string `json:"segment"`
|
||||
Source string `json:"source"`
|
||||
SubmittedStartMS *int64 `json:"submitted_start_ms,omitempty"`
|
||||
SubmittedEndMS *int64 `json:"submitted_end_ms,omitempty"`
|
||||
VideoDurationMS *int64 `json:"video_duration_ms,omitempty"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
SubmissionID *string `json:"submission_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
HTTPStatus *int `json:"http_status,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type markerEditAuditResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
ItemID *string `json:"item_id,omitempty"`
|
||||
ItemType *string `json:"item_type,omitempty"`
|
||||
MediaTitle *string `json:"media_title,omitempty"`
|
||||
FilePath *string `json:"file_path,omitempty"`
|
||||
Segment string `json:"segment"`
|
||||
Action string `json:"action"`
|
||||
Before *segmentMarker `json:"before"`
|
||||
After *segmentMarker `json:"after"`
|
||||
UserID *int `json:"user_id,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
ImpersonatorUserID *int `json:"impersonator_user_id,omitempty"`
|
||||
ImpersonatorUsername *string `json:"impersonator_username,omitempty"`
|
||||
APIKeyID *int64 `json:"api_key_id,omitempty"`
|
||||
RequestID *string `json:"request_id,omitempty"`
|
||||
ClientIP *string `json:"client_ip,omitempty"`
|
||||
UserAgent *string `json:"user_agent,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func fileMarkers(file *models.MediaFile) fileMarkersResponse {
|
||||
return fileMarkersResponse{
|
||||
FileID: file.ID,
|
||||
Intro: segmentMarker{file.IntroStart, file.IntroEnd, file.IntroMarkersSource, file.IntroMarkersProvider,
|
||||
file.IntroMarkersConfidence, file.IntroMarkersAlgorithm, file.IntroMarkersDetectedAt},
|
||||
Credits: segmentMarker{file.CreditsStart, file.CreditsEnd, file.CreditsMarkersSource, file.CreditsMarkersProvider,
|
||||
file.CreditsMarkersConfidence, file.CreditsMarkersAlgorithm, file.CreditsMarkersDetectedAt},
|
||||
Recap: segmentMarker{file.RecapStart, file.RecapEnd, file.RecapMarkersSource, file.RecapMarkersProvider,
|
||||
file.RecapMarkersConfidence, file.RecapMarkersAlgorithm, file.RecapMarkersDetectedAt},
|
||||
Preview: segmentMarker{file.PreviewStart, file.PreviewEnd, file.PreviewMarkersSource, file.PreviewMarkersProvider,
|
||||
file.PreviewMarkersConfidence, file.PreviewMarkersAlgorithm, file.PreviewMarkersDetectedAt},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MarkersHandler) loadFile(w http.ResponseWriter, r *http.Request) (*models.MediaFile, bool) {
|
||||
if h == nil || h.Files == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker editing is not configured")
|
||||
return nil, false
|
||||
}
|
||||
fileID, err := strconv.Atoi(chi.URLParam(r, "fileId"))
|
||||
if err != nil || fileID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "A valid file id is required")
|
||||
return nil, false
|
||||
}
|
||||
return h.authorizeFile(w, r, fileID)
|
||||
}
|
||||
|
||||
// authorizeFile loads a file, enforcing per-item access when an authorizer is
|
||||
// configured. The 404-on-denial mirrors the playback/subtitle paths so marker
|
||||
// editing can't be used to probe content outside a profile's library scope.
|
||||
func (h *MarkersHandler) authorizeFile(w http.ResponseWriter, r *http.Request, fileID int) (*models.MediaFile, bool) {
|
||||
if h.Authorizer != nil {
|
||||
file, err := h.Authorizer.Authorize(r, fileID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||||
default:
|
||||
h.logger.Error("markers: authorize failed", "file_id", fileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return file, true
|
||||
}
|
||||
file, err := h.Files.GetByID(r.Context(), fileID)
|
||||
if err != nil || file == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||||
return nil, false
|
||||
}
|
||||
return file, true
|
||||
}
|
||||
|
||||
func (h *MarkersHandler) loadItemPrimaryFile(w http.ResponseWriter, r *http.Request) (*models.MediaFile, bool) {
|
||||
if h == nil || h.Files == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker editing is not configured")
|
||||
return nil, false
|
||||
}
|
||||
itemID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if itemID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "A valid item id is required")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
files, err := h.Files.GetByEpisodeID(r.Context(), itemID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: episode file lookup failed", "item_id", itemID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return nil, false
|
||||
}
|
||||
if len(files) == 0 {
|
||||
files, err = h.Files.GetByContentID(r.Context(), itemID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: content file lookup failed", "item_id", itemID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found for item")
|
||||
return nil, false
|
||||
}
|
||||
// Re-validate the resolved primary file through the authorizer so item-id
|
||||
// edits are access-checked the same way file-id edits are.
|
||||
if h.Authorizer != nil {
|
||||
for _, file := range files {
|
||||
if file == nil {
|
||||
continue
|
||||
}
|
||||
authorized, err := h.Authorizer.Authorize(r, file.ID)
|
||||
if err == nil {
|
||||
return authorized, true
|
||||
}
|
||||
if errors.Is(err, catalog.ErrItemNotFound) || errors.Is(err, catalog.ErrEpisodeNotFound) {
|
||||
continue
|
||||
}
|
||||
h.logger.Error("markers: authorize item file failed", "item_id", itemID, "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||||
return nil, false
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found for item")
|
||||
return nil, false
|
||||
}
|
||||
for _, file := range files {
|
||||
if file != nil {
|
||||
return file, true
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found for item")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (h *MarkersHandler) requireMarkerEdit(w http.ResponseWriter, r *http.Request) bool {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if h == nil || h.Users == nil {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Marker editing permission required")
|
||||
return false
|
||||
}
|
||||
user, err := h.Users.GetByID(r.Context(), claims.UserID)
|
||||
if err != nil || user == nil || !auth.HasEffectivePermission(user, auth.PermissionMarkerEdit) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Marker editing permission required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *MarkersHandler) auditContext(r *http.Request) context.Context {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if claims == nil {
|
||||
return r.Context()
|
||||
}
|
||||
audit := scanner.MarkerAuditContext{
|
||||
UserID: &claims.UserID,
|
||||
ImpersonatorUserID: claims.ImpersonatorUserID,
|
||||
RequestID: chimw.GetReqID(r.Context()),
|
||||
ClientIP: clientip.FromContext(r.Context()),
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
if claims.APIKeyID > 0 {
|
||||
apiKeyID := claims.APIKeyID
|
||||
audit.APIKeyID = &apiKeyID
|
||||
}
|
||||
return scanner.WithMarkerAuditContext(r.Context(), audit)
|
||||
}
|
||||
|
||||
// HandleGetFileMarkers returns the current markers + provenance for a file.
|
||||
func (h *MarkersHandler) HandleGetFileMarkers(w http.ResponseWriter, r *http.Request) {
|
||||
file, ok := h.loadFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, fileMarkers(file))
|
||||
}
|
||||
|
||||
// HandleGetItemMarkers returns markers for the item's primary file.
|
||||
func (h *MarkersHandler) HandleGetItemMarkers(w http.ResponseWriter, r *http.Request) {
|
||||
file, ok := h.loadItemPrimaryFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, fileMarkers(file))
|
||||
}
|
||||
|
||||
// HandleSetFileMarkers upserts the manual marker layer. Each segment key may be
|
||||
// an object {start, end} to set, or null to clear; absent keys are unchanged.
|
||||
func (h *MarkersHandler) HandleSetFileMarkers(w http.ResponseWriter, r *http.Request) {
|
||||
file, ok := h.loadFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.requireMarkerEdit(w, r) {
|
||||
return
|
||||
}
|
||||
h.setMarkersForFile(w, r, file)
|
||||
}
|
||||
|
||||
// HandleSetItemMarkers upserts manual markers on the item's primary file.
|
||||
func (h *MarkersHandler) HandleSetItemMarkers(w http.ResponseWriter, r *http.Request) {
|
||||
file, ok := h.loadItemPrimaryFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.requireMarkerEdit(w, r) {
|
||||
return
|
||||
}
|
||||
h.setMarkersForFile(w, r, file)
|
||||
}
|
||||
|
||||
func (h *MarkersHandler) setMarkersForFile(w http.ResponseWriter, r *http.Request, file *models.MediaFile) {
|
||||
if h.Writer == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker writing is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
duration := float64(file.Duration)
|
||||
update := scanner.MarkerUpdate{
|
||||
MarkersSource: models.MarkerSourceManual,
|
||||
MarkersConfidence: &manualMarkerConfidence,
|
||||
MarkersAlgorithm: manualMarkerAlgorithm,
|
||||
}
|
||||
var clears []string
|
||||
var setSegs []string
|
||||
|
||||
for _, seg := range markerSegmentNames {
|
||||
val, present := raw[seg]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if isJSONNull(val) {
|
||||
clears = append(clears, seg)
|
||||
continue
|
||||
}
|
||||
var in segmentInput
|
||||
if err := json.Unmarshal(val, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+seg+" marker")
|
||||
return
|
||||
}
|
||||
start, end, err := normalizeManualSegment(seg, in, duration)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
applyManualSegment(&update, seg, start, end)
|
||||
setSegs = append(setSegs, seg)
|
||||
}
|
||||
|
||||
if _, err := h.Writer.UpsertAndClearMarkers(h.auditContext(r), file.ID, update, clears); err != nil {
|
||||
h.logger.Error("markers: save failed", "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save markers")
|
||||
return
|
||||
}
|
||||
|
||||
refreshed, err := h.reloadAndNotify(r.Context(), file.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: reload after save failed", "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Markers saved but failed to reload")
|
||||
return
|
||||
}
|
||||
h.maybeContribute(refreshed, setSegs)
|
||||
writeJSON(w, http.StatusOK, fileMarkers(refreshed))
|
||||
}
|
||||
|
||||
// HandleClearFileSegment clears a single segment.
|
||||
func (h *MarkersHandler) HandleClearFileSegment(w http.ResponseWriter, r *http.Request) {
|
||||
file, ok := h.loadFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.Writer == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker writing is not configured")
|
||||
return
|
||||
}
|
||||
if !h.requireMarkerEdit(w, r) {
|
||||
return
|
||||
}
|
||||
segment := chi.URLParam(r, "segment")
|
||||
if !isMarkerSegment(segment) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Unknown marker segment")
|
||||
return
|
||||
}
|
||||
if _, err := h.Writer.ClearMarkers(h.auditContext(r), file.ID, []string{segment}); err != nil {
|
||||
h.logger.Error("markers: clear segment failed", "file_id", file.ID, "segment", segment, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear marker")
|
||||
return
|
||||
}
|
||||
refreshed, err := h.reloadAndNotify(r.Context(), file.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: reload after clear failed", "file_id", file.ID, "segment", segment, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Marker cleared but failed to reload")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, fileMarkers(refreshed))
|
||||
}
|
||||
|
||||
// HandleContributeFile submits the file's eligible markers to enabled providers.
|
||||
func (h *MarkersHandler) HandleContributeFile(w http.ResponseWriter, r *http.Request) {
|
||||
file, ok := h.loadFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.Contributor == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Contribution is not configured")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Provider string `json:"provider"`
|
||||
Segments []string `json:"segments"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
var kinds []markers.MarkerKind
|
||||
for _, name := range body.Segments {
|
||||
kind, ok := markerKindForName(name)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Unknown segment "+name)
|
||||
return
|
||||
}
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
outcomes, err := h.Contributor.ContributeFile(r.Context(), file, markers.ContributeOptions{Provider: body.Provider, Segments: kinds})
|
||||
if err != nil {
|
||||
h.logger.Error("markers: contribute failed", "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Contribution failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"outcomes": contributionOutcomeResponses(outcomes)})
|
||||
}
|
||||
|
||||
// HandleListFileContributions returns the contribution history for a file.
|
||||
func (h *MarkersHandler) HandleListFileContributions(w http.ResponseWriter, r *http.Request) {
|
||||
file, ok := h.loadFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.Contributions == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"contributions": []contributionRowResponse{}})
|
||||
return
|
||||
}
|
||||
rows, err := h.Contributions.ListByFile(r.Context(), file.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: list contributions failed", "file_id", file.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load contributions")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"contributions": contributionRowResponses(rows)})
|
||||
}
|
||||
|
||||
// HandleListFileMarkerHistory returns recent semantic marker edit audit rows for one file.
|
||||
func (h *MarkersHandler) HandleListFileMarkerHistory(w http.ResponseWriter, r *http.Request) {
|
||||
file, ok := h.loadFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.listMarkerHistory(w, r, []int{file.ID})
|
||||
}
|
||||
|
||||
// HandleListItemMarkerHistory returns recent marker edit audit rows for every file version on an item.
|
||||
func (h *MarkersHandler) HandleListItemMarkerHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.Files == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker history is not configured")
|
||||
return
|
||||
}
|
||||
itemID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if itemID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "A valid item id is required")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.Files.GetByEpisodeID(r.Context(), itemID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: episode history file lookup failed", "item_id", itemID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return
|
||||
}
|
||||
if len(files) == 0 {
|
||||
files, err = h.Files.GetByContentID(r.Context(), itemID)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: content history file lookup failed", "item_id", itemID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fileIDs := make([]int, 0, len(files))
|
||||
for _, file := range files {
|
||||
if file != nil {
|
||||
fileIDs = append(fileIDs, file.ID)
|
||||
}
|
||||
}
|
||||
if len(fileIDs) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found for item")
|
||||
return
|
||||
}
|
||||
h.listMarkerHistory(w, r, fileIDs)
|
||||
}
|
||||
|
||||
// HandleListMarkerHistory returns recent semantic marker edit audit rows across all files.
|
||||
func (h *MarkersHandler) HandleListMarkerHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if h.AuditHistory == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"history": []markerEditAuditResponse{}})
|
||||
return
|
||||
}
|
||||
limit, ok := markerHistoryLimit(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.AuditHistory.ListAllMarkerEditAudit(r.Context(), limit)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: list all history failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker history")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"history": markerEditAuditResponses(rows)})
|
||||
}
|
||||
|
||||
func (h *MarkersHandler) listMarkerHistory(w http.ResponseWriter, r *http.Request, fileIDs []int) {
|
||||
if h.AuditHistory == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"history": []markerEditAuditResponse{}})
|
||||
return
|
||||
}
|
||||
limit, ok := markerHistoryLimit(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.AuditHistory.ListMarkerEditAudit(r.Context(), fileIDs, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("markers: list history failed", "file_ids", fileIDs, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker history")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"history": markerEditAuditResponses(rows)})
|
||||
}
|
||||
|
||||
func markerHistoryLimit(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||
limit := 25
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "A valid limit is required")
|
||||
return 0, false
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
return limit, true
|
||||
}
|
||||
|
||||
func (h *MarkersHandler) reloadAndNotify(ctx context.Context, fileID int) (*models.MediaFile, error) {
|
||||
refreshed, err := h.Files.GetByID(ctx, fileID)
|
||||
if err != nil || refreshed == nil {
|
||||
if err == nil {
|
||||
err = scanner.ErrFileNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if h.Notifier != nil {
|
||||
h.Notifier.MarkersUpdated(ctx, refreshed)
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// maybeContribute fires a best-effort, detached contribution run for the
|
||||
// segments just set by a manual save. ContributeFile self-gates on each
|
||||
// provider's contribute_enabled flag, only submits scanner/manual-sourced
|
||||
// segments, and is idempotent — so this is a no-op when contribution is off or
|
||||
// the marker was already submitted. It runs in the background so the save
|
||||
// responds immediately and is never blocked by provider rate limits.
|
||||
func (h *MarkersHandler) maybeContribute(file *models.MediaFile, segments []string) {
|
||||
if h == nil || h.Contributor == nil || file == nil || len(segments) == 0 {
|
||||
return
|
||||
}
|
||||
kinds := make([]markers.MarkerKind, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
if kind, ok := markerKindForName(seg); ok {
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
}
|
||||
if len(kinds) == 0 {
|
||||
return
|
||||
}
|
||||
base := h.BaseContext
|
||||
if base == nil {
|
||||
base = context.Background()
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(base, markerContributeTimeout)
|
||||
defer cancel()
|
||||
if _, err := h.Contributor.ContributeFile(ctx, file, markers.ContributeOptions{Segments: kinds}); err != nil {
|
||||
h.logger.Warn("markers: background contribution failed", "file_id", file.ID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func isJSONNull(raw json.RawMessage) bool {
|
||||
return string(bytes.TrimSpace(raw)) == "null"
|
||||
}
|
||||
|
||||
func isMarkerSegment(seg string) bool {
|
||||
_, ok := markerKindForName(seg)
|
||||
return ok
|
||||
}
|
||||
|
||||
func markerKindForName(name string) (markers.MarkerKind, bool) {
|
||||
switch name {
|
||||
case "intro":
|
||||
return markers.MarkerKindIntro, true
|
||||
case "credits":
|
||||
return markers.MarkerKindCredits, true
|
||||
case "recap":
|
||||
return markers.MarkerKindRecap, true
|
||||
case "preview":
|
||||
return markers.MarkerKindPreview, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func markerNameForKind(kind markers.MarkerKind) string {
|
||||
switch kind {
|
||||
case markers.MarkerKindIntro:
|
||||
return "intro"
|
||||
case markers.MarkerKindCredits:
|
||||
return "credits"
|
||||
case markers.MarkerKindRecap:
|
||||
return "recap"
|
||||
case markers.MarkerKindPreview:
|
||||
return "preview"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func contributionOutcomeResponses(outcomes []markers.ContributionOutcome) []contributionOutcomeResponse {
|
||||
resp := make([]contributionOutcomeResponse, 0, len(outcomes))
|
||||
for _, o := range outcomes {
|
||||
item := contributionOutcomeResponse{
|
||||
Provider: o.Provider,
|
||||
Segment: markerNameForKind(o.Segment),
|
||||
Status: o.Status,
|
||||
SubmissionID: o.SubmissionID,
|
||||
Reason: o.Reason,
|
||||
}
|
||||
if o.RetryAfter > 0 {
|
||||
item.RetryAfterSeconds = int(o.RetryAfter.Seconds())
|
||||
}
|
||||
resp = append(resp, item)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func contributionRowResponses(rows []markers.ContributionRow) []contributionRowResponse {
|
||||
resp := make([]contributionRowResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item := contributionRowResponse{
|
||||
ID: row.ID,
|
||||
MediaFileID: row.MediaFileID,
|
||||
Provider: row.Provider,
|
||||
Segment: row.SegmentKind,
|
||||
Source: row.Source,
|
||||
SubmittedStartMS: row.SubmittedStartMs,
|
||||
SubmittedEndMS: row.SubmittedEndMs,
|
||||
VideoDurationMS: row.VideoDurationMs,
|
||||
ContentHash: row.ContentHash,
|
||||
SubmissionID: row.SubmissionID,
|
||||
Status: row.Status,
|
||||
HTTPStatus: row.HTTPStatus,
|
||||
Error: row.Error,
|
||||
}
|
||||
if !row.SubmittedAt.IsZero() {
|
||||
submittedAt := row.SubmittedAt
|
||||
item.SubmittedAt = &submittedAt
|
||||
}
|
||||
if !row.UpdatedAt.IsZero() {
|
||||
updatedAt := row.UpdatedAt
|
||||
item.UpdatedAt = &updatedAt
|
||||
}
|
||||
resp = append(resp, item)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func markerEditAuditResponses(rows []scanner.MarkerEditAuditRow) []markerEditAuditResponse {
|
||||
resp := make([]markerEditAuditResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
resp = append(resp, markerEditAuditResponse{
|
||||
ID: row.ID,
|
||||
MediaFileID: row.MediaFileID,
|
||||
ItemID: row.ItemID,
|
||||
ItemType: row.ItemType,
|
||||
MediaTitle: row.MediaTitle,
|
||||
FilePath: row.FilePath,
|
||||
Segment: row.SegmentKind,
|
||||
Action: row.Action,
|
||||
Before: markerAuditSegmentResponse(row.Before),
|
||||
After: markerAuditSegmentResponse(row.After),
|
||||
UserID: row.UserID,
|
||||
Username: row.Username,
|
||||
ImpersonatorUserID: row.ImpersonatorUserID,
|
||||
ImpersonatorUsername: row.ImpersonatorUsername,
|
||||
APIKeyID: row.APIKeyID,
|
||||
RequestID: row.RequestID,
|
||||
ClientIP: row.ClientIP,
|
||||
UserAgent: row.UserAgent,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func markerAuditSegmentResponse(segment *scanner.MarkerAuditSegment) *segmentMarker {
|
||||
if segment == nil {
|
||||
return nil
|
||||
}
|
||||
return &segmentMarker{
|
||||
Start: segment.Start,
|
||||
End: segment.End,
|
||||
Source: segment.Source,
|
||||
Provider: segment.Provider,
|
||||
Confidence: segment.Confidence,
|
||||
Algorithm: segment.Algorithm,
|
||||
DetectedAt: segment.DetectedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeManualSegment applies the start/end defaults and validation, mirroring
|
||||
// the contribution rules: intro/recap may omit start (=0); credits/preview may
|
||||
// omit end (=duration); end must exceed start and stay within the file.
|
||||
func normalizeManualSegment(seg string, in segmentInput, duration float64) (start, end float64, err error) {
|
||||
switch seg {
|
||||
case "intro", "recap":
|
||||
if in.Start != nil {
|
||||
start = *in.Start
|
||||
}
|
||||
if in.End == nil {
|
||||
return 0, 0, errSegment(seg, "end is required")
|
||||
}
|
||||
end = *in.End
|
||||
default: // credits, preview
|
||||
if in.Start == nil {
|
||||
return 0, 0, errSegment(seg, "start is required")
|
||||
}
|
||||
start = *in.Start
|
||||
if in.End != nil {
|
||||
end = *in.End
|
||||
} else if duration > 0 {
|
||||
end = duration
|
||||
} else {
|
||||
return 0, 0, errSegment(seg, "end is required when duration is unknown")
|
||||
}
|
||||
}
|
||||
if start < 0 || end <= start {
|
||||
return 0, 0, errSegment(seg, "end must be greater than start")
|
||||
}
|
||||
if duration > 0 && end > duration+1 {
|
||||
return 0, 0, errSegment(seg, "end exceeds the file duration")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func applyManualSegment(update *scanner.MarkerUpdate, seg string, start, end float64) {
|
||||
s, e := start, end
|
||||
switch seg {
|
||||
case "intro":
|
||||
update.IntroStart, update.IntroEnd = &s, &e
|
||||
case "credits":
|
||||
update.CreditsStart, update.CreditsEnd = &s, &e
|
||||
case "recap":
|
||||
update.RecapStart, update.RecapEnd = &s, &e
|
||||
case "preview":
|
||||
update.PreviewStart, update.PreviewEnd = &s, &e
|
||||
}
|
||||
}
|
||||
|
||||
func errSegment(seg, msg string) error {
|
||||
return &markerValidationError{seg: seg, msg: msg}
|
||||
}
|
||||
|
||||
type markerValidationError struct {
|
||||
seg string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *markerValidationError) Error() string { return e.seg + " marker: " + e.msg }
|
||||
@@ -1,524 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
)
|
||||
|
||||
type fakeMarkerFiles struct {
|
||||
file *models.MediaFile
|
||||
byID map[int]*models.MediaFile
|
||||
contentFiles []*models.MediaFile
|
||||
episodeFiles []*models.MediaFile
|
||||
}
|
||||
|
||||
func (f fakeMarkerFiles) GetByID(_ context.Context, id int) (*models.MediaFile, error) {
|
||||
if f.byID != nil {
|
||||
return f.byID[id], nil
|
||||
}
|
||||
return f.file, nil
|
||||
}
|
||||
func (f fakeMarkerFiles) GetByContentID(context.Context, string) ([]*models.MediaFile, error) {
|
||||
return f.contentFiles, nil
|
||||
}
|
||||
func (f fakeMarkerFiles) GetByEpisodeID(context.Context, string) ([]*models.MediaFile, error) {
|
||||
return f.episodeFiles, nil
|
||||
}
|
||||
|
||||
type fakeMarkerWriter struct {
|
||||
upserts []scanner.MarkerUpdate
|
||||
clears [][]string
|
||||
audits []scanner.MarkerAuditContext
|
||||
}
|
||||
|
||||
func (f *fakeMarkerWriter) captureAudit(ctx context.Context) {
|
||||
if audit, ok := scanner.MarkerAuditContextFromContext(ctx); ok {
|
||||
f.audits = append(f.audits, audit)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeMarkerWriter) UpsertMarkers(ctx context.Context, _ int, u scanner.MarkerUpdate) (bool, error) {
|
||||
f.captureAudit(ctx)
|
||||
f.upserts = append(f.upserts, u)
|
||||
return true, nil
|
||||
}
|
||||
func (f *fakeMarkerWriter) ClearMarkers(ctx context.Context, _ int, segs []string) (bool, error) {
|
||||
f.captureAudit(ctx)
|
||||
f.clears = append(f.clears, segs)
|
||||
return true, nil
|
||||
}
|
||||
func (f *fakeMarkerWriter) UpsertAndClearMarkers(ctx context.Context, _ int, u scanner.MarkerUpdate, segs []string) (bool, error) {
|
||||
f.captureAudit(ctx)
|
||||
if u.HasAnySegment() {
|
||||
f.upserts = append(f.upserts, u)
|
||||
}
|
||||
if len(segs) > 0 {
|
||||
f.clears = append(f.clears, segs)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func markerPutRequest(body string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPut, "/markers/files/5", strings.NewReader(body))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("fileId", "5")
|
||||
return withMarkerAdminClaims(req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)))
|
||||
}
|
||||
|
||||
func markerItemPutRequest(body string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPut, "/markers/items/episode-1", strings.NewReader(body))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "episode-1")
|
||||
return withMarkerAdminClaims(req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)))
|
||||
}
|
||||
|
||||
func withMarkerAdminClaims(req *http.Request) *http.Request {
|
||||
return req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
SessionID: "session-1",
|
||||
}))
|
||||
}
|
||||
|
||||
func newMarkersHandler(writer ManualMarkerWriter) *MarkersHandler {
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
return NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
func TestSetFileMarkersWritesManual(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"intro":{"start":0,"end":60}}`))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(writer.upserts) != 1 {
|
||||
t.Fatalf("expected 1 upsert, got %d", len(writer.upserts))
|
||||
}
|
||||
u := writer.upserts[0]
|
||||
if u.MarkersSource != models.MarkerSourceManual {
|
||||
t.Errorf("source = %q, want manual", u.MarkersSource)
|
||||
}
|
||||
if u.IntroStart == nil || *u.IntroStart != 0 || u.IntroEnd == nil || *u.IntroEnd != 60 {
|
||||
t.Errorf("intro = %v..%v, want 0..60", u.IntroStart, u.IntroEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetItemMarkersWritesPrimaryEpisodeFile(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
file := &models.MediaFile{ID: 8, Duration: 1800}
|
||||
files := fakeMarkerFiles{
|
||||
byID: map[int]*models.MediaFile{8: file},
|
||||
episodeFiles: []*models.MediaFile{file},
|
||||
}
|
||||
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetItemMarkers(rec, markerItemPutRequest(`{"recap":{"start":0,"end":45}}`))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(writer.upserts) != 1 {
|
||||
t.Fatalf("expected 1 upsert, got %d", len(writer.upserts))
|
||||
}
|
||||
if writer.upserts[0].RecapEnd == nil || *writer.upserts[0].RecapEnd != 45 {
|
||||
t.Errorf("recap end = %v, want 45", writer.upserts[0].RecapEnd)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeMarkerItemAccess map[string]error
|
||||
|
||||
func (f fakeMarkerItemAccess) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error {
|
||||
return f[contentID]
|
||||
}
|
||||
|
||||
type fakeMarkerEpisodeLookup map[string]*models.Episode
|
||||
|
||||
func (f fakeMarkerEpisodeLookup) GetByID(_ context.Context, contentID string) (*models.Episode, error) {
|
||||
return f[contentID], nil
|
||||
}
|
||||
|
||||
type fakeMarkerUsers map[int]*models.User
|
||||
|
||||
func (f fakeMarkerUsers) GetByID(_ context.Context, id int) (*models.User, error) {
|
||||
return f[id], nil
|
||||
}
|
||||
|
||||
func TestGetItemMarkersUsesFirstAuthorizedFile(t *testing.T) {
|
||||
denied := &models.MediaFile{ID: 8, EpisodeID: "episode-denied", Duration: 1800}
|
||||
allowed := &models.MediaFile{ID: 9, EpisodeID: "episode-allowed", Duration: 1800}
|
||||
files := fakeMarkerFiles{
|
||||
byID: map[int]*models.MediaFile{
|
||||
8: denied,
|
||||
9: allowed,
|
||||
},
|
||||
episodeFiles: []*models.MediaFile{denied, allowed},
|
||||
}
|
||||
h := NewMarkersHandler(files, nil, nil, nil, nil, nil)
|
||||
h.Authorizer = &MediaFileAuthorizer{
|
||||
FileResolver: files,
|
||||
ItemAccess: fakeMarkerItemAccess{
|
||||
"series-denied": catalog.ErrItemNotFound,
|
||||
},
|
||||
EpisodeLookup: fakeMarkerEpisodeLookup{
|
||||
"episode-denied": {ContentID: "episode-denied", SeriesID: "series-denied"},
|
||||
"episode-allowed": {ContentID: "episode-allowed", SeriesID: "series-allowed"},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/markers/items/episode-1", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "episode-1")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleGetItemMarkers(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body fileMarkersResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.FileID != allowed.ID {
|
||||
t.Fatalf("file_id = %d, want first authorized file %d", body.FileID, allowed.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileMarkersDoesNotRequireMarkerEditPermission(t *testing.T) {
|
||||
h := newMarkersHandler(nil)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/markers/files/5", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("fileId", "5")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleGetFileMarkers(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFileMarkersRejectsUserWithoutMarkerEditPermission(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
|
||||
}
|
||||
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(writer.upserts) != 0 {
|
||||
t.Fatalf("permission failure wrote markers: %+v", writer.upserts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFileMarkersAllowsUserWithMarkerEditPermission(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: []string{"marker_edit"}},
|
||||
}
|
||||
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(writer.upserts) != 1 {
|
||||
t.Fatalf("expected marker write, got %d", len(writer.upserts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFileMarkersPassesAuditContextToWriter(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
apiKeyID := int64(99)
|
||||
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
|
||||
req.Header.Set("User-Agent", "marker-test-agent")
|
||||
ctx := clientip.SetContext(req.Context(), "203.0.113.10")
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{
|
||||
UserID: 7,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAPIKey,
|
||||
APIKeyID: apiKeyID,
|
||||
})
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(writer.audits) != 1 {
|
||||
t.Fatalf("audit contexts = %d, want 1", len(writer.audits))
|
||||
}
|
||||
audit := writer.audits[0]
|
||||
if audit.UserID == nil || *audit.UserID != 7 {
|
||||
t.Fatalf("audit user id = %v, want 7", audit.UserID)
|
||||
}
|
||||
if audit.APIKeyID == nil || *audit.APIKeyID != apiKeyID {
|
||||
t.Fatalf("audit api key id = %v, want %d", audit.APIKeyID, apiKeyID)
|
||||
}
|
||||
if audit.ClientIP != "203.0.113.10" || audit.UserAgent != "marker-test-agent" {
|
||||
t.Fatalf("audit request metadata = ip %q ua %q", audit.ClientIP, audit.UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFileMarkersClearsOnNull(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"credits":null}`))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if len(writer.upserts) != 0 {
|
||||
t.Errorf("expected no upsert for a null clear, got %d", len(writer.upserts))
|
||||
}
|
||||
if len(writer.clears) != 1 || len(writer.clears[0]) != 1 || writer.clears[0][0] != "credits" {
|
||||
t.Errorf("clears = %v, want [[credits]]", writer.clears)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFileMarkersRejectsInvalidRange(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"intro":{"start":60,"end":10}}`))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if len(writer.upserts) != 0 {
|
||||
t.Errorf("invalid range must not write, got %d upserts", len(writer.upserts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearFileSegmentRejectsUnknown(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/markers/files/5/bogus", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("fileId", "5")
|
||||
rctx.URLParams.Add("segment", "bogus")
|
||||
req = withMarkerAdminClaims(req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleClearFileSegment(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeMarkerContributor struct{ outcomes []markers.ContributionOutcome }
|
||||
|
||||
func (f fakeMarkerContributor) ContributeFile(context.Context, *models.MediaFile, markers.ContributeOptions) ([]markers.ContributionOutcome, error) {
|
||||
return f.outcomes, nil
|
||||
}
|
||||
|
||||
type captureMarkerContributor struct {
|
||||
opts markers.ContributeOptions
|
||||
}
|
||||
|
||||
func (c *captureMarkerContributor) ContributeFile(_ context.Context, _ *models.MediaFile, opts markers.ContributeOptions) ([]markers.ContributionOutcome, error) {
|
||||
c.opts = opts
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// signalContributor reports every ContributeFile call on a channel so a test
|
||||
// can wait for the detached background contribution kicked off by a save.
|
||||
type signalContributor struct {
|
||||
called chan markers.ContributeOptions
|
||||
}
|
||||
|
||||
func (s signalContributor) ContributeFile(_ context.Context, _ *models.MediaFile, opts markers.ContributeOptions) ([]markers.ContributionOutcome, error) {
|
||||
s.called <- opts
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSetFileMarkersTriggersBackgroundContribution(t *testing.T) {
|
||||
called := make(chan markers.ContributeOptions, 1)
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"intro":{"start":0,"end":60}}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case opts := <-called:
|
||||
if len(opts.Segments) != 1 || opts.Segments[0] != markers.MarkerKindIntro {
|
||||
t.Fatalf("contribute segments = %v, want [intro]", opts.Segments)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("expected a background contribution for the saved intro segment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFileMarkersClearOnlyDoesNotContribute(t *testing.T) {
|
||||
called := make(chan markers.ContributeOptions, 1)
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"credits":null}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("a clear-only save must not trigger contribution")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
type fakeContributionLister struct{ rows []markers.ContributionRow }
|
||||
|
||||
func (f fakeContributionLister) ListByFile(context.Context, int) ([]markers.ContributionRow, error) {
|
||||
return f.rows, nil
|
||||
}
|
||||
|
||||
func TestContributeFileUsesSnakeCaseResponse(t *testing.T) {
|
||||
h := NewMarkersHandler(
|
||||
fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}},
|
||||
nil,
|
||||
fakeMarkerContributor{outcomes: []markers.ContributionOutcome{{
|
||||
Provider: "introdb", Segment: markers.MarkerKindCredits, Status: markers.OutcomeStatusRateLimited,
|
||||
Reason: "usage limited", RetryAfter: 30 * time.Second,
|
||||
}}},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/files/5/contribute", strings.NewReader(`{}`))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("fileId", "5")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleContributeFile(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Outcomes []map[string]any `json:"outcomes"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(body.Outcomes) != 1 || body.Outcomes[0]["segment"] != "credits" {
|
||||
t.Fatalf("outcomes = %+v, want credits segment name", body.Outcomes)
|
||||
}
|
||||
if _, ok := body.Outcomes[0]["Segment"]; ok {
|
||||
t.Fatalf("response leaked internal Segment field: %s", rec.Body.String())
|
||||
}
|
||||
if body.Outcomes[0]["retry_after_seconds"] != float64(30) {
|
||||
t.Fatalf("retry_after_seconds = %v, want 30", body.Outcomes[0]["retry_after_seconds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContributeFileDecodesUnknownLengthBody(t *testing.T) {
|
||||
contributor := &captureMarkerContributor{}
|
||||
h := NewMarkersHandler(
|
||||
fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}},
|
||||
nil,
|
||||
contributor,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/files/5/contribute", strings.NewReader(`{"provider":"introdb","segments":["credits"]}`))
|
||||
req.ContentLength = -1
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("fileId", "5")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleContributeFile(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if contributor.opts.Provider != "introdb" {
|
||||
t.Fatalf("provider = %q, want introdb", contributor.opts.Provider)
|
||||
}
|
||||
if len(contributor.opts.Segments) != 1 || contributor.opts.Segments[0] != markers.MarkerKindCredits {
|
||||
t.Fatalf("segments = %+v, want [credits]", contributor.opts.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFileContributionsUsesSnakeCaseResponse(t *testing.T) {
|
||||
now := time.Unix(100, 0).UTC()
|
||||
h := NewMarkersHandler(
|
||||
fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}},
|
||||
nil,
|
||||
nil,
|
||||
fakeContributionLister{rows: []markers.ContributionRow{{
|
||||
ID: "row1", MediaFileID: 5, Provider: "introdb", SegmentKind: "intro",
|
||||
Source: "manual", ContentHash: "hash", Status: "pending", SubmittedAt: now, UpdatedAt: now,
|
||||
}}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/files/5/contributions", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("fileId", "5")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleListFileContributions(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"media_file_id":5`) || strings.Contains(body, "MediaFileID") {
|
||||
t.Fatalf("unexpected contribution response shape: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,6 @@ var personRefreshRate = ratelimit.Rate{
|
||||
Burst: 10,
|
||||
}
|
||||
|
||||
const personMetadataStaleAfter = 90 * 24 * time.Hour
|
||||
|
||||
// PeopleHandler serves person-related API endpoints.
|
||||
type PeopleHandler struct {
|
||||
personRepo peopleRepository
|
||||
@@ -133,8 +131,6 @@ func (h *PeopleHandler) HandleGetPerson(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
h.enqueuePersonRefreshIfDue(*person)
|
||||
|
||||
writeJSON(w, http.StatusOK, h.toResponse(r.Context(), *person))
|
||||
}
|
||||
|
||||
@@ -363,28 +359,6 @@ func (h *PeopleHandler) toResponse(ctx context.Context, p models.Person) personR
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *PeopleHandler) enqueuePersonRefreshIfDue(person models.Person) {
|
||||
if h.refreshQueue == nil || !personHasRefreshableProviderID(person) {
|
||||
return
|
||||
}
|
||||
|
||||
if personMetadataIncomplete(person) {
|
||||
return
|
||||
}
|
||||
|
||||
if person.UpdatedAt.Before(time.Now().Add(-personMetadataStaleAfter)) {
|
||||
h.refreshQueue.Enqueue(person.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func personHasRefreshableProviderID(person models.Person) bool {
|
||||
return person.TmdbID != "" || person.ImdbID != "" || person.TvdbID != ""
|
||||
}
|
||||
|
||||
func personMetadataIncomplete(person models.Person) bool {
|
||||
return person.Bio == "" || person.PhotoPath == "" || person.PhotoPath == "-" || person.BirthDate == nil
|
||||
}
|
||||
|
||||
func parseOptionalPersonDate(raw string) (*time.Time, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
|
||||
@@ -305,7 +305,6 @@ type subtitleURL struct {
|
||||
Forced bool `json:"forced"`
|
||||
HearingImpaired bool `json:"hearing_impaired"`
|
||||
URL string `json:"url"`
|
||||
FontBundleURL string `json:"font_bundle_url,omitempty"`
|
||||
}
|
||||
|
||||
// changeAudioRequest represents the JSON body for PATCH /playback/{session_id}/audio.
|
||||
@@ -1404,11 +1403,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
if resp.SubtitleURLs[i].Source == "embedded" {
|
||||
// Pass the ffmpeg-relative subtitle stream index to the proxy.
|
||||
embeddedIdx := resp.SubtitleURLs[i].Index - embeddedOffset
|
||||
proxySubtitleURL := proxyNode.URL + "/stream/subtitles/" + token + "/" + strconv.Itoa(embeddedIdx)
|
||||
resp.SubtitleURLs[i].URL = proxySubtitleURL + subtitleURLExt(resp.SubtitleURLs[i].Codec)
|
||||
if resp.SubtitleURLs[i].FontBundleURL != "" {
|
||||
resp.SubtitleURLs[i].FontBundleURL = proxySubtitleURL + "/fonts"
|
||||
}
|
||||
resp.SubtitleURLs[i].URL = proxyNode.URL + "/stream/subtitles/" + token + "/" + strconv.Itoa(embeddedIdx) + subtitleURLExt(resp.SubtitleURLs[i].Codec)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1464,7 +1459,6 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su
|
||||
Forced: track.Forced,
|
||||
HearingImpaired: track.HearingImpaired,
|
||||
URL: fmt.Sprintf("/stream/%s/subtitles/%d%s", sessionID, embeddedOffset+i, subtitleURLExt(track.Codec)),
|
||||
FontBundleURL: subtitleFontBundleURL(sessionID, embeddedOffset+i, track.Codec),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1484,13 +1478,6 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su
|
||||
return urls
|
||||
}
|
||||
|
||||
func subtitleFontBundleURL(sessionID string, trackIndex int, codec string) string {
|
||||
if !playback.IsASS(codec) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/stream/%s/subtitles/%d/fonts", sessionID, trackIndex)
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -1544,7 +1531,6 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update progress")
|
||||
return
|
||||
}
|
||||
h.syncSessionsNow(r.Context(), "progress")
|
||||
|
||||
// Persist progress to UserStore (best-effort).
|
||||
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
|
||||
@@ -1852,7 +1838,6 @@ type transcodeStartState struct {
|
||||
file *models.MediaFile
|
||||
session *playback.Session
|
||||
switchedFileID *int
|
||||
hwAccel string
|
||||
}
|
||||
|
||||
// finalizeTranscodeStart updates the playback session state after a transcode
|
||||
@@ -1893,7 +1878,6 @@ func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeSt
|
||||
TargetVideoCodec: st.req.TargetCodecVideo,
|
||||
TargetAudioCodec: st.req.TargetCodecAudio,
|
||||
TargetBitrateKbps: st.req.TargetBitrateKbps,
|
||||
TranscodeHWAccel: st.hwAccel,
|
||||
}); err != nil {
|
||||
slog.Error("failed to update transcode stream state", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID)
|
||||
}
|
||||
@@ -2068,35 +2052,16 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
slog.Error("remote transcode start rejected", "status", resp.StatusCode, "node", tcNode.URL)
|
||||
writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request")
|
||||
return
|
||||
}
|
||||
var nodeResp transcodenode.TranscodeStartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&nodeResp); err != nil {
|
||||
slog.Warn("remote transcode start response decode failed",
|
||||
"error", err,
|
||||
"node", tcNode.URL,
|
||||
"session", req.SessionID,
|
||||
"playback_session_id", req.SessionID,
|
||||
)
|
||||
}
|
||||
effectiveHWAccel := strings.TrimSpace(nodeResp.HWAccel)
|
||||
if effectiveHWAccel == "" {
|
||||
effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel)
|
||||
}
|
||||
|
||||
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL)
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{
|
||||
req: req,
|
||||
file: file,
|
||||
session: session,
|
||||
switchedFileID: switchedFileID,
|
||||
hwAccel: effectiveHWAccel,
|
||||
})
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{req: req, file: file, session: session, switchedFileID: switchedFileID})
|
||||
writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL))
|
||||
return
|
||||
}
|
||||
@@ -2144,13 +2109,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
h.monitorLocalTranscodeExit(req.SessionID, transcodeSession)
|
||||
|
||||
manifestURL := fmt.Sprintf("/playback/transcode/%s/master.m3u8", req.SessionID)
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{
|
||||
req: req,
|
||||
file: file,
|
||||
session: session,
|
||||
switchedFileID: switchedFileID,
|
||||
hwAccel: transcodeSession.Opts().HWAccel,
|
||||
})
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{req: req, file: file, session: session, switchedFileID: switchedFileID})
|
||||
writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL))
|
||||
}
|
||||
|
||||
@@ -2257,7 +2216,6 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||||
"last_produced_age_ms", lastProducedAgeMS,
|
||||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||||
"restart_on_timeout", decision.RestartOnTimeout,
|
||||
"reason", decision.Reason,
|
||||
"session", sessionID,
|
||||
"playback_session_id", sessionID,
|
||||
@@ -2271,7 +2229,6 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||||
"last_produced_age_ms", lastProducedAgeMS,
|
||||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||||
"restart_on_timeout", decision.RestartOnTimeout,
|
||||
"reason", decision.Reason,
|
||||
"session", sessionID,
|
||||
"playback_session_id", sessionID,
|
||||
@@ -2286,7 +2243,6 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||||
"last_produced_age_ms", lastProducedAgeMS,
|
||||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||||
"restart_on_timeout", decision.RestartOnTimeout,
|
||||
"reason", decision.Reason,
|
||||
"session", sessionID,
|
||||
"playback_session_id", sessionID,
|
||||
@@ -2298,7 +2254,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
// active encode range), either restart at the exact manifest-derived
|
||||
// timeline position or return 404 for copy-mode segments outside the
|
||||
// current manifest window.
|
||||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) && decision.RestartOnTimeout {
|
||||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||||
seekSeconds, ok, seekErr := transcodeSession.RestartSeekTarget(segNum)
|
||||
if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) {
|
||||
slog.Error("resolve transcode seek target", "error", seekErr, "segment", segmentName, "session", sessionID, "playback_session_id", sessionID)
|
||||
@@ -2313,7 +2269,6 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||||
"last_produced_age_ms", lastProducedAgeMS,
|
||||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||||
"restart_on_timeout", decision.RestartOnTimeout,
|
||||
"reason", decision.Reason,
|
||||
"seek_seconds", seekSeconds,
|
||||
"session", sessionID,
|
||||
|
||||
@@ -272,7 +272,7 @@ func (h *PlaybackHandler) fetchOnlineMarkersForPlayback(ctx context.Context, fil
|
||||
EpisodeNumber: ids.EpisodeNumber,
|
||||
Duration: time.Duration(file.Duration) * time.Second,
|
||||
}
|
||||
result, ok, err := h.MarkerRegistry.FetchMerged(ctx, req)
|
||||
result, ok, err := h.MarkerRegistry.FetchFirstHit(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -373,31 +373,17 @@ func hasLocalDetectionMarkers(file *models.MediaFile) bool {
|
||||
// each other.
|
||||
func markerUpdateFromPayload(p markers.MarkerUpdatePayload) scanner.MarkerUpdate {
|
||||
return scanner.MarkerUpdate{
|
||||
IntroStart: p.Intro.Start,
|
||||
IntroEnd: p.Intro.End,
|
||||
CreditsStart: p.Credits.Start,
|
||||
CreditsEnd: p.Credits.End,
|
||||
RecapStart: p.Recap.Start,
|
||||
RecapEnd: p.Recap.End,
|
||||
PreviewStart: p.Preview.Start,
|
||||
PreviewEnd: p.Preview.End,
|
||||
MarkersSource: p.SummarySource(),
|
||||
MarkersConfidence: p.SummaryConfidence(),
|
||||
IntroProvenance: segmentProvenance(p.Intro),
|
||||
CreditsProvenance: segmentProvenance(p.Credits),
|
||||
RecapProvenance: segmentProvenance(p.Recap),
|
||||
PreviewProvenance: segmentProvenance(p.Preview),
|
||||
}
|
||||
}
|
||||
|
||||
func segmentProvenance(s markers.SegmentPayload) *scanner.SegmentProvenance {
|
||||
if !s.Present() {
|
||||
return nil
|
||||
}
|
||||
return &scanner.SegmentProvenance{
|
||||
Source: s.Source,
|
||||
Provider: s.Provider,
|
||||
Confidence: s.Confidence,
|
||||
Algorithm: s.Algorithm,
|
||||
IntroStart: p.IntroStart,
|
||||
IntroEnd: p.IntroEnd,
|
||||
CreditsStart: p.CreditsStart,
|
||||
CreditsEnd: p.CreditsEnd,
|
||||
RecapStart: p.RecapStart,
|
||||
RecapEnd: p.RecapEnd,
|
||||
PreviewStart: p.PreviewStart,
|
||||
PreviewEnd: p.PreviewEnd,
|
||||
MarkersSource: p.Source,
|
||||
MarkersProvider: p.Provider,
|
||||
MarkersConfidence: p.Confidence,
|
||||
MarkersAlgorithm: p.Algorithm,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ type playbackSessionRow struct {
|
||||
FileDuration *int `json:"file_duration"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
HasPlaybackControl bool `json:"has_playback_control"`
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
@@ -54,7 +53,6 @@ type playbackSessionRow struct {
|
||||
TargetVideoCodec string `json:"target_video_codec,omitempty"`
|
||||
TargetAudioCodec string `json:"target_audio_codec,omitempty"`
|
||||
TargetBitrateKbps *int `json:"target_bitrate_kbps"`
|
||||
TranscodeHWAccel string `json:"transcode_hw_accel,omitempty"`
|
||||
SourceContainer string `json:"source_container,omitempty"`
|
||||
SourceBitrateKbps *int `json:"source_bitrate_kbps"`
|
||||
SourceVideoCodec string `json:"source_video_codec,omitempty"`
|
||||
@@ -145,7 +143,6 @@ func (l *PlaybackSessionsLoader) Load(
|
||||
mf.duration,
|
||||
s.started_at,
|
||||
s.updated_at,
|
||||
COALESCE(s.position_seconds, 0),
|
||||
COALESCE(s.is_paused, FALSE),
|
||||
COALESCE(s.has_websocket, FALSE),
|
||||
COALESCE(HOST(s.client_ip), ''),
|
||||
@@ -157,7 +154,6 @@ func (l *PlaybackSessionsLoader) Load(
|
||||
COALESCE(s.target_video_codec, ''),
|
||||
COALESCE(s.target_audio_codec, ''),
|
||||
s.target_bitrate_kbps,
|
||||
COALESCE(s.transcode_hw_accel, ''),
|
||||
COALESCE(mf.container, ''),
|
||||
mf.bitrate,
|
||||
COALESCE(mf.codec_video, ''),
|
||||
@@ -203,9 +199,9 @@ func (l *PlaybackSessionsLoader) Load(
|
||||
&s.MediaTitle, &s.MediaType, &s.SeriesName, &s.EpisodeName, &s.SeasonNumber, &s.EpisodeNumber,
|
||||
&posterPath,
|
||||
&s.PlayMethod, &s.ReportingNode, &s.NodeDisplayName, &s.FileDuration, &s.StartedAt, &s.UpdatedAt,
|
||||
&s.PositionSeconds, &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps,
|
||||
&s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps,
|
||||
&s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps,
|
||||
&s.TranscodeHWAccel, &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
|
||||
&s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
|
||||
&s.SourceAudioCodec, &sourceAudioChannels, &audioTracksJSON, &s.RequestedVideoCodec, &s.RequestedVideoResolution,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning playback session: %w", err)
|
||||
@@ -318,7 +314,7 @@ func sessionComponentDecision(playMethod string, transcodeAudio bool, targetVide
|
||||
}
|
||||
audioDec := "transcode"
|
||||
if !transcodeAudio {
|
||||
audioDec = "remux"
|
||||
audioDec = "direct"
|
||||
}
|
||||
return videoDec, audioDec
|
||||
default:
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSessionComponentDecisionLabelsCopiedAudioDuringHLSAsRemux(t *testing.T) {
|
||||
videoDecision, audioDecision := sessionComponentDecision("transcode", false, "copy")
|
||||
|
||||
if videoDecision != "remux" {
|
||||
t.Fatalf("videoDecision = %q, want remux", videoDecision)
|
||||
}
|
||||
if audioDecision != "remux" {
|
||||
t.Fatalf("audioDecision = %q, want remux", audioDecision)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -21,14 +20,9 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/metadata"
|
||||
"github.com/Silo-Server/silo-server/internal/pluginhost"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/uploads"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPluginUploadSize = 256 << 20
|
||||
maxPluginUploadChunkSize = 1 << 20
|
||||
defaultPluginChunkSize = 512 << 10
|
||||
)
|
||||
const maxPluginUploadSize = 256 << 20
|
||||
|
||||
type PluginHandler struct {
|
||||
repositories *plugins.RepositoryStore
|
||||
@@ -39,7 +33,6 @@ type PluginHandler struct {
|
||||
proxy *plugins.HTTPProxy
|
||||
chainRepo *metadata.ChainRepository
|
||||
imageResolver *metadata.PluginImageResolver
|
||||
uploads *uploads.Manager
|
||||
}
|
||||
|
||||
func NewPluginHandler(
|
||||
@@ -61,10 +54,6 @@ func NewPluginHandler(
|
||||
proxy: proxy,
|
||||
chainRepo: chainRepo,
|
||||
imageResolver: imageResolver,
|
||||
uploads: uploads.NewManager(uploads.ManagerOptions{
|
||||
MaxSize: maxPluginUploadSize,
|
||||
MaxChunkSize: maxPluginUploadChunkSize,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,12 +97,6 @@ type userPluginSettingsRequest struct {
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
type pluginChunkedUploadCreateRequest struct {
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ChunkSize int64 `json:"chunk_size,omitempty"`
|
||||
}
|
||||
|
||||
type pluginRepositoryResponse struct {
|
||||
ID int `json:"id"`
|
||||
URL string `json:"url"`
|
||||
@@ -265,18 +248,6 @@ type pluginTaskBindingUpdateResponse struct {
|
||||
RestartRequired bool `json:"restart_required"`
|
||||
}
|
||||
|
||||
type pluginChunkedUploadSessionResponse struct {
|
||||
UploadID string `json:"upload_id"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ChunkSize int64 `json:"chunk_size"`
|
||||
TotalChunks int `json:"total_chunks"`
|
||||
ReceivedChunks int `json:"received_chunks"`
|
||||
ReceivedBytes int64 `json:"received_bytes"`
|
||||
Complete bool `json:"complete"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (h *PluginHandler) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
|
||||
repositories, err := h.repositories.List(r.Context())
|
||||
if err != nil {
|
||||
@@ -520,114 +491,27 @@ func (h *PluginHandler) HandleUploadInstallation(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.installUploadedPlugin(r.Context(), tempPath)
|
||||
uploadData, err := os.ReadFile(tempPath)
|
||||
if err != nil {
|
||||
slog.Error("reading temp plugin upload file", "filename", header.Filename, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload")
|
||||
return
|
||||
}
|
||||
|
||||
var result *plugins.InstallResult
|
||||
if isZipUpload(uploadData) {
|
||||
result, err = h.service.InstallLocal(r.Context(), plugins.InstallArchiveRequest{
|
||||
ArchivePath: tempPath,
|
||||
})
|
||||
} else {
|
||||
result, err = h.service.InstallBinaryUpload(r.Context(), uploadData)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("installing uploaded plugin", "filename", header.Filename, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install uploaded plugin")
|
||||
return
|
||||
}
|
||||
|
||||
h.writeUploadedPluginResponse(w, r, result)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) HandleCreateChunkedUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var req pluginChunkedUploadCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.ChunkSize == 0 {
|
||||
req.ChunkSize = defaultPluginChunkSize
|
||||
}
|
||||
|
||||
session, err := h.uploads.Create(uploads.CreateRequest{
|
||||
Filename: req.Filename,
|
||||
SizeBytes: req.SizeBytes,
|
||||
ChunkSize: req.ChunkSize,
|
||||
})
|
||||
if err != nil {
|
||||
status, message := uploadErrorResponse(err)
|
||||
writeError(w, status, "upload_error", message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, toPluginChunkedUploadSessionResponse(session))
|
||||
}
|
||||
|
||||
func (h *PluginHandler) HandleUploadChunk(w http.ResponseWriter, r *http.Request) {
|
||||
uploadID := chi.URLParam(r, "upload_id")
|
||||
chunkIndex, err := strconv.Atoi(chi.URLParam(r, "chunk_index"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid chunk index")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, h.uploads.MaxChunkSize()+1)
|
||||
defer r.Body.Close()
|
||||
|
||||
session, err := h.uploads.PutChunk(r.Context(), uploadID, chunkIndex, r.Body, r.ContentLength)
|
||||
if err != nil {
|
||||
status, message := uploadErrorResponse(err)
|
||||
writeError(w, status, "upload_error", message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, toPluginChunkedUploadSessionResponse(session))
|
||||
}
|
||||
|
||||
func (h *PluginHandler) HandleCompleteChunkedUpload(w http.ResponseWriter, r *http.Request) {
|
||||
uploadID := chi.URLParam(r, "upload_id")
|
||||
upload, err := h.uploads.Complete(uploadID)
|
||||
if err != nil {
|
||||
status, message := uploadErrorResponse(err)
|
||||
writeError(w, status, "upload_error", message)
|
||||
return
|
||||
}
|
||||
defer upload.Cleanup()
|
||||
|
||||
result, err := h.installUploadedPlugin(r.Context(), upload.Path)
|
||||
if err != nil {
|
||||
slog.Error("installing chunked plugin upload",
|
||||
"filename", upload.Filename,
|
||||
"upload_id", upload.ID,
|
||||
"error", err,
|
||||
)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install uploaded plugin")
|
||||
return
|
||||
}
|
||||
|
||||
h.writeUploadedPluginResponse(w, r, result)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) HandleCancelChunkedUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.uploads.Cancel(chi.URLParam(r, "upload_id")); err != nil && !errors.Is(err, uploads.ErrNotFound) {
|
||||
status, message := uploadErrorResponse(err)
|
||||
writeError(w, status, "upload_error", message)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) installUploadedPlugin(ctx context.Context, path string) (*plugins.InstallResult, error) {
|
||||
zipUpload, err := isZipUploadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if zipUpload {
|
||||
return h.service.InstallLocal(ctx, plugins.InstallArchiveRequest{
|
||||
ArchivePath: path,
|
||||
})
|
||||
}
|
||||
|
||||
uploadData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read uploaded plugin binary: %w", err)
|
||||
}
|
||||
return h.service.InstallBinaryUpload(ctx, uploadData)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) writeUploadedPluginResponse(w http.ResponseWriter, r *http.Request, result *plugins.InstallResult) {
|
||||
h.syncMetadataProviders(r.Context(), result.Installation)
|
||||
h.syncImageResolvers(r.Context(), result.Installation)
|
||||
|
||||
@@ -709,55 +593,6 @@ func isZipUpload(data []byte) bool {
|
||||
bytes.Equal(data[:4], []byte("PK\x07\x08"))
|
||||
}
|
||||
|
||||
func isZipUploadFile(path string) (bool, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("open uploaded plugin file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var header [4]byte
|
||||
n, err := io.ReadFull(file, header[:])
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return false, fmt.Errorf("read uploaded plugin header: %w", err)
|
||||
}
|
||||
return isZipUpload(header[:n]), nil
|
||||
}
|
||||
|
||||
func toPluginChunkedUploadSessionResponse(session uploads.SessionInfo) pluginChunkedUploadSessionResponse {
|
||||
return pluginChunkedUploadSessionResponse{
|
||||
UploadID: session.ID,
|
||||
Filename: session.Filename,
|
||||
SizeBytes: session.SizeBytes,
|
||||
ChunkSize: session.ChunkSize,
|
||||
TotalChunks: session.TotalChunks,
|
||||
ReceivedChunks: session.ReceivedChunks,
|
||||
ReceivedBytes: session.ReceivedBytes,
|
||||
Complete: session.Complete,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func uploadErrorResponse(err error) (int, string) {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &maxBytesErr), errors.Is(err, uploads.ErrTooLarge):
|
||||
return http.StatusRequestEntityTooLarge, "Upload exceeds the maximum allowed size"
|
||||
case errors.Is(err, uploads.ErrNotFound):
|
||||
return http.StatusNotFound, "Upload session not found"
|
||||
case errors.Is(err, uploads.ErrExpired):
|
||||
return http.StatusGone, "Upload session expired"
|
||||
case errors.Is(err, uploads.ErrIncomplete):
|
||||
return http.StatusConflict, "Upload session is incomplete"
|
||||
case errors.Is(err, uploads.ErrAlreadyCompleted):
|
||||
return http.StatusConflict, "Upload session is already complete"
|
||||
case errors.Is(err, uploads.ErrInvalidChunk), errors.Is(err, uploads.ErrInvalidRequest):
|
||||
return http.StatusBadRequest, err.Error()
|
||||
default:
|
||||
return http.StatusInternalServerError, "Failed to process upload"
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseNamedIDParam(r, "id")
|
||||
if err != nil {
|
||||
|
||||
@@ -258,7 +258,7 @@ func (h *ProgressHandler) HandleSyncProgress(w http.ResponseWriter, r *http.Requ
|
||||
item.MediaItemID,
|
||||
"",
|
||||
"progress",
|
||||
userStateEventState{},
|
||||
"progress.updated",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,9 +789,12 @@ func (h *RecommendationsHandler) blendUpcomingIntoDiscoverRows(
|
||||
events, err := h.CalendarRepo.ListEvents(ctx, catalog.CalendarFilter{
|
||||
Start: start,
|
||||
End: end,
|
||||
Filter: "all",
|
||||
AllowedLibraryIDs: filter.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: filter.DisabledLibraryIDs,
|
||||
MaxContentRating: filter.MaxContentRating,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
})
|
||||
if err != nil {
|
||||
return discoverRows, err
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// forwardedHost returns the public host a reverse proxy or CDN forwarded the
|
||||
// request on behalf of, taken from the first X-Forwarded-Host value, or "" when
|
||||
// the header is absent.
|
||||
//
|
||||
// Behind a TLS-terminating CDN/proxy, r.Host is the internal origin host the
|
||||
// proxy dialed, while the public host the browser actually used arrives here.
|
||||
// Callers that need to reason about the client-facing host (origin checks,
|
||||
// absolute URL construction) must prefer this over r.Host.
|
||||
func forwardedHost(r *http.Request) string {
|
||||
forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
||||
if forwarded == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Multiple proxy hops produce a comma-separated list; the first entry is
|
||||
// the original client-facing host.
|
||||
if comma := strings.IndexByte(forwarded, ','); comma >= 0 {
|
||||
forwarded = forwarded[:comma]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(forwarded)
|
||||
}
|
||||
@@ -35,9 +35,8 @@ type RequestService interface {
|
||||
GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error)
|
||||
UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error)
|
||||
ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error)
|
||||
CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error
|
||||
UpsertIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
UpsertIntegrations(ctx context.Context, viewer mediarequests.Viewer, integrations []mediarequests.Integration) ([]mediarequests.Integration, error)
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
|
||||
|
||||
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
@@ -423,53 +422,26 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.
|
||||
}{Integrations: toIntegrationResponses(integrations)})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *RequestsHandler) HandleUpdateIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var integration mediarequests.Integration
|
||||
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
|
||||
var body struct {
|
||||
Integrations []mediarequests.Integration `json:"integrations"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
created, err := h.service.CreateIntegration(r.Context(), viewer, integration)
|
||||
updated, err := h.service.UpsertIntegrations(r.Context(), viewer, body.Integrations)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, requestIntegrationResponseFrom(*created))
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var integration mediarequests.Integration
|
||||
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
integration.ID = chi.URLParam(r, "id")
|
||||
updated, err := h.service.UpdateIntegration(r.Context(), viewer, integration)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, requestIntegrationResponseFrom(*updated))
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteIntegration(r.Context(), viewer, chi.URLParam(r, "id")); err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Integrations []requestIntegrationResponse `json:"integrations"`
|
||||
}{Integrations: toIntegrationResponses(updated)})
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -484,9 +456,7 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
}
|
||||
if id := strings.TrimSpace(chi.URLParam(r, "id")); id != "" {
|
||||
integration.ID = id
|
||||
}
|
||||
integration.Kind = chi.URLParam(r, "kind")
|
||||
options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration)
|
||||
if err != nil {
|
||||
writeRequestServiceError(w, err)
|
||||
@@ -588,69 +558,37 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i
|
||||
}
|
||||
|
||||
type requestIntegrationResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Is4K bool `json:"is_4k"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsDefault4K bool `json:"is_default_4k"`
|
||||
AnimeEnabled bool `json:"anime_enabled"`
|
||||
AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"`
|
||||
AnimeRootFolder string `json:"anime_root_folder,omitempty"`
|
||||
AnimeTags []int `json:"anime_tags"`
|
||||
BaseURL string `json:"base_url"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
RootFolder string `json:"root_folder"`
|
||||
QualityProfileID *int `json:"quality_profile_id,omitempty"`
|
||||
Tags []int `json:"tags"`
|
||||
Options map[string]any `json:"options"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
||||
LastCheckError string `json:"last_check_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func requestIntegrationResponseFrom(integration mediarequests.Integration) requestIntegrationResponse {
|
||||
// Normalize nil slices so they serialize as [] (not null); the frontend
|
||||
// types them as number[] and indexes/maps over them.
|
||||
tags := integration.Tags
|
||||
if tags == nil {
|
||||
tags = []int{}
|
||||
}
|
||||
animeTags := integration.AnimeTags
|
||||
if animeTags == nil {
|
||||
animeTags = []int{}
|
||||
}
|
||||
return requestIntegrationResponse{
|
||||
ID: integration.ID,
|
||||
Name: integration.Name,
|
||||
Kind: integration.Kind,
|
||||
Enabled: integration.Enabled,
|
||||
Is4K: integration.Is4K,
|
||||
IsDefault: integration.IsDefault,
|
||||
IsDefault4K: integration.IsDefault4K,
|
||||
AnimeEnabled: integration.AnimeEnabled,
|
||||
AnimeQualityProfileID: integration.AnimeQualityProfileID,
|
||||
AnimeRootFolder: integration.AnimeRootFolder,
|
||||
AnimeTags: animeTags,
|
||||
BaseURL: integration.BaseURL,
|
||||
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
|
||||
RootFolder: integration.RootFolder,
|
||||
QualityProfileID: integration.QualityProfileID,
|
||||
Tags: tags,
|
||||
Options: integration.Options,
|
||||
LastCheckAt: integration.LastCheckAt,
|
||||
LastCheckStatus: integration.LastCheckStatus,
|
||||
LastCheckError: integration.LastCheckError,
|
||||
UpdatedAt: integration.UpdatedAt,
|
||||
}
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
RootFolder string `json:"root_folder"`
|
||||
QualityProfileID *int `json:"quality_profile_id,omitempty"`
|
||||
Tags []int `json:"tags"`
|
||||
Options map[string]any `json:"options"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
||||
LastCheckError string `json:"last_check_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toIntegrationResponses(integrations []mediarequests.Integration) []requestIntegrationResponse {
|
||||
out := make([]requestIntegrationResponse, 0, len(integrations))
|
||||
for _, integration := range integrations {
|
||||
out = append(out, requestIntegrationResponseFrom(integration))
|
||||
out = append(out, requestIntegrationResponse{
|
||||
Kind: integration.Kind,
|
||||
Enabled: integration.Enabled,
|
||||
BaseURL: integration.BaseURL,
|
||||
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
|
||||
RootFolder: integration.RootFolder,
|
||||
QualityProfileID: integration.QualityProfileID,
|
||||
Tags: integration.Tags,
|
||||
Options: integration.Options,
|
||||
LastCheckAt: integration.LastCheckAt,
|
||||
LastCheckStatus: integration.LastCheckStatus,
|
||||
LastCheckError: integration.LastCheckError,
|
||||
UpdatedAt: integration.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -127,16 +127,12 @@ func (f *fakeRequestService) ListIntegrations(context.Context, mediarequests.Vie
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeRequestService) CreateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) {
|
||||
return &integration, nil
|
||||
func (f *fakeRequestService) UpsertIntegration(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.Integration, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeRequestService) UpdateIntegration(_ context.Context, _ mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error) {
|
||||
return &integration, nil
|
||||
}
|
||||
|
||||
func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Viewer, string) error {
|
||||
return nil
|
||||
func (f *fakeRequestService) UpsertIntegrations(context.Context, mediarequests.Viewer, []mediarequests.Integration) ([]mediarequests.Integration, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
|
||||
|
||||
@@ -32,7 +32,7 @@ type SectionHandler struct {
|
||||
StoreProvider userstore.UserStoreProvider
|
||||
UserRepo *auth.UserRepository
|
||||
DetailSvc *catalog.DetailService
|
||||
Settings catalog.SettingsStore
|
||||
Settings *catalog.ServerSettingsRepo
|
||||
CollectionRepo *catalog.LibraryCollectionRepository
|
||||
}
|
||||
|
||||
@@ -1359,7 +1359,15 @@ func (h *SectionHandler) toSectionItemResponse(sectionType sections.SectionType,
|
||||
// uses the cached w1280 backdrop for Continue Watching / Next Up rows.
|
||||
func sectionBackdropPath(sectionType sections.SectionType, path string) string {
|
||||
if sectionType == sections.SectionContinueWatching || sectionType == sections.SectionNextUp {
|
||||
return catalog.BackdropVariantPath(path, "w1280")
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
// Plugin-prefixed paths pass through; the resolver still receives the
|
||||
// existing "featured" semantic variant.
|
||||
if strings.Contains(path, "://") {
|
||||
return path
|
||||
}
|
||||
return strings.Replace(path, "/original.", "/w1280.", 1)
|
||||
}
|
||||
return featuredBackdropPath(path)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const SectionsAllowProfileCustomSettingKey = "sections.allow_profile_custom_sect
|
||||
|
||||
// SectionSettingsHandler exposes GET/PUT for the sections-related server setting.
|
||||
type SectionSettingsHandler struct {
|
||||
Settings catalog.SettingsStore
|
||||
Settings *catalog.ServerSettingsRepo
|
||||
}
|
||||
|
||||
type sectionsSettingResponse struct {
|
||||
|
||||
@@ -52,18 +52,6 @@ func TestSectionBackdropPathUsesExpectedVariants(t *testing.T) {
|
||||
path: "/tmdb/shows/1399/backdrop/original.jpg",
|
||||
want: "/tmdb/shows/1399/backdrop/w1920.jpg",
|
||||
},
|
||||
{
|
||||
name: "continue watching episode still clamps to w500",
|
||||
sectionType: sections.SectionContinueWatching,
|
||||
path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp",
|
||||
want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp",
|
||||
},
|
||||
{
|
||||
name: "featured section episode still clamps to w500",
|
||||
sectionType: sections.SectionRecentlyAdded,
|
||||
path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp",
|
||||
want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp",
|
||||
},
|
||||
{
|
||||
name: "http paths pass through",
|
||||
sectionType: sections.SectionContinueWatching,
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
var ErrServerRestartAlreadyRequested = errors.New("server restart already requested")
|
||||
|
||||
type ServerControlHandler struct {
|
||||
requestRestart func(ctx context.Context) error
|
||||
commands *playback.CommandDispatcher
|
||||
}
|
||||
|
||||
type serverRestartRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type serverRestartResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
NotifiedSessions int `json:"notified_sessions"`
|
||||
}
|
||||
|
||||
func NewServerControlHandler(
|
||||
requestRestart func(ctx context.Context) error,
|
||||
commands *playback.CommandDispatcher,
|
||||
) *ServerControlHandler {
|
||||
return &ServerControlHandler{
|
||||
requestRestart: requestRestart,
|
||||
commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRestart handles POST /admin/server/restart.
|
||||
func (h *ServerControlHandler) HandleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.requestRestart == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Server restart is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var req serverRestartRequest
|
||||
if err := decodeOptionalJSONBody(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
notifiedSessions := h.notifyPlaybackSessions(req)
|
||||
|
||||
if err := h.requestRestart(context.Background()); err != nil {
|
||||
if errors.Is(err, ErrServerRestartAlreadyRequested) {
|
||||
writeJSON(w, http.StatusAccepted, serverRestartResponse{
|
||||
Status: "already_requested",
|
||||
Message: "Server restart is already in progress.",
|
||||
NotifiedSessions: notifiedSessions,
|
||||
})
|
||||
return
|
||||
}
|
||||
slog.Error("server restart request failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request server restart")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, serverRestartResponse{
|
||||
Status: "restart_requested",
|
||||
Message: "Server restart requested. The process will shut down gracefully.",
|
||||
NotifiedSessions: notifiedSessions,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest) int {
|
||||
if h == nil || h.commands == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
title := req.Title
|
||||
if title == "" {
|
||||
title = "Server restarting"
|
||||
}
|
||||
message := req.Message
|
||||
if message == "" {
|
||||
message = "The server is restarting now. Playback may reconnect shortly."
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"title": title,
|
||||
"message": message,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("server restart: failed to build realtime payload", "error", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
results := h.commands.DispatchToAll(func(session *playback.Session) (playback.CommandEnvelope, time.Duration, func(), error) {
|
||||
if session == nil {
|
||||
return playback.CommandEnvelope{}, 0, nil, playback.ErrCommandDispatchUnavailable
|
||||
}
|
||||
command, err := playback.NewCommandEnvelope(
|
||||
session.ID,
|
||||
uuid.NewString(),
|
||||
playback.CommandServerRestarting,
|
||||
payload,
|
||||
)
|
||||
if err != nil {
|
||||
return playback.CommandEnvelope{}, 0, nil, err
|
||||
}
|
||||
command.Reason = req.Reason
|
||||
if command.Reason == "" {
|
||||
command.Reason = "server_restart_requested"
|
||||
}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
return command, 0, nil, nil
|
||||
})
|
||||
|
||||
delivered := 0
|
||||
for _, result := range results {
|
||||
if result.Delivered {
|
||||
delivered++
|
||||
continue
|
||||
}
|
||||
if result.DispatchErr != nil && !errors.Is(result.DispatchErr, playback.ErrRealtimeConnectionNotFound) {
|
||||
slog.Warn("server restart: failed to notify playback session",
|
||||
"session_id", result.SessionID,
|
||||
"error", result.DispatchErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
type serverControlTestConn struct {
|
||||
messages []any
|
||||
}
|
||||
|
||||
func (c *serverControlTestConn) WriteJSON(v any) error {
|
||||
c.messages = append(c.messages, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestServerControlRestartRequestsShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
called := 0
|
||||
handler := NewServerControlHandler(func(context.Context) error {
|
||||
called++
|
||||
return nil
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if called != 1 {
|
||||
t.Fatalf("restart calls = %d, want 1", called)
|
||||
}
|
||||
|
||||
var resp serverRestartResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Status != "restart_requested" {
|
||||
t.Fatalf("status = %q, want restart_requested", resp.Status)
|
||||
}
|
||||
if resp.NotifiedSessions != 0 {
|
||||
t.Fatalf("notified_sessions = %d, want 0", resp.NotifiedSessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerControlRestartUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewServerControlHandler(nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerControlRestartAlreadyRequested(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewServerControlHandler(func(context.Context) error {
|
||||
return ErrServerRestartAlreadyRequested
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp serverRestartResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Status != "already_requested" {
|
||||
t.Fatalf("status = %q, want already_requested", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerControlRestartNotifiesPlaybackSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", 100, playback.PlayDirect, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
realtimeHub := playback.NewRealtimeHub()
|
||||
conn := &serverControlTestConn{}
|
||||
registration := realtimeHub.Register(session.ID, conn)
|
||||
if registration == nil {
|
||||
t.Fatal("expected realtime registration")
|
||||
}
|
||||
defer realtimeHub.Unregister(registration)
|
||||
|
||||
dispatcher := playback.NewCommandDispatcher(sessionMgr, realtimeHub, nil)
|
||||
handler := NewServerControlHandler(func(context.Context) error {
|
||||
return nil
|
||||
}, dispatcher)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/admin/server/restart",
|
||||
strings.NewReader(`{"reason":"maintenance","message":"Restarting for maintenance"}`),
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp serverRestartResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.NotifiedSessions != 1 {
|
||||
t.Fatalf("notified_sessions = %d, want 1", resp.NotifiedSessions)
|
||||
}
|
||||
if len(conn.messages) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(conn.messages))
|
||||
}
|
||||
|
||||
command, ok := conn.messages[0].(playback.CommandEnvelope)
|
||||
if !ok {
|
||||
t.Fatalf("message type = %T, want playback.CommandEnvelope", conn.messages[0])
|
||||
}
|
||||
if command.Name != playback.CommandServerRestarting {
|
||||
t.Fatalf("command name = %q, want %q", command.Name, playback.CommandServerRestarting)
|
||||
}
|
||||
if command.Reason != "maintenance" {
|
||||
t.Fatalf("reason = %q, want maintenance", command.Reason)
|
||||
}
|
||||
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(command.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload["message"] != "Restarting for maintenance" {
|
||||
t.Fatalf("payload message = %q, want custom message", payload["message"])
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -17,10 +16,6 @@ import (
|
||||
)
|
||||
|
||||
const subtitleAppearanceSettingKey = "subtitle_appearance"
|
||||
const (
|
||||
libraryPageStateSettingKey = "ui.library_page_state"
|
||||
rememberLibraryPageStateSettingKey = "ui.remember_library_page_state"
|
||||
)
|
||||
|
||||
const (
|
||||
deviceIDHeader = "X-Silo-Device-Id"
|
||||
@@ -161,16 +156,6 @@ var settingsRegistry = map[string]settingSpec{
|
||||
DefaultValue: "",
|
||||
Validate: validateJSONSetting(subtitleAppearanceSettingKey),
|
||||
},
|
||||
libraryPageStateSettingKey: {
|
||||
Scope: scopeDevice,
|
||||
DefaultValue: "",
|
||||
Validate: validateJSONSetting(libraryPageStateSettingKey),
|
||||
},
|
||||
rememberLibraryPageStateSettingKey: {
|
||||
Scope: scopeDevice,
|
||||
DefaultValue: "true",
|
||||
Validate: validateBoolSetting(rememberLibraryPageStateSettingKey),
|
||||
},
|
||||
"player.hdr_enabled": {
|
||||
Scope: scopeDevice,
|
||||
DefaultValue: "true",
|
||||
@@ -373,7 +358,6 @@ func (h *SettingsHandler) HandleGetDeviceSetting(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
||||
return
|
||||
}
|
||||
registerRequestDevice(r.Context(), store, profileID, device)
|
||||
|
||||
value, err := store.GetDeviceSetting(r.Context(), profileID, device.DeviceID, key)
|
||||
if err != nil {
|
||||
@@ -473,7 +457,6 @@ func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *ht
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
||||
return
|
||||
}
|
||||
registerRequestDevice(r.Context(), store, profileID, device)
|
||||
|
||||
if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, key); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
|
||||
@@ -503,7 +486,6 @@ func (h *SettingsHandler) HandleGetEffectiveSettings(w http.ResponseWriter, r *h
|
||||
}
|
||||
|
||||
device := deviceMetadataFromRequest(r)
|
||||
registerRequestDevice(r.Context(), store, profileID, device)
|
||||
keys := parseSettingKeys(keysParam)
|
||||
resp := effectiveSettingsResponse{
|
||||
Settings: make([]effectiveSettingResponse, 0, len(keys)),
|
||||
@@ -534,7 +516,6 @@ func (h *SettingsHandler) HandleGetEffectiveSubtitleAppearance(w http.ResponseWr
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
||||
return
|
||||
}
|
||||
registerRequestDevice(r.Context(), store, profileID, device)
|
||||
|
||||
resolved, err := h.resolveEffectiveSetting(r.Context(), store, profileID, device, subtitleAppearanceSettingKey)
|
||||
if err != nil {
|
||||
@@ -595,36 +576,6 @@ func deviceMetadataFromRequest(r *http.Request) requestDeviceMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
func registerRequestDevice(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
device requestDeviceMetadata,
|
||||
) {
|
||||
if strings.TrimSpace(profileID) == "" || strings.TrimSpace(device.DeviceID) == "" {
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
registry, ok := store.(userstore.DeviceRegistry)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := registry.RegisterDevice(ctx, userstore.DeviceEntry{
|
||||
ProfileID: profileID,
|
||||
DeviceID: device.DeviceID,
|
||||
DeviceName: device.DeviceName,
|
||||
DevicePlatform: device.DevicePlatform,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to register request device",
|
||||
"profile_id", profileID,
|
||||
"device_id", device.DeviceID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func clampHeaderValue(value string, maxLen int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
runes := []rune(value)
|
||||
|
||||
@@ -47,14 +47,6 @@ func (r testAdminUserRepo) GetByID(_ context.Context, id int) (*models.User, err
|
||||
return r.users[id], nil
|
||||
}
|
||||
|
||||
func TestRegisterRequestDeviceNilStore(t *testing.T) {
|
||||
registerRequestDevice(context.Background(), nil, "profile-1", requestDeviceMetadata{
|
||||
DeviceID: "device-1",
|
||||
DeviceName: "Living Room",
|
||||
DevicePlatform: "web",
|
||||
})
|
||||
}
|
||||
|
||||
type mappedTestUserStoreProvider struct {
|
||||
stores map[int]userstore.UserStore
|
||||
}
|
||||
@@ -184,7 +176,7 @@ func TestGetEffectiveSettingsResolvesUserDeviceAndDefaultSources(t *testing.T) {
|
||||
handler := NewSettingsHandler(testUserStoreProvider{store: store})
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/settings/effective?keys=playback.preferred_quality,player.playback_speed,player.hdr_enabled,ui.remember_library_page_state",
|
||||
"/settings/effective?keys=playback.preferred_quality,player.playback_speed,player.hdr_enabled",
|
||||
nil,
|
||||
)
|
||||
req.Header.Set(deviceIDHeader, "apple-tv")
|
||||
@@ -200,7 +192,7 @@ func TestGetEffectiveSettingsResolvesUserDeviceAndDefaultSources(t *testing.T) {
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Settings) != 4 {
|
||||
if len(resp.Settings) != 3 {
|
||||
t.Fatalf("settings len = %d", len(resp.Settings))
|
||||
}
|
||||
byKey := make(map[string]effectiveSettingResponse, len(resp.Settings))
|
||||
@@ -216,9 +208,6 @@ func TestGetEffectiveSettingsResolvesUserDeviceAndDefaultSources(t *testing.T) {
|
||||
if got := byKey["player.hdr_enabled"]; got.EffectiveValue != "true" || got.Source != "default" {
|
||||
t.Fatalf("hdr_enabled = %#v", got)
|
||||
}
|
||||
if got := byKey[rememberLibraryPageStateSettingKey]; got.EffectiveValue != "true" || got.Source != "default" {
|
||||
t.Fatalf("remember_library_page_state = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericSettingsRejectInvalidRegisteredValues(t *testing.T) {
|
||||
@@ -241,129 +230,6 @@ func TestGenericSettingsRejectInvalidRegisteredValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryPageStateIsDeviceScopedJSONSetting(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewSettingsHandler(testUserStoreProvider{store: store})
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/settings/device/ui.library_page_state",
|
||||
bytes.NewBufferString(`{"value":"{\"version\":1,\"libraries\":{\"7\":{\"search\":\"tab=library\"}}}"}`),
|
||||
)
|
||||
req = withRouteParams(req, map[string]string{"key": libraryPageStateSettingKey})
|
||||
req.Header.Set(deviceIDHeader, "browser")
|
||||
req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleSetDeviceSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
entry, err := store.GetDeviceSetting(context.Background(), "profile-1", "browser", libraryPageStateSettingKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceSetting: %v", err)
|
||||
}
|
||||
if entry == nil || !strings.Contains(entry.Value, `"tab=library"`) {
|
||||
t.Fatalf("entry = %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryPageStateRejectsInvalidJSONAndUserScope(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewSettingsHandler(testUserStoreProvider{store: store})
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/settings/device/ui.library_page_state",
|
||||
bytes.NewBufferString(`{"value":"not json"}`),
|
||||
)
|
||||
req = withRouteParams(req, map[string]string{"key": libraryPageStateSettingKey})
|
||||
req.Header.Set(deviceIDHeader, "browser")
|
||||
req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleSetDeviceSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid JSON status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/settings/ui.library_page_state",
|
||||
bytes.NewBufferString(`{"value":"{\"version\":1,\"libraries\":{}}"}`),
|
||||
)
|
||||
req = withRouteParams(req, map[string]string{"key": libraryPageStateSettingKey})
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}))
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
handler.HandleSetSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("user-scope status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberLibraryPageStateIsDeviceScopedBoolSetting(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewSettingsHandler(testUserStoreProvider{store: store})
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/settings/device/ui.remember_library_page_state",
|
||||
bytes.NewBufferString(`{"value":"false"}`),
|
||||
)
|
||||
req = withRouteParams(req, map[string]string{"key": rememberLibraryPageStateSettingKey})
|
||||
req.Header.Set(deviceIDHeader, "browser")
|
||||
req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleSetDeviceSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
entry, err := store.GetDeviceSetting(context.Background(), "profile-1", "browser", rememberLibraryPageStateSettingKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceSetting: %v", err)
|
||||
}
|
||||
if entry == nil || entry.Value != "false" {
|
||||
t.Fatalf("entry = %#v", entry)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/settings/device/ui.remember_library_page_state",
|
||||
bytes.NewBufferString(`{"value":"maybe"}`),
|
||||
)
|
||||
req = withRouteParams(req, map[string]string{"key": rememberLibraryPageStateSettingKey})
|
||||
req.Header.Set(deviceIDHeader, "browser")
|
||||
req = req.WithContext(apimw.SetProfileID(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
handler.HandleSetDeviceSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid bool status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/settings/ui.remember_library_page_state",
|
||||
bytes.NewBufferString(`{"value":"false"}`),
|
||||
)
|
||||
req = withRouteParams(req, map[string]string{"key": rememberLibraryPageStateSettingKey})
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}))
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
handler.HandleSetSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("user-scope status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanResetSubtitleAppearanceDeviceOverrides(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
for _, deviceID := range []string{"apple-tv", "iphone"} {
|
||||
@@ -480,18 +346,6 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("SetDeviceSetting store1 second: %v", err)
|
||||
}
|
||||
store1Registry, ok := store1.(userstore.DeviceRegistry)
|
||||
if !ok {
|
||||
t.Fatalf("store1 does not support device registry")
|
||||
}
|
||||
if err := store1Registry.RegisterDevice(context.Background(), userstore.DeviceEntry{
|
||||
ProfileID: "profile-1",
|
||||
DeviceID: "bedroom",
|
||||
DeviceName: "Bedroom TV",
|
||||
DevicePlatform: "Android TV",
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterDevice store1: %v", err)
|
||||
}
|
||||
if err := store2.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{
|
||||
ProfileID: "profile-1",
|
||||
DeviceID: "phone",
|
||||
@@ -528,21 +382,8 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
|
||||
if err := json.NewDecoder(rec.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(listResp.Devices) != 3 {
|
||||
t.Fatalf("devices len = %d, want 3", len(listResp.Devices))
|
||||
}
|
||||
var bedroom *adminDeviceSummaryResponse
|
||||
for i := range listResp.Devices {
|
||||
if listResp.Devices[i].DeviceID == "bedroom" {
|
||||
bedroom = &listResp.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if bedroom == nil {
|
||||
t.Fatalf("registered device without overrides missing: %#v", listResp.Devices)
|
||||
}
|
||||
if bedroom.OverrideCount != 0 || bedroom.ProfileCount != 1 || bedroom.DeviceName != "Bedroom TV" {
|
||||
t.Fatalf("registered device summary = %#v", bedroom)
|
||||
if len(listResp.Devices) != 2 {
|
||||
t.Fatalf("devices len = %d, want 2", len(listResp.Devices))
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/devices/7/living-room", nil)
|
||||
@@ -562,26 +403,6 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
|
||||
if len(detailResp.Settings) != 2 {
|
||||
t.Fatalf("detail settings len = %d, want 2", len(detailResp.Settings))
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/devices/7/bedroom", nil)
|
||||
req = withRouteParams(req, map[string]string{"user_id": "7", "device_id": "bedroom"})
|
||||
rec = httptest.NewRecorder()
|
||||
handler.HandleGetDevice(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("registered detail status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&detailResp); err != nil {
|
||||
t.Fatalf("decode registered detail: %v", err)
|
||||
}
|
||||
if detailResp.DeviceName != "Bedroom TV" || detailResp.OverrideCount != 0 {
|
||||
t.Fatalf("registered detail response = %#v", detailResp)
|
||||
}
|
||||
if len(detailResp.Settings) != 0 {
|
||||
t.Fatalf("registered detail settings len = %d, want 0", len(detailResp.Settings))
|
||||
}
|
||||
if len(detailResp.Profiles) != 1 || detailResp.Profiles[0].ProfileID != "profile-1" {
|
||||
t.Fatalf("registered detail profiles = %#v", detailResp.Profiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanResetAllOverridesForOneDevice(t *testing.T) {
|
||||
@@ -612,24 +433,6 @@ func TestAdminCanResetAllOverridesForOneDevice(t *testing.T) {
|
||||
if len(entries) != 1 || entries[0].DeviceID != "phone" {
|
||||
t.Fatalf("entries after delete = %#v", entries)
|
||||
}
|
||||
registry, ok := store.(userstore.DeviceRegistry)
|
||||
if !ok {
|
||||
t.Fatalf("store does not support device registry")
|
||||
}
|
||||
devices, err := registry.ListDevices(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListDevices: %v", err)
|
||||
}
|
||||
foundLivingRoom := false
|
||||
for _, device := range devices {
|
||||
if device.ProfileID == "profile-1" && device.DeviceID == "living-room" {
|
||||
foundLivingRoom = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundLivingRoom {
|
||||
t.Fatalf("registry devices after delete = %#v", devices)
|
||||
}
|
||||
}
|
||||
|
||||
func withRouteParams(req *http.Request, params map[string]string) *http.Request {
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -263,86 +262,6 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
|
||||
}
|
||||
|
||||
// HandleSubtitleFonts extracts embedded container font attachments for ASS/SSA
|
||||
// playback. The web player loads these bytes into JASSUB before creating the
|
||||
// renderer so libass can resolve script font names deterministically.
|
||||
func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := chi.URLParam(r, "session_id")
|
||||
if sessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||||
return
|
||||
}
|
||||
setPlaybackSessionLogContext(r, sessionID)
|
||||
|
||||
session, err := h.sessionMgr.GetSession(sessionID)
|
||||
if err != nil {
|
||||
writePlaybackSessionNotFound(w)
|
||||
return
|
||||
}
|
||||
if session.UserID != userID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||||
return
|
||||
}
|
||||
if file == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||||
return
|
||||
}
|
||||
if err := preflightPlaybackFile(r.Context(), file, h.MissingMarker, h.EventsHub); err != nil {
|
||||
if isPlaybackFileMissing(err) {
|
||||
h.abortPlaybackSession(r.Context(), session)
|
||||
}
|
||||
writePlaybackFilePreflightError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
trackParam := chi.URLParam(r, "track")
|
||||
trackIndex, _, err := playback.ParseSubtitleTrackParam(trackParam)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid subtitle track index")
|
||||
return
|
||||
}
|
||||
|
||||
embeddedIndex := trackIndex - len(file.ExternalSubtitles)
|
||||
if embeddedIndex < 0 || embeddedIndex >= len(file.SubtitleTracks) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Embedded subtitle track not found")
|
||||
return
|
||||
}
|
||||
if !playback.IsASS(file.SubtitleTracks[embeddedIndex].Codec) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Subtitle font bundles are only available for ASS/SSA tracks")
|
||||
return
|
||||
}
|
||||
|
||||
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.FFmpegPath)
|
||||
if err != nil {
|
||||
slog.WarnContext(r.Context(), "subtitle font extraction failed",
|
||||
"file_id", file.ID,
|
||||
"track", trackIndex,
|
||||
"error", err,
|
||||
)
|
||||
writeError(w, http.StatusInternalServerError, "font_extract_failed", "Failed to extract subtitle fonts")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := json.NewEncoder(w).Encode(playback.EncodeSubtitleFontBundle(fonts)); err != nil {
|
||||
slog.WarnContext(r.Context(), "subtitle font response encode failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StreamHandler) syncSessionsNow(ctx context.Context, reason string) {
|
||||
if h == nil || h.SessionSyncer == nil {
|
||||
return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user