diff --git a/Makefile.local.example b/Makefile.local.example deleted file mode 100644 index 83047b6c..00000000 --- a/Makefile.local.example +++ /dev/null @@ -1,110 +0,0 @@ -.PHONY: deploy-dev dev-deploy deploy-dev-image dev-remote-setup dev-remote-stop deploy-dev-transcode dev-deploy-transcode deploy-dev-import dev-deploy-import deploy-dev-import-reset dev-deploy-import-reset - -# Copy this file to Makefile.local and adjust the host/path values for your -# private development environment. Makefile.local is intentionally gitignored. -DEV_DEPLOY_HOST ?= root@silo-dev.example.invalid -DEV_DEPLOY_DIR ?= /opt/git/silo-dev -DEV_TRANSCODE_DEPLOY_HOST ?= -DEV_TRANSCODE_DEPLOY_DIR ?= /opt/git/silo -DEV_IMPORT_MEDIA_ROOT ?= /srv/media -DEV_MEDIA_ROOT ?= /srv/media -DEV_IMPORT_CATALOG_SEED_PATH ?= /tmp/silo-catalog-seed -DEV_PLUGIN_SDK_DIR ?= ../silo-plugin-sdk -DEV_LOCAL_IMAGE ?= silo:dev-local -DEV_JELLYCOMPAT_DEBUG_LOG ?= /opt/silo/jellycompat-debug.log -DEV_COMPOSE_FILE ?= docker-compose.yml -RSYNC ?= $(shell command -v rsync || echo /usr/bin/rsync) -SILO_IMAGE ?= ghcr.io/silo-server/silo-server:latest - -RSYNC_EXCLUDE := --exclude='.git' --exclude='node_modules' --exclude='web/dist' \ - --exclude='.env' --exclude='.env.*' --exclude='*.md' --exclude='.agents' \ - --exclude='.claude' --exclude='.codex' --exclude='.cursor' \ - --exclude='.worktrees' --exclude='.playwright-mcp' --exclude='demo' \ - --exclude='output' - -dev-remote-setup: - DEV_HOST=$(DEV_DEPLOY_HOST) DEV_DIR=$(DEV_DEPLOY_DIR) ./scripts/dev-remote-setup.sh - -deploy-dev: - @echo "==> Syncing source to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/..." - $(RSYNC) -az --delete $(RSYNC_EXCLUDE) ./ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ - @if [ -f .env ]; then \ - $(RSYNC) -az .env $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/; \ - fi - @echo "==> Syncing plugin SDK to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/silo-plugin-sdk/..." - $(RSYNC) -az --delete --exclude='.git' $(DEV_PLUGIN_SDK_DIR)/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/silo-plugin-sdk/ - @echo "==> Stopping air tmux session if running..." - ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' - @echo "==> Normalizing jellycompat debug log path..." - ssh $(DEV_DEPLOY_HOST) 'set -e; docker rm -f silo >/dev/null 2>&1 || true; mkdir -p $$(dirname "$(DEV_JELLYCOMPAT_DEBUG_LOG)"); if [ -d "$(DEV_JELLYCOMPAT_DEBUG_LOG)" ]; then if ! rmdir "$(DEV_JELLYCOMPAT_DEBUG_LOG)" 2>/dev/null; then mv "$(DEV_JELLYCOMPAT_DEBUG_LOG)" "$(DEV_JELLYCOMPAT_DEBUG_LOG).dir.$$(date +%s)"; fi; fi; touch "$(DEV_JELLYCOMPAT_DEBUG_LOG)"' - @echo "==> Building $(DEV_LOCAL_IMAGE) on remote..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker buildx build \ - -f Dockerfile.dev \ - --build-context silo_plugin_sdk=$(DEV_DEPLOY_DIR)/silo-plugin-sdk \ - -t $(DEV_LOCAL_IMAGE) \ - --load \ - .' - @echo "==> Starting services via Docker Compose..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && SILO_IMAGE=$(DEV_LOCAL_IMAGE) MEDIA_ROOT="$(DEV_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f $(DEV_COMPOSE_FILE) up -d --force-recreate silo' - @echo "==> Pruning old images..." - ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' - @echo "==> Verifying deployment..." - @sleep 5 - ssh $(DEV_DEPLOY_HOST) 'docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"' - @echo "==> Deploy complete (local build)." - -dev-deploy: deploy-dev - -deploy-dev-image: - @echo "==> Syncing compose config to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/..." - $(RSYNC) -az $(DEV_COMPOSE_FILE) $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ - $(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/ - @if [ -f .env ]; then \ - $(RSYNC) -az .env $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/; \ - fi - @echo "==> Stopping air tmux session if running..." - ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' - @echo "==> Normalizing jellycompat debug log path..." - ssh $(DEV_DEPLOY_HOST) 'set -e; docker rm -f silo >/dev/null 2>&1 || true; mkdir -p $$(dirname "$(DEV_JELLYCOMPAT_DEBUG_LOG)"); if [ -d "$(DEV_JELLYCOMPAT_DEBUG_LOG)" ]; then if ! rmdir "$(DEV_JELLYCOMPAT_DEBUG_LOG)" 2>/dev/null; then mv "$(DEV_JELLYCOMPAT_DEBUG_LOG)" "$(DEV_JELLYCOMPAT_DEBUG_LOG).dir.$$(date +%s)"; fi; fi; touch "$(DEV_JELLYCOMPAT_DEBUG_LOG)"' - @echo "==> Pulling $(SILO_IMAGE) on remote..." - ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' - @echo "==> Starting services via Docker Compose..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f $(DEV_COMPOSE_FILE) up -d --force-recreate silo' - @echo "==> Verifying deployment..." - @sleep 5 - ssh $(DEV_DEPLOY_HOST) 'docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"' - @echo "==> Deploy complete (image pull)." - -dev-remote-stop: - @echo "==> Stopping air tmux session if running..." - ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true' - @echo "==> Stopping dev stack..." - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f $(DEV_COMPOSE_FILE) down' - @echo "==> Done." - -deploy-dev-transcode: - @if [ -z "$(strip $(DEV_TRANSCODE_DEPLOY_HOST))" ]; then \ - echo "DEV_TRANSCODE_DEPLOY_HOST is not set; skipping remote transcode deploy."; \ - exit 0; \ - fi - ssh $(DEV_TRANSCODE_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' - $(RSYNC) -az docker-compose.remote-transcode.yml $(DEV_TRANSCODE_DEPLOY_HOST):$(DEV_TRANSCODE_DEPLOY_DIR)/ - ssh $(DEV_TRANSCODE_DEPLOY_HOST) 'set -e; cd $(DEV_TRANSCODE_DEPLOY_DIR) && docker compose -f docker-compose.remote-transcode.yml up -d --force-recreate silo-transcode && sleep 5 && docker compose -f docker-compose.remote-transcode.yml ps && docker inspect -f "{{.State.Status}}" silo-transcode | grep -qx running || { echo; echo "silo-transcode container is not running; recent logs:"; docker logs silo-transcode --tail 120; exit 1; }' - ssh $(DEV_TRANSCODE_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' - -dev-deploy-transcode: deploy-dev-transcode - -deploy-dev-import: - $(RSYNC) -az $(DEV_COMPOSE_FILE) $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/ - $(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/ - ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)' - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" CATALOG_SEED_PATH="$(DEV_IMPORT_CATALOG_SEED_PATH)" docker compose -p silo-import -f $(DEV_COMPOSE_FILE) up -d --force-recreate' - ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"' - -dev-deploy-import: deploy-dev-import - -deploy-dev-import-reset: - ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -p silo-import -f $(DEV_COMPOSE_FILE) down -v --remove-orphans || true' - $(MAKE) deploy-dev-import - -dev-deploy-import-reset: deploy-dev-import-reset diff --git a/docs/superpowers/plans/2026-05-24-date-named-episodes.md b/docs/superpowers/plans/2026-05-24-date-named-episodes.md new file mode 100644 index 00000000..f418c455 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-date-named-episodes.md @@ -0,0 +1,884 @@ +# Date-Named Episodes 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:** Make date-named TV episode files visible as episodes even when TVDB/TMDB child episode metadata has not caught up yet. + +**Architecture:** Keep the fix server-side in the metadata layer. The naming parser already extracts `AirDate` and season-folder `SeasonNum`; metadata linking should use that season hint to disambiguate existing episode rows, synthesize conservative `scanner_fallback` episode rows when provider rows are missing, and reconcile those fallback rows by `air_date` when provider metadata later arrives with real numbering. + +**Tech Stack:** Go, PostgreSQL via `pgx`, existing `catalog` repositories, `internal/metadata` fallback/linking flow, targeted Go tests. + +--- + +## Validated Direction + +Three subagents independently reviewed scanner/naming, metadata/linking, and provider/refresh implications. + +- Parser support already exists. `naming.ParseFilename` returns both `AirDate` and a season-folder `SeasonNum` for date-named files in `Season NN` folders. +- Do not depend on plugin changes. TVDB/TMDB plugins already expose season episode lists; the issue is provider incompleteness/latency and server fallback policy. +- Do not make scanner persistence the core fix. The scanner currently drops date-only season hints from `media_files`, but metadata reparses file paths and can link/synthesize without a schema migration. +- Add provider reconciliation. Fallback episodes use provisional episode numbers, so provider refresh must adopt an existing `scanner_fallback` row by exact `air_date` instead of creating a duplicate if provider numbering differs. + +## File Structure + +- Modify `internal/naming/filename_test.go` + - Add a regression for the exact Late Show date-name shape so parser behavior stays pinned. +- Modify `internal/catalog/episode_repo.go` + - Add unfiltered episode listing for one season. + - Add a repository method that upgrades one exact `scanner_fallback` episode by air date to provider metadata. +- Modify `internal/metadata/service.go` + - Preserve season hints for date-named files. + - Filter air-date matches by explicit season when present. + - Synthesize date-named fallback episodes for explicit season folders. + - Use provider-by-air-date reconciliation before inserting provider episode rows. +- Modify `internal/metadata/fallback_episode_test.go` + - Extend fakes for the new repository methods. + - Cover season-hinted air-date disambiguation, Late Show fallback synthesis, safe skips, and provider adoption. + +No migration is planned. No plugin repo changes are planned. + +--- + +### Task 1: Pin Date-Name Parser Behavior + +**Files:** +- Modify: `internal/naming/filename_test.go` + +- [ ] **Step 1: Add the Late Show parser regression** + +Add this case to the existing table that covers date-named series files, or create `TestParseFilenameDateNamedLateShowSeasonFolder` if the surrounding table shape is awkward: + +```go +func TestParseFilenameDateNamedLateShowSeasonFolder(t *testing.T) { + hints := ParseFilename( + "/mnt/sharedrives/zd-storage-ceph/television/10s/The Late Show with Stephen Colbert (2015) {tvdb-289574}/Season 11/The Late Show with Stephen Colbert (2015) - 2026-05-21 - A Goodbye Celebration [WEBDL-1080p 8-bit h264 AAC 2.0]-ILP.mkv", + "series", + ) + if hints == nil { + t.Fatal("ParseFilename returned nil") + } + if hints.Type != "series" { + t.Fatalf("Type = %q, want series", hints.Type) + } + if hints.SeasonNum != 11 { + t.Fatalf("SeasonNum = %d, want 11", hints.SeasonNum) + } + if hints.EpisodeNum != 0 { + t.Fatalf("EpisodeNum = %d, want 0 for date-named file", hints.EpisodeNum) + } + if hints.AirDate != "2026-05-21" { + t.Fatalf("AirDate = %q, want 2026-05-21", hints.AirDate) + } + if hints.Title != "The Late Show with Stephen Colbert" { + t.Fatalf("Title = %q, want The Late Show with Stephen Colbert", hints.Title) + } + if hints.Year != 2015 { + t.Fatalf("Year = %d, want 2015", hints.Year) + } +} +``` + +- [ ] **Step 2: Run the parser test** + +Run: + +```bash +go test ./internal/naming -run TestParseFilenameDateNamedLateShowSeasonFolder -count=1 +``` + +Expected: pass. This is a guardrail test for existing parser behavior, not a failing implementation test. + +- [ ] **Step 3: Commit** + +```bash +git add internal/naming/filename_test.go +git commit -m "test(metadata): pin date-named season filename parsing" +``` + +--- + +### Task 2: Use Season Hints When Linking Existing Air-Date Episodes + +**Files:** +- Modify: `internal/metadata/service.go` +- Modify: `internal/metadata/fallback_episode_test.go` + +- [ ] **Step 1: Add a failing ambiguity regression** + +Add this test near the existing air-date linking tests in `internal/metadata/fallback_episode_test.go`: + +```go +func TestEnsureSeriesEpisodeLinks_UsesSeasonHintForAirDateAmbiguity(t *testing.T) { + h := newFallbackTestHarness() + ctx := context.Background() + + seriesID := "series-late-show-season-hint" + h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: seriesID, + Title: "The Late Show with Stephen Colbert", + Type: "series", + Status: "matched", + TvdbID: "289574", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }) + h.episodeRepo.Upsert(ctx, &models.Episode{ + ContentID: "special-2026-02-16", + SeriesID: seriesID, + SeasonID: "season-0", + SeasonNumber: 0, + EpisodeNumber: 16, + Title: "Texas Legislature Special", + AirDate: mustDate(t, "2026-02-16"), + MetadataSource: "provider", + }) + h.episodeRepo.Upsert(ctx, &models.Episode{ + ContentID: "season11-episode73", + SeriesID: seriesID, + SeasonID: "season-11", + SeasonNumber: 11, + EpisodeNumber: 73, + Title: "Jennifer Garner, Robert Duvall", + AirDate: mustDate(t, "2026-02-16"), + MetadataSource: "provider", + }) + + file := &models.MediaFile{ + ID: 120, + MediaFolderID: 10, + FilePath: "/media/tv/The Late Show with Stephen Colbert (2015) {tvdb-289574}/Season 11/The Late Show with Stephen Colbert (2015) - 2026-02-16 - Jennifer Garner - [ WEBDL-1080p h264 EAC3 2.0 ]-JOAN.mkv", + } + h.fileRepo.addFile(file) + h.fileRepo.contentIDs[file.ID] = seriesID + + if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil { + t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err) + } + if got := h.fileRepo.episodeLinks[file.ID]; got != "season11-episode73" { + t.Fatalf("episode link = %q, want season11-episode73", got) + } + linked := h.fileRepo.files[file.ID] + if linked.SeasonNumber != 11 || linked.EpisodeNumber != 73 { + t.Fatalf("linked season/episode = S%dE%d, want S11E73", linked.SeasonNumber, linked.EpisodeNumber) + } +} +``` + +- [ ] **Step 2: Run the failing regression** + +Run: + +```bash +go test ./internal/metadata -run TestEnsureSeriesEpisodeLinks_UsesSeasonHintForAirDateAmbiguity -count=1 +``` + +Expected before implementation: fail because the current air-date hint discards `SeasonNum`, leaving two candidates for `2026-02-16`. + +- [ ] **Step 3: Preserve season hints for air-date files** + +In `internal/metadata/service.go`, change the air-date branch in `parseEpisodeLinkHint` from: + +```go +if fnh.AirDate != "" { + return episodeLinkHint{airDate: fnh.AirDate, ok: true} +} +``` + +to: + +```go +if fnh.AirDate != "" { + return episodeLinkHint{seasonNum: fnh.SeasonNum, airDate: fnh.AirDate, ok: true} +} +``` + +- [ ] **Step 4: Filter candidates by explicit season before provider preference** + +Change `selectAirDateEpisodeCandidate` to accept `seasonHint int` and filter when `seasonHint > 0`: + +```go +func selectAirDateEpisodeCandidate(candidates []*models.Episode, seriesItem *models.MediaItem, seasonHint int) (*models.Episode, bool) { + if seasonHint > 0 { + filtered := make([]*models.Episode, 0, len(candidates)) + for _, candidate := range candidates { + if candidate != nil && candidate.SeasonNumber == seasonHint { + filtered = append(filtered, candidate) + } + } + if len(filtered) == 1 { + return filtered[0], true + } + if len(filtered) > 1 { + candidates = filtered + } + } + if len(candidates) == 0 { + return nil, false + } + if len(candidates) == 1 { + return candidates[0], true + } + for _, provider := range preferredEpisodeProviders(seriesItem) { + filtered := filterEpisodesByProviderID(candidates, provider) + if len(filtered) == 1 { + return filtered[0], true + } + if len(filtered) > 1 { + return nil, false + } + } + return nil, false +} +``` + +Update the call site in `linkSeriesFilesToEpisodesWithOptions`: + +```go +selected, ok := selectAirDateEpisodeCandidate(candidates, seriesItem, hint.seasonNum) +``` + +- [ ] **Step 5: Run the targeted metadata test** + +Run: + +```bash +go test ./internal/metadata -run 'TestEnsureSeriesEpisodeLinks_(UsesSeasonHintForAirDateAmbiguity|SkipsAmbiguousAirDateMatch|PrefersSeriesProviderForAirDateMatch|LinksDateNamedFileByAirDate)' -count=1 +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add internal/metadata/service.go internal/metadata/fallback_episode_test.go +git commit -m "fix(metadata): use season hints for air-date episode links" +``` + +--- + +### Task 3: Add Episode Repository Primitives For Fallback Inference And Adoption + +**Files:** +- Modify: `internal/catalog/episode_repo.go` +- Modify: `internal/metadata/service.go` +- Modify: `internal/metadata/fallback_episode_test.go` + +- [ ] **Step 1: Extend the metadata episode repository interface** + +In `internal/metadata/service.go`, extend `metadataEpisodeRepo` with: + +```go + ListBySeriesAndSeasonUnscoped(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error) + AdoptScannerFallbackEpisode(ctx context.Context, ep *models.Episode) (bool, error) +``` + +- [ ] **Step 2: Add unfiltered season listing to `EpisodeRepository`** + +Add this method in `internal/catalog/episode_repo.go` near `ListBySeason`: + +```go +// ListBySeriesAndSeasonUnscoped returns all episode rows for a series season, +// including rows without episode_libraries memberships. Metadata fallback +// inference needs provider rows before local files exist for every episode. +func (r *EpisodeRepository) ListBySeriesAndSeasonUnscoped(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error) { + query := `SELECT ` + episodeColumns + ` + FROM episodes + WHERE series_id = $1 AND season_number = $2 + ORDER BY episode_number ASC` + + rows, err := r.pool.Query(ctx, query, seriesID, seasonNum) + if err != nil { + return nil, fmt.Errorf("listing unscoped episodes by series season: %w", err) + } + defer rows.Close() + + return scanEpisodes(rows) +} +``` + +- [ ] **Step 3: Add provider adoption to `EpisodeRepository`** + +Add a method named `AdoptScannerFallbackEpisode`. The method must: + +- return `false, nil` when `ep` is nil, `ep.AirDate` is nil, or required identity fields are empty +- run in a transaction +- clear stale external IDs for `imdb_id`, `tmdb_id`, and `tvdb_id` in the same series before updating +- find fallback rows with the exact same `series_id`, exact same `air_date`, and `metadata_source = 'scanner_fallback'` +- require the fallback row's current `season_number` to equal the provider episode's `SeasonNumber` +- adopt only when exactly one fallback row exists +- refuse adoption if another row already owns the target `(series_id, season_number, episode_number)` +- update the fallback row in place, preserving its `content_id` +- run `updateSeriesLastAirDateSQL` before committing + +Use this SQL shape inside the transaction: + +```go +rows, err := tx.Query(ctx, ` + SELECT content_id + FROM episodes + WHERE series_id = $1 + AND air_date = $2 + AND season_number = $3 + AND metadata_source = 'scanner_fallback' + ORDER BY content_id ASC +`, ep.SeriesID, ep.AirDate, ep.SeasonNumber) +``` + +Then verify the target key: + +```go +var existingTarget string +targetErr := tx.QueryRow(ctx, ` + SELECT content_id + FROM episodes + WHERE series_id = $1 AND season_number = $2 AND episode_number = $3 +`, ep.SeriesID, ep.SeasonNumber, ep.EpisodeNumber).Scan(&existingTarget) +if targetErr == nil && existingTarget != fallbackID { + return false, nil +} +if targetErr != nil && !errors.Is(targetErr, pgx.ErrNoRows) { + return false, fmt.Errorf("checking provider episode target before fallback adoption: %w", targetErr) +} +``` + +Update the fallback row: + +```go +_, err = tx.Exec(ctx, ` + UPDATE episodes + SET season_id = $2, + season_number = $3, + episode_number = $4, + title = $5, + default_metadata_language = $6, + overview = $7, + air_date = $8, + runtime = $9, + rating_imdb = $10, + rating_tmdb = $11, + imdb_id = COALESCE(NULLIF($12, ''), imdb_id), + tmdb_id = COALESCE(NULLIF($13, ''), tmdb_id), + tvdb_id = COALESCE(NULLIF($14, ''), tvdb_id), + still_path = $15, + still_thumbhash = $16, + metadata_s3_path = $17, + metadata_etag = $18, + metadata_source = $19, + updated_at = NOW() + WHERE content_id = $1 +`, fallbackID, nilIfEmpty(ep.SeasonID), ep.SeasonNumber, ep.EpisodeNumber, + ep.Title, ep.DefaultMetadataLanguage, ep.Overview, ep.AirDate, ep.Runtime, + ep.RatingIMDB, ep.RatingTMDB, ep.ImdbID, ep.TmdbID, ep.TvdbID, + ep.StillPath, ep.StillThumbhash, ep.MetadataS3Path, ep.MetadataEtag, + ep.MetadataSource) +``` + +Set `ep.ContentID = fallbackID` when adoption succeeds. + +- [ ] **Step 4: Extend the fake episode repo** + +In `internal/metadata/fallback_episode_test.go`, add fake methods matching the new interface. `ListBySeriesAndSeasonUnscoped` should return all episodes for the series/season without availability filtering. `AdoptScannerFallbackEpisode` should find exactly one fallback row by `SeriesID`, `SeasonNumber`, `AirDate`, and `MetadataSource == "scanner_fallback"`, move it from its old `episodeKey` to the provider key, copy provider fields, preserve `ContentID`, and return `true`. + +- [ ] **Step 5: Add provider adoption regression** + +Add this test: + +```go +func TestPersistSeasonsAndEpisodes_AdoptsScannerFallbackByAirDate(t *testing.T) { + h := newFallbackTestHarness() + ctx := context.Background() + + seriesID := "series-provider-adopts-airdate" + h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: seriesID, + Title: "Daily Show", + Type: "series", + Status: "matched", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }) + h.seasonRepo.Upsert(ctx, &models.Season{ + ContentID: "season-11", + SeriesID: seriesID, + SeasonNumber: 11, + Title: "Season 11", + MetadataSource: "provider", + }) + h.episodeRepo.Upsert(ctx, &models.Episode{ + ContentID: "fallback-episode", + SeriesID: seriesID, + SeasonID: "season-11", + SeasonNumber: 11, + EpisodeNumber: 103, + Title: "Episode 103", + AirDate: mustDate(t, "2026-05-21"), + MetadataSource: "scanner_fallback", + }) + + h.service.persistSeasonsAndEpisodes(ctx, seriesID, "en", "en", []SeasonResult{{ + SeasonNumber: 11, + Title: "Season 11", + }}, []EpisodeResult{{ + ProviderIDs: map[string]string{"tvdb": "real-tvdb-episode"}, + SeasonNumber: 11, + EpisodeNumber: 120, + Title: "A Goodbye Celebration", + AirDate: "2026-05-21", + }}, MergeReplace) + + if _, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 11, 103); err == nil { + t.Fatal("old fallback natural key still exists; expected provider adoption to move it") + } + adopted, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 11, 120) + if err != nil { + t.Fatalf("adopted provider episode not found: %v", err) + } + if adopted.ContentID != "fallback-episode" { + t.Fatalf("ContentID = %q, want fallback-episode", adopted.ContentID) + } + if adopted.MetadataSource != "provider" { + t.Fatalf("MetadataSource = %q, want provider", adopted.MetadataSource) + } + if adopted.TvdbID != "real-tvdb-episode" { + t.Fatalf("TvdbID = %q, want real-tvdb-episode", adopted.TvdbID) + } +} +``` + +- [ ] **Step 6: Add cross-season adoption safety regression** + +Add a regression that creates one `scanner_fallback` row for `S11` with `air_date = 2026-02-16`, then persists a provider `EpisodeResult` for `S00E16` with the same air date. Assert that `AdoptScannerFallbackEpisode` returns false through `persistSeasonsAndEpisodes`, the `S11` fallback keeps its original `content_id` and natural key, and the provider special is inserted or updated as its own row. This protects the real Late Show ambiguity where specials and regular episodes can share an air date. + +- [ ] **Step 7: Wire provider adoption into persistence** + +In `persistSeasonsAndEpisodes`, after `dbEp` is built and before `s.episodeRepo.Upsert(ctx, dbEp)`, add: + +```go +adopted := false +if existingEpisode == nil && dbEp.AirDate != nil { + var adoptErr error + adopted, adoptErr = s.episodeRepo.AdoptScannerFallbackEpisode(ctx, dbEp) + if adoptErr != nil { + slog.Warn("metadata: failed to adopt scanner fallback episode", + "series_id", seriesID, + "season", dbEp.SeasonNumber, + "episode", dbEp.EpisodeNumber, + "air_date", dbEp.AirDate.Format("2006-01-02"), + "error", adoptErr) + adopted = false + } +} +if !adopted { + if err := s.episodeRepo.Upsert(ctx, dbEp); err != nil { + slog.Warn("metadata: failed to upsert episode", + "series_id", seriesID, + "season", ep.SeasonNumber, + "episode", ep.EpisodeNumber, + "error", err) + continue + } +} +``` + +Keep the existing localization block immediately after this upsert/adoption branch. Adopted episodes skip only the normal `Upsert` call; non-canonical localization still runs with `dbEp.ContentID`, which `AdoptScannerFallbackEpisode` has set to the preserved fallback `content_id`. Do not duplicate localization logic. + +- [ ] **Step 8: Run adoption tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestPersistSeasonsAndEpisodes_(AdoptsScannerFallbackByAirDate|DoesNotAdoptFallbackAcrossSeasonByAirDate)' -count=1 +``` + +Expected: pass. + +- [ ] **Step 9: Commit** + +```bash +git add internal/catalog/episode_repo.go internal/metadata/service.go internal/metadata/fallback_episode_test.go +git commit -m "fix(metadata): adopt date fallback episodes during provider refresh" +``` + +--- + +### Task 4: Synthesize Date-Named Fallback Episodes + +**Files:** +- Modify: `internal/metadata/service.go` +- Modify: `internal/metadata/fallback_episode_test.go` + +- [ ] **Step 1: Add failing Late Show fallback regression** + +Add this test: + +```go +func TestEnsureSeriesEpisodeLinks_SynthesizesDateNamedEpisodesAfterLatestKnownEpisode(t *testing.T) { + h := newFallbackTestHarness() + ctx := context.Background() + + seriesID := "series-late-show-date-fallback" + h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: seriesID, + Title: "The Late Show with Stephen Colbert", + Type: "series", + Status: "matched", + TvdbID: "289574", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }) + h.seasonRepo.Upsert(ctx, &models.Season{ + ContentID: "season-11", + SeriesID: seriesID, + SeasonNumber: 11, + Title: "Season 11", + MetadataSource: "provider", + }) + h.episodeRepo.Upsert(ctx, &models.Episode{ + ContentID: "season11-episode102", + SeriesID: seriesID, + SeasonID: "season-11", + SeasonNumber: 11, + EpisodeNumber: 102, + Title: "Anderson Cooper, Patton Oswalt", + AirDate: mustDate(t, "2026-04-16"), + MetadataSource: "provider", + }) + + files := []*models.MediaFile{ + { + ID: 201, + MediaFolderID: 10, + FilePath: "/media/tv/The Late Show with Stephen Colbert (2015) {tvdb-289574}/Season 11/The Late Show with Stephen Colbert (2015) - 2026-04-20 - Don Cheadle Jake Tapper [WEBDL-1080p 8-bit h264 AAC 2.0]-JOAN.mkv", + }, + { + ID: 202, + MediaFolderID: 10, + FilePath: "/media/tv/The Late Show with Stephen Colbert (2015) {tvdb-289574}/Season 11/The Late Show with Stephen Colbert (2015) - 2026-04-21 - Neil deGrasse Tyson RAYE John Kerry [WEBDL-1080p 8-bit h264 EAC3 2.0]-JOAN.mkv", + }, + } + for _, file := range files { + h.fileRepo.addFile(file) + h.fileRepo.contentIDs[file.ID] = seriesID + } + + if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil { + t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err) + } + + ep103, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 11, 103) + if err != nil { + t.Fatalf("S11E103 fallback not created: %v", err) + } + if ep103.MetadataSource != "scanner_fallback" { + t.Fatalf("S11E103 MetadataSource = %q, want scanner_fallback", ep103.MetadataSource) + } + if ep103.AirDate == nil || ep103.AirDate.Format("2006-01-02") != "2026-04-20" { + t.Fatalf("S11E103 AirDate = %v, want 2026-04-20", ep103.AirDate) + } + if got := h.fileRepo.episodeLinks[201]; got != ep103.ContentID { + t.Fatalf("file 201 episode link = %q, want %q", got, ep103.ContentID) + } + + ep104, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 11, 104) + if err != nil { + t.Fatalf("S11E104 fallback not created: %v", err) + } + if ep104.AirDate == nil || ep104.AirDate.Format("2006-01-02") != "2026-04-21" { + t.Fatalf("S11E104 AirDate = %v, want 2026-04-21", ep104.AirDate) + } + if got := h.fileRepo.episodeLinks[202]; got != ep104.ContentID { + t.Fatalf("file 202 episode link = %q, want %q", got, ep104.ContentID) + } + + item, err := h.itemRepo.GetByID(ctx, seriesID) + if err != nil { + t.Fatalf("series item not found: %v", err) + } + if !item.EpisodeMetadataIncomplete { + t.Fatal("expected EpisodeMetadataIncomplete=true after scanner fallback synthesis") + } +} +``` + +- [ ] **Step 2: Add a safety regression for seasonless date files** + +Add: + +```go +func TestEnsureSeriesEpisodeLinks_DoesNotSynthesizeDateFallbackWithoutSeasonHint(t *testing.T) { + h := newFallbackTestHarness() + ctx := context.Background() + + seriesID := "series-date-no-season" + h.itemRepo.Upsert(ctx, &models.MediaItem{ + ContentID: seriesID, + Title: "Daily Show", + Type: "series", + Status: "matched", + Studios: []string{}, + Networks: []string{}, + Countries: []string{}, + Genres: []string{}, + }) + file := &models.MediaFile{ + ID: 220, + MediaFolderID: 10, + FilePath: "/media/tv/Daily Show/Daily Show - 2026-04-24.mkv", + } + h.fileRepo.addFile(file) + h.fileRepo.contentIDs[file.ID] = seriesID + + if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil { + t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err) + } + if got := h.fileRepo.episodeLinks[file.ID]; got != "" { + t.Fatalf("unexpected episode link = %q", got) + } + if episodes := h.episodeRepo.listBySeries(seriesID); len(episodes) != 0 { + t.Fatalf("synthesized %d episodes without explicit season hint", len(episodes)) + } +} +``` + +- [ ] **Step 3: Run the failing fallback tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestEnsureSeriesEpisodeLinks_(SynthesizesDateNamedEpisodesAfterLatestKnownEpisode|DoesNotSynthesizeDateFallbackWithoutSeasonHint)' -count=1 +``` + +Expected before implementation: the synthesis test fails because date-only files are skipped. + +- [ ] **Step 4: Add date fallback helper types** + +In `internal/metadata/service.go`, add helper structs near `episodeLinkHint`: + +```go +type dateFallbackCandidate struct { + file *models.MediaFile + hint episodeLinkHint + airDate time.Time + airDateID string +} + +type dateFallbackPlan struct { + file *models.MediaFile + seasonNum int + episodeNum int + airDate time.Time +} +``` + +- [ ] **Step 5: Add date fallback synthesis entrypoint** + +Add `synthesizeDateFallbackEpisodes` to `internal/metadata/service.go`. The method should: + +- list unlinked series files +- parse `episodeLinkHint` +- keep only hints where `airDate != ""`, `seasonNum > 0`, and `episodeNum == 0` +- group by season +- load all existing episodes for each season using `ListBySeriesAndSeasonUnscoped` +- skip any date that already has an episode in that same season +- infer provisional episode numbers with append/gap-safe logic +- create missing season rows if needed using the same season creation pattern as numeric fallback +- create `scanner_fallback` episode rows with `AirDate` +- call `linkSeriesFilesToEpisodesWithOptions(ctx, seriesID, true)` after inserts + +Use this inference policy: + +```text +For each explicit season: +1. Sort existing episodes by air_date, then episode_number. +2. Sort missing local dates ascending. +3. If the season has no existing episodes, assign missing dates episode numbers 1..N. +4. If a missing date is after the latest existing dated episode, assign latest episode_number + ordinal_after_latest. +5. If a missing date is between two existing dated provider episodes, assign only when every local missing date in that date range exactly fills the numeric gap between those provider episode numbers. +6. Skip leading dates before the first existing dated episode. +7. Skip season 0. +8. Never overwrite an existing episode row. +``` + +- [ ] **Step 6: Call date fallback from ensure flow** + +In `ensureSeriesEpisodeLinksCore`, after the existing `linkSeriesFilesToEpisodesWithOptions` call for the non-numeric path, call date fallback synthesis and then refresh metadata state: + +```go +if !needsSynthesis { + if err := s.linkSeriesFilesToEpisodesWithOptions(ctx, seriesID, item.EpisodeMetadataIncomplete); err != nil { + return err + } + if err := s.synthesizeDateFallbackEpisodes(ctx, seriesID); err != nil { + return err + } + s.refreshSeriesEpisodeMetadataState(ctx, seriesID, time.Now()) + return nil +} +``` + +Also call `s.synthesizeDateFallbackEpisodes(ctx, seriesID)` inside `synthesizeFallbackSeriesStructure` after the existing numeric fallback loop and before the final link call. This keeps explicit numeric and date fallback paths consistent. + +- [ ] **Step 7: Keep fallback titles simple and stable** + +Use `fallbackEpisodeTitle(episodeNum)` for synthesized date fallback rows in this task. Do not parse guest names from filenames in this implementation. Provider refresh will replace fallback titles, and avoiding title parsing keeps the matching fix focused and deterministic. + +- [ ] **Step 8: Run fallback tests** + +Run: + +```bash +go test ./internal/metadata -run 'TestEnsureSeriesEpisodeLinks_(SynthesizesDateNamedEpisodesAfterLatestKnownEpisode|DoesNotSynthesizeDateFallbackWithoutSeasonHint|SkipsMissingAirDateMatch|UsesSeasonHintForAirDateAmbiguity)' -count=1 +``` + +Expected: pass. + +- [ ] **Step 9: Commit** + +```bash +git add internal/metadata/service.go internal/metadata/fallback_episode_test.go +git commit -m "fix(metadata): synthesize fallback episodes for date-named files" +``` + +--- + +### Task 5: Full Regression Sweep And Static Checks + +**Files:** +- No new files. +- Verify changes from prior tasks. + +- [ ] **Step 1: Run focused packages** + +Run: + +```bash +go test ./internal/naming ./internal/catalog ./internal/metadata -count=1 +``` + +Expected: pass. + +- [ ] **Step 2: Run scanner package to confirm no scanner regression** + +Run: + +```bash +go test ./internal/scanner -count=1 +``` + +Expected: pass. This plan intentionally avoids scanner persistence changes; this command guards the shared naming/parser usage. + +- [ ] **Step 3: Run formatting** + +Run: + +```bash +gofmt -w internal/naming/filename_test.go internal/catalog/episode_repo.go internal/metadata/service.go internal/metadata/fallback_episode_test.go +``` + +Expected: no output. + +- [ ] **Step 4: Re-run focused packages after formatting** + +Run: + +```bash +go test ./internal/naming ./internal/catalog ./internal/metadata ./internal/scanner -count=1 +``` + +Expected: pass. + +- [ ] **Step 5: Optional full backend test** + +Run when time allows: + +```bash +go test ./internal/... -count=1 +``` + +Expected: pass. If unrelated packages fail, capture the failing package and error in the final handoff instead of hiding it. + +- [ ] **Step 6: Commit verification-only cleanup if formatting changed files** + +If `gofmt` changed files not already committed: + +```bash +git add internal/naming/filename_test.go internal/catalog/episode_repo.go internal/metadata/service.go internal/metadata/fallback_episode_test.go +git commit -m "chore(metadata): format date episode fallback changes" +``` + +If no files changed, do not create an empty commit. + +--- + +### Task 6: Dev Runtime Verification + +**Files:** +- No code files. +- Use the deployed dev server and database after code is merged/deployed. + +- [ ] **Step 1: Confirm pre-deploy symptom on dev** + +Run: + +```bash +ssh root@100.86.116.20 'docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c "SELECT count(*) AS unlinked_date_named FROM media_files WHERE content_id = '\''120822227783385091'\'' AND episode_id IS NULL AND file_path LIKE '\''%/Season 11/%'\'' AND file_path ~ '\'' - [0-9]{4}-[0-9]{2}-[0-9]{2} - '\'';"' +``` + +Expected before deploy: greater than zero. + +- [ ] **Step 2: Deploy using the repo's normal dev workflow** + +Use the existing Silo dev deploy command for this repository. If no deploy command is available in the current branch, build locally first: + +```bash +make build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Trigger metadata processing for the affected series** + +Use the admin UI refresh action or the existing admin refresh endpoint for item `120822227783385091`. If an auth token is not available to the agent, open the item in the admin UI and trigger "Refresh metadata" manually, then continue verification. + +- [ ] **Step 4: Verify fallback rows and links** + +Run: + +```bash +ssh root@100.86.116.20 'docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c "SELECT e.air_date, e.season_number, e.episode_number, e.title, e.metadata_source, mf.id AS file_id FROM episodes e JOIN media_files mf ON mf.episode_id = e.content_id WHERE e.series_id = '\''120822227783385091'\'' AND e.season_number = 11 AND e.air_date >= DATE '\''2026-04-20'\'' ORDER BY e.air_date;"' +``` + +Expected after refresh: rows exist for the date-named files after `2026-04-16`, `metadata_source` is `scanner_fallback` until provider metadata catches up, and each row has a `file_id`. + +- [ ] **Step 5: Verify catalog sort date improves** + +Run: + +```bash +ssh root@100.86.116.20 'docker exec silo-postgres-1 psql -U continuum -d continuum -P pager=off -c "SELECT content_id, last_air_date, last_air_date_at, episode_metadata_incomplete FROM media_items WHERE content_id = '\''120822227783385091'\'';"' +``` + +Expected: `last_air_date_at` reflects the newest fallback episode air date that is not in the future, and `episode_metadata_incomplete` is true while fallback rows remain. + +--- + +## Implementation Notes + +- `scanner_fallback` rows are intentionally provisional. The real stable identity remains the episode `content_id`; provider adoption preserves that `content_id` while updating the natural key when exact `air_date` provider metadata appears. +- Do not synthesize season `0` date fallback rows. +- Do not infer episode numbers from day-of-year, weekdays, show schedules, or filenames. +- Do not change plugin repos for this fix. +- Do not add a `media_files.air_date` column in this implementation. If later performance profiling shows repeated filename parsing is expensive, add a separate migration-backed optimization. + +## Self-Review + +- Spec coverage: parser pinning, air-date season disambiguation, date fallback synthesis, same-season provider adoption, cross-season adoption refusal, refresh-debt visibility, and dev verification are covered. +- Placeholder scan: no TBD/TODO placeholders remain. +- Type consistency: new metadata interface methods are named consistently across `service.go`, `episode_repo.go`, and fake repos. +- Scope check: one server-side feature, no plugin or client changes required. diff --git a/docs/superpowers/plans/2026-05-24-jellyfin-autoscan-scan-compat.md b/docs/superpowers/plans/2026-05-24-jellyfin-autoscan-scan-compat.md new file mode 100644 index 00000000..0cdc2380 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-jellyfin-autoscan-scan-compat.md @@ -0,0 +1,1365 @@ +# Jellyfin Autoscan Scan Compatibility 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:** Make Autoscan's stock Jellyfin target work against Silo by supporting Jellyfin library discovery and scan notification routes backed by Silo's existing scan queue. + +**Architecture:** Extract native scan path resolution into a shared `internal/scantrigger` package, then use it from both the native `/api/v1/scan` handler and a new Jellyfin Autoscan compatibility handler. Add narrow admin API-key authentication for Autoscan routes without broadening normal Jellyfin playback/browse auth. + +**Tech Stack:** Go, chi, existing Silo catalog/scanner/scanqueue/auth packages, focused Go unit tests. + +--- + +## File Structure + +- Create: `internal/scantrigger/scantrigger.go` + - Owns shared library/path scan target resolution and queue enqueue helpers. +- Create: `internal/scantrigger/scantrigger_test.go` + - Pins root, subtree, file, disabled library, missing path, and all-or-fail validation behavior. +- Modify: `internal/api/handlers/libraries.go` + - Replaces local resolver/path helpers with `scantrigger`. +- Create: `internal/jellycompat/auth_api_key.go` + - Validates Silo admin API keys from Jellyfin token locations and provides session-or-admin route middleware. +- Create: `internal/jellycompat/handlers_autoscan.go` + - Handles Autoscan-facing `GET /Library/VirtualFolders` and `POST /Library/Media/Updated`. +- Create: `internal/jellycompat/handlers_autoscan_test.go` + - Tests admin API-key auth, library locations, scan enqueue, and all-or-fail behavior. +- Modify: `internal/jellycompat/router.go` + - Registers Autoscan compatibility routes and keeps existing session behavior for normal clients. +- Modify: `internal/jellycompat/server.go` + - Adds dependencies for API-key validation and scan enqueueing. +- Modify: `cmd/silo/main.go` + - Wires API-key repository, user repository, and scan queue into Jellyfin compatibility dependencies. +- Modify: `docs/scan-api.md` + - Documents using Autoscan's stock Jellyfin target with a Silo admin API key. + +--- + +### Task 1: Extract Shared Scan Target Resolution + +**Files:** +- Create: `internal/scantrigger/scantrigger.go` +- Create: `internal/scantrigger/scantrigger_test.go` + +- [ ] **Step 1: Write failing resolver tests** + +Create `internal/scantrigger/scantrigger_test.go`: + +```go +package scantrigger + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakeFolderRepo struct { + folders []*models.MediaFolder +} + +func (r *fakeFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) { + for _, folder := range r.folders { + if folder.ID == id { + return folder, nil + } + } + return nil, catalog.ErrFolderNotFound +} + +func (r *fakeFolderRepo) List(context.Context) ([]*models.MediaFolder, error) { + return r.folders, nil +} + +func TestResolverClassifiesLibraryRoot(t *testing.T) { + root := t.TempDir() + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 7, + Name: "Movies", + Enabled: true, + Paths: []string{root}, + }}} + + target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root}) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if target.LibraryID != 7 || target.Mode != ModeLibrary || target.Path != "" { + t.Fatalf("unexpected target: %#v", target) + } +} + +func TestResolverClassifiesSubtree(t *testing.T) { + root := t.TempDir() + subtree := filepath.Join(root, "Show") + if err := os.Mkdir(subtree, 0o755); err != nil { + t.Fatal(err) + } + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 8, + Name: "TV", + Enabled: true, + Paths: []string{root}, + }}} + + target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: subtree}) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if target.LibraryID != 8 || target.Mode != ModeSubtree || target.Path != filepath.Clean(subtree) { + t.Fatalf("unexpected target: %#v", target) + } +} + +func TestResolverClassifiesVideoFile(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Movie (2024).mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 9, + Name: "Movies", + Enabled: true, + Paths: []string{root}, + }}} + + target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: filePath}) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if target.LibraryID != 9 || target.Mode != ModeFile || target.Path != filepath.Clean(filePath) { + t.Fatalf("unexpected target: %#v", target) + } +} + +func TestResolverRejectsDisabledLibrary(t *testing.T) { + root := t.TempDir() + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 10, + Name: "Disabled", + Enabled: false, + Paths: []string{root}, + }}} + + _, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root}) + var reqErr *RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("expected RequestError, got %T: %v", err, err) + } + if reqErr.Status != http.StatusConflict || reqErr.Code != "conflict" { + t.Fatalf("unexpected error: %#v", reqErr) + } +} + +func TestResolveAllIsAllOrFail(t *testing.T) { + root := t.TempDir() + valid := filepath.Join(root, "Movie.mkv") + if err := os.WriteFile(valid, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + repo := &fakeFolderRepo{folders: []*models.MediaFolder{{ + ID: 11, + Name: "Movies", + Enabled: true, + Paths: []string{root}, + }}} + + _, err := NewResolver(repo).ResolveAll(context.Background(), []Request{ + {Path: valid}, + {Path: filepath.Join(root, "missing.mkv")}, + }) + var reqErr *RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("expected RequestError, got %T: %v", err, err) + } + if reqErr.Message != "Path does not exist" { + t.Fatalf("unexpected error message: %q", reqErr.Message) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +go test ./internal/scantrigger +``` + +Expected: fail because `internal/scantrigger` does not exist yet. + +- [ ] **Step 3: Add shared resolver implementation** + +Create `internal/scantrigger/scantrigger.go`: + +```go +package scantrigger + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scanner" +) + +const ( + ModeLibrary = "library" + ModeSubtree = "subtree" + ModeFile = "file" +) + +type FolderRepository interface { + GetByID(ctx context.Context, id int) (*models.MediaFolder, error) + List(ctx context.Context) ([]*models.MediaFolder, error) +} + +type Queuer interface { + EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error) +} + +type Request struct { + LibraryID *int + Path string + Trigger string +} + +type Target struct { + Folder *models.MediaFolder + LibraryID int + Mode string + Path string + Trigger string +} + +type RequestError struct { + Status int + Code string + Message string +} + +func (e *RequestError) Error() string { + return e.Message +} + +type Resolver struct { + folders FolderRepository +} + +func NewResolver(folders FolderRepository) *Resolver { + return &Resolver{folders: folders} +} + +func (r *Resolver) ResolveAll(ctx context.Context, requests []Request) ([]Target, error) { + targets := make([]Target, 0, len(requests)) + for _, req := range requests { + target, err := r.Resolve(ctx, req) + if err != nil { + return nil, err + } + targets = append(targets, *target) + } + return targets, nil +} + +func (r *Resolver) Resolve(ctx context.Context, req Request) (*Target, error) { + if r == nil || r.folders == nil { + return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"} + } + if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" { + return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Either library_id or path is required"} + } + + var folder *models.MediaFolder + var err error + if req.LibraryID != nil { + folder, err = r.folders.GetByID(ctx, *req.LibraryID) + if err != nil { + if errors.Is(err, catalog.ErrFolderNotFound) { + return nil, &RequestError{Status: http.StatusNotFound, Code: "not_found", Message: "Library not found"} + } + return nil, fmt.Errorf("fetching library for scan: %w", err) + } + } + + trigger := strings.TrimSpace(req.Trigger) + if trigger == "" { + trigger = "manual" + } + if strings.TrimSpace(req.Path) == "" { + if folder != nil && !folder.Enabled { + return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"} + } + return &Target{Folder: folder, LibraryID: folder.ID, Mode: ModeLibrary, Trigger: trigger}, nil + } + + cleanPath := filepath.Clean(req.Path) + var matchedRoot string + if folder != nil { + matchedRoot, err = LongestMatchingRoot(cleanPath, folder.Paths) + if err != nil { + return nil, err + } + if matchedRoot == "" { + return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not belong to the specified library"} + } + } else { + folders, listErr := r.folders.List(ctx) + if listErr != nil { + return nil, fmt.Errorf("listing libraries for scan: %w", listErr) + } + folder, matchedRoot, err = MatchFolderForPath(cleanPath, folders) + if err != nil { + return nil, err + } + } + if folder != nil && !folder.Enabled { + return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"} + } + + mode, err := ClassifyPath(cleanPath, matchedRoot) + if err != nil { + return nil, err + } + if trigger == "manual" { + trigger = "path" + if req.LibraryID != nil { + trigger = "library_id_path" + } + } + + targetPath := cleanPath + if mode == ModeLibrary { + targetPath = "" + } + return &Target{Folder: folder, LibraryID: folder.ID, Mode: mode, Path: targetPath, Trigger: trigger}, nil +} + +func EnqueueAll(ctx context.Context, queue Queuer, targets []Target) error { + if queue == nil { + return &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"} + } + for _, target := range targets { + if _, err := queue.EnqueueScan(ctx, target.LibraryID, target.Mode, target.Path, target.Trigger); err != nil { + return fmt.Errorf("queueing library scan: %w", err) + } + } + return nil +} + +func LongestMatchingRoot(targetPath string, roots []string) (string, error) { + bestRoot := "" + bestLen := -1 + for _, root := range roots { + if !PathWithinRoot(targetPath, root) { + continue + } + cleanRoot := filepath.Clean(root) + rootLen := len(cleanRoot) + if rootLen > bestLen { + bestRoot = cleanRoot + bestLen = rootLen + } + } + return bestRoot, nil +} + +func MatchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) { + var bestFolder *models.MediaFolder + bestRoot := "" + bestLen := -1 + ambiguous := false + + for _, folder := range folders { + if folder == nil { + continue + } + root, err := LongestMatchingRoot(targetPath, folder.Paths) + if err != nil { + return nil, "", err + } + if root == "" { + continue + } + rootLen := len(root) + if rootLen > bestLen { + bestFolder = folder + bestRoot = root + bestLen = rootLen + ambiguous = false + continue + } + if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID { + ambiguous = true + } + } + + if ambiguous { + return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path matches multiple libraries"} + } + if bestFolder == nil { + return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "No library matches the given path"} + } + return bestFolder, bestRoot, nil +} + +func ClassifyPath(targetPath, matchedRoot string) (string, error) { + if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) { + return ModeLibrary, nil + } + + info, err := os.Stat(targetPath) + if err != nil { + switch { + case errors.Is(err, os.ErrNotExist): + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not exist"} + case errors.Is(err, os.ErrPermission): + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Permission denied for path"} + default: + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path could not be inspected"} + } + } + if info.IsDir() { + return ModeSubtree, nil + } + if !info.Mode().IsRegular() { + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path must be a file or directory"} + } + if !scanner.SupportsVideoFile(targetPath) { + return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Unsupported media file extension"} + } + return ModeFile, nil +} + +func PathWithinRoot(targetPath, rootPath string) bool { + cleanTarget := filepath.Clean(targetPath) + cleanRoot := filepath.Clean(rootPath) + rel, err := filepath.Rel(cleanRoot, cleanTarget) + if err != nil { + return false + } + if rel == "." || rel == "" { + return true + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} +``` + +- [ ] **Step 4: Run resolver tests** + +Run: + +```bash +go test ./internal/scantrigger +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/scantrigger/scantrigger.go internal/scantrigger/scantrigger_test.go +git commit -m "refactor(scan): extract scan trigger resolver" +``` + +--- + +### Task 2: Use Shared Resolver in Native Scan API + +**Files:** +- Modify: `internal/api/handlers/libraries.go` + +- [ ] **Step 1: Update imports** + +In `internal/api/handlers/libraries.go`, add: + +```go + "github.com/Silo-Server/silo-server/internal/scantrigger" +``` + +Remove now-unused imports after the refactor: + +```go + "os" + "path/filepath" +``` + +Keep `strings` if other functions still use it in the file. + +- [ ] **Step 2: Replace local scan mode constants and resolver types** + +Remove the local `scanMode`, `resolvedScanTarget`, and `scanRequestError` declarations. Use `scantrigger.Target` and `scantrigger.RequestError` instead. + +Update the start of `HandleScan` to: + +```go + target, err := scantrigger.NewResolver(h.folderRepo).Resolve(r.Context(), scantrigger.Request{ + LibraryID: req.LibraryID, + Path: req.Path, + }) + if err != nil { + var reqErr *scantrigger.RequestError + if errors.As(err, &reqErr) { + writeError(w, reqErr.Status, reqErr.Code, reqErr.Message) + return + } + slog.Error("resolving scan target", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve scan target") + return + } +``` + +- [ ] **Step 3: Update enqueue and direct-scan dispatch** + +In `HandleScan`, replace `target.folder`, `target.mode`, and `target.path` with exported fields: + +```go + if h.ScanQueue != nil { + if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.LibraryID, target.Mode, target.Path, target.Trigger); err != nil { + slog.Error("queueing library scan", "library_id", target.LibraryID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan") + return + } + } else if h.ingester != nil { + scanID := ulid.Make().String() + h.recordAcceptedScan(scanID, target) + switch target.Mode { + case scantrigger.ModeFile: + h.runFileScanAsync(scanID, target.Folder, target.Path, target.Trigger) + case scantrigger.ModeSubtree: + h.runSubtreeScanAsync(scanID, target.Folder, target.Path, target.Trigger) + default: + h.runFolderScanAsync(scanID, target.Folder, target.Trigger) + } + } else { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") + return + } + + writeJSON(w, http.StatusAccepted, scanResponse{ + Status: "accepted", + Mode: target.Mode, + LibraryID: target.LibraryID, + }) +``` + +- [ ] **Step 4: Update `recordAcceptedScan` signature** + +Change: + +```go +func (h *LibraryHandler) recordAcceptedScan(scanID string, target *resolvedScanTarget) { +``` + +to: + +```go +func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.Target) { +``` + +Inside it, use: + +```go +if h == nil || h.ScanRegistry == nil || target == nil || target.Folder == nil { + return +} +h.ScanRegistry.Upsert(evt.ScanRun{ + ID: scanID, + LibraryID: target.LibraryID, + Mode: target.Mode, + Path: target.Path, + Trigger: target.Trigger, + Status: "accepted", +}) +``` + +Keep existing fields that are already in the local `evt.ScanRun` literal; only update the target field names. + +- [ ] **Step 5: Delete old resolver helpers** + +Remove these functions from `internal/api/handlers/libraries.go` after the native handler compiles against `scantrigger`: + +```go +resolveScanTarget +longestMatchingRoot +matchFolderForPath +classifyScanPath +pathWithinRoot +``` + +- [ ] **Step 6: Run targeted tests** + +Run: + +```bash +go test ./internal/api/handlers ./internal/scantrigger +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/api/handlers/libraries.go internal/scantrigger/scantrigger.go internal/scantrigger/scantrigger_test.go +git commit -m "refactor(api): share scan target resolution" +``` + +--- + +### Task 3: Add Jellyfin Admin API-Key Auth for Autoscan + +**Files:** +- Modify: `internal/jellycompat/server.go` +- Create: `internal/jellycompat/auth_api_key.go` +- Modify: `cmd/silo/main.go` + +- [ ] **Step 1: Add dependency fields** + +In `internal/jellycompat/server.go`, extend `Dependencies`: + +```go + // Autoscan / admin compatibility support. + APIKeyValidator apiKeyValidator + APIKeyUserLoader apiKeyUserLoader + ScanQueue scantrigger.Queuer +``` + +Add the import: + +```go + "github.com/Silo-Server/silo-server/internal/scantrigger" +``` + +- [ ] **Step 2: Add failing auth tests** + +Add tests to `internal/jellycompat/auth_test.go`: + +```go +func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) { + authn := NewAdminAPIKeyAuthenticator( + &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, + &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}}, + ) + req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil) + req.Header.Set("X-Emby-Token", "sa_test") + rec := httptest.NewRecorder() + + authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !AdminAPIKeyFromContext(r.Context()) { + t.Fatal("expected admin API key marker in context") + } + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) { + authn := NewAdminAPIKeyAuthenticator( + &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, + &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}}, + ) + req := httptest.NewRequest("POST", "/Library/Media/Updated", nil) + req.Header.Set("X-Emby-Token", "sa_test") + rec := httptest.NewRecorder() + + authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not run") + })).ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String()) + } +} +``` + +Add supporting fakes in the same file: + +```go +type fakeAPIKeyValidator struct { + key *models.APIKey +} + +func (f *fakeAPIKeyValidator) GetByKey(_ context.Context, key string) (*models.APIKey, error) { + if f.key != nil && f.key.Key == key { + return f.key, nil + } + return nil, auth.ErrAPIKeyNotFound +} + +func (f *fakeAPIKeyValidator) UpdateLastUsed(context.Context, int64) error { + return nil +} + +type fakeAPIKeyUserLoader struct { + user *models.User +} + +func (f *fakeAPIKeyUserLoader) GetByID(_ context.Context, id int) (*models.User, error) { + if f.user != nil && f.user.ID == id { + return f.user, nil + } + return nil, auth.ErrNotFound +} +``` + +Update imports in `auth_test.go`: + +```go + "context" + + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/models" +``` + +- [ ] **Step 3: Run auth tests to verify failure** + +Run: + +```bash +go test ./internal/jellycompat -run 'TestRequireAdminAPIKey' +``` + +Expected: fail because `NewAdminAPIKeyAuthenticator` and `AdminAPIKeyFromContext` do not exist. + +- [ ] **Step 4: Add API-key auth helper** + +Create `internal/jellycompat/auth_api_key.go`: + +```go +package jellycompat + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type adminAPIKeyContextKey string + +const adminAPIKeyKey adminAPIKeyContextKey = "jellycompat_admin_api_key" + +type apiKeyValidator interface { + GetByKey(ctx context.Context, key string) (*models.APIKey, error) + UpdateLastUsed(ctx context.Context, id int64) error +} + +type apiKeyUserLoader interface { + GetByID(ctx context.Context, id int) (*models.User, error) +} + +type AdminAPIKeyAuthenticator struct { + keys apiKeyValidator + users apiKeyUserLoader +} + +type adminAPIKeyAuthResult struct { + ctx context.Context + status int + ok bool +} + +func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator { + if keys == nil || users == nil { + return nil + } + return &AdminAPIKeyAuthenticator{keys: keys, users: users} +} + +func AdminAPIKeyFromContext(ctx context.Context) bool { + ok, _ := ctx.Value(adminAPIKeyKey).(bool) + return ok +} + +func (a *AdminAPIKeyAuthenticator) RequireAdminAPIKey(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + result := a.authenticate(r) + if !result.ok { + writeError(w, result.status, authErrorCode(result.status), authErrorMessage(result.status)) + return + } + next.ServeHTTP(w, r.WithContext(result.ctx)) + }) +} + +func RequireSessionOrAdminAPIKey(sessionAuth *Authenticator, keyAuth *AdminAPIKeyAuthenticator) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := ExtractToken(r) + if ok && strings.HasPrefix(token, "sa_") { + result := keyAuth.authenticate(r) + if !result.ok { + writeError(w, result.status, authErrorCode(result.status), authErrorMessage(result.status)) + return + } + next.ServeHTTP(w, r.WithContext(result.ctx)) + return + } + sessionAuth.RequireSession(next).ServeHTTP(w, r) + }) + } +} + +func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuthResult { + if a == nil || a.keys == nil || a.users == nil { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + token, ok := ExtractToken(r) + if !ok || !strings.HasPrefix(token, "sa_") { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + apiKey, err := a.keys.GetByKey(r.Context(), token) + if err != nil { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + user, err := a.users.GetByID(r.Context(), apiKey.UserID) + if err != nil || user == nil || !user.Enabled { + return adminAPIKeyAuthResult{ctx: r.Context(), status: http.StatusUnauthorized} + } + if user.Role != "admin" { + return adminAPIKeyAuthResult{ctx: context.WithValue(r.Context(), adminAPIKeyKey, false), status: http.StatusForbidden} + } + go func(id int64) { + if err := a.keys.UpdateLastUsed(context.Background(), id); err != nil { + slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err) + } + }(apiKey.ID) + return adminAPIKeyAuthResult{ + ctx: context.WithValue(r.Context(), adminAPIKeyKey, true), + status: http.StatusOK, + ok: true, + } +} + +func authErrorCode(status int) string { + if status == http.StatusForbidden { + return "Forbidden" + } + return "Unauthorized" +} + +func authErrorMessage(status int) string { + if status == http.StatusForbidden { + return "Admin access required" + } + return "Invalid API key" +} +``` + +- [ ] **Step 5: Wire dependencies in `cmd/silo/main.go`** + +Inside the compat DB wiring block, after `userRepo := auth.NewUserRepository(deps.DB)`, add: + +```go + compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB) + compatDeps.APIKeyUserLoader = userRepo + compatDeps.ScanQueue = deps.LibraryScanQueue +``` + +- [ ] **Step 6: Run auth tests** + +Run: + +```bash +go test ./internal/jellycompat -run 'TestRequireAdminAPIKey' +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/jellycompat/server.go internal/jellycompat/auth_api_key.go internal/jellycompat/auth_test.go cmd/silo/main.go +git commit -m "feat(jellycompat): accept admin api keys for autoscan" +``` + +--- + +### Task 4: Add Jellyfin Autoscan Handlers and Routes + +**Files:** +- Create: `internal/jellycompat/handlers_autoscan.go` +- Create: `internal/jellycompat/handlers_autoscan_test.go` +- Modify: `internal/jellycompat/router.go` + +- [ ] **Step 1: Write failing handler tests** + +Create `internal/jellycompat/handlers_autoscan_test.go`: + +```go +package jellycompat + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakeAutoscanFolders struct { + folders []*models.MediaFolder +} + +func (f *fakeAutoscanFolders) GetByID(_ context.Context, id int) (*models.MediaFolder, error) { + for _, folder := range f.folders { + if folder.ID == id { + return folder, nil + } + } + return nil, catalog.ErrFolderNotFound +} + +func (f *fakeAutoscanFolders) List(context.Context) ([]*models.MediaFolder, error) { + return f.folders, nil +} + +type fakeAutoscanQueue struct { + calls []queuedScan +} + +type queuedScan struct { + libraryID int + mode string + path string + trigger string +} + +func (q *fakeAutoscanQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) { + q.calls = append(q.calls, queuedScan{libraryID: folderID, mode: mode, path: path, trigger: trigger}) + return true, nil +} + +func TestAutoscanVirtualFoldersIncludesEnabledLocationsForAdminKey(t *testing.T) { + enabledRoot := t.TempDir() + disabledRoot := t.TempDir() + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{ + {ID: 1, Name: "Movies", Type: "movie", Enabled: true, Paths: []string{enabledRoot}}, + {ID: 2, Name: "Disabled", Type: "movie", Enabled: false, Paths: []string{disabledRoot}}, + }}, nil, NewResourceIDCodec(), nil) + + req := httptest.NewRequest(http.MethodGet, "/Library/VirtualFolders", nil) + req = req.WithContext(context.WithValue(req.Context(), adminAPIKeyKey, true)) + rec := httptest.NewRecorder() + + handler.HandleVirtualFolders(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var got []virtualFolderDTO + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected one enabled library, got %d", len(got)) + } + if got[0].Name != "Movies" || len(got[0].Locations) != 1 || got[0].Locations[0] != enabledRoot { + t.Fatalf("unexpected folder response: %#v", got[0]) + } +} + +func TestAutoscanMediaUpdatedEnqueuesResolvedPath(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Movie.mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 3, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + body := []byte(`{"Updates":[{"path":` + strconv.Quote(filePath) + `,"updateType":"Modified"}]}`) + req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.HandleMediaUpdated(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + if len(queue.calls) != 1 { + t.Fatalf("expected one queued scan, got %d", len(queue.calls)) + } + if queue.calls[0].libraryID != 3 || queue.calls[0].mode != "file" || queue.calls[0].path != filePath || queue.calls[0].trigger != "jellyfin_autoscan" { + t.Fatalf("unexpected queued scan: %#v", queue.calls[0]) + } +} + +func TestAutoscanMediaUpdatedAllOrFail(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Movie.mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 4, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + payload := map[string]any{"Updates": []map[string]string{ + {"path": filePath, "updateType": "Modified"}, + {"path": filepath.Join(root, "missing.mkv"), "updateType": "Modified"}, + }} + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data)) + rec := httptest.NewRecorder() + + handler.HandleMediaUpdated(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + if len(queue.calls) != 0 { + t.Fatalf("expected no partial enqueue, got %#v", queue.calls) + } +} +``` + +- [ ] **Step 2: Run handler tests to verify failure** + +Run: + +```bash +go test ./internal/jellycompat -run 'TestAutoscan' +``` + +Expected: fail because `NewAutoscanHandler` does not exist. + +- [ ] **Step 3: Add Autoscan handler** + +Create `internal/jellycompat/handlers_autoscan.go`: + +```go +package jellycompat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scantrigger" +) + +const autoscanTrigger = "jellyfin_autoscan" + +type autoscanFolderRepository interface { + GetByID(ctx context.Context, id int) (*models.MediaFolder, error) + List(ctx context.Context) ([]*models.MediaFolder, error) +} + +type autoscanVirtualFolderFallback interface { + HandleVirtualFolders(w http.ResponseWriter, r *http.Request) +} + +type AutoscanHandler struct { + folders autoscanFolderRepository + queue scantrigger.Queuer + codec *ResourceIDCodec + fallback autoscanVirtualFolderFallback +} + +func NewAutoscanHandler( + folders autoscanFolderRepository, + queue scantrigger.Queuer, + codec *ResourceIDCodec, + fallback autoscanVirtualFolderFallback, +) *AutoscanHandler { + if codec == nil { + codec = NewResourceIDCodec() + } + return &AutoscanHandler{folders: folders, queue: queue, codec: codec, fallback: fallback} +} + +func (h *AutoscanHandler) HandleVirtualFolders(w http.ResponseWriter, r *http.Request) { + if !AdminAPIKeyFromContext(r.Context()) { + if h.fallback != nil { + h.fallback.HandleVirtualFolders(w, r) + return + } + writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") + return + } + if h == nil || h.folders == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Library discovery not available") + return + } + folders, err := h.folders.List(r.Context()) + if err != nil { + slog.Error("jellycompat autoscan: listing libraries", "error", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to list libraries") + return + } + resp := make([]virtualFolderDTO, 0, len(folders)) + for _, folder := range folders { + if folder == nil || !folder.Enabled { + continue + } + resp = append(resp, virtualFolderDTO{ + Name: folder.Name, + Locations: folder.Paths, + CollectionType: libraryCollectionType(folder.Type), + ItemID: h.codec.EncodeIntID(EncodedIDLibrary, int64(folder.ID)), + LibraryOptions: virtualLibraryOptDTO{ + Enabled: true, + EnableRealtimeMonitor: true, + EnableInternetProviders: true, + SeasonZeroDisplayName: "Specials", + TypeOptions: []string{}, + }, + }) + } + writeJSON(w, http.StatusOK, resp) +} + +type mediaUpdatedRequest struct { + Updates []mediaUpdatedEntry `json:"Updates"` +} + +type mediaUpdatedEntry struct { + Path string `json:"path"` + UpdateType string `json:"updateType"` +} + +func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Request) { + if h == nil || h.folders == nil || h.queue == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") + return + } + var req mediaUpdatedRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "BadRequest", "Invalid request body") + return + } + if len(req.Updates) == 0 { + writeError(w, http.StatusBadRequest, "BadRequest", "Updates is required") + return + } + scanRequests := make([]scantrigger.Request, 0, len(req.Updates)) + for _, update := range req.Updates { + path := strings.TrimSpace(update.Path) + if path == "" { + writeError(w, http.StatusBadRequest, "BadRequest", "Update path is required") + return + } + scanRequests = append(scanRequests, scantrigger.Request{ + Path: path, + Trigger: autoscanTrigger, + }) + } + targets, err := scantrigger.NewResolver(h.folders).ResolveAll(r.Context(), scanRequests) + if err != nil { + writeScanTriggerError(w, err) + return + } + if err := scantrigger.EnqueueAll(r.Context(), h.queue, targets); err != nil { + writeScanTriggerError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func writeScanTriggerError(w http.ResponseWriter, err error) { + var reqErr *scantrigger.RequestError + if errors.As(err, &reqErr) { + writeError(w, reqErr.Status, reqErr.Code, reqErr.Message) + return + } + slog.Error("jellycompat autoscan: scan update failed", "error", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", fmt.Sprintf("Failed to process scan update: %v", err)) +} +``` + +- [ ] **Step 4: Register routes** + +In `internal/jellycompat/router.go`, after `itemsHandler` is created, add: + +```go + autoscanHandler := NewAutoscanHandler(deps.FolderRepo, deps.ScanQueue, deps.IDCodec, itemsHandler) + adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader) + autoscanVirtualFoldersRegistered := false + if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil { + r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)). + Get("/Library/VirtualFolders", autoscanHandler.HandleVirtualFolders) + r.With(adminAPIKeyAuth.RequireAdminAPIKey). + Post("/Library/Media/Updated", autoscanHandler.HandleMediaUpdated) + autoscanVirtualFoldersRegistered = true + } +``` + +Inside the existing authenticated group, replace: + +```go + r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders) +``` + +with: + +```go + if !autoscanVirtualFoldersRegistered { + r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders) + } +``` + +- [ ] **Step 5: Run handler tests** + +Run: + +```bash +go test ./internal/jellycompat -run 'TestAutoscan' +``` + +Expected: PASS. + +- [ ] **Step 6: Run broader compat tests** + +Run: + +```bash +go test ./internal/jellycompat +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/jellycompat/handlers_autoscan.go internal/jellycompat/handlers_autoscan_test.go internal/jellycompat/router.go internal/jellycompat/server.go +git commit -m "feat(jellycompat): add autoscan media update route" +``` + +--- + +### Task 5: Update Documentation and Full Verification + +**Files:** +- Modify: `docs/scan-api.md` + +- [ ] **Step 1: Update Autoscan documentation** + +Replace the opening paragraph under `## Integration with Autoscan` in `docs/scan-api.md` with: + +```markdown +[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and +other sources for new downloads, then relays scan requests to media servers. +Silo supports Autoscan's stock Jellyfin target through the Jellyfin compatibility +server. + +Use: + +- URL: Silo's Jellyfin compatibility URL, usually `http://your-server:8096` +- Token: a Silo admin API key beginning with `sa_` +- Target type: Autoscan `jellyfin` + +Autoscan discovers library roots from `GET /Library/VirtualFolders` and sends +changed paths to `POST /Library/Media/Updated`. The paths must be server-side +paths as Silo sees them. +``` + +Keep the custom script section below it, but rename the heading: + +```markdown +### Alternative: Autoscan Custom Script Target +``` + +- [ ] **Step 2: Run final Go tests** + +Run: + +```bash +go test ./internal/scantrigger ./internal/api/handlers ./internal/jellycompat ./cmd/silo +``` + +Expected: PASS. + +- [ ] **Step 3: Run formatting** + +Run: + +```bash +gofmt -w internal/scantrigger/scantrigger.go internal/scantrigger/scantrigger_test.go internal/api/handlers/libraries.go internal/jellycompat/auth_api_key.go internal/jellycompat/auth_test.go internal/jellycompat/handlers_autoscan.go internal/jellycompat/handlers_autoscan_test.go internal/jellycompat/router.go internal/jellycompat/server.go cmd/silo/main.go +``` + +Then rerun: + +```bash +go test ./internal/scantrigger ./internal/api/handlers ./internal/jellycompat ./cmd/silo +``` + +Expected: PASS. + +- [ ] **Step 4: Inspect diff** + +Run: + +```bash +git diff --stat +git diff -- internal/scantrigger internal/api/handlers/libraries.go internal/jellycompat cmd/silo/main.go docs/scan-api.md +``` + +Expected: only the planned scan resolver, Jellyfin Autoscan compatibility, and docs changes are present. + +- [ ] **Step 5: Commit docs and final adjustments** + +```bash +git add docs/scan-api.md internal/scantrigger internal/api/handlers/libraries.go internal/jellycompat cmd/silo/main.go +git commit -m "docs: document jellyfin autoscan setup" +``` + +--- + +## Plan Self-Review + +- Spec coverage: + - Jellyfin only: Task 4 registers `/Library/Media/Updated` without Emby aliases. + - Admin Silo API keys as Jellyfin token: Task 3. + - Real library locations: Task 4. + - Existing scan behavior reused: Tasks 1 and 2. + - All-or-fail multi-update validation: Tasks 1 and 4. + - Tests and docs: Tasks 1, 3, 4, and 5. +- Marker scan: no incomplete-work markers are intentionally left in the tasks. +- Type consistency: + - `scantrigger.Request`, `scantrigger.Target`, and `scantrigger.Queuer` are introduced before use. + - `AdminAPIKeyFromContext`, `NewAdminAPIKeyAuthenticator`, and route middleware are introduced before handler routing. diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index ed908a2c..a7ca5f6b 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -29,6 +29,7 @@ type RequestService interface { Decline(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error) Cancel(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error) Retry(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error) + GetFeatureStatus(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.FeatureStatus, error) GetSettings(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.Settings, error) UpdateSettings(ctx context.Context, viewer mediarequests.Viewer, settings mediarequests.Settings) (mediarequests.Settings, error) GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error) @@ -362,6 +363,19 @@ func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, req) } +func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) { + viewer, ok := requestViewer(w, r, true) + if !ok { + return + } + status, err := h.service.GetFeatureStatus(r.Context(), viewer) + if err != nil { + writeRequestServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, status) +} + func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) { viewer, ok := requestViewer(w, r, false) if !ok { diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go index c84934d4..2f0da73e 100644 --- a/internal/api/handlers/requests_test.go +++ b/internal/api/handlers/requests_test.go @@ -103,6 +103,10 @@ func (f *fakeRequestService) Retry(context.Context, mediarequests.Viewer, string return nil, nil } +func (f *fakeRequestService) GetFeatureStatus(context.Context, mediarequests.Viewer) (mediarequests.FeatureStatus, error) { + return mediarequests.FeatureStatus{}, nil +} + func (f *fakeRequestService) GetSettings(context.Context, mediarequests.Viewer) (mediarequests.Settings, error) { return mediarequests.Settings{}, nil } diff --git a/internal/api/router.go b/internal/api/router.go index 20983d51..26a6169a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1404,6 +1404,7 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/discover/browse/genre/{slug}", requestHandler.HandleBrowseGenre) r.Get("/discover/{section}", requestHandler.HandleDiscoverSection) r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail) + r.Get("/status", requestHandler.HandleGetStatus) r.Post("/", requestHandler.HandleCreate) r.Get("/mine", requestHandler.HandleListMine) r.Get("/{id}", requestHandler.HandleGet) diff --git a/internal/requests/discover_brand.go b/internal/requests/discover_brand.go index cf95ff09..17ca01ea 100644 --- a/internal/requests/discover_brand.go +++ b/internal/requests/discover_brand.go @@ -22,10 +22,13 @@ type DiscoverBrandCard struct { } // ListStudios returns the bundled studios with their curated logo URLs. -func (s *Service) ListStudios(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { - if s == nil { +func (s *Service) ListStudios(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.store == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } out := make([]DiscoverBrandCard, 0, len(BundledStudios)) for _, studio := range BundledStudios { out = append(out, DiscoverBrandCard{ @@ -39,10 +42,13 @@ func (s *Service) ListStudios(_ context.Context, _ Viewer) ([]DiscoverBrandCard, } // ListNetworks returns the bundled TV networks with their curated logo URLs. -func (s *Service) ListNetworks(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { - if s == nil { +func (s *Service) ListNetworks(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.store == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } out := make([]DiscoverBrandCard, 0, len(BundledNetworks)) for _, network := range BundledNetworks { out = append(out, DiscoverBrandCard{ @@ -58,10 +64,13 @@ func (s *Service) ListNetworks(_ context.Context, _ Viewer) ([]DiscoverBrandCard // ListGenres returns the bundled genres. Each card carries gradient hints // (no logo URL) and a SeriesSupported flag for the browse page to decide // whether to show the Series tab. -func (s *Service) ListGenres(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { - if s == nil { +func (s *Service) ListGenres(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.store == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } out := make([]DiscoverBrandCard, 0, len(BundledGenres)) for _, genre := range BundledGenres { out = append(out, DiscoverBrandCard{ @@ -113,9 +122,12 @@ const defaultBrowseSort = "popularity" // BrowseStudio returns a page of movies from a bundled studio, enriched with // Silo availability and request state. func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { - if s == nil || s.tmdb == nil { + if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } studio, ok := FindStudioBySlug(strings.TrimSpace(slug)) if !ok { return nil, ErrNotFound @@ -151,9 +163,12 @@ func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort st // BrowseNetwork returns a page of series from a bundled TV network. func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { - if s == nil || s.tmdb == nil { + if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } network, ok := FindNetworkBySlug(strings.TrimSpace(slug)) if !ok { return nil, ErrNotFound @@ -189,9 +204,12 @@ func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort s // BrowseGenre returns a page of movies or series from a bundled genre. func (s *Service) BrowseGenre(ctx context.Context, viewer Viewer, slug string, rawMediaType MediaType, sort string, page int) (*DiscoverBrowseResponse, error) { - if s == nil || s.tmdb == nil { + if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } genre, ok := FindGenreBySlug(strings.TrimSpace(slug)) if !ok { return nil, ErrNotFound diff --git a/internal/requests/service.go b/internal/requests/service.go index b6456210..dc265871 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -95,6 +95,9 @@ func (s *Service) Search(ctx context.Context, viewer Viewer, query string, media if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } mediaType, err := normalizeSearchMediaType(mediaType) if err != nil { return nil, err @@ -114,6 +117,9 @@ func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, p if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } section = strings.TrimSpace(section) if _, ok := discoverySectionTitles[section]; !ok { return nil, fmt.Errorf("%w: invalid discovery section", ErrInvalidInput) @@ -137,6 +143,12 @@ func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, p } func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySection, error) { + if s == nil || s.store == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } sections := make([]DiscoverySection, len(discoverySectionOrder)) group, gctx := errgroup.WithContext(ctx) group.SetLimit(externalIDHydrationConcurrency) @@ -164,6 +176,9 @@ func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaT if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } mediaType, err := normalizeMediaType(mediaType) if err != nil { return nil, err @@ -256,6 +271,12 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create if err := validateViewer(viewer); err != nil { return nil, err } + if s == nil || s.store == nil { + return nil, fmt.Errorf("request service is not configured") + } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } normalized, err := normalizeCreateInput(input) if err != nil { return nil, err @@ -342,6 +363,9 @@ func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter if viewer.UserID == 0 { return nil, ErrForbidden } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } return s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter)) } @@ -353,6 +377,9 @@ func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilte } func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) { + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) if err != nil { return nil, err @@ -412,6 +439,11 @@ func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) if viewer.UserID == 0 { return nil, ErrForbidden } + if !viewer.IsAdmin { + if err := s.ensureRequestsEnabled(ctx); err != nil { + return nil, err + } + } req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) if err != nil { return nil, err @@ -506,6 +538,25 @@ func (s *Service) GetSettings(ctx context.Context, viewer Viewer) (Settings, err return s.store.GetSettings(ctx) } +func (s *Service) GetFeatureStatus(ctx context.Context, _ Viewer) (FeatureStatus, error) { + settings, err := s.store.GetSettings(ctx) + if err != nil { + return FeatureStatus{}, err + } + return FeatureStatus{RequestsEnabled: settings.RequestsEnabled}, nil +} + +func (s *Service) ensureRequestsEnabled(ctx context.Context) error { + settings, err := s.store.GetSettings(ctx) + if err != nil { + return err + } + if !settings.RequestsEnabled { + return ErrRequestsDisabled + } + return nil +} + func (s *Service) UpdateSettings(ctx context.Context, viewer Viewer, settings Settings) (Settings, error) { if !viewer.IsAdmin { return Settings{}, ErrForbidden diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 4f0689de..b25af25f 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -37,10 +37,10 @@ func TestCreateRequestQuotaExceeded(t *testing.T) { func TestNormalizeListFilterCapsLimit(t *testing.T) { cases := []struct { - name string - in ListFilter - wantLim int - wantOff int + name string + in ListFilter + wantLim int + wantOff int }{ {"zero defaults", ListFilter{}, defaultRequestListLimit, 0}, {"negative defaults", ListFilter{Limit: -10, Offset: -5}, defaultRequestListLimit, 0}, @@ -597,6 +597,100 @@ func TestSearchWithoutMediaTypeSearchesMoviesAndSeries(t *testing.T) { } } +func TestDisabledRequestsBlockUserSurfaces(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = false + store.requests["req-1"] = &Request{ + ID: "req-1", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusPending, + Outcome: OutcomeActive, + RequestedByUserID: 1, + } + tmdbClient := &fakeTMDBClient{ + page: &tmdb.MediaPage{Results: []tmdb.MediaResult{{ID: 550, MediaType: "movie", Title: "Fight Club"}}}, + detail: &tmdb.MediaDetail{ID: 550, MediaType: "movie", Title: "Fight Club"}, + discoverPage: &tmdb.MediaPage{Results: []tmdb.MediaResult{{ID: 550, MediaType: "movie", Title: "Fight Club"}}}, + } + service := newTestServiceWithTMDB(store, tmdbClient) + viewer := testViewer(1) + + cases := []struct { + name string + call func() error + }{ + {"search", func() error { + _, err := service.Search(context.Background(), viewer, "fight", MediaTypeMovie, 1) + return err + }}, + {"discover all", func() error { + _, err := service.DiscoverAll(context.Background(), viewer) + return err + }}, + {"discover section", func() error { + _, err := service.Discover(context.Background(), viewer, "popular_movies", 1) + return err + }}, + {"detail", func() error { + _, err := service.GetDetail(context.Background(), viewer, MediaTypeMovie, 550) + return err + }}, + {"create", func() error { + _, err := service.CreateRequest(context.Background(), viewer, CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + return err + }}, + {"mine", func() error { + _, err := service.ListMine(context.Background(), viewer, ListFilter{}) + return err + }}, + {"get", func() error { + _, err := service.GetRequest(context.Background(), viewer, "req-1") + return err + }}, + {"cancel", func() error { + _, err := service.Cancel(context.Background(), viewer, "req-1", "") + return err + }}, + {"studios", func() error { + _, err := service.ListStudios(context.Background(), viewer) + return err + }}, + {"networks", func() error { + _, err := service.ListNetworks(context.Background(), viewer) + return err + }}, + {"genres", func() error { + _, err := service.ListGenres(context.Background(), viewer) + return err + }}, + {"browse studio", func() error { + _, err := service.BrowseStudio(context.Background(), viewer, "marvel-studios", "popularity", 1) + return err + }}, + {"browse network", func() error { + _, err := service.BrowseNetwork(context.Background(), viewer, "netflix", "popularity", 1) + return err + }}, + {"browse genre", func() error { + _, err := service.BrowseGenre(context.Background(), viewer, "action", MediaTypeMovie, "popularity", 1) + return err + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := tc.call(); !errors.Is(err, ErrRequestsDisabled) { + t.Fatalf("err = %v, want ErrRequestsDisabled", err) + } + }) + } +} + func TestReconcileRequestsCompletesFromCatalogPresence(t *testing.T) { store := newFakeStore() store.candidates = []*Request{{ @@ -875,6 +969,7 @@ type fakeStore struct { func newFakeStore() *fakeStore { return &fakeStore{ settings: Settings{ + RequestsEnabled: true, GlobalMaxRequests: 5, GlobalWindowDays: 7, }, diff --git a/internal/requests/types.go b/internal/requests/types.go index b7331821..b4c5b6c0 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -70,6 +70,10 @@ type Settings struct { UpdatedAt time.Time `json:"updated_at"` } +type FeatureStatus struct { + RequestsEnabled bool `json:"requests_enabled"` +} + type UserLimit struct { UserID int `json:"user_id"` LimitMode LimitMode `json:"limit_mode"` diff --git a/web/src/App.tsx b/web/src/App.tsx index d83574a9..2d85ddc3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -62,6 +62,7 @@ import Calendar from "@/pages/Calendar"; import Signup from "@/pages/Signup"; import TasteSeed from "@/pages/TasteSeed"; import { useFavorites } from "@/hooks/queries/favorites"; +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; import { isTasteSeedDismissed } from "@/lib/tasteSeed"; import SettingsLayout from "@/pages/SettingsLayout"; import AppearanceSettings from "@/pages/settings/AppearanceSettings"; @@ -177,6 +178,20 @@ function RequirePrimaryOrAdmin({ children }: { children: ReactNode }) { return <>{children}; } +function RequireRequestsEnabled({ children }: { children: ReactNode }) { + const status = useRequestFeatureStatus(); + if (status.isLoading) { + return ( +
+ Loading request availability + Loading... +
+ ); + } + if (status.data?.requests_enabled !== true) return ; + return <>{children}; +} + /** * Redirects new profiles (no favorites yet, no skip flag) to the taste-seed * onboarding screen the first time they land on Home. Only checks on Home so @@ -449,19 +464,45 @@ function AppRoutes() { path="/collections/:id" element={} /> - } /> - } /> + + + + } + /> + + + + } + /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> } /> { const installations = pluginSettings?.installations ?? []; const links: { id: string; basePath: string; label: string; pluginId: string }[] = []; @@ -495,23 +498,25 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar Recommendations -
  • - - {isActive("/requests") && ( - - )} - - Requests - -
  • + {showRequestsNav && ( +
  • + + {isActive("/requests") && ( + + )} + + Requests + +
  • + )}
  • ["requests", "status"] as const, discovery: () => ["requests", "discovery"] as const, discoverySection: (section: string, page: number) => ["requests", "discovery", section, page] as const, diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index dc85e2ba..1566ee08 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -13,6 +13,7 @@ import type { MediaRequestsListResponse, RequestDiscoveryResponse, RequestDiscoverySection, + RequestFeatureStatus, RequestIntegration, RequestIntegrationOptions, RequestIntegrationsResponse, @@ -63,6 +64,14 @@ export function useRequestDiscovery() { }); } +export function useRequestFeatureStatus() { + return useQuery({ + queryKey: requestKeys.status(), + queryFn: () => api("/requests/status"), + staleTime: REQUESTS_STALE_TIME, + }); +} + export function useRequestDiscoverySection(section: string, page = 1) { return useQuery({ queryKey: requestKeys.discoverySection(section, page), @@ -269,6 +278,7 @@ export function useUpdateRequestSettings() { onSuccess: () => { toast.success("Request settings saved"); queryClient.invalidateQueries({ queryKey: adminKeys.requestSettings() }); + queryClient.invalidateQueries({ queryKey: requestKeys.status() }); invalidateRequestSurfaces(queryClient); }, onError: (err) => {