Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fb9bef7ed | ||
|
|
843bfdf3eb | ||
|
|
dd04f5b354 | ||
|
|
86a77beae2 | ||
|
|
0327336ed9 | ||
|
|
5f317da348 | ||
|
|
3460678b6b | ||
|
|
bde7fc80b3 | ||
|
|
ce6e85ddb9 | ||
|
|
165b0205ae | ||
|
|
bbc421a809 | ||
|
|
399807a6fe | ||
|
|
b16e014dc6 | ||
|
|
5e43be2278 | ||
|
|
edc01ef733 | ||
|
|
69c5e6d1a1 | ||
|
|
0cd37aa618 | ||
|
|
4d894379df | ||
|
|
44fc0090c7 | ||
|
|
38c7fb44e1 | ||
|
|
c0222a55e1 | ||
|
|
ea6636f9d9 | ||
|
|
8c172ec9dd | ||
|
|
514b72cddb | ||
|
|
6983925b2e | ||
|
|
d897124e94 | ||
|
|
543534a058 | ||
|
|
234491f1cc | ||
|
|
cb0cb04534 | ||
|
|
d4dc695e1d | ||
|
|
f4b9d4cf38 | ||
|
|
fd41f56381 | ||
|
|
aef670c3bc | ||
|
|
3a43537e67 | ||
|
|
bc2b815391 | ||
|
|
7af0cf241e | ||
|
|
209fb50f16 | ||
|
|
a5a0fad86e | ||
|
|
9c6964e1a1 | ||
|
|
901285b225 | ||
|
|
620cd2746b | ||
|
|
355da1a4fd | ||
|
|
8deb18273d | ||
|
|
a8227723bf | ||
|
|
28ef3ac3c8 | ||
|
|
6d77e5a135 |
@@ -1,12 +1,6 @@
|
||||
# Silo Docker deployment
|
||||
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
|
||||
|
||||
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
|
||||
# Linux/macOS example for enabling NVIDIA override:
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
# Windows example:
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
|
||||
|
||||
# Required for docker compose: host path to your media library.
|
||||
# Example: MEDIA_ROOT=/srv/media
|
||||
MEDIA_ROOT=/srv/media
|
||||
@@ -46,9 +40,6 @@ POSTGRES_SHM_SIZE=8gb
|
||||
# PROXY_PORT=8083
|
||||
# TRANSCODE_PORT=8082
|
||||
|
||||
# Optional NVIDIA override controls (used by docker-compose.nvidia.yml).
|
||||
# NVIDIA_GPU_COUNT=1
|
||||
|
||||
# Run from source / advanced overrides
|
||||
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
|
||||
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
|
||||
|
||||
@@ -34,29 +34,6 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
|
||||
|
||||
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
|
||||
|
||||
### Optional NVIDIA/NVENC
|
||||
|
||||
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
|
||||
|
||||
Install the NVIDIA Container Toolkit and use a Docker Compose version with GPU reservation support before enabling this override.
|
||||
|
||||
Use the optional override file when you want NVENC:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
|
||||
```
|
||||
|
||||
If you want this controlled from `.env`, set `COMPOSE_FILE`:
|
||||
|
||||
```dotenv
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
NVIDIA_GPU_COUNT=1
|
||||
```
|
||||
|
||||
Windows uses `;` instead of `:` between compose files.
|
||||
|
||||
Then `docker compose up -d` will include the NVIDIA override automatically.
|
||||
|
||||
4. **Configure through the admin UI**
|
||||
|
||||
Add libraries, users, metadata providers, and playback settings from the web interface.
|
||||
|
||||
+48
-33
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -17,7 +18,6 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -425,8 +425,6 @@ func main() {
|
||||
|
||||
appCtx, appCancel := context.WithCancel(ctx)
|
||||
defer appCancel()
|
||||
restartReqCh := make(chan struct{}, 1)
|
||||
var restartRequested atomic.Bool
|
||||
|
||||
eventBus := cache.NewEventBus(cfg.Redis.URL)
|
||||
logStreamHub := logstream.NewHub(nodeID, eventBus)
|
||||
@@ -527,20 +525,14 @@ func main() {
|
||||
OpsLogRepo: opsRepo,
|
||||
FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID),
|
||||
PublicURL: os.Getenv("SILO_PUBLIC_URL"),
|
||||
RequestServerRestart: func(context.Context) error {
|
||||
if !restartRequested.CompareAndSwap(false, true) {
|
||||
return handlers.ErrServerRestartAlreadyRequested
|
||||
}
|
||||
restartReqCh <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
|
||||
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
|
||||
audiobooksEnabled, err := audiobooksService.Enabled(appCtx)
|
||||
if err != nil {
|
||||
slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err)
|
||||
absCompatEnabled = false
|
||||
slog.Warn("audiobooks feature disabled; failed to read setting", "err", err)
|
||||
audiobooksEnabled = false
|
||||
}
|
||||
deps.AudiobooksEnabled = audiobooksEnabled
|
||||
adminJobCancelRegistry := adminjob.NewCancelRegistry()
|
||||
deps.AdminJobCancelRegistry = adminJobCancelRegistry
|
||||
if needsWorkers && deps.DB != nil {
|
||||
@@ -1443,7 +1435,6 @@ func main() {
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil {
|
||||
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
|
||||
}
|
||||
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
|
||||
if deps.IntroAnalyzer != nil {
|
||||
taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo))
|
||||
}
|
||||
@@ -1499,9 +1490,6 @@ func main() {
|
||||
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil {
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB)
|
||||
if err := autoscanRepo.MarkInterruptedEvents(appCtx); err != nil {
|
||||
slog.Warn("autoscan: failed to mark interrupted polls", "err", err)
|
||||
}
|
||||
autoscanSvc := api.BuildAutoscanService(
|
||||
autoscanRepo,
|
||||
pluginService,
|
||||
@@ -1531,7 +1519,7 @@ func main() {
|
||||
taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler))
|
||||
taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler))
|
||||
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB)))
|
||||
if audiobookEnricher != nil {
|
||||
if audiobooksEnabled && audiobookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
@@ -1554,7 +1542,7 @@ func main() {
|
||||
// available. Routes are mounted at the root level by NewRouter (not under
|
||||
// /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and
|
||||
// /abs/socket.io/* without path prefix hacks.
|
||||
if absCompatEnabled && deps.DB != nil {
|
||||
if audiobooksEnabled && deps.DB != nil {
|
||||
absUserRepo := auth.NewUserRepository(deps.DB)
|
||||
absSessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
absJWTService := auth.NewJWTService(
|
||||
@@ -1896,17 +1884,47 @@ func main() {
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
|
||||
|
||||
// Access filter resolver for viewer-scoped library access.
|
||||
// Backed by the shared access.Resolver so account-level library
|
||||
// restrictions (users.library_ids), profile restrictions,
|
||||
// user-disabled libraries, and rating/quality ceilings apply to
|
||||
// the compat API exactly as they do to the native API.
|
||||
if userStoreProvider != nil {
|
||||
compatDeps.AccessFilterFn = jellycompat.NewScopeAccessFilter(access.NewResolver(
|
||||
userRepo,
|
||||
userStoreProvider,
|
||||
nil, // profile tokens unused: compat login already verifies PINs
|
||||
))
|
||||
// Access filter resolver for profile-scoped library access.
|
||||
compatDeps.AccessFilterFn = func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
|
||||
if userStoreProvider == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
store, err := userStoreProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
profile, err := store.GetProfile(ctx, profileID)
|
||||
if err != nil || profile == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
filter := catalog.AccessFilter{
|
||||
MaxContentRating: profile.MaxContentRating,
|
||||
}
|
||||
if profile.LibraryRestrictionsEnabled && len(profile.AllowedLibraryIDs) > 0 {
|
||||
filter.AllowedLibraryIDs = profile.AllowedLibraryIDs
|
||||
}
|
||||
// Apply user-disabled library IDs.
|
||||
if raw, err := store.GetSetting(ctx, "disabled_library_ids"); err == nil && raw != "" {
|
||||
var disabled []int
|
||||
if json.Unmarshal([]byte(raw), &disabled) == nil && len(disabled) > 0 {
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
filtered := make([]int, 0, len(filter.AllowedLibraryIDs))
|
||||
disSet := make(map[int]struct{}, len(disabled))
|
||||
for _, id := range disabled {
|
||||
disSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range filter.AllowedLibraryIDs {
|
||||
if _, ok := disSet[id]; !ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
filter.AllowedLibraryIDs = filtered
|
||||
} else {
|
||||
filter.DisabledLibraryIDs = disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1998,9 +2016,6 @@ func main() {
|
||||
case sig := <-sigCh:
|
||||
appCancel()
|
||||
slog.Info("received signal, shutting down", "signal", sig)
|
||||
case <-restartReqCh:
|
||||
appCancel()
|
||||
slog.Info("server restart requested, shutting down")
|
||||
case serverErr := <-errCh:
|
||||
appCancel()
|
||||
slog.Error("server error, shutting down", "error", serverErr)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
services:
|
||||
silo:
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: ${NVIDIA_GPU_COUNT:-1}
|
||||
capabilities: [gpu]
|
||||
|
||||
# Optional distributed-mode example. Most single-host installs should
|
||||
# just use the integrated service above.
|
||||
# silo-transcode:
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: ${NVIDIA_GPU_COUNT:-1}
|
||||
# capabilities: [gpu]
|
||||
+1
-1
@@ -46,7 +46,7 @@ services:
|
||||
- "${ABS_PORT:-13378}:13378"
|
||||
volumes:
|
||||
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}}:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
# Ebooks Like Audiobooks Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add ebooks as a core silo media type by mirroring the audiobook scanner architecture in `main`. Metadata enrichment is documented below as follow-up PR work, not part of the foundation PR.
|
||||
|
||||
**Architecture:** The foundation PR scans ebooks through core `internal/scanner` code. Later PRs add a core `internal/ebooks.Enricher` task that resolves plugin metadata providers with `content_level = 'ebook'`. The first-party `silo-plugin-ebook-metadata` provides lookup data; there is no ebook scanner plugin and no ebook details repository/table.
|
||||
|
||||
**Tech Stack:** Go, PostgreSQL Goose migrations, Silo scanner/catalog repositories, Silo metadata plugin chain, taskmanager scheduled tasks.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `internal/scanner/ebook.go`: local ebook parser and format support.
|
||||
- `internal/scanner/ebook_scan.go`: ebook scan flow matching `audiobook_scan.go`.
|
||||
- `internal/scanner/scanner.go`: route `ebook(s)` libraries to the ebook scanner and add ebook walk mode.
|
||||
- `migrations/sql/20260608000100_ebook_series.sql`: `ebook_series` table equivalent to `audiobook_series`.
|
||||
- Follow-up PR: `internal/ebooks/enrichment.go`: ebook metadata sweep equivalent to `internal/audiobooks/enrichment.go`.
|
||||
- Follow-up PR: `internal/taskmanager/tasks/sync_ebook_metadata.go`: scheduled/manual metadata task equivalent to `sync_audiobook_metadata.go`.
|
||||
- Follow-up PR: `cmd/silo/main.go`: wire `ebooks.NewEnricher` next to `audiobooks.NewEnricher`.
|
||||
- Tests live beside changed files and should pin “authors only, ISBN only, content_level ebook.”
|
||||
|
||||
### Task 1: Scanner Routing And Local Parser
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/scanner/scanner.go`
|
||||
- Modify: `internal/scanner/scanner_test.go`
|
||||
- Create/modify: `internal/scanner/ebook.go`
|
||||
- Create/modify: `internal/scanner/ebook_test.go`
|
||||
|
||||
- [ ] **Step 1: Write or keep failing scanner/parser tests**
|
||||
|
||||
Ensure these tests exist:
|
||||
|
||||
```go
|
||||
func TestIsEbookLibraryType(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"ebooks", true},
|
||||
{"ebook", true},
|
||||
{"Ebook", true},
|
||||
{" EBOOKS ", true},
|
||||
{"audiobooks", false},
|
||||
{"movies", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isEbookLibraryType(tc.in); got != tc.want {
|
||||
t.Errorf("isEbookLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkModeForEbookLibraryTypes(t *testing.T) {
|
||||
for _, libraryType := range []string{"ebook", "ebooks", " EBOOKS "} {
|
||||
if got := walkModeFor(libraryType); got != walkModeEbook {
|
||||
t.Fatalf("walkModeFor(%q) = %v, want walkModeEbook", libraryType, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsEbookFile(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"book.epub", true},
|
||||
{"book.PDF", true},
|
||||
{"book.azw3", true},
|
||||
{"book.mp3", false},
|
||||
{"movie.mkv", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := SupportsEbookFile(tc.path); got != tc.want {
|
||||
t.Errorf("SupportsEbookFile(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run scanner/parser red test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/scanner -run 'Test(IsEbook|WalkModeForEbook|WalkModeEbook|SupportsEbook|ParseEbook)' -count=1
|
||||
```
|
||||
|
||||
Expected before implementation: fail for missing ebook mode/parser functions. Expected after existing parser work is kept: pass.
|
||||
|
||||
- [ ] **Step 3: Implement minimal parser and walk mode**
|
||||
|
||||
Implement:
|
||||
|
||||
```go
|
||||
var ebookExtensions = map[string]bool{
|
||||
".epub": true,
|
||||
".pdf": true,
|
||||
".mobi": true,
|
||||
".azw": true,
|
||||
".azw3": true,
|
||||
".fb2": true,
|
||||
}
|
||||
|
||||
func SupportsEbookFile(filePath string) bool {
|
||||
return ebookExtensions[strings.ToLower(filepath.Ext(filePath))]
|
||||
}
|
||||
|
||||
func isEbookLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "ebook", "ebooks":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `walkModeEbook` and make `walkMode.acceptsExt` call `SupportsEbookFile`.
|
||||
|
||||
- [ ] **Step 4: Run scanner/parser green test**
|
||||
|
||||
Run the same command as Step 2. Expected: `ok github.com/Silo-Server/silo-server/internal/scanner`.
|
||||
|
||||
- [ ] **Step 5: Commit Task 1**
|
||||
|
||||
```bash
|
||||
git add internal/scanner/scanner.go internal/scanner/scanner_test.go internal/scanner/ebook.go internal/scanner/ebook_test.go
|
||||
git commit -m "feat: add ebook scanner parser foundation"
|
||||
```
|
||||
|
||||
### Task 2: Ebook Core Scan Persistence
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/scanner/ebook_scan.go`
|
||||
- Modify: `internal/scanner/ebook_test.go`
|
||||
- Modify: `internal/scanner/scanner.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for audiobook-shaped ebook helpers**
|
||||
|
||||
Add tests for:
|
||||
|
||||
```go
|
||||
func TestEbookIdentityConfidenceReflectsMetadataCompleteness(t *testing.T) {
|
||||
book := &parsedEbook{Title: "Tagged Ebook", Authors: []string{"Author"}, Year: 2024, ISBN: "9780306406157"}
|
||||
if got := ebookIdentityConfidence(book); got != "high" {
|
||||
t.Fatalf("complete metadata confidence = %q, want high", got)
|
||||
}
|
||||
book = &parsedEbook{Title: "Tagged Ebook", Authors: []string{"Author"}, Year: 2024}
|
||||
if got := ebookIdentityConfidence(book); got != "medium" {
|
||||
t.Fatalf("partial metadata confidence = %q, want medium", got)
|
||||
}
|
||||
book = &parsedEbook{}
|
||||
if got := ebookIdentityConfidence(book); got != "low" {
|
||||
t.Fatalf("empty metadata confidence = %q, want low", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEbookMediaItemCreatesNewWhenRootHasNoClaim(t *testing.T) {
|
||||
finder := &fakeRootContentFinder{}
|
||||
writer := &fakeFilesystemItemWriter{}
|
||||
got, err := resolveEbookMediaItem(context.Background(), finder, writer, 7, "/library/Author/Book.epub", &parsedEbook{
|
||||
Title: "Book", Year: 2024, Authors: []string{"Author"}, Description: "Overview", Publisher: "Publisher", Genres: []string{"Fiction"}, Language: "en",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveEbookMediaItem: %v", err)
|
||||
}
|
||||
if got == "" || len(writer.upserts) != 1 {
|
||||
t.Fatalf("contentID/upserts = %q/%d, want id and one upsert", got, len(writer.upserts))
|
||||
}
|
||||
item := writer.upserts[0]
|
||||
if item.Type != "ebook" || item.Title != "Book" || item.Year != 2024 || item.Overview != "Overview" || item.OriginalLanguage != "en" {
|
||||
t.Fatalf("upserted ebook item = %+v", item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run red helper tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/scanner -run 'Test(EbookIdentity|ResolveEbook|EbookPeople)' -count=1
|
||||
```
|
||||
|
||||
Expected before implementation: fail with undefined ebook scan helper functions.
|
||||
|
||||
- [ ] **Step 3: Implement ebook scan helpers mirroring audiobook names**
|
||||
|
||||
Create `internal/scanner/ebook_scan.go` with functions shaped after `audiobook_scan.go`:
|
||||
|
||||
```go
|
||||
func (s *Scanner) ScanEbookFolder(ctx context.Context, folder *models.MediaFolder) error
|
||||
func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFolder, filePath string, skipped *int64) error
|
||||
func (s *Scanner) ebookFileShouldSkip(ctx context.Context, folder *models.MediaFolder, filePath string, size int64, modifiedAt time.Time) (bool, error)
|
||||
func (s *Scanner) upsertEbookMediaItem(ctx context.Context, folderID int, filePath string, book *parsedEbook) (string, error)
|
||||
func resolveEbookMediaItem(ctx context.Context, rootFinder filesystemRootContentFinder, itemWriter filesystemMediaItemWriter, folderID int, filePath string, book *parsedEbook) (string, error)
|
||||
func createEbookMediaItem(ctx context.Context, itemWriter filesystemMediaItemWriter, book *parsedEbook, cleanTitle string) (string, error)
|
||||
func applyEbookToMediaItem(item *models.MediaItem, book *parsedEbook)
|
||||
func (s *Scanner) upsertEbookMediaFile(ctx context.Context, folder *models.MediaFolder, contentID string, filePath string, size int64, modifiedAt time.Time, book *parsedEbook) error
|
||||
func (s *Scanner) upsertEbookPeople(ctx context.Context, contentID string, book *parsedEbook) error
|
||||
func (s *Scanner) upsertEbookSeries(ctx context.Context, contentID string, book *parsedEbook) error
|
||||
func ebookIdentityConfidence(book *parsedEbook) string
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- `ScanEbookFolder` walks `folder.Paths`, collects files where `SupportsEbookFile` is true, processes in a worker pool like `ScanAudiobookFolder`, logs `ebook scan: starting/progress/completed/indexed`.
|
||||
- `upsertEbookMediaItem` reuses `FindContentIDByRootPath(ctx, folderID, filePath, "ebook")`, then finds by existing `media_files.file_path`, then creates a new `media_items` row.
|
||||
- `applyEbookToMediaItem` sets type/title/year/overview/publisher-as-studio/genres/release date/original language.
|
||||
- `upsertEbookMediaFile` writes one `media_files` row with `CanonicalRootPath`, `ObservedRootPath`, `ContentGroupKey`, `FilePath` all tied to the ebook file path or content ID in the same style as audiobook file persistence.
|
||||
- `upsertEbookPeople` writes author credits only with `models.PersonKindAuthor`; it never writes `models.PersonKindNarrator`.
|
||||
- `reconcileEbookFile` writes ISBN provider ID with:
|
||||
|
||||
```sql
|
||||
INSERT INTO media_item_provider_ids (content_id, provider, provider_id, item_type)
|
||||
VALUES ($1, 'isbn', $2, 'ebook')
|
||||
ON CONFLICT DO NOTHING
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Route ebook libraries in `ScanFolder`**
|
||||
|
||||
Add this block beside audiobook/podcast routing:
|
||||
|
||||
```go
|
||||
if isEbookLibraryType(folder.Type) {
|
||||
if err := s.ScanEbookFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run green helper tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/scanner -run 'Test(EbookIdentity|ResolveEbook|EbookPeople|ScanEbook)' -count=1
|
||||
```
|
||||
|
||||
Expected: scanner tests pass.
|
||||
|
||||
- [ ] **Step 6: Commit Task 2**
|
||||
|
||||
```bash
|
||||
git add internal/scanner/scanner.go internal/scanner/ebook_scan.go internal/scanner/ebook_test.go
|
||||
git commit -m "feat: scan ebook libraries in core"
|
||||
```
|
||||
|
||||
### Task 3: Ebook Series Migration
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/sql/20260608000100_ebook_series.sql`
|
||||
- Modify: `internal/scanner/ebook_scan.go`
|
||||
|
||||
- [ ] **Step 1: Add migration mirroring `audiobook_series`**
|
||||
|
||||
Create:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE ebook_series (
|
||||
content_id TEXT PRIMARY KEY REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
series_name TEXT NOT NULL,
|
||||
series_index NUMERIC,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_series_name_lower
|
||||
ON ebook_series (LOWER(series_name), series_index NULLS LAST);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS ebook_series;
|
||||
-- +goose StatementEnd
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add scanner series upsert SQL**
|
||||
|
||||
Use the same write/delete/skip-identical shape as `upsertAudiobookSeries`, with table name `ebook_series` and `book.SeriesIndex` parsed through existing `parseSeriesIndex`.
|
||||
|
||||
- [ ] **Step 3: Run migration and scanner tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/scanner -run 'Test(Ebook|ResolveEbook)' -count=1
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 4: Commit Task 3**
|
||||
|
||||
```bash
|
||||
git add migrations/sql/20260608000100_ebook_series.sql internal/scanner/ebook_scan.go
|
||||
git commit -m "feat: persist ebook series membership"
|
||||
```
|
||||
|
||||
### Follow-up PR Task 4: Ebook Metadata Enricher
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/ebooks/enrichment.go`
|
||||
- Create: `internal/ebooks/enrichment_test.go`
|
||||
|
||||
- [ ] **Step 1: Copy audiobook enricher structure with ebook domain changes**
|
||||
|
||||
Create `internal/ebooks/enrichment.go` from `internal/audiobooks/enrichment.go` and change:
|
||||
- package `ebooks`
|
||||
- log prefix `ebook enrichment`
|
||||
- default env var `SILO_EBOOK_ENRICH_WORKERS`
|
||||
- batch query `WHERE mi.type = 'ebook'`
|
||||
- chain resolution `metadata.ResolveChain(ctx, item.FolderID, "ebook", e.chainRepo, e.resolver)`
|
||||
- search query `ContentType: "ebook"`
|
||||
- metadata request `ContentType: "ebook"`
|
||||
- people persistence filters to author credits only; discard narrator credits if a plugin returns them
|
||||
- no local ffmpeg audiobook cover fallback
|
||||
|
||||
- [ ] **Step 2: Add tests for chain content level and authors-only people**
|
||||
|
||||
Add tests that instantiate an `Enricher` with fake provider behavior where possible, or unit-test narrow helpers:
|
||||
|
||||
```go
|
||||
func TestEbookEnricherContentType(t *testing.T) {
|
||||
if got := ebookContentType(); got != "ebook" {
|
||||
t.Fatalf("ebookContentType() = %q, want ebook", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEbookPeopleKeepsAuthorsOnly(t *testing.T) {
|
||||
in := []models.ItemPerson{
|
||||
{Person: models.Person{Name: "Author"}, Kind: models.PersonKindAuthor},
|
||||
{Person: models.Person{Name: "Narrator"}, Kind: models.PersonKindNarrator},
|
||||
}
|
||||
got := filterEbookPeople(in)
|
||||
if len(got) != 1 || got[0].Kind != models.PersonKindAuthor || got[0].Person.Name != "Author" {
|
||||
t.Fatalf("filterEbookPeople() = %+v, want author only", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run red/green ebook enricher tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/ebooks -count=1
|
||||
```
|
||||
|
||||
Expected after implementation: pass.
|
||||
|
||||
- [ ] **Step 4: Commit Task 4**
|
||||
|
||||
```bash
|
||||
git add internal/ebooks/enrichment.go internal/ebooks/enrichment_test.go
|
||||
git commit -m "feat: add ebook metadata enricher"
|
||||
```
|
||||
|
||||
### Follow-up PR Task 5: Ebook Metadata Task And Main Wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/taskmanager/tasks/sync_ebook_metadata.go`
|
||||
- Modify: `cmd/silo/main.go`
|
||||
|
||||
- [ ] **Step 1: Add sync task matching audiobook task**
|
||||
|
||||
Create task:
|
||||
|
||||
```go
|
||||
type SyncEbookMetadataTask struct {
|
||||
enricher *ebooks.Enricher
|
||||
}
|
||||
|
||||
func NewSyncEbookMetadataTask(enricher *ebooks.Enricher) *SyncEbookMetadataTask {
|
||||
return &SyncEbookMetadataTask{enricher: enricher}
|
||||
}
|
||||
|
||||
func (t *SyncEbookMetadataTask) Key() string { return "sync_ebook_metadata" }
|
||||
func (t *SyncEbookMetadataTask) Name() string { return "Sync Ebook Metadata" }
|
||||
func (t *SyncEbookMetadataTask) Description() string {
|
||||
return "Fetches metadata (cover art, overview, authors) for ebooks that have not yet been enriched"
|
||||
}
|
||||
func (t *SyncEbookMetadataTask) Category() taskmanager.TaskCategory {
|
||||
return taskmanager.TaskCategoryMetadata
|
||||
}
|
||||
func (t *SyncEbookMetadataTask) IsHidden() bool { return false }
|
||||
func (t *SyncEbookMetadataTask) DefaultTriggers() []taskmanager.TriggerConfig {
|
||||
return []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeInterval, IntervalMs: 5 * 60 * 1000}}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire `ebooks.NewEnricher` beside audiobook enricher**
|
||||
|
||||
In `cmd/silo/main.go`:
|
||||
- add import `github.com/Silo-Server/silo-server/internal/ebooks`
|
||||
- add `var ebookEnricher *ebooks.Enricher`
|
||||
- construct it with the same dependencies as `audiobooks.NewEnricher`
|
||||
- set image cacher if object storage is available and the ebook enricher supports remote image caching
|
||||
- register `tasks.NewSyncEbookMetadataTask(ebookEnricher)` beside `NewSyncAudiobookMetadataTask`
|
||||
|
||||
- [ ] **Step 3: Run task/main build tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/taskmanager/tasks ./internal/ebooks -count=1
|
||||
go test ./cmd/silo -run TestNonExistent -count=1
|
||||
```
|
||||
|
||||
Expected: packages compile and tests pass.
|
||||
|
||||
- [ ] **Step 4: Commit Task 5**
|
||||
|
||||
```bash
|
||||
git add cmd/silo/main.go internal/taskmanager/tasks/sync_ebook_metadata.go
|
||||
git commit -m "feat: wire ebook metadata sync task"
|
||||
```
|
||||
|
||||
### Task 6: Verification And PR Cleanup
|
||||
|
||||
**Files:**
|
||||
- Review all modified files.
|
||||
|
||||
- [ ] **Step 1: Run focused Go tests**
|
||||
|
||||
```bash
|
||||
go test ./internal/scanner ./internal/ebooks ./internal/taskmanager/tasks -count=1
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 2: Run broader compile/test set**
|
||||
|
||||
```bash
|
||||
go test ./internal/catalog ./internal/metadata ./cmd/silo -count=1
|
||||
```
|
||||
|
||||
Expected: pass, or record unrelated baseline failures with exact package/error.
|
||||
|
||||
- [ ] **Step 3: Check no wrong architecture remains**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "ebook_details|silo-plugin-ebooks|PersonKindNarrator|asin" internal/scanner internal/ebooks docs/superpowers/specs docs/superpowers/plans
|
||||
```
|
||||
|
||||
Expected:
|
||||
- no `ebook_details`
|
||||
- no scanner dependency on `silo-plugin-ebooks`
|
||||
- no ebook code writing `PersonKindNarrator`
|
||||
- no ebook code writing ASIN
|
||||
|
||||
- [ ] **Step 4: Commit final cleanup if needed**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git add <only ebook-related cleanup files>
|
||||
git commit -m "test: verify ebook audiobook-parity path"
|
||||
```
|
||||
|
||||
Only commit if Step 1-3 caused file changes.
|
||||
@@ -1,102 +0,0 @@
|
||||
# Ebooks Like Audiobooks Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add ebooks to silo by matching the audiobook architecture in `main` as closely
|
||||
as possible. Ebooks are a core media type in `silo-server`; only metadata
|
||||
provider lookup lives in a plugin.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not add or keep a scanner/runtime plugin named `silo-plugin-ebooks`.
|
||||
- Do not treat ebooks as an audiobook sub-feature.
|
||||
- Do not add ebook narrators.
|
||||
- Do not add ebook ASIN handling.
|
||||
- Do not introduce an ebook details repository/table unless the audiobook path
|
||||
gains the same shape first.
|
||||
|
||||
## Audiobook Pattern In Main
|
||||
|
||||
Audiobooks have three cooperating pieces:
|
||||
|
||||
1. Core scan:
|
||||
- `internal/scanner/scanner.go` routes `audiobook` and `audiobooks` library
|
||||
types to `ScanAudiobookFolder`.
|
||||
- `internal/scanner/audiobook_scan.go` writes `media_items`,
|
||||
`media_files`, `item_people`, `audiobook_series`,
|
||||
`media_item_libraries`, and durable provider IDs.
|
||||
2. Core metadata enrichment:
|
||||
- `cmd/silo/main.go` wires `audiobooks.NewEnricher`.
|
||||
- `internal/taskmanager/tasks/sync_audiobook_metadata.go` registers the
|
||||
periodic/manual task.
|
||||
- `internal/audiobooks/enrichment.go` sweeps unenriched `audiobook` items,
|
||||
resolves the configured metadata provider chain at
|
||||
`content_level = 'audiobook'`, calls `Search` then `GetMetadata`, and
|
||||
persists provider IDs, poster/overview/scalar metadata, and people.
|
||||
3. First-party metadata plugin:
|
||||
- `silo-plugin-audiobook-metadata` exposes `metadata_provider.v1` with an
|
||||
audiobook capability and provider implementations.
|
||||
|
||||
## Ebook Equivalent Across PRs
|
||||
|
||||
Ebooks should mirror that shape:
|
||||
|
||||
1. Core scan:
|
||||
- `internal/scanner/scanner.go` routes `ebook` and `ebooks` libraries to an
|
||||
ebook scan path.
|
||||
- `internal/scanner/ebook.go` parses local ebook metadata from supported
|
||||
files.
|
||||
- `internal/scanner/ebook_scan.go` writes:
|
||||
- `media_items.type = 'ebook'`
|
||||
- `media_files.base_type = 'ebook'`
|
||||
- `item_people` author credits only
|
||||
- `ebook_series`
|
||||
- `media_item_libraries`
|
||||
- `media_item_provider_ids` using provider `isbn` when an ISBN is parsed
|
||||
2. Follow-up core metadata enrichment:
|
||||
- Add `internal/ebooks/enrichment.go`, shaped after
|
||||
`internal/audiobooks/enrichment.go`.
|
||||
- Add `internal/taskmanager/tasks/sync_ebook_metadata.go`, shaped after
|
||||
`sync_audiobook_metadata.go`.
|
||||
- Wire `ebooks.NewEnricher` in `cmd/silo/main.go` next to the audiobook
|
||||
enricher.
|
||||
- Resolve provider chains at `content_level = 'ebook'`.
|
||||
- Search and fetch metadata with `ContentType = "ebook"`.
|
||||
- Persist provider IDs, poster/overview/scalar metadata, and author people.
|
||||
3. First-party metadata plugin:
|
||||
- Use `silo-plugin-ebook-metadata` as the first-party ebook metadata
|
||||
provider plugin.
|
||||
- The core app should rely on that plugin for provider-specific metadata
|
||||
lookups, just as audiobooks rely on `silo-plugin-audiobook-metadata`.
|
||||
|
||||
## Ebook Domain Rules
|
||||
|
||||
- Ebooks have ISBNs.
|
||||
- Ebooks have authors.
|
||||
- Ebooks do not have narrators.
|
||||
- Ebooks do not have ASINs.
|
||||
- Ebook metadata plugins may return provider IDs, but core scanner-derived ISBN
|
||||
should be stored under provider `isbn`.
|
||||
- Local parsing may read embedded ebook metadata from EPUB/FB2 and format/file
|
||||
facts from other supported ebook formats.
|
||||
|
||||
## First PR Scope
|
||||
|
||||
The first implementation PR should establish the mirrored foundation:
|
||||
|
||||
- ebook library type and walk mode
|
||||
- local ebook parser
|
||||
- ebook scan routing and persistence
|
||||
- `ebook_series` migration
|
||||
- tests proving ebook authors-only behavior, ISBN provider ID behavior, and
|
||||
scanner-owned ebook series behavior
|
||||
|
||||
Deferred follow-up PR scope:
|
||||
|
||||
- ebook metadata enrichment package/task/wiring, analogous to
|
||||
`internal/audiobooks.Enricher`
|
||||
- plugin chain resolution at `content_level = 'ebook'`
|
||||
|
||||
UI/detail page, ebook reader experience, ABS ebook compatibility endpoints, and
|
||||
advanced provider-specific plugin improvements can be separate PRs unless they
|
||||
are required to prove the core path works.
|
||||
@@ -64,8 +64,8 @@ type PluginScanSourceAdapter struct {
|
||||
Svc *plugins.Service
|
||||
}
|
||||
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClient(ctx, installationID, capabilityID)
|
||||
}
|
||||
|
||||
// scanSourceCapabilityType is the plugin capability type autoscan discovery
|
||||
@@ -75,6 +75,15 @@ const scanSourceCapabilityType = "scan_source.v1"
|
||||
// PluginScanSourceLister adapts the plugin installation store to
|
||||
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
|
||||
// capability across ALL installed plugins, regardless of enabled state.
|
||||
//
|
||||
// Using List (not ListEnabled) is deliberate for orphan detection: a temporarily
|
||||
// DISABLED-but-installed plugin must NOT be treated as orphaned. If we dropped
|
||||
// disabled plugins here, PollOnce would prune their sources and skip them with no
|
||||
// last_error — they'd vanish silently. Keeping them present means PollOnce still
|
||||
// attempts them; the plugin client fails to load (not running) and the source
|
||||
// gets a visible RecordError instead. Only a fully UNINSTALLED plugin (gone from
|
||||
// List entirely) is a true orphan. The Add-source picker shares this all-installed
|
||||
// set, which is fine — operators can bind sources against an installed capability.
|
||||
type PluginScanSourceLister struct {
|
||||
Store *plugins.InstallationStore
|
||||
}
|
||||
@@ -95,9 +104,10 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
|
||||
continue
|
||||
}
|
||||
out = append(out, autoscan.DiscoveredSource{
|
||||
PluginID: inst.PluginID,
|
||||
CapabilityID: c.ID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
InstallationID: c.InstallationID,
|
||||
CapabilityID: c.ID,
|
||||
PluginID: inst.PluginID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,7 @@ type AdminStats struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
TotalUsers int `json:"total_users"`
|
||||
TotalMovies int `json:"total_movies"`
|
||||
TotalMovieFiles int `json:"total_movie_files"`
|
||||
TotalShows int `json:"total_shows"`
|
||||
TotalShowFiles int `json:"total_show_files"`
|
||||
ActiveStreams int `json:"active_streams"`
|
||||
TotalStorageBytes int64 `json:"total_storage_bytes"`
|
||||
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
|
||||
@@ -129,15 +127,13 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
}
|
||||
|
||||
var (
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalMovieFiles int64
|
||||
totalShows int64
|
||||
totalShowFiles int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalShows int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
)
|
||||
|
||||
row := pool.QueryRow(ctx, `
|
||||
@@ -155,34 +151,8 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
file_stats AS (
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'movie')::bigint AS total_movie_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'series')::bigint AS total_show_files,
|
||||
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
|
||||
FROM (
|
||||
SELECT
|
||||
media_files.file_size,
|
||||
CASE
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'movie'
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'series'
|
||||
THEN 'series'
|
||||
WHEN episodes.content_id IS NOT NULL
|
||||
THEN 'series'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
WHEN lower(trim(media_folders.type)) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(media_folders.type)) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
ELSE ''
|
||||
END AS file_kind
|
||||
FROM media_files
|
||||
JOIN media_folders ON media_folders.id = media_files.media_folder_id
|
||||
LEFT JOIN media_items ON media_items.content_id = media_files.content_id
|
||||
LEFT JOIN episodes ON episodes.content_id = media_files.episode_id
|
||||
) classified_files
|
||||
FROM media_files
|
||||
),
|
||||
session_stats AS (
|
||||
SELECT COUNT(*)::bigint AS active_streams
|
||||
@@ -193,9 +163,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
item_stats.total_items,
|
||||
file_stats.total_files,
|
||||
item_stats.total_movies,
|
||||
file_stats.total_movie_files,
|
||||
item_stats.total_shows,
|
||||
file_stats.total_show_files,
|
||||
session_stats.active_streams,
|
||||
file_stats.total_storage_bytes
|
||||
FROM user_stats
|
||||
@@ -208,9 +176,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
&totalItems,
|
||||
&totalFiles,
|
||||
&totalMovies,
|
||||
&totalMovieFiles,
|
||||
&totalShows,
|
||||
&totalShowFiles,
|
||||
&activeStreams,
|
||||
&totalStorage,
|
||||
); err != nil {
|
||||
@@ -228,9 +194,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
TotalItems: int(totalItems),
|
||||
TotalFiles: int(totalFiles),
|
||||
TotalMovies: int(totalMovies),
|
||||
TotalMovieFiles: int(totalMovieFiles),
|
||||
TotalShows: int(totalShows),
|
||||
TotalShowFiles: int(totalShowFiles),
|
||||
ActiveStreams: int(activeStreams),
|
||||
TotalStorageBytes: totalStorage,
|
||||
WatchProviderActivity: activity,
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// HandleGetAudiobook serves GET /api/v1/audiobooks/{id}. Returns the
|
||||
// audiobook media_item, its media_files (with embedded chapters),
|
||||
// author/narrator from item_people (kinds 7/8), and the caller's
|
||||
// per-profile listening progress.
|
||||
func (h *AudiobookHandler) HandleGetAudiobook(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load audiobook failed")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
item := items[0]
|
||||
if item == nil || item.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.Files.GetByContentID(r.Context(), contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
|
||||
return
|
||||
}
|
||||
|
||||
author, narrator, err := h.fetchAuthorNarrator(r, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load people failed")
|
||||
return
|
||||
}
|
||||
|
||||
progress := h.fetchProgress(r, contentID)
|
||||
|
||||
alsoByAuthor := h.fetchAlsoByAuthor(r.Context(), contentID)
|
||||
inSeries := h.fetchInSeries(r.Context(), contentID)
|
||||
similar := h.fetchSimilar(r.Context(), contentID)
|
||||
otherNarrations := h.fetchOtherNarrations(r.Context(), contentID)
|
||||
|
||||
resp := audiobookDetailResponse{
|
||||
Audiobook: audiobookDetailItem{
|
||||
ContentID: item.ContentID,
|
||||
Title: item.Title,
|
||||
Year: item.Year,
|
||||
Overview: item.Overview,
|
||||
PosterURL: h.presignAudiobookPoster(r.Context(), item.PosterPath),
|
||||
Publisher: pickFirstString(item.Studios),
|
||||
Genres: append([]string(nil), item.Genres...),
|
||||
},
|
||||
Author: author,
|
||||
Narrator: narrator,
|
||||
Files: audiobookDetailFiles(files),
|
||||
Progress: progress,
|
||||
AlsoByAuthor: alsoByAuthor,
|
||||
InSeries: inSeries,
|
||||
SimilarAudiobooks: similar,
|
||||
OtherNarrations: otherNarrations,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// fetchAuthorNarrator retrieves the first author (kind=7) and narrator (kind=8)
|
||||
// name from item_people for the given content ID.
|
||||
func (h *AudiobookHandler) fetchAuthorNarrator(r *http.Request, contentID string) (author, narrator string, err error) {
|
||||
people, err := h.Items.GetPeople(r.Context(), contentID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, p := range people {
|
||||
switch p.Kind {
|
||||
case models.PersonKindAuthor:
|
||||
if author == "" {
|
||||
author = p.Name
|
||||
}
|
||||
case models.PersonKindNarrator:
|
||||
if narrator == "" {
|
||||
narrator = p.Name
|
||||
}
|
||||
}
|
||||
if author != "" && narrator != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return author, narrator, nil
|
||||
}
|
||||
|
||||
// fetchAlsoByAuthor returns other audiobooks sharing any author (item_people
|
||||
// kind=PersonKindAuthor) with the given book. Capped at 12 to keep the rail
|
||||
// snappy on the client. Returns an empty slice when there is no author or
|
||||
// no other matching titles.
|
||||
func (h *AudiobookHandler) fetchAlsoByAuthor(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
const q = `
|
||||
SELECT m2.content_id, m2.title, COALESCE(m2.year, 0), COALESCE(m2.poster_path, '')
|
||||
FROM item_people ip1
|
||||
JOIN item_people ip2
|
||||
ON ip2.person_id = ip1.person_id
|
||||
AND ip2.kind = ip1.kind
|
||||
AND ip2.content_id <> ip1.content_id
|
||||
JOIN media_items m2
|
||||
ON m2.content_id = ip2.content_id
|
||||
AND m2.type = 'audiobook'
|
||||
WHERE ip1.content_id = $1
|
||||
AND ip1.kind = $2
|
||||
ORDER BY m2.year DESC NULLS LAST, LOWER(m2.sort_title)
|
||||
LIMIT 12
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]audiobookRelatedItem, 0, 12)
|
||||
seen := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var it audiobookRelatedItem
|
||||
var poster string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, dup := seen[it.ContentID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[it.ContentID] = struct{}{}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchSimilar returns the "You might also like" rail. Prefers embedding-based
|
||||
// nearest-neighbor search when the source book has an embedding; falls back to
|
||||
// shared-genre matching otherwise (book is brand new, embedding job hasn't run,
|
||||
// or the embedding lookup errored). The genre fallback excludes same-author
|
||||
// books so the rail complements rather than duplicates also_by_author.
|
||||
func (h *AudiobookHandler) fetchSimilar(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Recs != nil {
|
||||
if items := h.fetchSimilarByEmbedding(ctx, contentID); items != nil {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return h.fetchSimilarByGenres(ctx, contentID)
|
||||
}
|
||||
|
||||
// fetchSimilarByEmbedding returns nearest-neighbor audiobooks by cosine
|
||||
// similarity on the gemini embedding vector. Returns nil (not an empty slice)
|
||||
// when the source book has no embedding yet, so the caller can fall back to
|
||||
// the genre-based recommender.
|
||||
func (h *AudiobookHandler) fetchSimilarByEmbedding(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Recs == nil || h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
emb, err := h.Recs.GetEmbedding(ctx, contentID)
|
||||
if err != nil || emb == nil {
|
||||
return nil
|
||||
}
|
||||
// Pull extra candidates so we can drop the source book's own author from
|
||||
// the rail (they belong in also_by_author).
|
||||
scored, err := h.Recs.FindSimilar(ctx, emb, []string{contentID}, "audiobook", 36)
|
||||
if err != nil || len(scored) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(scored))
|
||||
for _, s := range scored {
|
||||
ids = append(ids, s.MediaItemID)
|
||||
}
|
||||
const q = `
|
||||
WITH this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
||||
)
|
||||
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
|
||||
FROM media_items m
|
||||
WHERE m.content_id = ANY($3)
|
||||
AND m.type = 'audiobook'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $2
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor, ids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
byID := make(map[string]audiobookRelatedItem, len(scored))
|
||||
for rows.Next() {
|
||||
var it audiobookRelatedItem
|
||||
var poster string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
|
||||
return nil
|
||||
}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
byID[it.ContentID] = it
|
||||
}
|
||||
out := make([]audiobookRelatedItem, 0, 12)
|
||||
for _, s := range scored {
|
||||
if it, ok := byID[s.MediaItemID]; ok {
|
||||
out = append(out, it)
|
||||
if len(out) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchOtherNarrations returns sibling audiobook items that share the same
|
||||
// "core" title (after stripping the common "read by X" / "(Read by X)" /
|
||||
// "(UK Version: Read by X)" suffixes) and the same author. Powers the
|
||||
// narrator picker dropdown on the detail page when a single book has
|
||||
// multiple narration editions stored as separate items in the catalog.
|
||||
func (h *AudiobookHandler) fetchOtherNarrations(ctx context.Context, contentID string) []audiobookNarration {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
// The regex strips a trailing block of the form "[ separator] [( ]read by ...[) ]".
|
||||
// Tested against patterns in the corpus: "Foo Read By X",
|
||||
// "Foo - read by X", "Foo (Read by X)", "Foo (UK Version: Read by X)".
|
||||
const stripRE = `\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*[Rr]ead [Bb]y [^()]+\)?\s*$`
|
||||
const q = `
|
||||
WITH this_book AS (
|
||||
SELECT trim(regexp_replace(title, $1, '', 'g')) AS norm_title
|
||||
FROM media_items WHERE content_id = $2
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $2 AND kind = $3
|
||||
)
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(MAX(p.name), '') AS narrator,
|
||||
COALESCE(m.year, 0)
|
||||
FROM media_items m
|
||||
LEFT JOIN item_people ipn ON ipn.content_id = m.content_id AND ipn.kind = $4
|
||||
LEFT JOIN people p ON p.id = ipn.person_id
|
||||
WHERE m.type = 'audiobook'
|
||||
AND m.content_id <> $2
|
||||
AND trim(regexp_replace(m.title, $1, '', 'g')) = (SELECT norm_title FROM this_book)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $3
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
GROUP BY m.content_id, m.title, m.year
|
||||
ORDER BY m.year DESC NULLS LAST, m.title
|
||||
LIMIT 8
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q,
|
||||
stripRE, contentID, models.PersonKindAuthor, models.PersonKindNarrator)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]audiobookNarration, 0, 4)
|
||||
for rows.Next() {
|
||||
var n audiobookNarration
|
||||
if err := rows.Scan(&n.ContentID, &n.Title, &n.Narrator, &n.Year); err != nil {
|
||||
return nil
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchSimilarByGenres returns up to 12 audiobooks sharing genres with the
|
||||
// given book, excluding books by the same author so the rail complements
|
||||
// rather than duplicates also_by_author. Used as a fallback when no
|
||||
// embedding exists for the source yet.
|
||||
func (h *AudiobookHandler) fetchSimilarByGenres(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
const q = `
|
||||
WITH this_genres AS (
|
||||
SELECT unnest(genres) AS g FROM media_items WHERE content_id = $1
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
||||
)
|
||||
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
|
||||
FROM media_items m
|
||||
WHERE m.type = 'audiobook'
|
||||
AND m.content_id <> $1
|
||||
AND m.genres && (SELECT array_agg(g) FROM this_genres)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $2
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
ORDER BY
|
||||
cardinality(ARRAY(SELECT unnest(m.genres) INTERSECT SELECT g FROM this_genres)) DESC,
|
||||
COALESCE(m.year, 0) DESC,
|
||||
LOWER(m.sort_title)
|
||||
LIMIT 12
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]audiobookRelatedItem, 0, 12)
|
||||
for rows.Next() {
|
||||
var it audiobookRelatedItem
|
||||
var poster string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
|
||||
return nil
|
||||
}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchInSeries returns the series the audiobook belongs to (if any) along
|
||||
// with the ordered list of sibling entries. Driven by the audiobook_series
|
||||
// table populated by either the scanner (tag-derived) or migration 145's
|
||||
// best-effort title-pattern backfill.
|
||||
func (h *AudiobookHandler) fetchInSeries(ctx context.Context, contentID string) *audiobookSeriesGroup {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
const q = `
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(m.year, 0),
|
||||
COALESCE(m.poster_path, ''),
|
||||
s.series_name,
|
||||
s.series_index
|
||||
FROM audiobook_series root
|
||||
JOIN audiobook_series s ON LOWER(s.series_name) = LOWER(root.series_name)
|
||||
JOIN media_items m ON m.content_id = s.content_id AND m.type = 'audiobook'
|
||||
WHERE root.content_id = $1
|
||||
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
|
||||
LIMIT 30
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var seriesName string
|
||||
entries := make([]audiobookRelatedItem, 0, 16)
|
||||
for rows.Next() {
|
||||
var (
|
||||
it audiobookRelatedItem
|
||||
poster string
|
||||
thisName string
|
||||
idxNum *float64
|
||||
)
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster, &thisName, &idxNum); err != nil {
|
||||
return nil
|
||||
}
|
||||
if seriesName == "" {
|
||||
seriesName = thisName
|
||||
}
|
||||
if idxNum != nil {
|
||||
n := int(*idxNum)
|
||||
if float64(n) == *idxNum {
|
||||
it.SeriesIndex = &n
|
||||
}
|
||||
}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
entries = append(entries, it)
|
||||
}
|
||||
// A series with only this book is not useful as a rail.
|
||||
if len(entries) < 2 {
|
||||
return nil
|
||||
}
|
||||
return &audiobookSeriesGroup{Name: seriesName, Entries: entries}
|
||||
}
|
||||
|
||||
// fetchProgress returns the caller's listening progress for this audiobook, or
|
||||
// nil when no progress exists or the caller is not authenticated with a profile.
|
||||
func (h *AudiobookHandler) fetchProgress(r *http.Request, contentID string) *audiobookDetailProgress {
|
||||
if h.StoreProvider == nil {
|
||||
return nil
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if userID == 0 {
|
||||
return nil
|
||||
}
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if profileID == "" {
|
||||
return nil
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
||||
if err != nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
wp, err := store.GetProgress(r.Context(), profileID, contentID)
|
||||
if err != nil || wp == nil {
|
||||
return nil
|
||||
}
|
||||
return &audiobookDetailProgress{
|
||||
PositionSeconds: wp.PositionSeconds,
|
||||
Completed: wp.Completed,
|
||||
UpdatedAt: wp.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type audiobookDetailResponse struct {
|
||||
Audiobook audiobookDetailItem `json:"audiobook"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
Files []audiobookDetailFile `json:"files"`
|
||||
Progress *audiobookDetailProgress `json:"progress,omitempty"`
|
||||
AlsoByAuthor []audiobookRelatedItem `json:"also_by_author,omitempty"`
|
||||
InSeries *audiobookSeriesGroup `json:"in_series,omitempty"`
|
||||
SimilarAudiobooks []audiobookRelatedItem `json:"similar_audiobooks,omitempty"`
|
||||
OtherNarrations []audiobookNarration `json:"other_narrations,omitempty"`
|
||||
}
|
||||
|
||||
// audiobookNarration is a sibling narration (same book, different
|
||||
// narrator/edition). Returned alongside the primary item so the UI can
|
||||
// surface a narrator picker.
|
||||
type audiobookNarration struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookRelatedItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
SeriesIndex *int `json:"series_index,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookSeriesGroup struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Entries []audiobookRelatedItem `json:"entries"`
|
||||
}
|
||||
|
||||
type audiobookDetailItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) presignPoster(ctx context.Context, path string) string {
|
||||
if path == "" || h.Detail == nil {
|
||||
return ""
|
||||
}
|
||||
return h.Detail.PresignURL(ctx, path, "featured")
|
||||
}
|
||||
|
||||
func pickFirstString(values []string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type audiobookDetailFile struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Container string `json:"container,omitempty"`
|
||||
CodecAudio string `json:"codec_audio,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
AudioChannels int `json:"audio_channels,omitempty"`
|
||||
Chapters []models.MediaChapter `json:"chapters,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookDetailProgress struct {
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
Completed bool `json:"completed"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func audiobookDetailFiles(files []*models.MediaFile) []audiobookDetailFile {
|
||||
out := make([]audiobookDetailFile, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, audiobookDetailFile{
|
||||
ID: f.ID,
|
||||
Path: f.FilePath,
|
||||
DurationSeconds: f.Duration,
|
||||
Container: f.Container,
|
||||
CodecAudio: f.CodecAudio,
|
||||
Bitrate: f.Bitrate,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Chapters: f.Chapters,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// AudiobookHandler serves silo-native audiobook endpoints under
|
||||
// /api/v1/audiobooks/*. Sub-plan 3 implements list/detail/progress;
|
||||
// sub-plan 4's ABS-compat layer is separate.
|
||||
type AudiobookHandler struct {
|
||||
Items *catalog.ItemRepository
|
||||
Files *scanner.FileRepository
|
||||
StoreProvider userstore.UserStoreProvider
|
||||
Detail *catalog.DetailService
|
||||
// Recs is optional. When set, the detail handler uses embedding-based
|
||||
// nearest-neighbor search for the "You might also like" rail and falls
|
||||
// back to shared-genre matching only when no embedding exists for the
|
||||
// source book yet.
|
||||
Recs *recommendations.Repo
|
||||
}
|
||||
|
||||
// HandleListAudiobooks serves GET /api/v1/audiobooks?limit=&offset=.
|
||||
// Paginated catalog of media_items with type='audiobook' scoped to the
|
||||
// caller's accessible libraries.
|
||||
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
access := requestAccessFilter(r)
|
||||
genre := r.URL.Query().Get("genre")
|
||||
conditions, args, argIdx, empty := audiobookListConditions(access, genre)
|
||||
if empty {
|
||||
writeAudiobookListResponse(w, nil, 0, limit, offset)
|
||||
return
|
||||
}
|
||||
|
||||
pool := h.Files.Pool()
|
||||
var total int
|
||||
countSQL := "SELECT COUNT(*) FROM media_items mi WHERE " + strings.Join(conditions, " AND ")
|
||||
if err := pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks count failed")
|
||||
return
|
||||
}
|
||||
|
||||
dataArgs := append([]any(nil), args...)
|
||||
limitIdx := argIdx
|
||||
dataArgs = append(dataArgs, limit)
|
||||
offsetIdx := argIdx + 1
|
||||
dataArgs = append(dataArgs, offset)
|
||||
|
||||
rows, err := pool.Query(ctx, fmt.Sprintf(`
|
||||
SELECT
|
||||
mi.content_id,
|
||||
mi.title,
|
||||
COALESCE(mi.year, 0),
|
||||
COALESCE(mi.poster_path, ''),
|
||||
COALESCE(SUM(mf.duration), 0)::int AS duration_seconds,
|
||||
COALESCE(MIN(mf.container), '') AS container,
|
||||
COALESCE(MIN(mf.codec_audio), '') AS codec_audio,
|
||||
COUNT(mf.id) AS file_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN media_files mf ON mf.content_id = mi.content_id
|
||||
WHERE %s
|
||||
GROUP BY mi.content_id, mi.title, mi.year, mi.poster_path, mi.sort_title
|
||||
ORDER BY LOWER(mi.sort_title), LOWER(mi.title)
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, strings.Join(conditions, " AND "), limitIdx, offsetIdx), dataArgs...)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]audiobookListItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var it audiobookListItem
|
||||
var posterPath string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &posterPath, &it.DurationSeconds, &it.Container, &it.CodecAudio, &it.FileCount); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "scan audiobook row failed")
|
||||
return
|
||||
}
|
||||
it.PosterURL = h.presignAudiobookPoster(ctx, posterPath)
|
||||
items = append(items, it)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "iterate audiobook rows failed")
|
||||
return
|
||||
}
|
||||
writeAudiobookListResponse(w, items, total, limit, offset)
|
||||
}
|
||||
|
||||
func audiobookListConditions(access catalog.AccessFilter, genre string) ([]string, []any, int, bool) {
|
||||
conditions := []string{"mi.type = 'audiobook'"}
|
||||
var args []any
|
||||
argIdx := 1
|
||||
|
||||
if genre != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("$%d = ANY(mi.genres)", argIdx))
|
||||
args = append(args, genre)
|
||||
argIdx++
|
||||
}
|
||||
if access.AllowedLibraryIDs != nil {
|
||||
if len(access.AllowedLibraryIDs) == 0 {
|
||||
return nil, nil, 1, true
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf(`
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil
|
||||
WHERE mil.content_id = mi.content_id
|
||||
AND mil.media_folder_id = ANY($%d)
|
||||
)`, argIdx))
|
||||
args = append(args, access.AllowedLibraryIDs)
|
||||
argIdx++
|
||||
}
|
||||
if len(access.DisabledLibraryIDs) > 0 {
|
||||
if access.AllowedLibraryIDs == nil {
|
||||
conditions = append(conditions, `
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil
|
||||
WHERE mil.content_id = mi.content_id
|
||||
)`)
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf(`
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil
|
||||
WHERE mil.content_id = mi.content_id
|
||||
AND mil.media_folder_id = ANY($%d)
|
||||
)`, argIdx))
|
||||
args = append(args, access.DisabledLibraryIDs)
|
||||
argIdx++
|
||||
}
|
||||
catalog.ApplySectionAccessFilter("mi", catalog.AccessFilter{MaxContentRating: access.MaxContentRating}, &conditions, &args, &argIdx)
|
||||
return conditions, args, argIdx, false
|
||||
}
|
||||
|
||||
func writeAudiobookListResponse(w http.ResponseWriter, items []audiobookListItem, total, limit, offset int) {
|
||||
if items == nil {
|
||||
items = []audiobookListItem{}
|
||||
}
|
||||
resp := struct {
|
||||
Items []audiobookListItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}{Items: items, Total: total, Limit: limit, Offset: offset}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
type audiobookListItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Container string `json:"container,omitempty"`
|
||||
CodecAudio string `json:"codec_audio,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) presignAudiobookPoster(ctx context.Context, path string) string {
|
||||
if h == nil || h.Detail == nil || path == "" {
|
||||
return ""
|
||||
}
|
||||
return h.Detail.PresignURL(ctx, cardThumbnailPath(path), "card")
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
type audiobookProgressRequest struct {
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
MediaFileID int `json:"media_file_id,omitempty"`
|
||||
}
|
||||
|
||||
// HandleReportAudiobookProgress serves POST /api/v1/audiobooks/{id}/progress.
|
||||
// UPSERTs into user_watch_progress for the caller's (user_id, profile_id, content_id).
|
||||
func (h *AudiobookHandler) HandleReportAudiobookProgress(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req audiobookProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.PositionSeconds < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "position_seconds must be >= 0")
|
||||
return
|
||||
}
|
||||
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if userID == 0 || profileID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if h.Items == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "catalog unavailable")
|
||||
return
|
||||
}
|
||||
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "check access failed")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 || items[0] == nil || items[0].Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
durationSeconds, err := h.audiobookProgressDuration(r.Context(), contentID, req.MediaFileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
|
||||
return
|
||||
}
|
||||
|
||||
if h.StoreProvider == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
|
||||
return
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
||||
if err != nil || store == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SetProgress(r.Context(), profileID, contentID, req.PositionSeconds, durationSeconds, userstore.ProgressThresholds{}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "save progress failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) audiobookProgressDuration(ctx context.Context, contentID string, mediaFileID int) (float64, error) {
|
||||
if h == nil || h.Files == nil {
|
||||
return 0, nil
|
||||
}
|
||||
files, err := h.Files.GetByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return audiobookDurationForProgress(files, mediaFileID), nil
|
||||
}
|
||||
|
||||
func audiobookDurationForProgress(files []*models.MediaFile, mediaFileID int) float64 {
|
||||
var total int
|
||||
for _, f := range files {
|
||||
if f == nil || f.Duration <= 0 {
|
||||
continue
|
||||
}
|
||||
if mediaFileID > 0 && f.ID == mediaFileID {
|
||||
return float64(f.Duration)
|
||||
}
|
||||
total += f.Duration
|
||||
}
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(total)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestPresignAudiobookPosterDoesNotExposeRawKeysWithoutPresigner(t *testing.T) {
|
||||
h := &AudiobookHandler{}
|
||||
got := h.presignAudiobookPoster(context.Background(), "metadata/audiobooks/book-1/poster/original.webp")
|
||||
if got != "" {
|
||||
t.Fatalf("PosterURL = %q, want empty without presigner", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookListConditionsIncludeAccessPredicates(t *testing.T) {
|
||||
conditions, args, _, empty := audiobookListConditions(catalog.AccessFilter{
|
||||
AllowedLibraryIDs: []int{1, 2},
|
||||
DisabledLibraryIDs: []int{9},
|
||||
MaxContentRating: "PG-13",
|
||||
}, "Fantasy")
|
||||
if empty {
|
||||
t.Fatal("empty = true, want false")
|
||||
}
|
||||
sql := strings.Join(conditions, " AND ")
|
||||
for _, want := range []string{
|
||||
"mi.type = 'audiobook'",
|
||||
"ANY(mi.genres)",
|
||||
"EXISTS",
|
||||
"NOT EXISTS",
|
||||
"mi.content_rating = ANY",
|
||||
} {
|
||||
if !strings.Contains(sql, want) {
|
||||
t.Fatalf("conditions missing %q in:\n%s", want, sql)
|
||||
}
|
||||
}
|
||||
if len(args) != 4 {
|
||||
t.Fatalf("len(args) = %d, want 4", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookDurationForProgress(t *testing.T) {
|
||||
files := []*models.MediaFile{
|
||||
{ID: 10, Duration: 120},
|
||||
{ID: 11, Duration: 180},
|
||||
}
|
||||
|
||||
if got := audiobookDurationForProgress(files, 10); got != 120 {
|
||||
t.Fatalf("selected file duration = %v, want 120", got)
|
||||
}
|
||||
if got := audiobookDurationForProgress(files, 0); got != 300 {
|
||||
t.Fatalf("total duration = %v, want 300", got)
|
||||
}
|
||||
if got := audiobookDurationForProgress([]*models.MediaFile{{ID: 1}}, 1); got != 0 {
|
||||
t.Fatalf("unknown duration = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@ type autoscanStore interface {
|
||||
CountAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) (int, error)
|
||||
ListEvents(ctx context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
|
||||
CountEvents(ctx context.Context, filter autoscan.EventListFilter) (int, error)
|
||||
ListRunningEvents(ctx context.Context) ([]autoscan.Event, error)
|
||||
GetQueueSummary(ctx context.Context) (autoscan.QueueSummary, error)
|
||||
LatestEventAt(ctx context.Context) (*time.Time, error)
|
||||
}
|
||||
@@ -310,7 +309,7 @@ func (h *AutoscanHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.
|
||||
// credentials: the connection link is by id only.
|
||||
type autoscanSourceResponse struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -332,7 +331,7 @@ func sourceResponse(s autoscan.Source) autoscanSourceResponse {
|
||||
config := normalizeSourceConfig(s.SourceConfig)
|
||||
return autoscanSourceResponse{
|
||||
ID: s.ID,
|
||||
PluginID: s.PluginID,
|
||||
InstallationID: s.InstallationID,
|
||||
CapabilityID: s.CapabilityID,
|
||||
ConnectionID: s.ConnectionID,
|
||||
Enabled: s.Enabled,
|
||||
@@ -365,9 +364,10 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
|
||||
// --- Available scan-source plugins (Add-source picker) ---
|
||||
|
||||
type autoscanScanSourcePluginResponse struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// HandleListAvailableScanSources returns every installed scan_source capability
|
||||
@@ -381,9 +381,10 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
out := make([]autoscanScanSourcePluginResponse, 0, len(available))
|
||||
for _, a := range available {
|
||||
out = append(out, autoscanScanSourcePluginResponse{
|
||||
PluginID: a.PluginID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
DisplayName: a.DisplayName,
|
||||
InstallationID: a.InstallationID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
PluginID: a.PluginID,
|
||||
DisplayName: a.DisplayName,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
@@ -393,12 +394,12 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
|
||||
// --- Create source ---
|
||||
|
||||
// autoscanCreateSourceInput is the create payload: it names the scan_source
|
||||
// capability to bind (plugin_id + capability_id) plus the initial
|
||||
// binding/scheduling fields. Unlike the update payload, identity is supplied
|
||||
// here rather than read from an existing row.
|
||||
// autoscanCreateSourceInput is the create payload: it names the installed
|
||||
// scan_source capability to bind (installation_id + capability_id) plus the
|
||||
// initial binding/scheduling fields. Unlike the update payload, identity is
|
||||
// supplied here rather than read from an existing row.
|
||||
type autoscanCreateSourceInput struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -419,11 +420,6 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
capID := strings.TrimSpace(in.CapabilityID)
|
||||
pluginID := strings.TrimSpace(in.PluginID)
|
||||
if pluginID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id is required")
|
||||
return
|
||||
}
|
||||
if capID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
|
||||
return
|
||||
@@ -436,7 +432,7 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
// Validate the (plugin_id, capability_id) is a currently-installed
|
||||
// Validate the (installation_id, capability_id) is a currently-installed
|
||||
// scan_source capability — a source may only be created against an installed
|
||||
// plugin capability.
|
||||
available, err := h.svc.ListAvailableScanSources(r.Context())
|
||||
@@ -444,13 +440,13 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
if !scanSourceInstalled(available, pluginID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id + capability_id is not a currently-installed scan_source capability")
|
||||
if !scanSourceInstalled(available, in.InstallationID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "installation_id + capability_id is not a currently-installed scan_source capability")
|
||||
return
|
||||
}
|
||||
connArg := normalizeConnectionID(in.ConnectionID)
|
||||
created, err := h.repo.CreateSource(r.Context(), autoscan.Source{
|
||||
PluginID: pluginID,
|
||||
InstallationID: in.InstallationID,
|
||||
CapabilityID: capID,
|
||||
ConnectionID: connArg,
|
||||
Enabled: in.Enabled,
|
||||
@@ -466,18 +462,18 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusCreated, sourceResponse(created))
|
||||
}
|
||||
|
||||
// scanSourceInstalled reports whether (pluginID, capabilityID) is in the
|
||||
// scanSourceInstalled reports whether (installationID, capabilityID) is in the
|
||||
// set of currently-installed scan_source capabilities.
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, pluginID, capabilityID string) bool {
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, installationID int, capabilityID string) bool {
|
||||
for _, a := range available {
|
||||
if a.PluginID == pluginID && a.CapabilityID == capabilityID {
|
||||
if a.InstallationID == installationID && a.CapabilityID == capabilityID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoscanSourceInput is the source write payload. The (plugin_id,
|
||||
// autoscanSourceInput is the source write payload. The (installation_id,
|
||||
// capability_id) identity is read from the existing source row, so only the
|
||||
// schedulable/binding fields are accepted.
|
||||
//
|
||||
@@ -552,7 +548,7 @@ func (h *AutoscanHandler) HandleUpdateSource(w http.ResponseWriter, r *http.Requ
|
||||
// connection_id is a full-state field: nil means unbind, a UUID string
|
||||
// means bind. A whitespace-only string is normalised to nil (unbound).
|
||||
connArg := normalizeConnectionID(in.ConnectionID)
|
||||
// Update by id; identity (plugin_id, capability_id) is immutable and
|
||||
// Update by id; identity (installation_id, capability_id) is immutable and
|
||||
// preserved by the repo. A missing source maps to 404.
|
||||
updated, err := h.repo.UpdateSource(r.Context(), autoscan.Source{
|
||||
ID: id,
|
||||
@@ -585,8 +581,9 @@ func normalizeConnectionID(in *string) *string {
|
||||
}
|
||||
|
||||
// HandleDeleteSource removes a source row. This is the operator's escape hatch
|
||||
// for an orphaned source (one whose scan_source plugin was uninstalled or no
|
||||
// longer resolves cleanly). An unknown id maps to 404 via autoscan.ErrNotFound.
|
||||
// for an orphaned source (one whose scan_source plugin was uninstalled): the
|
||||
// poll loop skips such rows quietly, and this endpoint clears them. An unknown
|
||||
// id maps to 404 via autoscan.ErrNotFound.
|
||||
func (h *AutoscanHandler) HandleDeleteSource(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if err := h.repo.DeleteSource(r.Context(), id); err != nil {
|
||||
@@ -713,7 +710,7 @@ type autoscanEventScanRunResponse struct {
|
||||
type autoscanEventResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID *string `json:"source_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
@@ -756,7 +753,7 @@ func eventResponse(event autoscan.EventWithRuns) autoscanEventResponse {
|
||||
return autoscanEventResponse{
|
||||
ID: e.ID,
|
||||
SourceID: e.SourceID,
|
||||
PluginID: e.PluginID,
|
||||
InstallationID: e.InstallationID,
|
||||
CapabilityID: e.CapabilityID,
|
||||
StartedAt: e.StartedAt,
|
||||
CompletedAt: e.CompletedAt,
|
||||
@@ -797,9 +794,9 @@ func (h *AutoscanHandler) HandleListEvents(w http.ResponseWriter, r *http.Reques
|
||||
filter.Offset = offset
|
||||
}
|
||||
switch filter.Status {
|
||||
case "", autoscan.EventStatusRunning, autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
case "", autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be running, success, error, or unresolved")
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be success, error, or unresolved")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -838,7 +835,7 @@ type autoscanScanResponse struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
AutoscanEventID *int64 `json:"autoscan_event_id,omitempty"`
|
||||
SourceID *string `json:"source_id,omitempty"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
CapabilityID string `json:"capability_id,omitempty"`
|
||||
EventStatus string `json:"event_status,omitempty"`
|
||||
EventCompletedAt *time.Time `json:"event_completed_at,omitempty"`
|
||||
@@ -865,7 +862,7 @@ func autoscanScanRunResponse(scan autoscan.ScanWithEvent) autoscanScanResponse {
|
||||
CompletedAt: scan.CompletedAt,
|
||||
AutoscanEventID: scan.AutoscanEventID,
|
||||
SourceID: scan.SourceID,
|
||||
PluginID: scan.PluginID,
|
||||
InstallationID: scan.InstallationID,
|
||||
CapabilityID: scan.CapabilityID,
|
||||
EventStatus: string(scan.EventStatus),
|
||||
EventCompletedAt: scan.EventCompletedAt,
|
||||
@@ -926,35 +923,24 @@ func (h *AutoscanHandler) HandleListScans(w http.ResponseWriter, r *http.Request
|
||||
// --- Status ---
|
||||
|
||||
type autoscanStatusSource struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Label string `json:"label"`
|
||||
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type autoscanRunningPollResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID *string `json:"source_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
MarkerBefore *string `json:"marker_before,omitempty"`
|
||||
ID string `json:"id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Label string `json:"label"`
|
||||
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type autoscanStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Sources []autoscanStatusSource `json:"sources"`
|
||||
RunningPolls []autoscanRunningPollResponse `json:"running_polls"`
|
||||
ActiveScans int `json:"active_scans"`
|
||||
AcceptedScans int `json:"accepted_scans"`
|
||||
RunningScans int `json:"running_scans"`
|
||||
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Sources []autoscanStatusSource `json:"sources"`
|
||||
ActiveScans int `json:"active_scans"`
|
||||
AcceptedScans int `json:"accepted_scans"`
|
||||
RunningScans int `json:"running_scans"`
|
||||
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -974,11 +960,6 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
runningEvents, err := h.repo.ListRunningEvents(ctx)
|
||||
if err != nil {
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
latestEventAt, err := h.repo.LatestEventAt(ctx)
|
||||
if err != nil {
|
||||
writeAutoscanError(w, err)
|
||||
@@ -991,38 +972,20 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
rewrites = []autoscan.PathRewrite{}
|
||||
}
|
||||
trimmed = append(trimmed, autoscanStatusSource{
|
||||
ID: src.ID,
|
||||
PluginID: src.PluginID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
ConnectionID: src.ConnectionID,
|
||||
Enabled: src.Enabled,
|
||||
Label: src.Label,
|
||||
PathRewrites: rewrites,
|
||||
LastRunAt: src.LastRunAt,
|
||||
LastError: src.LastError,
|
||||
})
|
||||
}
|
||||
now := time.Now()
|
||||
runningPolls := make([]autoscanRunningPollResponse, 0, len(runningEvents))
|
||||
for _, event := range runningEvents {
|
||||
elapsed := now.Sub(event.StartedAt)
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
runningPolls = append(runningPolls, autoscanRunningPollResponse{
|
||||
ID: event.ID,
|
||||
SourceID: event.SourceID,
|
||||
PluginID: event.PluginID,
|
||||
CapabilityID: event.CapabilityID,
|
||||
StartedAt: event.StartedAt,
|
||||
ElapsedMS: elapsed.Milliseconds(),
|
||||
MarkerBefore: event.MarkerBefore,
|
||||
ID: src.ID,
|
||||
InstallationID: src.InstallationID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
ConnectionID: src.ConnectionID,
|
||||
Enabled: src.Enabled,
|
||||
Label: src.Label,
|
||||
PathRewrites: rewrites,
|
||||
LastRunAt: src.LastRunAt,
|
||||
LastError: src.LastError,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, autoscanStatusResponse{
|
||||
Enabled: s.Enabled,
|
||||
Sources: trimmed,
|
||||
RunningPolls: runningPolls,
|
||||
ActiveScans: queue.Active,
|
||||
AcceptedScans: queue.Accepted,
|
||||
RunningScans: queue.Running,
|
||||
|
||||
@@ -31,7 +31,6 @@ type fakeAutoscanStore struct {
|
||||
countScansFn func(autoscan.ScanListFilter) (int, error)
|
||||
listEventsFn func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
|
||||
countEventsFn func(autoscan.EventListFilter) (int, error)
|
||||
listRunningFn func() ([]autoscan.Event, error)
|
||||
queueSummaryFn func() (autoscan.QueueSummary, error)
|
||||
latestEventAtFn func() (*time.Time, error)
|
||||
}
|
||||
@@ -141,13 +140,6 @@ func (f *fakeAutoscanStore) CountEvents(_ context.Context, filter autoscan.Event
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanStore) ListRunningEvents(context.Context) ([]autoscan.Event, error) {
|
||||
if f.listRunningFn != nil {
|
||||
return f.listRunningFn()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanStore) GetQueueSummary(context.Context) (autoscan.QueueSummary, error) {
|
||||
if f.queueSummaryFn != nil {
|
||||
return f.queueSummaryFn()
|
||||
@@ -281,7 +273,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
listSourcesFn: func() ([]autoscan.Source, error) {
|
||||
listed = true
|
||||
return []autoscan.Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr"}}, nil
|
||||
return []autoscan.Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr"}}, nil
|
||||
},
|
||||
}
|
||||
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
|
||||
@@ -308,7 +300,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
|
||||
func TestAutoscanHandleListAvailableScanSources(t *testing.T) {
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "sonarr", CapabilityID: "arr", DisplayName: "Sonarr"},
|
||||
{InstallationID: 1, CapabilityID: "arr", PluginID: "sonarr", DisplayName: "Sonarr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
|
||||
|
||||
@@ -339,19 +331,19 @@ func TestAutoscanHandleCreateSourceSucceeds(t *testing.T) {
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
|
||||
`{"installation_id":1,"capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.PluginID != "silo.autoscan.arr" || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
if got.InstallationID != 1 || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
t.Fatalf("unexpected created source: %+v", got)
|
||||
}
|
||||
var body autoscanSourceResponse
|
||||
@@ -371,14 +363,14 @@ func TestAutoscanHandleCreateSourceRejectsUnknownCapability(t *testing.T) {
|
||||
return s, nil
|
||||
},
|
||||
}
|
||||
// Installed set does NOT include ("silo.autoscan.arr", "arr").
|
||||
// Installed set does NOT include (1, "arr").
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "silo.autoscan.other", CapabilityID: "other"},
|
||||
{InstallationID: 2, CapabilityID: "other"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":false}`, "")
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":false}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -402,12 +394,12 @@ func TestAutoscanHandleCreateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":true}`, "")
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":true}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -623,7 +615,7 @@ func TestAutoscanHandleUpdateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -649,7 +641,7 @@ func TestAutoscanHandleUpdateSourceEnableWithConnectionSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -677,7 +669,7 @@ func TestAutoscanHandleUpdateSourceBindConnectionSucceeds(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
// Source starts with no connection.
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -705,7 +697,7 @@ func TestAutoscanHandleUpdateSourceUnbindConnectionSucceeds(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
// Source starts with a connection already bound.
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -735,7 +727,7 @@ func TestAutoscanHandleUpdateSourceUnbindWhileEnabledSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -933,7 +925,7 @@ func TestAutoscanHandleUpdateSourceRoundTripsPathRewrites(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -998,7 +990,7 @@ func TestAutoscanHandleUpdateSourceRejectsBlankRewrite(t *testing.T) {
|
||||
upserted := false
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
upserted = true
|
||||
@@ -1028,7 +1020,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
eventCompleted := completed.Add(-1 * time.Second)
|
||||
eventID := int64(42)
|
||||
sourceID := "src-1"
|
||||
pluginID := "silo.autoscan.cephfs"
|
||||
installationID := 7
|
||||
var gotFilter autoscan.ScanListFilter
|
||||
var gotCountFilter autoscan.ScanListFilter
|
||||
store := &fakeAutoscanStore{
|
||||
@@ -1048,7 +1040,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
},
|
||||
AutoscanEventID: &eventID,
|
||||
SourceID: &sourceID,
|
||||
PluginID: pluginID,
|
||||
InstallationID: &installationID,
|
||||
CapabilityID: "cephfs",
|
||||
EventStatus: autoscan.EventStatusSuccess,
|
||||
EventCompletedAt: &eventCompleted,
|
||||
@@ -1092,7 +1084,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
if scan.AutoscanEventID == nil || *scan.AutoscanEventID != eventID || scan.EventStatus != "success" {
|
||||
t.Fatalf("event context = %+v", scan)
|
||||
}
|
||||
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.PluginID != pluginID || scan.CapabilityID != "cephfs" {
|
||||
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.InstallationID == nil || *scan.InstallationID != installationID || scan.CapabilityID != "cephfs" {
|
||||
t.Fatalf("source context = %+v", scan)
|
||||
}
|
||||
}
|
||||
@@ -1136,7 +1128,7 @@ func TestAutoscanHandleListEventsSerializesFiltersAndRuns(t *testing.T) {
|
||||
Event: autoscan.Event{
|
||||
ID: 42,
|
||||
SourceID: &sourceID,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
InstallationID: 7,
|
||||
CapabilityID: "cephfs",
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
@@ -1266,30 +1258,18 @@ func TestAutoscanHandleUpdateSourceNormalizesLabel(t *testing.T) {
|
||||
|
||||
func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
|
||||
latestEventAt := time.Date(2026, 6, 4, 14, 30, 0, 0, time.UTC)
|
||||
runningStartedAt := time.Now().Add(-90 * time.Second)
|
||||
sourceID := "src-1"
|
||||
store := &fakeAutoscanStore{
|
||||
getSettingsFn: func() (autoscan.Settings, error) {
|
||||
return autoscan.Settings{Enabled: true}, nil
|
||||
},
|
||||
listSourcesFn: func() ([]autoscan.Source, error) {
|
||||
return []autoscan.Source{
|
||||
{ID: "src-1", PluginID: "silo.autoscan.cephfs", CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
|
||||
{ID: "src-1", InstallationID: 7, CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
|
||||
}, nil
|
||||
},
|
||||
queueSummaryFn: func() (autoscan.QueueSummary, error) {
|
||||
return autoscan.QueueSummary{Active: 3, Accepted: 2, Running: 1}, nil
|
||||
},
|
||||
listRunningFn: func() ([]autoscan.Event, error) {
|
||||
return []autoscan.Event{{
|
||||
ID: 44,
|
||||
SourceID: &sourceID,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
CapabilityID: "cephfs",
|
||||
StartedAt: runningStartedAt,
|
||||
Status: autoscan.EventStatusRunning,
|
||||
}}, nil
|
||||
},
|
||||
latestEventAtFn: func() (*time.Time, error) {
|
||||
return &latestEventAt, nil
|
||||
},
|
||||
@@ -1316,16 +1296,6 @@ func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
|
||||
if body.ActiveScans != 3 || body.AcceptedScans != 2 || body.RunningScans != 1 {
|
||||
t.Errorf("queue summary = %+v", body)
|
||||
}
|
||||
if len(body.RunningPolls) != 1 {
|
||||
t.Fatalf("running_polls = %+v", body.RunningPolls)
|
||||
}
|
||||
poll := body.RunningPolls[0]
|
||||
if poll.ID != 44 || poll.SourceID == nil || *poll.SourceID != sourceID || poll.PluginID != "silo.autoscan.cephfs" || poll.CapabilityID != "cephfs" {
|
||||
t.Fatalf("running poll identity = %+v", poll)
|
||||
}
|
||||
if poll.ElapsedMS <= 0 {
|
||||
t.Fatalf("running poll elapsed_ms = %d, want > 0", poll.ElapsedMS)
|
||||
}
|
||||
if body.LatestEventAt == nil || !body.LatestEventAt.Equal(latestEventAt) {
|
||||
t.Errorf("latest_event_at = %v, want %v", body.LatestEventAt, latestEventAt)
|
||||
}
|
||||
|
||||
@@ -221,9 +221,6 @@ func (h *CatalogHandler) HandleGetCatalogFacetSearch(w http.ResponseWriter, r *h
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
||||
return
|
||||
}
|
||||
if n > 100 {
|
||||
n = 100
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
|
||||
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
|
||||
}
|
||||
}
|
||||
case "movie", "episode", "audiobook":
|
||||
case "movie", "episode":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
|
||||
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
@@ -2036,10 +2036,8 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
levels = []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
levels = []string{"movie"}
|
||||
case "audiobooks", "audiobook":
|
||||
levels = []string{"audiobook"}
|
||||
case "mixed":
|
||||
levels = []string{"movie", "series", "season", "episode", "audiobook"}
|
||||
levels = []string{"movie", "series", "season", "episode"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
var ErrServerRestartAlreadyRequested = errors.New("server restart already requested")
|
||||
|
||||
type ServerControlHandler struct {
|
||||
requestRestart func(ctx context.Context) error
|
||||
commands *playback.CommandDispatcher
|
||||
}
|
||||
|
||||
type serverRestartRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type serverRestartResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
NotifiedSessions int `json:"notified_sessions"`
|
||||
}
|
||||
|
||||
func NewServerControlHandler(
|
||||
requestRestart func(ctx context.Context) error,
|
||||
commands *playback.CommandDispatcher,
|
||||
) *ServerControlHandler {
|
||||
return &ServerControlHandler{
|
||||
requestRestart: requestRestart,
|
||||
commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRestart handles POST /admin/server/restart.
|
||||
func (h *ServerControlHandler) HandleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.requestRestart == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Server restart is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var req serverRestartRequest
|
||||
if err := decodeOptionalJSONBody(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
notifiedSessions := h.notifyPlaybackSessions(req)
|
||||
|
||||
if err := h.requestRestart(context.Background()); err != nil {
|
||||
if errors.Is(err, ErrServerRestartAlreadyRequested) {
|
||||
writeJSON(w, http.StatusAccepted, serverRestartResponse{
|
||||
Status: "already_requested",
|
||||
Message: "Server restart is already in progress.",
|
||||
NotifiedSessions: notifiedSessions,
|
||||
})
|
||||
return
|
||||
}
|
||||
slog.Error("server restart request failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request server restart")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, serverRestartResponse{
|
||||
Status: "restart_requested",
|
||||
Message: "Server restart requested. The process will shut down gracefully.",
|
||||
NotifiedSessions: notifiedSessions,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest) int {
|
||||
if h == nil || h.commands == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
title := req.Title
|
||||
if title == "" {
|
||||
title = "Server restarting"
|
||||
}
|
||||
message := req.Message
|
||||
if message == "" {
|
||||
message = "The server is restarting now. Playback may reconnect shortly."
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"title": title,
|
||||
"message": message,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("server restart: failed to build realtime payload", "error", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
results := h.commands.DispatchToAll(func(session *playback.Session) (playback.CommandEnvelope, time.Duration, func(), error) {
|
||||
if session == nil {
|
||||
return playback.CommandEnvelope{}, 0, nil, playback.ErrCommandDispatchUnavailable
|
||||
}
|
||||
command, err := playback.NewCommandEnvelope(
|
||||
session.ID,
|
||||
uuid.NewString(),
|
||||
playback.CommandServerRestarting,
|
||||
payload,
|
||||
)
|
||||
if err != nil {
|
||||
return playback.CommandEnvelope{}, 0, nil, err
|
||||
}
|
||||
command.Reason = req.Reason
|
||||
if command.Reason == "" {
|
||||
command.Reason = "server_restart_requested"
|
||||
}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
return command, 0, nil, nil
|
||||
})
|
||||
|
||||
delivered := 0
|
||||
for _, result := range results {
|
||||
if result.Delivered {
|
||||
delivered++
|
||||
continue
|
||||
}
|
||||
if result.DispatchErr != nil && !errors.Is(result.DispatchErr, playback.ErrRealtimeConnectionNotFound) {
|
||||
slog.Warn("server restart: failed to notify playback session",
|
||||
"session_id", result.SessionID,
|
||||
"error", result.DispatchErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
type serverControlTestConn struct {
|
||||
messages []any
|
||||
}
|
||||
|
||||
func (c *serverControlTestConn) WriteJSON(v any) error {
|
||||
c.messages = append(c.messages, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestServerControlRestartRequestsShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
called := 0
|
||||
handler := NewServerControlHandler(func(context.Context) error {
|
||||
called++
|
||||
return nil
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if called != 1 {
|
||||
t.Fatalf("restart calls = %d, want 1", called)
|
||||
}
|
||||
|
||||
var resp serverRestartResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Status != "restart_requested" {
|
||||
t.Fatalf("status = %q, want restart_requested", resp.Status)
|
||||
}
|
||||
if resp.NotifiedSessions != 0 {
|
||||
t.Fatalf("notified_sessions = %d, want 0", resp.NotifiedSessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerControlRestartUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewServerControlHandler(nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerControlRestartAlreadyRequested(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewServerControlHandler(func(context.Context) error {
|
||||
return ErrServerRestartAlreadyRequested
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp serverRestartResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Status != "already_requested" {
|
||||
t.Fatalf("status = %q, want already_requested", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerControlRestartNotifiesPlaybackSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", 100, playback.PlayDirect, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
realtimeHub := playback.NewRealtimeHub()
|
||||
conn := &serverControlTestConn{}
|
||||
registration := realtimeHub.Register(session.ID, conn)
|
||||
if registration == nil {
|
||||
t.Fatal("expected realtime registration")
|
||||
}
|
||||
defer realtimeHub.Unregister(registration)
|
||||
|
||||
dispatcher := playback.NewCommandDispatcher(sessionMgr, realtimeHub, nil)
|
||||
handler := NewServerControlHandler(func(context.Context) error {
|
||||
return nil
|
||||
}, dispatcher)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/admin/server/restart",
|
||||
strings.NewReader(`{"reason":"maintenance","message":"Restarting for maintenance"}`),
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp serverRestartResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.NotifiedSessions != 1 {
|
||||
t.Fatalf("notified_sessions = %d, want 1", resp.NotifiedSessions)
|
||||
}
|
||||
if len(conn.messages) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(conn.messages))
|
||||
}
|
||||
|
||||
command, ok := conn.messages[0].(playback.CommandEnvelope)
|
||||
if !ok {
|
||||
t.Fatalf("message type = %T, want playback.CommandEnvelope", conn.messages[0])
|
||||
}
|
||||
if command.Name != playback.CommandServerRestarting {
|
||||
t.Fatalf("command name = %q, want %q", command.Name, playback.CommandServerRestarting)
|
||||
}
|
||||
if command.Reason != "maintenance" {
|
||||
t.Fatalf("reason = %q, want maintenance", command.Reason)
|
||||
}
|
||||
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(command.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload["message"] != "Restarting for maintenance" {
|
||||
t.Fatalf("payload message = %q, want custom message", payload["message"])
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,14 @@ import (
|
||||
type SystemHandler struct {
|
||||
transcodePool *nodepool.TranscodePool
|
||||
jwtSecret string
|
||||
ffmpegPath string
|
||||
buildInfo buildinfo.Info
|
||||
}
|
||||
|
||||
// NewSystemHandler creates a SystemHandler.
|
||||
func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string, ffmpegPath string) *SystemHandler {
|
||||
func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string) *SystemHandler {
|
||||
return &SystemHandler{
|
||||
transcodePool: transcodePool,
|
||||
jwtSecret: jwtSecret,
|
||||
ffmpegPath: ffmpegPath,
|
||||
buildInfo: buildinfo.Current(),
|
||||
}
|
||||
}
|
||||
@@ -47,7 +45,7 @@ func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
info := playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)
|
||||
info := playback.DetectHWAccel()
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@ type tasteSeedSubmitResponse struct {
|
||||
|
||||
// HandleTasteSeedItems handles GET /recommendations/taste-seed/items.
|
||||
//
|
||||
// Returns a paginated, hydrated list of posters used for the new-user
|
||||
// taste-seeding picker. Blends server-watched popularity with rating reliability
|
||||
// and recency so fresh servers (no watch history yet) still surface recognizable
|
||||
// content. The user_state field carries the existing is_favorite flag, so the UI
|
||||
// can pre-select items the profile already favorited.
|
||||
// Returns a paginated, hydrated list of "popular on this server" posters used
|
||||
// for the new-user taste-seeding picker. Blends server-watched popularity with
|
||||
// TMDB rating so fresh servers (no watch history yet) still surface meaningful
|
||||
// content. The user_state field carries the existing is_favorite flag, so the
|
||||
// UI can pre-select items the profile already favorited.
|
||||
func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r *http.Request) {
|
||||
if h.recsRepo == nil || h.Fetcher == nil {
|
||||
writeJSON(w, http.StatusOK, tasteSeedItemsResponse{Items: []sectionItemResponse{}})
|
||||
|
||||
+18
-9
@@ -143,7 +143,6 @@ type Dependencies struct {
|
||||
PlaybackRealtimeHub *playback.RealtimeHub
|
||||
OnUserSessionsRevoked func(ctx context.Context, userID int)
|
||||
OnServerSettingUpdated func(ctx context.Context, key, value string)
|
||||
RequestServerRestart func(ctx context.Context) error
|
||||
|
||||
// UserCollectionSync handles per-profile imported collections (TMDB /
|
||||
// Trakt / MDBList) — the user-facing analogue of CollectionService.
|
||||
@@ -165,6 +164,9 @@ type Dependencies struct {
|
||||
// clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all
|
||||
// resolve correctly. May be nil; no ABS routes are registered in that case.
|
||||
ABSHandler absHandler
|
||||
// AudiobooksEnabled gates native audiobook admin/UI routes behind the same
|
||||
// server setting as the ABS compatibility listener.
|
||||
AudiobooksEnabled bool
|
||||
}
|
||||
|
||||
// absHandler is the narrow interface the router needs from the ABS handler.
|
||||
@@ -577,7 +579,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// Build playback handler if session manager is available.
|
||||
var playbackHandler *handlers.PlaybackHandler
|
||||
var adminPlaybackControlHandler *handlers.AdminPlaybackControlHandler
|
||||
var playbackCommandDispatcher *playback.CommandDispatcher
|
||||
var streamHandler *handlers.StreamHandler
|
||||
var watchTogetherHandler *handlers.WatchTogetherHandler
|
||||
if deps.SessionMgr != nil {
|
||||
@@ -661,7 +662,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
playbackHandler.RealtimeHub = realtimeHub
|
||||
playbackHandler.CommandTracker = commandTracker
|
||||
playbackHandler.CommandDispatcher = playback.NewCommandDispatcher(deps.SessionMgr, realtimeHub, commandTracker)
|
||||
playbackCommandDispatcher = playbackHandler.CommandDispatcher
|
||||
playbackHandler.IntroAnalyzer = deps.IntroAnalyzer
|
||||
playbackHandler.IntroRepository = deps.IntroRepository
|
||||
playbackHandler.MarkerRegistry = deps.MarkerRegistry
|
||||
@@ -700,8 +700,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
streamHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
|
||||
}
|
||||
|
||||
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher)
|
||||
|
||||
// Build admin handler if we have a user repo.
|
||||
var adminHandler *handlers.AdminHandler
|
||||
var catalogSeedHandler *handlers.CatalogSeedHandler
|
||||
@@ -1811,6 +1809,20 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/sections/recipes", recipeHandler.HandleList)
|
||||
r.Get("/sections/recipes/{type}/candidates", recipeHandler.HandleCandidates)
|
||||
|
||||
// Audiobook endpoints (no profile required — catalog-level list/detail).
|
||||
if deps.AudiobooksEnabled && itemRepo != nil {
|
||||
audiobookHandler := &handlers.AudiobookHandler{
|
||||
Items: itemRepo,
|
||||
Files: deps.FileRepo,
|
||||
Detail: detailSvc,
|
||||
StoreProvider: deps.UserStoreProvider,
|
||||
Recs: recsRepoForStale,
|
||||
}
|
||||
r.Get("/audiobooks", audiobookHandler.HandleListAudiobooks)
|
||||
r.Get("/audiobooks/{id}", audiobookHandler.HandleGetAudiobook)
|
||||
r.Post("/audiobooks/{id}/progress", audiobookHandler.HandleReportAudiobookProgress)
|
||||
}
|
||||
|
||||
// Section endpoints (profile-scoped).
|
||||
if sectionHandler != nil {
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -1908,7 +1920,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
|
||||
r.Get("/unmatched", adminHandler.HandleListUnmatched)
|
||||
r.Get("/stats", adminHandler.HandleGetStats)
|
||||
r.Post("/server/restart", serverControlHandler.HandleRestart)
|
||||
r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus)
|
||||
r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection)
|
||||
if sectionSettingsHandler != nil {
|
||||
@@ -2102,12 +2113,10 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// System inspection.
|
||||
{
|
||||
sysJWTSecret := ""
|
||||
sysFFmpegPath := ""
|
||||
if deps.Config != nil {
|
||||
sysJWTSecret = deps.Config.Auth.JWTSecret
|
||||
sysFFmpegPath = deps.Config.Playback.FFmpegPath
|
||||
}
|
||||
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret, sysFFmpegPath)
|
||||
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret)
|
||||
r.Route("/system", func(r chi.Router) {
|
||||
r.Get("/build", systemHandler.HandleBuildInfo)
|
||||
r.Get("/hw-accel", systemHandler.HandleHWAccel)
|
||||
|
||||
@@ -18,8 +18,7 @@ func (h *Handler) handleAuthorDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
access, err := h.accessFilterForAuth(r.Context(), a)
|
||||
if err != nil {
|
||||
slog.Warn("abs author access resolution failed", "err", err, "id", id)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
author, err := h.deps.MediaStore.GetAuthorByID(r.Context(), id, access)
|
||||
@@ -48,8 +47,7 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
access, err := h.accessFilterForAuth(r.Context(), a)
|
||||
if err != nil {
|
||||
slog.Warn("abs series access resolution failed", "err", err, "id", id)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
series, err := h.deps.MediaStore.GetSeriesByName(r.Context(), id, access)
|
||||
|
||||
@@ -44,9 +44,7 @@ func TestAuthor_Detail_ReturnsBooks(t *testing.T) {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got["name"] != "Brandon Sanderson" {
|
||||
t.Errorf("name = %v", got["name"])
|
||||
}
|
||||
@@ -80,9 +78,7 @@ func TestSeries_Detail_ReturnsBooks(t *testing.T) {
|
||||
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got["name"] != "Mistborn" {
|
||||
t.Errorf("name = %v", got["name"])
|
||||
}
|
||||
|
||||
@@ -61,12 +61,7 @@ func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
|
||||
if err != nil {
|
||||
slog.Error("abs bookmark item lookup failed", "err", err, "user", a.UserID, "item", itemID)
|
||||
http.Error(w, "item lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
if err != nil || item == nil {
|
||||
http.Error(w, "item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
@@ -158,14 +157,10 @@ func (p *recordingPublisher) snapshot() []publishedEvent {
|
||||
// handler tests can drive both the 200 and 404 branches.
|
||||
type stubMediaStore struct {
|
||||
noopMediaStore
|
||||
known map[string]*models.MediaItem // itemID → row (nil means "exists but no row needed")
|
||||
lookupErr error
|
||||
known map[string]*models.MediaItem // itemID → row (nil means "exists but no row needed")
|
||||
}
|
||||
|
||||
func (s *stubMediaStore) GetAudiobookByID(_ context.Context, id string, _ catalog.AccessFilter) (*models.MediaItem, error) {
|
||||
if s.lookupErr != nil {
|
||||
return nil, s.lookupErr
|
||||
}
|
||||
if it, ok := s.known[id]; ok {
|
||||
if it == nil {
|
||||
return &models.MediaItem{ContentID: id}, nil
|
||||
@@ -416,19 +411,6 @@ func TestMissingItem_404(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemLookup_ErrorFromMediaStore_500(t *testing.T) {
|
||||
hb := newBookmarksHarness(t, "book-1")
|
||||
hb.H.deps.MediaStore = &stubMediaStore{
|
||||
known: map[string]*models.MediaItem{"book-1": nil},
|
||||
lookupErr: errors.New("media lookup failed"),
|
||||
}
|
||||
|
||||
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"x","time":1}`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidBody_400(t *testing.T) {
|
||||
hb := newBookmarksHarness(t, "book-1")
|
||||
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{not json`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
@@ -40,12 +39,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == nil {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(*body.Name)
|
||||
if name == "" {
|
||||
if body.Name == nil || *body.Name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -54,7 +48,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
ID: ulid.Make().String(),
|
||||
UserID: a.UserID,
|
||||
ProfileID: a.ProfileID,
|
||||
Name: name,
|
||||
Name: *body.Name,
|
||||
}
|
||||
if body.Description != nil {
|
||||
c.Description = *body.Description
|
||||
@@ -273,12 +267,7 @@ func (h *Handler) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if body.Name != nil {
|
||||
name := strings.TrimSpace(*body.Name)
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.Name = name
|
||||
c.Name = *body.Name
|
||||
}
|
||||
if body.Description != nil {
|
||||
c.Description = *body.Description
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
// and profile_id; we mirror that so List can filter correctly).
|
||||
type memCollectionStore struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]Collection // id -> row
|
||||
rows map[string]Collection // id -> row
|
||||
items map[string][]CollectionItem // collection_id -> items
|
||||
}
|
||||
|
||||
@@ -281,16 +281,8 @@ func TestCollection_List_DoesNotLeakOtherUsers(t *testing.T) {
|
||||
// User 2 lists.
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/collections", nil, nil, "2", "", hb.H.handleListCollections)
|
||||
var env map[string]any
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
list, ok := env["collections"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("response missing 'collections' key; body=%s", rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
list, _ := env["collections"].([]any)
|
||||
if len(list) != 0 {
|
||||
t.Errorf("user 2 sees %d collections, want 0", len(list))
|
||||
}
|
||||
@@ -303,16 +295,8 @@ func TestCollection_List_ProfileIsolation(t *testing.T) {
|
||||
_ = dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(`{"name":"A"}`), "1", pA, hb.H.handleCreateCollection)
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/collections", nil, nil, "1", pB, hb.H.handleListCollections)
|
||||
var env map[string]any
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
list, ok := env["collections"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("response missing 'collections' key; body=%s", rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
list, _ := env["collections"].([]any)
|
||||
if len(list) != 0 {
|
||||
t.Errorf("profile B sees %d collections, want 0", len(list))
|
||||
}
|
||||
|
||||
@@ -32,12 +32,7 @@ func (h *Handler) setHideFromContinue(w http.ResponseWriter, r *http.Request, hi
|
||||
return
|
||||
}
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
|
||||
if err != nil {
|
||||
slog.Error("abs continue item lookup failed", "err", err, "user", a.UserID, "item", itemID)
|
||||
http.Error(w, "item lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
if err != nil || item == nil {
|
||||
http.Error(w, "item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package abs
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -74,18 +73,3 @@ func TestContinue_UnknownItem_404(t *testing.T) {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinue_MediaLookupError_500(t *testing.T) {
|
||||
prog := &recordingProgressFake{}
|
||||
media := &stubMediaStore{
|
||||
known: map[string]*models.MediaItem{"book-1": nil},
|
||||
lookupErr: errors.New("media lookup failed"),
|
||||
}
|
||||
h := New(Dependencies{MediaStore: media, ProgressStore: prog})
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/book-1/remove-from-continue-listening",
|
||||
map[string]string{"itemId": "book-1"}, nil, "1", "", h.handleRemoveFromContinueListening)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,18 +151,14 @@ func (h *Handler) handleDeleteItemProgress(w http.ResponseWriter, r *http.Reques
|
||||
// returns the empty progress shape so the client can store offline state
|
||||
// without raising an error.
|
||||
func (h *Handler) handleSetEpisodeProgress(w http.ResponseWriter, r *http.Request) {
|
||||
if a, ok := absAuthFrom(r); !ok || a.UserID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"libraryItemId": chi.URLParam(r, "libraryItemId"),
|
||||
"episodeId": chi.URLParam(r, "episodeId"),
|
||||
"currentTime": 0.0,
|
||||
"duration": 0.0,
|
||||
"isFinished": false,
|
||||
"progress": 0.0,
|
||||
"lastUpdate": 0,
|
||||
"libraryItemId": chi.URLParam(r, "libraryItemId"),
|
||||
"episodeId": chi.URLParam(r, "episodeId"),
|
||||
"currentTime": 0.0,
|
||||
"duration": 0.0,
|
||||
"isFinished": false,
|
||||
"progress": 0.0,
|
||||
"lastUpdate": 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,8 +182,8 @@ func (h *Handler) handleEbookFile(w http.ResponseWriter, _ *http.Request) {
|
||||
// optimistic update succeeds.
|
||||
func (h *Handler) handleEbookStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"libraryItemId": chi.URLParam(r, "id"),
|
||||
"fileId": chi.URLParam(r, "fileid"),
|
||||
"libraryItemId": chi.URLParam(r, "id"),
|
||||
"fileId": chi.URLParam(r, "fileid"),
|
||||
"isSupplementary": false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ type SettingsReader interface {
|
||||
GetString(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// Service owns the Audiobookshelf compatibility adapters. Native audiobook
|
||||
// libraries are normal catalog media_items and do not depend on this service.
|
||||
// Service is the audiobooks feature's top-level orchestrator. Sub-plan 1
|
||||
// exposes only Enabled(); subsequent sub-plans hang additional methods
|
||||
// off Service as new capabilities (scanner branches, ABS handlers, etc.)
|
||||
// come online.
|
||||
type Service struct {
|
||||
settings SettingsReader
|
||||
ABSHandler *abs.Handler
|
||||
@@ -179,16 +181,17 @@ func buildABSRecommender(deps ABSHandlerDeps) abs.Recommender {
|
||||
return &ABSRecommender{Pool: deps.Pool, Recs: deps.Recs}
|
||||
}
|
||||
|
||||
// ABSCompatEnabled reports whether the Audiobookshelf compatibility listener
|
||||
// should be started. Native audiobook libraries intentionally do not read this
|
||||
// setting.
|
||||
func (s *Service) ABSCompatEnabled(ctx context.Context) (bool, error) {
|
||||
// Enabled reports whether the audiobooks feature flag (set by
|
||||
// 160_audiobooks_feature_flag and toggled by operators) is currently true.
|
||||
// Any value other than the literal string "true" reads as false; this matches how silo
|
||||
// treats other boolean server_settings rows.
|
||||
func (s *Service) Enabled(ctx context.Context) (bool, error) {
|
||||
if s == nil || s.settings == nil {
|
||||
return false, nil
|
||||
}
|
||||
value, err := s.settings.GetString(ctx, "audiobookshelf_compat.enabled")
|
||||
value, err := s.settings.GetString(ctx, "audiobooks.enabled")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read audiobookshelf_compat.enabled: %w", err)
|
||||
return false, fmt.Errorf("read audiobooks.enabled: %w", err)
|
||||
}
|
||||
return value == "true", nil
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@ type fakeSettingsReader struct {
|
||||
}
|
||||
|
||||
func (f *fakeSettingsReader) GetString(_ context.Context, key string) (string, error) {
|
||||
if key != "audiobookshelf_compat.enabled" {
|
||||
if key != "audiobooks.enabled" {
|
||||
return "", errors.New("unexpected key: " + key)
|
||||
}
|
||||
return f.value, f.err
|
||||
}
|
||||
|
||||
func TestServiceABSCompatEnabledReadsFlag(t *testing.T) {
|
||||
func TestServiceEnabledReadsFlag(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stored string
|
||||
@@ -33,44 +33,44 @@ func TestServiceABSCompatEnabledReadsFlag(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := New(&fakeSettingsReader{value: tc.stored})
|
||||
got, err := svc.ABSCompatEnabled(context.Background())
|
||||
got, err := svc.Enabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ABSCompatEnabled returned error: %v", err)
|
||||
t.Fatalf("Enabled returned error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("ABSCompatEnabled = %v, want %v", got, tc.want)
|
||||
t.Fatalf("Enabled = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceABSCompatEnabledPropagatesError(t *testing.T) {
|
||||
func TestServiceEnabledPropagatesError(t *testing.T) {
|
||||
wantErr := errors.New("db down")
|
||||
svc := New(&fakeSettingsReader{err: wantErr})
|
||||
_, err := svc.ABSCompatEnabled(context.Background())
|
||||
_, err := svc.Enabled(context.Background())
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("ABSCompatEnabled error = %v, want %v wrapped", err, wantErr)
|
||||
t.Fatalf("Enabled error = %v, want %v wrapped", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceABSCompatEnabledNilReceiverReturnsFalse(t *testing.T) {
|
||||
func TestServiceEnabledNilReceiverReturnsFalse(t *testing.T) {
|
||||
var svc *Service
|
||||
got, err := svc.ABSCompatEnabled(context.Background())
|
||||
got, err := svc.Enabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ABSCompatEnabled returned error: %v", err)
|
||||
t.Fatalf("Enabled returned error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatal("ABSCompatEnabled = true, want false")
|
||||
t.Fatal("Enabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
|
||||
func TestServiceEnabledNilSettingsReturnsFalse(t *testing.T) {
|
||||
svc := New(nil)
|
||||
got, err := svc.ABSCompatEnabled(context.Background())
|
||||
got, err := svc.Enabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ABSCompatEnabled returned error: %v", err)
|
||||
t.Fatalf("Enabled returned error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatal("ABSCompatEnabled = true, want false")
|
||||
t.Fatal("Enabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,19 @@ import (
|
||||
// enriched with the metadata the Add-source picker needs (plugin id + a
|
||||
// human-friendly display name).
|
||||
type DiscoveredSource struct {
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
// PluginID is the installation's plugin id (e.g. "sonarr"); empty when the
|
||||
// lister cannot supply it.
|
||||
PluginID string
|
||||
CapabilityID string
|
||||
PluginID string
|
||||
// DisplayName is a human-friendly label for the capability (from the
|
||||
// capability's manifest display_name, falling back to plugin/capability ids).
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// ScanSourceLister enumerates every installed scan_source.v1 capability so the
|
||||
// engine can offer them in the Add-source picker.
|
||||
// engine can (a) offer them in the Add-source picker and (b) detect orphaned
|
||||
// source rows whose plugin has been uninstalled.
|
||||
type ScanSourceLister interface {
|
||||
// ListScanSources returns one entry per installed scan_source.v1 capability,
|
||||
// enriched with plugin id + display name.
|
||||
@@ -29,9 +31,17 @@ type ScanSourceLister interface {
|
||||
// AvailableScanSource is one installed scan_source capability an operator can
|
||||
// create a source against (the Add-source picker list).
|
||||
type AvailableScanSource struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// installedKey identifies an installed scan_source capability for set
|
||||
// membership tests (orphan detection in PollOnce).
|
||||
type installedKey struct {
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
}
|
||||
|
||||
// ListAvailableScanSources enumerates every installed scan_source capability so
|
||||
@@ -49,10 +59,30 @@ func (s *Service) ListAvailableScanSources(ctx context.Context) ([]AvailableScan
|
||||
out := make([]AvailableScanSource, 0, len(discovered))
|
||||
for _, d := range discovered {
|
||||
out = append(out, AvailableScanSource{
|
||||
PluginID: d.PluginID,
|
||||
CapabilityID: d.CapabilityID,
|
||||
DisplayName: d.DisplayName,
|
||||
InstallationID: d.InstallationID,
|
||||
CapabilityID: d.CapabilityID,
|
||||
PluginID: d.PluginID,
|
||||
DisplayName: d.DisplayName,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// installedScanSources returns the set of currently-installed scan_source
|
||||
// capabilities so PollOnce can skip orphaned source rows (their plugin is
|
||||
// gone). A nil set means the set is unavailable (no lister) — callers must treat
|
||||
// that as "discovery unavailable" and NOT prune.
|
||||
func (s *Service) installedScanSources(ctx context.Context) (map[installedKey]struct{}, error) {
|
||||
if s.lister == nil {
|
||||
return nil, nil
|
||||
}
|
||||
discovered, err := s.lister.ListScanSources(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scan sources: %w", err)
|
||||
}
|
||||
present := make(map[installedKey]struct{}, len(discovered))
|
||||
for _, d := range discovered {
|
||||
present[installedKey{InstallationID: d.InstallationID, CapabilityID: d.CapabilityID}] = struct{}{}
|
||||
}
|
||||
return present, nil
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ func (f fakeLister) ListScanSources(context.Context) ([]DiscoveredSource, error)
|
||||
|
||||
func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) {
|
||||
lister := fakeLister{sources: []DiscoveredSource{
|
||||
{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"},
|
||||
{PluginID: "radarr", CapabilityID: "arr-b", DisplayName: "Radarr"},
|
||||
{InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"},
|
||||
{InstallationID: 2, CapabilityID: "arr-b", PluginID: "radarr", DisplayName: "Radarr"},
|
||||
}}
|
||||
svc := &Service{lister: lister}
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) {
|
||||
if len(available) != 2 {
|
||||
t.Fatalf("expected 2 available, got %d: %+v", len(available), available)
|
||||
}
|
||||
if available[0] != (AvailableScanSource{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"}) {
|
||||
if available[0] != (AvailableScanSource{InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"}) {
|
||||
t.Fatalf("unexpected first available: %+v", available[0])
|
||||
}
|
||||
}
|
||||
@@ -41,3 +41,32 @@ func TestListAvailableScanSourcesNilListerEmpty(t *testing.T) {
|
||||
t.Fatalf("nil lister must return empty, got %+v", available)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledScanSourcesSetMembership(t *testing.T) {
|
||||
lister := fakeLister{sources: []DiscoveredSource{
|
||||
{InstallationID: 1, CapabilityID: "arr-a"},
|
||||
}}
|
||||
svc := &Service{lister: lister}
|
||||
|
||||
present, err := svc.installedScanSources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("installedScanSources: %v", err)
|
||||
}
|
||||
if _, ok := present[installedKey{1, "arr-a"}]; !ok {
|
||||
t.Fatalf("expected 1/arr-a present: %+v", present)
|
||||
}
|
||||
if _, ok := present[installedKey{2, "arr-b"}]; ok {
|
||||
t.Fatalf("did not expect 2/arr-b present: %+v", present)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledScanSourcesNilListerNilSet(t *testing.T) {
|
||||
svc := &Service{lister: nil}
|
||||
present, err := svc.installedScanSources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("installedScanSources: %v", err)
|
||||
}
|
||||
if present != nil {
|
||||
t.Fatalf("nil lister must return a nil set, got %+v", present)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,3 @@ import "errors"
|
||||
// ErrNotFound is returned when an autoscan operation references a connection or
|
||||
// source that does not exist. The admin API surfaces it as a 404.
|
||||
var ErrNotFound = errors.New("autoscan: not found")
|
||||
|
||||
// ErrPollAlreadyRunning is returned when a source already has an active poll
|
||||
// event. Callers should treat it as a no-op skip, not a source failure.
|
||||
var ErrPollAlreadyRunning = errors.New("autoscan: source poll already running")
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestTestConnectionNoProbeConfigured(t *testing.T) {
|
||||
func TestSuggestRewritesMatchesArrRootsToSiloFolders(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
sources: []Source{
|
||||
{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1")},
|
||||
{ID: "src-1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1")},
|
||||
},
|
||||
}
|
||||
arr := &fakeArrClient{roots: []string{"/data/tv"}}
|
||||
@@ -87,7 +87,7 @@ func TestSuggestRewritesMatchesArrRootsToSiloFolders(t *testing.T) {
|
||||
|
||||
func TestSuggestRewritesNoConnection(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
sources: []Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}},
|
||||
sources: []Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}},
|
||||
}
|
||||
svc := &Service{store: store, connres: passthroughConnRes{}, rootFolders: &fakeArrClient{}, folders: fakeFolderLister{}}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// ScanSourceProvider yields changed paths for one source. The engine calls
|
||||
// PollChanges; production wraps the plugins.Service scan_source resolver.
|
||||
type ScanSourceProvider interface {
|
||||
PollChanges(ctx context.Context, pluginID, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) (changes []Change, nextMarker string, err error)
|
||||
PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) (changes []Change, nextMarker string, err error)
|
||||
}
|
||||
|
||||
// PollChangesClient is the slice of *pluginhost.ScanSourceClient used here. It
|
||||
@@ -20,10 +20,10 @@ type PollChangesClient interface {
|
||||
PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error)
|
||||
}
|
||||
|
||||
// ScanSourceResolver yields a per-(plugin, capability) scan-source client.
|
||||
// ScanSourceResolver yields a per-(installation, capability) scan-source client.
|
||||
// Exported for the same cross-package adapter reason as PollChangesClient.
|
||||
type ScanSourceResolver interface {
|
||||
ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (PollChangesClient, error)
|
||||
ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (PollChangesClient, error)
|
||||
}
|
||||
|
||||
type pluginProvider struct{ resolver ScanSourceResolver }
|
||||
@@ -34,8 +34,8 @@ func NewPluginProvider(resolver ScanSourceResolver) ScanSourceProvider {
|
||||
return &pluginProvider{resolver: resolver}
|
||||
}
|
||||
|
||||
func (p *pluginProvider) PollChanges(ctx context.Context, pluginID, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
client, err := p.resolver.ScanSourceClient(ctx, pluginID, capabilityID)
|
||||
func (p *pluginProvider) PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
client, err := p.resolver.ScanSourceClient(ctx, installationID, capabilityID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -17,17 +17,10 @@ func (c *capturingClient) PollChanges(_ context.Context, req *pluginv1.PollChang
|
||||
return &pluginv1.PollChangesResponse{SourcePaths: []string{"/mnt/media/x"}, NextMarker: "m2"}, nil
|
||||
}
|
||||
|
||||
// capturingResolver yields a fixed PollChangesClient and records the requested
|
||||
// stable scan-source identity.
|
||||
type capturingResolver struct {
|
||||
client PollChangesClient
|
||||
lastPluginID string
|
||||
lastCapID string
|
||||
}
|
||||
// capturingResolver yields a fixed PollChangesClient.
|
||||
type capturingResolver struct{ client PollChangesClient }
|
||||
|
||||
func (r *capturingResolver) ScanSourceClient(_ context.Context, pluginID, capabilityID string) (PollChangesClient, error) {
|
||||
r.lastPluginID = pluginID
|
||||
r.lastCapID = capabilityID
|
||||
func (r capturingResolver) ScanSourceClient(context.Context, int, string) (PollChangesClient, error) {
|
||||
return r.client, nil
|
||||
}
|
||||
|
||||
@@ -35,12 +28,11 @@ func (r *capturingResolver) ScanSourceClient(_ context.Context, pluginID, capabi
|
||||
// delivered to the plugin on the PollChangesRequest.
|
||||
func TestPluginProviderPopulatesConnection(t *testing.T) {
|
||||
client := &capturingClient{}
|
||||
resolver := &capturingResolver{client: client}
|
||||
prov := NewPluginProvider(resolver)
|
||||
prov := NewPluginProvider(capturingResolver{client})
|
||||
|
||||
conn := ResolvedConnection{BaseURL: "https://arr.example", APIKey: "secret-key"}
|
||||
sourceConfig := map[string]string{"exclusions": ".downloads"}
|
||||
changes, next, err := prov.PollChanges(context.Background(), "silo.autoscan.arr", "cap", "m1", conn, sourceConfig)
|
||||
changes, next, err := prov.PollChanges(context.Background(), 1, "cap", "m1", conn, sourceConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("PollChanges: %v", err)
|
||||
}
|
||||
@@ -53,9 +45,6 @@ func TestPluginProviderPopulatesConnection(t *testing.T) {
|
||||
if client.last.GetCapabilityId() != "cap" || client.last.GetMarker() != "m1" {
|
||||
t.Fatalf("unexpected request fields: cap=%q marker=%q", client.last.GetCapabilityId(), client.last.GetMarker())
|
||||
}
|
||||
if resolver.lastPluginID != "silo.autoscan.arr" || resolver.lastCapID != "cap" {
|
||||
t.Fatalf("resolver identity = %q/%q", resolver.lastPluginID, resolver.lastCapID)
|
||||
}
|
||||
rc := client.last.GetConnection()
|
||||
if rc == nil {
|
||||
t.Fatal("expected connection to be populated on the request")
|
||||
@@ -70,9 +59,9 @@ func TestPluginProviderPopulatesConnection(t *testing.T) {
|
||||
|
||||
func TestPluginProviderPrefersStructuredChanges(t *testing.T) {
|
||||
client := &capturingStructuredClient{}
|
||||
prov := NewPluginProvider(&capturingResolver{client: client})
|
||||
prov := NewPluginProvider(capturingResolver{client: client})
|
||||
|
||||
changes, next, err := prov.PollChanges(context.Background(), "silo.autoscan.arr", "cap", "m1", ResolvedConnection{}, nil)
|
||||
changes, next, err := prov.PollChanges(context.Background(), 1, "cap", "m1", ResolvedConnection{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PollChanges: %v", err)
|
||||
}
|
||||
|
||||
+26
-124
@@ -175,14 +175,14 @@ func (r *Repository) GetConnection(ctx context.Context, id string) (Connection,
|
||||
|
||||
// --- Sources ---
|
||||
|
||||
const sourceColumns = `id, plugin_id, capability_id, connection_id, enabled,
|
||||
const sourceColumns = `id, installation_id, capability_id, connection_id, enabled,
|
||||
poll_interval_seconds, path_rewrites, source_config, label, marker, last_run_at, last_error`
|
||||
|
||||
func scanSource(row interface{ Scan(...any) error }) (Source, error) {
|
||||
var s Source
|
||||
var pathRewrites []byte
|
||||
var sourceConfig []byte
|
||||
if err := row.Scan(&s.ID, &s.PluginID, &s.CapabilityID, &s.ConnectionID,
|
||||
if err := row.Scan(&s.ID, &s.InstallationID, &s.CapabilityID, &s.ConnectionID,
|
||||
&s.Enabled, &s.PollIntervalSeconds, &pathRewrites, &sourceConfig, &s.Label, &s.Marker, &s.LastRunAt, &s.LastError); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
@@ -283,11 +283,11 @@ func (r *Repository) CreateSource(ctx context.Context, s Source) (Source, error)
|
||||
}
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO autoscan_sources (
|
||||
plugin_id, capability_id, connection_id, enabled, poll_interval_seconds, path_rewrites, source_config, label
|
||||
installation_id, capability_id, connection_id, enabled, poll_interval_seconds, path_rewrites, source_config, label
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING `+sourceColumns,
|
||||
s.PluginID, s.CapabilityID, connectionIDArg(s.ConnectionID), s.Enabled, s.PollIntervalSeconds, rewrites, sourceConfig, s.Label)
|
||||
s.InstallationID, s.CapabilityID, connectionIDArg(s.ConnectionID), s.Enabled, s.PollIntervalSeconds, rewrites, sourceConfig, s.Label)
|
||||
out, err := scanSource(row)
|
||||
if err != nil {
|
||||
if connID, ok := connectionFKViolation(err, s.ConnectionID); ok {
|
||||
@@ -299,7 +299,7 @@ func (r *Repository) CreateSource(ctx context.Context, s Source) (Source, error)
|
||||
}
|
||||
|
||||
// UpdateSource updates a source's binding/scheduling fields by id. Identity
|
||||
// (plugin_id, capability_id) and bookkeeping fields (marker/last_run_at/
|
||||
// (installation_id, capability_id) and bookkeeping fields (marker/last_run_at/
|
||||
// last_error) are left untouched. An unknown id maps to ErrNotFound; a
|
||||
// non-existent connection trips the FK constraint and also maps to ErrNotFound.
|
||||
func (r *Repository) UpdateSource(ctx context.Context, s Source) (Source, error) {
|
||||
@@ -353,7 +353,7 @@ func connectionFKViolation(err error, connectionID *string) (string, bool) {
|
||||
|
||||
func (r *Repository) ListSources(ctx context.Context) ([]Source, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+sourceColumns+`
|
||||
FROM autoscan_sources ORDER BY plugin_id, capability_id`)
|
||||
FROM autoscan_sources ORDER BY installation_id, capability_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list autoscan sources: %w", err)
|
||||
}
|
||||
@@ -364,7 +364,7 @@ func (r *Repository) ListSources(ctx context.Context) ([]Source, error) {
|
||||
func (r *Repository) ListEnabledSources(ctx context.Context) ([]Source, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+sourceColumns+`
|
||||
FROM autoscan_sources WHERE enabled = true
|
||||
ORDER BY plugin_id, capability_id`)
|
||||
ORDER BY installation_id, capability_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled autoscan sources: %w", err)
|
||||
}
|
||||
@@ -462,7 +462,7 @@ func (r *Repository) RecordError(ctx context.Context, sourceID, msg string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
const eventColumns = `id, source_id, plugin_id, capability_id, started_at, completed_at,
|
||||
const eventColumns = `id, source_id, installation_id, capability_id, started_at, completed_at,
|
||||
duration_ms, status, changes_returned, changes_resolved, targets_claimed, scans_created,
|
||||
scans_reused, scans_suppressed, error_message, marker_before, marker_after`
|
||||
|
||||
@@ -472,7 +472,7 @@ func scanEvent(row interface{ Scan(...any) error }) (Event, error) {
|
||||
if err := row.Scan(
|
||||
&e.ID,
|
||||
&e.SourceID,
|
||||
&e.PluginID,
|
||||
&e.InstallationID,
|
||||
&e.CapabilityID,
|
||||
&e.StartedAt,
|
||||
&e.CompletedAt,
|
||||
@@ -499,80 +499,25 @@ func (r *Repository) CreateEvent(ctx context.Context, in EventCreate) (int64, er
|
||||
if started.IsZero() {
|
||||
started = time.Now()
|
||||
}
|
||||
sourceID := strings.TrimSpace(in.SourceID)
|
||||
if sourceID == "" {
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO autoscan_events (
|
||||
source_id, plugin_id, capability_id, started_at, completed_at,
|
||||
duration_ms, status, error_message, marker_before
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $4, 0, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
nil,
|
||||
in.PluginID,
|
||||
in.CapabilityID,
|
||||
started,
|
||||
string(EventStatusRunning),
|
||||
"",
|
||||
nullable(in.MarkerBefore),
|
||||
)
|
||||
var id int64
|
||||
if err := row.Scan(&id); err != nil {
|
||||
return 0, fmt.Errorf("create autoscan event: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin autoscan event: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(900173, hashtext($1))`, sourceID); err != nil {
|
||||
return 0, fmt.Errorf("lock autoscan event: %w", err)
|
||||
}
|
||||
|
||||
var runningID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id
|
||||
FROM autoscan_events
|
||||
WHERE source_id = $1
|
||||
AND status = $2
|
||||
ORDER BY started_at ASC, id ASC
|
||||
LIMIT 1`,
|
||||
sourceID,
|
||||
string(EventStatusRunning),
|
||||
).Scan(&runningID)
|
||||
if err == nil {
|
||||
return 0, fmt.Errorf("%w: source %s event %d", ErrPollAlreadyRunning, sourceID, runningID)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, fmt.Errorf("check running autoscan event: %w", err)
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO autoscan_events (
|
||||
source_id, plugin_id, capability_id, started_at, completed_at,
|
||||
source_id, installation_id, capability_id, started_at, completed_at,
|
||||
duration_ms, status, error_message, marker_before
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $4, 0, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
sourceID,
|
||||
in.PluginID,
|
||||
nullable(in.SourceID),
|
||||
in.InstallationID,
|
||||
in.CapabilityID,
|
||||
started,
|
||||
string(EventStatusRunning),
|
||||
"",
|
||||
string(EventStatusError),
|
||||
"poll started but did not finish",
|
||||
nullable(in.MarkerBefore),
|
||||
)
|
||||
var id int64
|
||||
if err := row.Scan(&id); err != nil {
|
||||
return 0, fmt.Errorf("create autoscan event: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, fmt.Errorf("commit autoscan event: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -620,25 +565,6 @@ func (r *Repository) FinishEvent(ctx context.Context, in EventFinish) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkInterruptedEvents(ctx context.Context) error {
|
||||
msg := "poll started but did not finish"
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE autoscan_events
|
||||
SET completed_at = now(),
|
||||
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint,
|
||||
status = $1,
|
||||
error_message = $2
|
||||
WHERE status = $3`,
|
||||
string(EventStatusError),
|
||||
msg,
|
||||
string(EventStatusRunning),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark interrupted autoscan events: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clampAutoscanLimit bounds a requested page size to a sane window: the
|
||||
// default page when unset, capped so a single query can never fan out.
|
||||
func clampAutoscanLimit(limit int) int {
|
||||
@@ -832,30 +758,6 @@ func (r *Repository) ListEvents(ctx context.Context, filter EventListFilter) ([]
|
||||
return events, runRows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) ListRunningEvents(ctx context.Context) ([]Event, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+eventColumns+`
|
||||
FROM autoscan_events
|
||||
WHERE status = $1
|
||||
ORDER BY started_at ASC, id ASC`,
|
||||
string(EventStatusRunning),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list running autoscan events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]Event, 0)
|
||||
for rows.Next() {
|
||||
event, scanErr := scanEvent(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilter) ([]ScanWithEvent, error) {
|
||||
limit := clampAutoscanLimit(filter.Limit)
|
||||
offset := filter.Offset
|
||||
@@ -878,15 +780,15 @@ func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilte
|
||||
sr.trigger,
|
||||
sr.status,
|
||||
COALESCE(sr.error_message, ''),
|
||||
sr.requested_at,
|
||||
sr.started_at,
|
||||
sr.completed_at,
|
||||
sr.autoscan_event_id,
|
||||
e.source_id,
|
||||
COALESCE(e.plugin_id, ''),
|
||||
COALESCE(e.capability_id, ''),
|
||||
COALESCE(e.status, ''),
|
||||
e.completed_at
|
||||
sr.requested_at,
|
||||
sr.started_at,
|
||||
sr.completed_at,
|
||||
sr.autoscan_event_id,
|
||||
e.source_id,
|
||||
e.installation_id,
|
||||
COALESCE(e.capability_id, ''),
|
||||
COALESCE(e.status, ''),
|
||||
e.completed_at
|
||||
FROM scan_runs sr
|
||||
LEFT JOIN autoscan_events e ON e.id = sr.autoscan_event_id
|
||||
WHERE `+strings.Join(clauses, " AND ")+`
|
||||
@@ -916,7 +818,7 @@ func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilte
|
||||
&scan.CompletedAt,
|
||||
&scan.AutoscanEventID,
|
||||
&scan.SourceID,
|
||||
&scan.PluginID,
|
||||
&scan.InstallationID,
|
||||
&scan.CapabilityID,
|
||||
&eventStatus,
|
||||
&scan.EventCompletedAt,
|
||||
@@ -951,7 +853,7 @@ func (r *Repository) GetQueueSummary(ctx context.Context) (QueueSummary, error)
|
||||
|
||||
func (r *Repository) LatestEventAt(ctx context.Context) (*time.Time, error) {
|
||||
var latest *time.Time
|
||||
err := r.pool.QueryRow(ctx, `SELECT max(completed_at) FROM autoscan_events WHERE status <> $1`, string(EventStatusRunning)).Scan(&latest)
|
||||
err := r.pool.QueryRow(ctx, `SELECT max(completed_at) FROM autoscan_events`).Scan(&latest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get latest autoscan event time: %w", err)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,8 @@ func (s *Service) SetSuggesterDeps(rootFolders RootFolderClient, folders FolderL
|
||||
// from scan_source plugins; connres resolves a source's connection to concrete
|
||||
// credentials; resolver/queue/suppress drive the resolve→suppress→enqueue loop.
|
||||
// lister enumerates installed scan_source capabilities so the Add-source picker
|
||||
// can offer them; it may be nil (the picker then returns an empty list).
|
||||
// can offer them and PollOnce can skip orphaned source rows; it may be nil
|
||||
// (the picker then returns an empty list and orphan-pruning is disabled).
|
||||
func NewService(
|
||||
store Store,
|
||||
provider ScanSourceProvider,
|
||||
@@ -112,15 +113,24 @@ func NewService(
|
||||
}
|
||||
}
|
||||
|
||||
// PollOnce runs one autoscan cycle. Per-source failures are logged, recorded on
|
||||
// the source/event, and the loop continues; only settings/listing errors
|
||||
// propagate. The opaque next marker returned by the
|
||||
// PollOnce runs one autoscan cycle. Per-source failures are logged and skipped;
|
||||
// only settings/listing errors propagate. The opaque next marker returned by the
|
||||
// provider is stored verbatim, but only when the cycle's work is genuinely
|
||||
// consumed: the provider returned no paths, or it returned paths and at least one
|
||||
// resolved+enqueued. When paths come back but NONE resolve to a library folder
|
||||
// (e.g. a freshly-enabled source with unconfigured rewrites) the marker is held
|
||||
// and an error recorded, so those imports aren't skipped forever.
|
||||
func (s *Service) PollOnce(ctx context.Context) error {
|
||||
// Fetch the set of currently-installed scan_source capabilities so we can skip
|
||||
// orphaned source rows (their plugin was uninstalled). Listing failures are
|
||||
// non-fatal: a nil set means the installed set is unavailable, in which case
|
||||
// we do NOT prune orphans — every enabled source is assumed present.
|
||||
present, derr := s.installedScanSources(ctx)
|
||||
if derr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: list installed scan sources failed", "err", derr)
|
||||
present = nil
|
||||
}
|
||||
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -136,6 +146,16 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
now := time.Now()
|
||||
|
||||
for _, src := range sources {
|
||||
// Skip orphaned sources: an enabled source whose scan_source plugin has
|
||||
// been uninstalled/disabled is no longer in the discovered set, so polling
|
||||
// it would error every cycle. Skip it quietly (no RecordError) so the spam
|
||||
// stops; the operator can delete it via the source delete endpoint. A nil
|
||||
// `present` set means discovery is unavailable — don't prune in that case.
|
||||
if present != nil {
|
||||
if _, ok := present[installedKey{InstallationID: src.InstallationID, CapabilityID: src.CapabilityID}]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Honor the per-source poll interval as a "poll at most every N seconds"
|
||||
// floor: the global task fires at the default cadence, so a source with a
|
||||
// longer interval is skipped until enough time has elapsed.
|
||||
@@ -150,10 +170,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
if src.Marker != nil {
|
||||
marker = *src.Marker
|
||||
}
|
||||
eventID, started := s.createEvent(ctx, src, marker, time.Now())
|
||||
if !started {
|
||||
continue
|
||||
}
|
||||
eventID := s.createEvent(ctx, src, marker, time.Now())
|
||||
// A connection is OPTIONAL. Server-based providers (Sonarr/Radarr) bind a
|
||||
// connection and the resolved {base_url, api_key} is handed to the plugin.
|
||||
// Other providers (e.g. a filesystem/CephFS watcher) need none and get an
|
||||
@@ -178,7 +195,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
}
|
||||
conn = resolved
|
||||
}
|
||||
changes, next, perr := s.provider.PollChanges(ctx, src.PluginID, src.CapabilityID, marker, conn, src.SourceConfig)
|
||||
changes, next, perr := s.provider.PollChanges(ctx, src.InstallationID, src.CapabilityID, marker, conn, src.SourceConfig)
|
||||
if perr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: poll changes failed", "source_id", src.ID, "err", perr)
|
||||
if rerr := s.store.RecordError(ctx, src.ID, perr.Error()); rerr != nil {
|
||||
@@ -278,26 +295,22 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) createEvent(ctx context.Context, src Source, marker string, startedAt time.Time) (int64, bool) {
|
||||
func (s *Service) createEvent(ctx context.Context, src Source, marker string, startedAt time.Time) int64 {
|
||||
if s == nil || s.store == nil {
|
||||
return 0, true
|
||||
return 0
|
||||
}
|
||||
id, err := s.store.CreateEvent(ctx, EventCreate{
|
||||
SourceID: src.ID,
|
||||
PluginID: src.PluginID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
StartedAt: startedAt,
|
||||
MarkerBefore: marker,
|
||||
SourceID: src.ID,
|
||||
InstallationID: src.InstallationID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
StartedAt: startedAt,
|
||||
MarkerBefore: marker,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPollAlreadyRunning) {
|
||||
slog.DebugContext(ctx, "autoscan: source poll already running", "source_id", src.ID)
|
||||
return 0, false
|
||||
}
|
||||
slog.WarnContext(ctx, "autoscan: create event failed", "source_id", src.ID, "err", err)
|
||||
return 0, true
|
||||
return 0
|
||||
}
|
||||
return id, true
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *Service) finishEvent(ctx context.Context, eventID int64, finish EventFinish) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package autoscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -13,14 +12,13 @@ import (
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
settings Settings
|
||||
sources []Source
|
||||
connection Connection
|
||||
advanced map[string]string // source ID -> marker
|
||||
recorded map[string]string // source ID -> error message
|
||||
createdEvents []EventCreate
|
||||
events []EventFinish
|
||||
createEventErr error
|
||||
settings Settings
|
||||
sources []Source
|
||||
connection Connection
|
||||
advanced map[string]string // source ID -> marker
|
||||
recorded map[string]string // source ID -> error message
|
||||
createdEvents []EventCreate
|
||||
events []EventFinish
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetSettings(context.Context) (Settings, error) { return f.settings, nil }
|
||||
@@ -53,9 +51,6 @@ func (f *fakeStore) RecordError(_ context.Context, sourceID, msg string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) CreateEvent(_ context.Context, event EventCreate) (int64, error) {
|
||||
if f.createEventErr != nil {
|
||||
return 0, f.createEventErr
|
||||
}
|
||||
f.createdEvents = append(f.createdEvents, event)
|
||||
return int64(len(f.createdEvents)), nil
|
||||
}
|
||||
@@ -70,20 +65,14 @@ type fakeProvider struct {
|
||||
changes map[string][]Change // key: capabilityID, structured changes
|
||||
nextMarker string
|
||||
err error
|
||||
errByCap map[string]error
|
||||
lastConfig map[string]string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeProvider) PollChanges(_ context.Context, _ string, capabilityID, _ string, _ ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
f.calls++
|
||||
func (f *fakeProvider) PollChanges(_ context.Context, _ int, capabilityID, _ string, _ ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
f.lastConfig = sourceConfig
|
||||
if f.err != nil {
|
||||
return nil, "", f.err
|
||||
}
|
||||
if err := f.errByCap[capabilityID]; err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if changes, ok := f.changes[capabilityID]; ok {
|
||||
return changes, f.nextMarker, nil
|
||||
}
|
||||
@@ -98,7 +87,7 @@ func TestPollOncePassesSourceConfigToProvider(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
|
||||
SourceConfig: map[string]string{"exclusions": ".downloads\n.recyclebin"},
|
||||
}},
|
||||
}
|
||||
@@ -112,38 +101,6 @@ func TestPollOncePassesSourceConfigToProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollOnceSkipsSourceWhenPollAlreadyRunning(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
createEventErr: ErrPollAlreadyRunning,
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"cephfs": {"/mnt/media/Movie/movie.mkv"}}, nextMarker: "m1"}
|
||||
q := &recordingQueuer{}
|
||||
svc := newService(store, prov, q, allowSuppressor{})
|
||||
|
||||
if err := svc.PollOnce(context.Background()); err != nil {
|
||||
t.Fatalf("PollOnce: %v", err)
|
||||
}
|
||||
if prov.calls != 0 {
|
||||
t.Fatalf("provider calls = %d, want 0", prov.calls)
|
||||
}
|
||||
if len(q.enqueued) != 0 {
|
||||
t.Fatalf("enqueued scans = %+v, want none", q.enqueued)
|
||||
}
|
||||
if len(store.events) != 0 {
|
||||
t.Fatalf("finished events = %+v, want none", store.events)
|
||||
}
|
||||
if len(store.recorded) != 0 {
|
||||
t.Fatalf("recorded source errors = %+v, want none", store.recorded)
|
||||
}
|
||||
if len(store.advanced) != 0 {
|
||||
t.Fatalf("advanced markers = %+v, want none", store.advanced)
|
||||
}
|
||||
}
|
||||
|
||||
// passthroughConnRes resolves to empty credentials; the engine doesn't inspect
|
||||
// them in tests (the fake provider ignores conn).
|
||||
type passthroughConnRes struct{}
|
||||
@@ -253,7 +210,7 @@ func TestPollOnceEnqueuesDedupedFolders(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -284,7 +241,7 @@ func TestPollOnceRecordsSuccessfulEvent(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, Marker: &marker,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, Marker: &marker,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -326,7 +283,7 @@ func TestPollOnceRecordsReusedScanCounts(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -361,7 +318,7 @@ func TestPollOnceAppliesSourceRewritesBeforeEnqueue(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}},
|
||||
}},
|
||||
}
|
||||
@@ -390,7 +347,7 @@ func TestPollOnceStructuredFileChangeEnqueuesExactFile(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/ceph/tv", To: "/mnt/media/tv"}},
|
||||
}},
|
||||
}
|
||||
@@ -423,7 +380,7 @@ func TestPollOnceStructuredSubtreeChangeEnqueuesExactSubtree(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/ceph/movies", To: "/mnt/media/movies"}},
|
||||
}},
|
||||
}
|
||||
@@ -459,7 +416,7 @@ func TestPollOnceScansDistinctPathsUnderSameFolder(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -489,7 +446,7 @@ func TestPollOnceStoresOpaqueMarkerVerbatim(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
const opaque = "eyJjdXJzb3IiOiJhYmMxMjMifQ==|2026-06-02T14:10:00Z"
|
||||
@@ -513,7 +470,7 @@ func TestPollOnceHoldsMarkerWhenPathsReturnedButNoneResolve(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -566,7 +523,7 @@ func TestPollOnceAdvancesMarkerWhenResolvedButAllSuppressed(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
// rewrite /data/tv -> /mnt/media/tv so fakeResolver resolves the paths.
|
||||
PathRewrites: []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}},
|
||||
}},
|
||||
@@ -599,7 +556,7 @@ func TestPollOnceAdvancesMarkerWhenZeroPathsReturned(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"arr": {}}, nextMarker: "m1"}
|
||||
@@ -631,7 +588,7 @@ func TestPollOnceDisabledNoop(t *testing.T) {
|
||||
func TestPollOnceProviderErrorKeepsMarker(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
sources: []Source{{ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
}
|
||||
prov := &fakeProvider{err: context.DeadlineExceeded}
|
||||
q := &recordingQueuer{}
|
||||
@@ -650,7 +607,7 @@ func TestPollOnceProviderErrorKeepsMarker(t *testing.T) {
|
||||
func TestPollOnceReleasesClaimOnEnqueueFailure(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
sources: []Source{{ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"}
|
||||
sup := &recordingSuppressor{}
|
||||
@@ -683,7 +640,7 @@ func TestPollOncePollsConnectionlessSource(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil, Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil, Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"}
|
||||
@@ -709,7 +666,7 @@ func TestPollOnceSkipsSourcePolledTooRecently(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
PollIntervalSeconds: &interval, LastRunAt: &recent,
|
||||
}},
|
||||
}
|
||||
@@ -733,7 +690,7 @@ func TestPollOnceRunsSourcePastItsInterval(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
PollIntervalSeconds: &interval, LastRunAt: &old,
|
||||
}},
|
||||
}
|
||||
@@ -751,23 +708,25 @@ func TestPollOnceRunsSourcePastItsInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollOnceRecordsScanSourceResolutionFailure(t *testing.T) {
|
||||
// Two enabled sources: one polls normally, while the other simulates the
|
||||
// production resolver failing because the plugin cannot be resolved.
|
||||
func TestPollOnceSkipsOrphanedSourceQuietly(t *testing.T) {
|
||||
// Two enabled sources: only "arr-live" is still discovered; "arr-gone"'s
|
||||
// plugin has been uninstalled. The orphan must be skipped quietly — no poll,
|
||||
// no RecordError spam — while the live source still polls normally.
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{
|
||||
{ID: "live", PluginID: "silo.autoscan.live", CapabilityID: "arr-live", ConnectionID: strptr("c1"), Enabled: true},
|
||||
{ID: "gone", PluginID: "silo.autoscan.gone", CapabilityID: "arr-gone", ConnectionID: strptr("c1"), Enabled: true},
|
||||
{ID: "live", InstallationID: 1, CapabilityID: "arr-live", ConnectionID: strptr("c1"), Enabled: true},
|
||||
{ID: "gone", InstallationID: 2, CapabilityID: "arr-gone", ConnectionID: strptr("c1"), Enabled: true},
|
||||
},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
"arr-live": {"/mnt/media/Show/S01/E01.mkv"},
|
||||
}, errByCap: map[string]error{
|
||||
"arr-gone": errors.New("scan source plugin \"silo.autoscan.gone\" is not installed"),
|
||||
"arr-gone": {"/mnt/media/Other/S01/E01.mkv"},
|
||||
}, nextMarker: "m1"}
|
||||
q := &recordingQueuer{}
|
||||
svc := newService(store, prov, q, allowSuppressor{})
|
||||
// Lister reports only the live capability; the orphan is absent.
|
||||
lister := fakeLister{sources: []DiscoveredSource{{InstallationID: 1, CapabilityID: "arr-live"}}}
|
||||
svc := NewService(store, prov, passthroughConnRes{}, fakeResolver{}, q, allowSuppressor{}, lister)
|
||||
|
||||
if err := svc.PollOnce(context.Background()); err != nil {
|
||||
t.Fatalf("PollOnce: %v", err)
|
||||
@@ -779,15 +738,9 @@ func TestPollOnceRecordsScanSourceResolutionFailure(t *testing.T) {
|
||||
t.Fatalf("expected live source marker advanced")
|
||||
}
|
||||
if _, ok := store.advanced["gone"]; ok {
|
||||
t.Fatalf("failed source must NOT advance")
|
||||
t.Fatalf("orphaned source must NOT poll/advance")
|
||||
}
|
||||
if got := store.recorded["gone"]; !strings.Contains(got, "not installed") {
|
||||
t.Fatalf("failed source error = %q", got)
|
||||
}
|
||||
if len(store.events) != 2 {
|
||||
t.Fatalf("expected one event per source, got %+v", store.events)
|
||||
}
|
||||
if store.events[1].Status != EventStatusError || !strings.Contains(store.events[1].ErrorMessage, "not installed") {
|
||||
t.Fatalf("failed source event = %+v", store.events[1])
|
||||
if _, ok := store.recorded["gone"]; ok {
|
||||
t.Fatalf("orphaned source must be skipped quietly, but an error was recorded: %q", store.recorded["gone"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ type Change struct {
|
||||
Scope ChangeScope
|
||||
}
|
||||
|
||||
// Source ties a scan_source plugin capability to a connection plus the
|
||||
// Source ties a scan_source plugin capability instance to a connection plus the
|
||||
// host-owned scheduling/bookkeeping state.
|
||||
type Source struct {
|
||||
ID string
|
||||
PluginID string
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
ConnectionID *string // nil until an operator binds a connection
|
||||
Enabled bool
|
||||
@@ -65,7 +65,6 @@ type Source struct {
|
||||
type EventStatus string
|
||||
|
||||
const (
|
||||
EventStatusRunning EventStatus = "running"
|
||||
EventStatusSuccess EventStatus = "success"
|
||||
EventStatusError EventStatus = "error"
|
||||
EventStatusUnresolved EventStatus = "unresolved"
|
||||
@@ -74,7 +73,7 @@ const (
|
||||
type Event struct {
|
||||
ID int64
|
||||
SourceID *string
|
||||
PluginID string
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
StartedAt time.Time
|
||||
CompletedAt time.Time
|
||||
@@ -92,11 +91,11 @@ type Event struct {
|
||||
}
|
||||
|
||||
type EventCreate struct {
|
||||
SourceID string
|
||||
PluginID string
|
||||
CapabilityID string
|
||||
StartedAt time.Time
|
||||
MarkerBefore string
|
||||
SourceID string
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
StartedAt time.Time
|
||||
MarkerBefore string
|
||||
}
|
||||
|
||||
type EventFinish struct {
|
||||
@@ -155,7 +154,7 @@ type ScanWithEvent struct {
|
||||
ScanRunSummary
|
||||
AutoscanEventID *int64
|
||||
SourceID *string
|
||||
PluginID string
|
||||
InstallationID *int
|
||||
CapabilityID string
|
||||
EventStatus EventStatus
|
||||
EventCompletedAt *time.Time
|
||||
|
||||
@@ -1089,9 +1089,8 @@ func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFi
|
||||
if def.MediaScope != "" &&
|
||||
def.MediaScope != "movie" &&
|
||||
def.MediaScope != "series" &&
|
||||
def.MediaScope != "episode" &&
|
||||
def.MediaScope != "audiobook" {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', or 'audiobook'", ErrInvalidCatalogRequest)
|
||||
def.MediaScope != "episode" {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', or 'episode'", ErrInvalidCatalogRequest)
|
||||
}
|
||||
if def.Match != "" && def.Match != "all" && def.Match != "any" {
|
||||
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
|
||||
|
||||
@@ -154,51 +154,6 @@ type ItemDetail struct {
|
||||
EffectiveVersionHDR *bool `json:"effective_version_hdr,omitempty"`
|
||||
EffectiveVersionCodecVideo *string `json:"effective_version_codec_video,omitempty"`
|
||||
EffectiveVersionEditionKey *string `json:"effective_version_edition_key,omitempty"`
|
||||
|
||||
// Audiobook-specific detail. Present only when Type == "audiobook".
|
||||
Audiobook *AudiobookDetailExtension `json:"audiobook,omitempty"`
|
||||
}
|
||||
|
||||
type AudiobookDetailExtension struct {
|
||||
Authors []AudiobookPerson `json:"authors"`
|
||||
Narrators []AudiobookPerson `json:"narrators"`
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
TotalDurationSeconds int `json:"total_duration_seconds"`
|
||||
Series *AudiobookSeriesGroup `json:"series,omitempty"`
|
||||
OtherNarrations []AudiobookNarration `json:"other_narrations"`
|
||||
Related AudiobookRelatedContent `json:"related"`
|
||||
}
|
||||
|
||||
type AudiobookPerson struct {
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
PhotoURL string `json:"photo_url,omitempty"`
|
||||
PhotoThumbhash string `json:"photo_thumbhash,omitempty"`
|
||||
}
|
||||
|
||||
type AudiobookRelatedContent struct {
|
||||
AlsoByAuthor []AudiobookRelatedItem `json:"also_by_author"`
|
||||
Similar []AudiobookRelatedItem `json:"similar"`
|
||||
}
|
||||
|
||||
type AudiobookRelatedItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
SeriesIndex *int `json:"series_index,omitempty"`
|
||||
}
|
||||
|
||||
type AudiobookSeriesGroup struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Entries []AudiobookRelatedItem `json:"entries"`
|
||||
}
|
||||
|
||||
type AudiobookNarration struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Narrators []string `json:"narrators"`
|
||||
}
|
||||
|
||||
// ItemUserState is per-profile viewer state included in item detail responses.
|
||||
@@ -269,7 +224,6 @@ type FileVersion struct {
|
||||
PresentationKind string `json:"presentation_kind,omitempty"`
|
||||
PresentationGroupKey string `json:"presentation_group_key,omitempty"`
|
||||
PresentationPartIndex int `json:"presentation_part_index,omitempty"`
|
||||
PresentationPartTotal int `json:"presentation_part_total,omitempty"`
|
||||
MultiEpisodeStart int `json:"multi_episode_start,omitempty"`
|
||||
MultiEpisodeEnd int `json:"multi_episode_end,omitempty"`
|
||||
EffectiveAudioTrackIndex *int `json:"effective_audio_track_index,omitempty"`
|
||||
@@ -879,9 +833,6 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
|
||||
}
|
||||
|
||||
files = FilterMediaFilesByAccess(files, filter)
|
||||
if item.Type == "audiobook" {
|
||||
sortAudiobookMediaFiles(files)
|
||||
}
|
||||
files = s.preparePlaybackFiles(ctx, files)
|
||||
detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits, detail.Recap, detail.Preview = s.buildPlaybackInfo(
|
||||
ctx,
|
||||
@@ -892,10 +843,6 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
|
||||
detail.OverlaySummary = overlays.BuildSummary(files)
|
||||
}
|
||||
|
||||
if item.Type == "audiobook" {
|
||||
detail.Audiobook = s.buildAudiobookExtension(ctx, item, detail.Versions, crewCredits, filter)
|
||||
}
|
||||
|
||||
// Series folder paths from confirmed claims when available, otherwise from
|
||||
// the file links that currently belong to the item. This keeps provisional
|
||||
// root-scoped series visible for manual match/admin flows without implying
|
||||
@@ -1005,351 +952,6 @@ func splitCastCrew(credits []PersonCredit) ([]CastCredit, []CrewCredit) {
|
||||
return cast, crew
|
||||
}
|
||||
|
||||
func (s *DetailService) buildAudiobookExtension(
|
||||
ctx context.Context,
|
||||
item *models.MediaItem,
|
||||
versions []FileVersion,
|
||||
crew []CrewCredit,
|
||||
filter AccessFilter,
|
||||
) *AudiobookDetailExtension {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
totalDuration := 0
|
||||
for _, version := range versions {
|
||||
if version.Duration > 0 {
|
||||
totalDuration += version.Duration
|
||||
}
|
||||
}
|
||||
|
||||
return &AudiobookDetailExtension{
|
||||
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
|
||||
Narrators: audiobookPeopleFromCrew(crew, models.PersonKindNarrator.String()),
|
||||
Publisher: firstNonEmptyString(item.Studios),
|
||||
TotalDurationSeconds: totalDuration,
|
||||
Series: s.fetchAudiobookSeries(ctx, item.ContentID, filter),
|
||||
OtherNarrations: s.fetchAudiobookOtherNarrations(ctx, item.ContentID, filter),
|
||||
Related: AudiobookRelatedContent{
|
||||
AlsoByAuthor: s.fetchAudiobookAlsoByAuthor(ctx, item.ContentID, filter),
|
||||
Similar: s.fetchAudiobookSimilarByGenres(ctx, item.ContentID, filter),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func audiobookPeopleFromCrew(crew []CrewCredit, job string) []AudiobookPerson {
|
||||
out := make([]AudiobookPerson, 0)
|
||||
for _, credit := range crew {
|
||||
if credit.Job != job || strings.TrimSpace(credit.Name) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, AudiobookPerson{
|
||||
PersonID: credit.PersonID,
|
||||
Name: credit.Name,
|
||||
PhotoURL: credit.PhotoURL,
|
||||
PhotoThumbhash: credit.PhotoThumbhash,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values []string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func appendAudiobookItemAccessConditions(
|
||||
alias string,
|
||||
filter AccessFilter,
|
||||
conditions *[]string,
|
||||
args *[]any,
|
||||
argIdx *int,
|
||||
) bool {
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
if len(filter.AllowedLibraryIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
*conditions = append(*conditions, fmt.Sprintf(`
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil_allowed
|
||||
WHERE mil_allowed.content_id = %s.content_id
|
||||
AND mil_allowed.media_folder_id = ANY($%d)
|
||||
)`, alias, *argIdx))
|
||||
*args = append(*args, filter.AllowedLibraryIDs)
|
||||
*argIdx = *argIdx + 1
|
||||
}
|
||||
if len(filter.DisabledLibraryIDs) > 0 {
|
||||
if filter.AllowedLibraryIDs == nil {
|
||||
*conditions = append(*conditions, fmt.Sprintf(`
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil_visible
|
||||
WHERE mil_visible.content_id = %s.content_id
|
||||
)`, alias))
|
||||
}
|
||||
*conditions = append(*conditions, fmt.Sprintf(`
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil_disabled
|
||||
WHERE mil_disabled.content_id = %s.content_id
|
||||
AND mil_disabled.media_folder_id = ANY($%d)
|
||||
)`, alias, *argIdx))
|
||||
*args = append(*args, filter.DisabledLibraryIDs)
|
||||
*argIdx = *argIdx + 1
|
||||
}
|
||||
ApplySectionAccessFilter(alias, AccessFilter{MaxContentRating: filter.MaxContentRating}, conditions, args, argIdx)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
args := []any{contentID, models.PersonKindAuthor}
|
||||
argIdx := 3
|
||||
conditions := []string{
|
||||
"ip1.content_id = $1",
|
||||
"ip1.kind = $2",
|
||||
"m2.type = 'audiobook'",
|
||||
}
|
||||
if !appendAudiobookItemAccessConditions("m2", filter, &conditions, &args, &argIdx) {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT m2.content_id, m2.title, COALESCE(m2.year, 0), COALESCE(m2.poster_path, '')
|
||||
FROM item_people ip1
|
||||
JOIN item_people ip2
|
||||
ON ip2.person_id = ip1.person_id
|
||||
AND ip2.kind = ip1.kind
|
||||
AND ip2.content_id <> ip1.content_id
|
||||
JOIN media_items m2 ON m2.content_id = ip2.content_id
|
||||
WHERE %s
|
||||
ORDER BY m2.year DESC NULLS LAST, LOWER(m2.sort_title)
|
||||
LIMIT 12
|
||||
`, strings.Join(conditions, " AND "))
|
||||
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]AudiobookRelatedItem, 0, 12)
|
||||
seen := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var item AudiobookRelatedItem
|
||||
var posterPath string
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
if _, ok := seen[item.ContentID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.ContentID] = struct{}{}
|
||||
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
args := []any{contentID, models.PersonKindAuthor}
|
||||
argIdx := 3
|
||||
conditions := []string{
|
||||
"m.type = 'audiobook'",
|
||||
"m.content_id <> $1",
|
||||
"m.genres && (SELECT array_agg(g) FROM this_genres)",
|
||||
`NOT EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $2
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)`,
|
||||
}
|
||||
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
WITH this_genres AS (
|
||||
SELECT unnest(genres) AS g FROM media_items WHERE content_id = $1
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
||||
)
|
||||
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
|
||||
FROM media_items m
|
||||
WHERE %s
|
||||
ORDER BY
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM unnest(m.genres) AS genre(value)
|
||||
WHERE genre.value IN (SELECT g FROM this_genres)
|
||||
) DESC,
|
||||
COALESCE(m.year, 0) DESC,
|
||||
LOWER(m.sort_title)
|
||||
LIMIT 12
|
||||
`, strings.Join(conditions, " AND "))
|
||||
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]AudiobookRelatedItem, 0, 12)
|
||||
for rows.Next() {
|
||||
var item AudiobookRelatedItem
|
||||
var posterPath string
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return nil
|
||||
}
|
||||
args := []any{contentID}
|
||||
argIdx := 2
|
||||
conditions := []string{
|
||||
"root.content_id = $1",
|
||||
"m.type = 'audiobook'",
|
||||
}
|
||||
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
|
||||
return nil
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(m.year, 0),
|
||||
COALESCE(m.poster_path, ''),
|
||||
s.series_name,
|
||||
s.series_index
|
||||
FROM audiobook_series root
|
||||
JOIN audiobook_series s ON LOWER(s.series_name) = LOWER(root.series_name)
|
||||
JOIN media_items m ON m.content_id = s.content_id
|
||||
WHERE %s
|
||||
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
|
||||
LIMIT 30
|
||||
`, strings.Join(conditions, " AND "))
|
||||
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var seriesName string
|
||||
entries := make([]AudiobookRelatedItem, 0, 16)
|
||||
for rows.Next() {
|
||||
var (
|
||||
item AudiobookRelatedItem
|
||||
poster string
|
||||
name string
|
||||
index *float64
|
||||
)
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &poster, &name, &index); err != nil {
|
||||
return nil
|
||||
}
|
||||
if seriesName == "" {
|
||||
seriesName = name
|
||||
}
|
||||
if index != nil {
|
||||
n := int(*index)
|
||||
if float64(n) == *index {
|
||||
item.SeriesIndex = &n
|
||||
}
|
||||
}
|
||||
item.PosterURL = s.PresignURL(ctx, poster, "featured")
|
||||
entries = append(entries, item)
|
||||
}
|
||||
if len(entries) < 2 {
|
||||
return nil
|
||||
}
|
||||
return &AudiobookSeriesGroup{Name: seriesName, Entries: entries}
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchAudiobookOtherNarrations(ctx context.Context, contentID string, filter AccessFilter) []AudiobookNarration {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return []AudiobookNarration{}
|
||||
}
|
||||
const stripRE = `\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*[Rr]ead [Bb]y [^()]+\)?\s*$`
|
||||
args := []any{stripRE, contentID, models.PersonKindAuthor, models.PersonKindNarrator}
|
||||
argIdx := 5
|
||||
conditions := []string{
|
||||
"m.type = 'audiobook'",
|
||||
"m.content_id <> $2",
|
||||
"trim(regexp_replace(m.title, $1, '', 'g')) = (SELECT norm_title FROM this_book)",
|
||||
`EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $3
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)`,
|
||||
}
|
||||
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
|
||||
return []AudiobookNarration{}
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
WITH this_book AS (
|
||||
SELECT trim(regexp_replace(title, $1, '', 'g')) AS norm_title
|
||||
FROM media_items WHERE content_id = $2
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $2 AND kind = $3
|
||||
)
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(m.year, 0),
|
||||
COALESCE(string_agg(DISTINCT NULLIF(BTRIM(p.name), ''), '|||'), '') AS narrators
|
||||
FROM media_items m
|
||||
LEFT JOIN item_people ipn ON ipn.content_id = m.content_id AND ipn.kind = $4
|
||||
LEFT JOIN people p ON p.id = ipn.person_id
|
||||
WHERE %s
|
||||
GROUP BY m.content_id, m.title, m.year
|
||||
ORDER BY m.year DESC NULLS LAST, m.title
|
||||
LIMIT 8
|
||||
`, strings.Join(conditions, " AND "))
|
||||
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return []AudiobookNarration{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]AudiobookNarration, 0, 4)
|
||||
for rows.Next() {
|
||||
var item AudiobookNarration
|
||||
var narrators string
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &narrators); err != nil {
|
||||
return []AudiobookNarration{}
|
||||
}
|
||||
item.Narrators = splitDelimitedNames(narrators)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitDelimitedNames(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(raw, "|||")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value := strings.TrimSpace(part); value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seriesFolderPathsFromFiles(files []*models.MediaFile) []string {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
@@ -1965,30 +1567,6 @@ func markerFromRange(start, end *float64) *Marker {
|
||||
return &Marker{Start: *start, End: *end}
|
||||
}
|
||||
|
||||
func sortAudiobookMediaFiles(files []*models.MediaFile) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
a, b := files[i], files[j]
|
||||
if a == nil || b == nil {
|
||||
return a != nil
|
||||
}
|
||||
if a.PresentationPartIndex > 0 || b.PresentationPartIndex > 0 {
|
||||
if a.PresentationPartIndex != b.PresentationPartIndex {
|
||||
if a.PresentationPartIndex == 0 {
|
||||
return false
|
||||
}
|
||||
if b.PresentationPartIndex == 0 {
|
||||
return true
|
||||
}
|
||||
return a.PresentationPartIndex < b.PresentationPartIndex
|
||||
}
|
||||
}
|
||||
if a.FilePath != b.FilePath {
|
||||
return strings.Compare(a.FilePath, b.FilePath) < 0
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DetailService) buildPlaybackInfo(
|
||||
ctx context.Context,
|
||||
files []*models.MediaFile,
|
||||
@@ -2044,7 +1622,6 @@ func (s *DetailService) buildPlaybackInfo(
|
||||
PresentationKind: f.PresentationKind,
|
||||
PresentationGroupKey: f.PresentationGroupKey,
|
||||
PresentationPartIndex: f.PresentationPartIndex,
|
||||
PresentationPartTotal: f.PresentationPartTotal,
|
||||
MultiEpisodeStart: f.MultiEpisodeStart,
|
||||
MultiEpisodeEnd: f.MultiEpisodeEnd,
|
||||
EffectiveAudioTrackIndex: intPtr(effectiveAudioSelection.Index),
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestSortAudiobookMediaFilesPreservesPresentationPartOrder(t *testing.T) {
|
||||
files := []*models.MediaFile{
|
||||
{ID: 30, FilePath: "/books/book/03.mp3", PresentationPartIndex: 3},
|
||||
{ID: 10, FilePath: "/books/book/01.mp3", PresentationPartIndex: 1},
|
||||
{ID: 20, FilePath: "/books/book/02.mp3", PresentationPartIndex: 2},
|
||||
}
|
||||
|
||||
sortAudiobookMediaFiles(files)
|
||||
|
||||
got := []int{files[0].ID, files[1].ID, files[2].ID}
|
||||
want := []int{10, 20, 30}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("sorted IDs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortAudiobookMediaFilesFallsBackToPath(t *testing.T) {
|
||||
files := []*models.MediaFile{
|
||||
{ID: 20, FilePath: "/books/book/02.mp3"},
|
||||
{ID: 10, FilePath: "/books/book/01.mp3"},
|
||||
}
|
||||
|
||||
sortAudiobookMediaFiles(files)
|
||||
|
||||
got := []int{files[0].ID, files[1].ID}
|
||||
want := []int{10, 20}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("sorted IDs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package catalog
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -137,15 +136,3 @@ func TestDeleteInBatchesReturnsError(t *testing.T) {
|
||||
t.Fatalf("expected sentinel, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLateOrphanCleanupUsesProvisionalSafetyPredicate(t *testing.T) {
|
||||
conditions := normalizePredicateSQL(orphanedProvisionalMediaItemConditions)
|
||||
for name, query := range map[string]string{
|
||||
"collect": collectOrphanedProvisionalIDsByContentIDSQL,
|
||||
"delete": deleteOrphanedProvisionalIDsByContentIDSQL,
|
||||
} {
|
||||
if !strings.Contains(normalizePredicateSQL(query), conditions) {
|
||||
t.Fatalf("%s late orphan query does not use provisional safety predicate", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-142
@@ -563,6 +563,16 @@ func (r *FolderRepository) DeleteWithStats(
|
||||
}
|
||||
}
|
||||
|
||||
// Filter accumulated image dirs once, now that orphans are gone, against
|
||||
// any surviving content. Empty deleting-set means "exclude nothing".
|
||||
if len(rawDirs) > 0 {
|
||||
filtered, err := filterUnreferencedImageDirs(ctx, r.pool, dirSetToSlice(rawDirs), []string{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.OrphanedImageDirs = filtered
|
||||
}
|
||||
|
||||
// Phase 2: delete this folder's media_files (folder-tied, not item-tied).
|
||||
if progress != nil {
|
||||
progress(orphanTotal, orphanTotal, "Deleting media files")
|
||||
@@ -582,29 +592,22 @@ func (r *FolderRepository) DeleteWithStats(
|
||||
}
|
||||
|
||||
// Phase 3: delete remaining folder memberships (shared items kept; only the
|
||||
// membership in this folder is removed). Deleting the memberships with
|
||||
// RETURNING lets us catch skeleton items that a matcher created after the
|
||||
// phase-1 orphan sweep but before the delete job reached this phase.
|
||||
// membership in this folder is removed).
|
||||
if progress != nil {
|
||||
progress(orphanTotal, orphanTotal, "Removing library memberships")
|
||||
}
|
||||
lateOrphans, lateImageDirs, err := r.deleteFolderMemberships(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deleting media item links: %w", err)
|
||||
}
|
||||
stats.OrphanedItems += lateOrphans
|
||||
for _, d := range lateImageDirs {
|
||||
rawDirs[d] = struct{}{}
|
||||
}
|
||||
|
||||
// Filter accumulated image dirs once, now that all item orphans are gone,
|
||||
// against any surviving content. Empty deleting-set means "exclude nothing".
|
||||
if len(rawDirs) > 0 {
|
||||
filtered, err := filterUnreferencedImageDirs(ctx, r.pool, dirSetToSlice(rawDirs), []string{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, err := deleteInBatches(ctx, folderChildDeleteBatch, func(ctx context.Context) (int64, error) {
|
||||
tag, e := r.pool.Exec(ctx, `
|
||||
DELETE FROM media_item_libraries
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2
|
||||
)`, id, folderChildDeleteBatch)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
stats.OrphanedImageDirs = filtered
|
||||
return tag.RowsAffected(), nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("deleting media item links: %w", err)
|
||||
}
|
||||
|
||||
// Phase 4: delete the now-lightweight folder row. Tolerate 0 rows so a
|
||||
@@ -653,129 +656,6 @@ func (r *FolderRepository) collectOrphanBatch(ctx context.Context, folderID, lim
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// deleteFolderMemberships removes this folder's remaining item memberships and
|
||||
// deletes any content that becomes orphaned as a result. This is intentionally
|
||||
// separate from the phase-1 orphan sweep because in-flight matchers can create
|
||||
// new skeleton memberships after phase 1 has already drained.
|
||||
func (r *FolderRepository) deleteFolderMemberships(ctx context.Context, folderID int) (int, []string, error) {
|
||||
var orphaned int
|
||||
var imageDirs []string
|
||||
for {
|
||||
var contentIDs []string
|
||||
if err := retryOnDeadlock(ctx, func() error {
|
||||
ids, err := r.deleteFolderMembershipBatch(ctx, folderID, folderChildDeleteBatch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentIDs = ids
|
||||
return nil
|
||||
}); err != nil {
|
||||
return orphaned, imageDirs, err
|
||||
}
|
||||
if len(contentIDs) > 0 {
|
||||
deleted, dirs, err := r.deleteOrphanedItemsByContentID(ctx, contentIDs)
|
||||
if err != nil {
|
||||
return orphaned, imageDirs, err
|
||||
}
|
||||
orphaned += deleted
|
||||
imageDirs = append(imageDirs, dirs...)
|
||||
}
|
||||
if len(contentIDs) < folderChildDeleteBatch {
|
||||
return orphaned, imageDirs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FolderRepository) deleteFolderMembershipBatch(ctx context.Context, folderID, limit int) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
DELETE FROM media_item_libraries
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2
|
||||
)
|
||||
RETURNING content_id`, folderID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contentIDs []string
|
||||
for rows.Next() {
|
||||
var contentID string
|
||||
if err := rows.Scan(&contentID); err != nil {
|
||||
return nil, fmt.Errorf("scanning deleted membership content_id: %w", err)
|
||||
}
|
||||
contentIDs = append(contentIDs, contentID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contentIDs, nil
|
||||
}
|
||||
|
||||
const collectOrphanedProvisionalIDsByContentIDSQL = `
|
||||
SELECT mi.content_id
|
||||
FROM public.media_items mi
|
||||
WHERE mi.content_id = ANY($1)
|
||||
AND ` + orphanedMediaItemSafetyConditions
|
||||
|
||||
const deleteOrphanedProvisionalIDsByContentIDSQL = `
|
||||
DELETE FROM public.media_items mi
|
||||
WHERE mi.content_id = ANY($1)
|
||||
AND ` + orphanedMediaItemSafetyConditions
|
||||
|
||||
func (r *FolderRepository) deleteOrphanedItemsByContentID(ctx context.Context, contentIDs []string) (int, []string, error) {
|
||||
if len(contentIDs) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
orphanIDs, err := r.collectOrphanedProvisionalIDsByContentID(ctx, contentIDs)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if len(orphanIDs) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
imageDirs, err := collectImageDirs(ctx, r.pool, orphanIDs)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
var deleted int64
|
||||
if err := retryOnDeadlock(ctx, func() error {
|
||||
tag, err := r.pool.Exec(ctx, deleteOrphanedProvisionalIDsByContentIDSQL, orphanIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted = tag.RowsAffected()
|
||||
return nil
|
||||
}); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return int(deleted), imageDirs, nil
|
||||
}
|
||||
|
||||
func (r *FolderRepository) collectOrphanedProvisionalIDsByContentID(ctx context.Context, contentIDs []string) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, collectOrphanedProvisionalIDsByContentIDSQL, contentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var orphanIDs []string
|
||||
for rows.Next() {
|
||||
var contentID string
|
||||
if err := rows.Scan(&contentID); err != nil {
|
||||
return nil, fmt.Errorf("scanning orphan content_id: %w", err)
|
||||
}
|
||||
orphanIDs = append(orphanIDs, contentID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return orphanIDs, nil
|
||||
}
|
||||
|
||||
// dirSetToSlice returns the keys of set as a slice (nil if empty).
|
||||
func dirSetToSlice(set map[string]struct{}) []string {
|
||||
if len(set) == 0 {
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const defaultOrphanedProvisionalCleanupBatchSize = 1000
|
||||
|
||||
const orphanedMediaItemSafetyConditions = `NOT EXISTS (
|
||||
SELECT 1 FROM public.media_item_libraries mil
|
||||
WHERE mil.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.media_files mf
|
||||
WHERE mf.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.episodes e
|
||||
WHERE e.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.seasons s
|
||||
WHERE s.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.library_collection_items lci
|
||||
WHERE lci.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.abs_bookmarks ab
|
||||
WHERE ab.library_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.abs_playback_sessions aps
|
||||
WHERE aps.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.abs_rss_feeds arf
|
||||
WHERE arf.library_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.downloads d
|
||||
WHERE d.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.playback_history_admin pha
|
||||
WHERE pha.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.plex_sync_item_bindings psib
|
||||
WHERE psib.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.plex_sync_item_state psis
|
||||
WHERE psis.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.podcast_feeds pf
|
||||
WHERE pf.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_favorites uf
|
||||
WHERE uf.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_downloads ud
|
||||
WHERE ud.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_history_hidden_items uhhi
|
||||
WHERE uhhi.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_home_item_dismissals uhid
|
||||
WHERE uhid.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_home_item_dismissals uhid_series
|
||||
WHERE uhid_series.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_audio_preferences uap
|
||||
WHERE uap.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_personal_collection_items upci
|
||||
WHERE upci.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_ratings ur
|
||||
WHERE ur.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_series_playback_preferences uspp
|
||||
WHERE uspp.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_subtitle_preferences usp
|
||||
WHERE usp.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_watch_history uwh
|
||||
WHERE uwh.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_watch_progress uwp
|
||||
WHERE uwp.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_watchlist uwl
|
||||
WHERE uwl.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_provider_favorite_items wpfi
|
||||
WHERE wpfi.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_provider_history_exports wphe
|
||||
WHERE wphe.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_provider_scrobble_sessions wpss
|
||||
WHERE wpss.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_together_rooms wtr
|
||||
WHERE wtr.selected_content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_together_suggestions wts
|
||||
WHERE wts.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.webhook_sync_item_state wsis
|
||||
WHERE wsis.media_item_id = mi.content_id
|
||||
)`
|
||||
|
||||
const orphanedProvisionalMediaItemConditions = `mi.status IN ('pending', 'unmatched', 'ambiguous')
|
||||
AND ` + orphanedMediaItemSafetyConditions
|
||||
|
||||
const orphanedProvisionalMediaItemPredicate = `
|
||||
WHERE ` + orphanedProvisionalMediaItemConditions
|
||||
|
||||
type OrphanedProvisionalCleanupStats struct {
|
||||
Candidates int
|
||||
Deleted int
|
||||
}
|
||||
|
||||
type OrphanedProvisionalCleaner struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewOrphanedProvisionalCleaner(pool *pgxpool.Pool) *OrphanedProvisionalCleaner {
|
||||
return &OrphanedProvisionalCleaner{pool: pool}
|
||||
}
|
||||
|
||||
func (c *OrphanedProvisionalCleaner) Cleanup(ctx context.Context, batchSize int) (OrphanedProvisionalCleanupStats, error) {
|
||||
var stats OrphanedProvisionalCleanupStats
|
||||
if c == nil || c.pool == nil {
|
||||
return stats, fmt.Errorf("orphaned provisional cleanup is not configured")
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = defaultOrphanedProvisionalCleanupBatchSize
|
||||
}
|
||||
|
||||
if err := c.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM public.media_items mi `+orphanedProvisionalMediaItemPredicate,
|
||||
).Scan(&stats.Candidates); err != nil {
|
||||
return stats, fmt.Errorf("counting orphaned provisional media items: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
tag, err := c.pool.Exec(ctx, `
|
||||
WITH candidates AS (
|
||||
SELECT mi.content_id
|
||||
FROM public.media_items mi
|
||||
`+orphanedProvisionalMediaItemPredicate+`
|
||||
ORDER BY mi.content_id ASC
|
||||
LIMIT $1
|
||||
)
|
||||
DELETE FROM public.media_items mi
|
||||
USING candidates c
|
||||
WHERE mi.content_id = c.content_id
|
||||
`, batchSize)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("deleting orphaned provisional media items: %w", err)
|
||||
}
|
||||
|
||||
deleted := int(tag.RowsAffected())
|
||||
stats.Deleted += deleted
|
||||
if deleted < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func normalizePredicateSQL(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
func TestOrphanedProvisionalPredicatePreservesDurableMediaItemReferences(t *testing.T) {
|
||||
predicate := normalizePredicateSQL(orphanedProvisionalMediaItemPredicate)
|
||||
for _, want := range []string{
|
||||
"public.abs_bookmarks ab WHERE ab.library_item_id = mi.content_id",
|
||||
"public.abs_playback_sessions aps WHERE aps.content_id = mi.content_id",
|
||||
"public.abs_rss_feeds arf WHERE arf.library_item_id = mi.content_id",
|
||||
"public.episodes e WHERE e.series_id = mi.content_id",
|
||||
"public.podcast_feeds pf WHERE pf.media_item_id = mi.content_id",
|
||||
"public.seasons s WHERE s.series_id = mi.content_id",
|
||||
"public.user_audio_preferences uap WHERE uap.series_id = mi.content_id",
|
||||
"public.user_home_item_dismissals uhid WHERE uhid.media_item_id = mi.content_id",
|
||||
"public.user_home_item_dismissals uhid_series WHERE uhid_series.series_id = mi.content_id",
|
||||
"public.user_personal_collection_items upci WHERE upci.media_item_id = mi.content_id",
|
||||
"public.user_series_playback_preferences uspp WHERE uspp.series_id = mi.content_id",
|
||||
"public.user_subtitle_preferences usp WHERE usp.series_id = mi.content_id",
|
||||
} {
|
||||
if !strings.Contains(predicate, normalizePredicateSQL(want)) {
|
||||
t.Fatalf("cleanup predicate missing durable reference guard %q", want)
|
||||
}
|
||||
}
|
||||
for _, droppedTable := range []string{
|
||||
"abs_collection_items",
|
||||
"abs_playlist_items",
|
||||
"abs_playlists",
|
||||
} {
|
||||
if strings.Contains(predicate, droppedTable) {
|
||||
t.Fatalf("cleanup predicate must not reference dropped legacy table %q", droppedTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,9 +185,8 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
|
||||
if normalized.MediaScope != "" &&
|
||||
normalized.MediaScope != "movie" &&
|
||||
normalized.MediaScope != "series" &&
|
||||
normalized.MediaScope != "episode" &&
|
||||
normalized.MediaScope != "audiobook" {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', or 'audiobook'")
|
||||
normalized.MediaScope != "episode" {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', or 'episode'")
|
||||
}
|
||||
|
||||
if normalized.Match != "all" && normalized.Match != "any" {
|
||||
|
||||
@@ -60,18 +60,6 @@ func TestValidate_EpisodeMediaScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AudiobookMediaScope(t *testing.T) {
|
||||
qd := QueryDefinition{
|
||||
MediaScope: "audiobook",
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{},
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
}
|
||||
if err := qd.Validate(); err != nil {
|
||||
t.Fatalf("expected audiobook media scope to be valid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWithSortScope_RejectsPersonalizedSortWithoutProfileScope(t *testing.T) {
|
||||
qd := QueryDefinition{
|
||||
Match: "all",
|
||||
|
||||
@@ -32,7 +32,7 @@ func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string
|
||||
runExtract = runFFmpegFrameExtract
|
||||
}
|
||||
|
||||
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
|
||||
resolvedAccel := playback.ResolveHWAccel(opts.HWAccel)
|
||||
resolvedDevice := opts.HWDevice
|
||||
if resolvedDevice == "" && (resolvedAccel == "qsv" || resolvedAccel == "vaapi") {
|
||||
resolvedDevice = playback.PickRenderDevice("")
|
||||
|
||||
@@ -652,7 +652,7 @@ func (s *Service) extractFrameLocal(ctx context.Context, inputPath string, seekS
|
||||
|
||||
func (s *Service) resolveHWConfig() (string, string) {
|
||||
s.hwResolveOnce.Do(func() {
|
||||
s.resolvedHWAccel = playback.ResolveHWAccelWithFFmpeg(s.hwAccel, s.ffmpegPath)
|
||||
s.resolvedHWAccel = playback.ResolveHWAccel(s.hwAccel)
|
||||
s.resolvedHWDevice = s.hwDevice
|
||||
if s.resolvedHWDevice == "" && (s.resolvedHWAccel == "qsv" || s.resolvedHWAccel == "vaapi") {
|
||||
s.resolvedHWDevice = playback.PickRenderDevice("")
|
||||
|
||||
@@ -198,12 +198,13 @@ type authConfigRaw struct {
|
||||
RefreshTokenExpiry string `yaml:"refresh_token_expiry"`
|
||||
}
|
||||
|
||||
// AudiobookshelfCompatConfig holds the dedicated ABS-compat listener setting.
|
||||
// The listener binds its own port (:13378, ABS's
|
||||
// AudiobookshelfCompatConfig holds the dedicated ABS-compat listener
|
||||
// settings. The listener binds its own port (default :13378, ABS's
|
||||
// conventional port) and serves the full ABS protocol — login, libraries,
|
||||
// items, sessions — without colliding with silo's SPA on the main listener.
|
||||
type AudiobookshelfCompatConfig struct {
|
||||
Listen string `yaml:"-"`
|
||||
Listen string `yaml:"listen"`
|
||||
PublicURL string `yaml:"public_url"`
|
||||
}
|
||||
|
||||
// JellyfinCompatConfig holds compatibility proxy settings.
|
||||
|
||||
@@ -356,13 +356,15 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
|
||||
|
||||
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
|
||||
// listener off unless ABS compatibility is explicitly enabled.
|
||||
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
|
||||
// listener off unless the audiobooks feature flag is explicitly enabled.
|
||||
audiobooksEnabled, err := boolOr(m, "audiobooks.enabled", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if absCompatEnabled {
|
||||
cfg.AudiobookshelfCompat.Listen = ":13378"
|
||||
if audiobooksEnabled {
|
||||
// Default :13378 mirrors the real Audiobookshelf server convention.
|
||||
cfg.AudiobookshelfCompat.Listen = stringOr(m, "audiobookshelf_compat.listen", ":13378")
|
||||
cfg.AudiobookshelfCompat.PublicURL = stringOr(m, "audiobookshelf_compat.public_url", "http://127.0.0.1:13378")
|
||||
}
|
||||
|
||||
// JellyfinCompat
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
|
||||
func TestLoadFromDBAudiobooksFeatureFlagGatesCompatListener(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{"audiobooks.enabled": "false"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromDB() returned error: %v", err)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
|
||||
t.Fatalf("disabled audiobooks listener = %q, want empty", cfg.AudiobookshelfCompat.Listen)
|
||||
}
|
||||
|
||||
cfg, err = LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "true"})
|
||||
cfg, err = LoadFromDB(map[string]string{"audiobooks.enabled": "true"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromDB() returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
// ScopeResolver resolves a viewer request into an effective access scope.
|
||||
// Implemented by *access.Resolver.
|
||||
type ScopeResolver interface {
|
||||
Resolve(ctx context.Context, input access.ResolveInput) (access.Scope, error)
|
||||
}
|
||||
|
||||
// NewScopeAccessFilter returns an AccessFilterResolver backed by the shared
|
||||
// access scope resolver, so account-level library restrictions
|
||||
// (users.library_ids), profile library restrictions, user-disabled
|
||||
// libraries, content-rating ceilings, and playback-quality ceilings all
|
||||
// apply to the Jellyfin-compat API exactly as they do to the native API.
|
||||
//
|
||||
// PIN verification is skipped because compat login already verified the
|
||||
// profile PIN (password#pin convention). Resolution failures fail closed:
|
||||
// the viewer gets an empty library allowlist rather than full access.
|
||||
func NewScopeAccessFilter(resolver ScopeResolver) AccessFilterResolver {
|
||||
return func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
|
||||
scope, err := resolver.Resolve(ctx, access.ResolveInput{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
SkipPINVerification: true,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("jellycompat: access scope resolution failed; denying library access",
|
||||
"user_id", userID,
|
||||
"profile_id", profileID,
|
||||
"error", err,
|
||||
)
|
||||
return catalog.AccessFilter{
|
||||
AllowedLibraryIDs: []int{},
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
}
|
||||
}
|
||||
return catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type stubScopeResolver struct {
|
||||
scope access.Scope
|
||||
err error
|
||||
input access.ResolveInput
|
||||
}
|
||||
|
||||
func (s *stubScopeResolver) Resolve(_ context.Context, input access.ResolveInput) (access.Scope, error) {
|
||||
s.input = input
|
||||
return s.scope, s.err
|
||||
}
|
||||
|
||||
func TestScopeAccessFilterMapsScope(t *testing.T) {
|
||||
resolver := &stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
AllowedLibraryIDs: []int{2, 19},
|
||||
LibrariesRestricted: true,
|
||||
MaxContentRating: "PG-13",
|
||||
MaxPlaybackQuality: "1080p",
|
||||
},
|
||||
}
|
||||
|
||||
filter := NewScopeAccessFilter(resolver)(context.Background(), 7, "profile-1")
|
||||
|
||||
if !resolver.input.SkipPINVerification {
|
||||
t.Fatal("expected SkipPINVerification=true (PIN is verified at compat login)")
|
||||
}
|
||||
if resolver.input.UserID != 7 || resolver.input.ProfileID != "profile-1" {
|
||||
t.Fatalf("unexpected resolve input: %+v", resolver.input)
|
||||
}
|
||||
if !reflect.DeepEqual(filter.AllowedLibraryIDs, []int{2, 19}) {
|
||||
t.Fatalf("AllowedLibraryIDs = %v, want [2 19]", filter.AllowedLibraryIDs)
|
||||
}
|
||||
if filter.MaxContentRating != "PG-13" {
|
||||
t.Fatalf("MaxContentRating = %q, want PG-13", filter.MaxContentRating)
|
||||
}
|
||||
if filter.MaxPlaybackQuality != "1080p" {
|
||||
t.Fatalf("MaxPlaybackQuality = %q, want 1080p", filter.MaxPlaybackQuality)
|
||||
}
|
||||
if filter.UserID != 7 || filter.ProfileID != "profile-1" {
|
||||
t.Fatalf("identity not propagated: %+v", filter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAccessFilterUnrestrictedScope(t *testing.T) {
|
||||
resolver := &stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
UserID: 1,
|
||||
DisabledLibraryIDs: []int{7},
|
||||
},
|
||||
}
|
||||
|
||||
filter := NewScopeAccessFilter(resolver)(context.Background(), 1, "profile-1")
|
||||
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
t.Fatalf("AllowedLibraryIDs = %v, want nil (unrestricted)", filter.AllowedLibraryIDs)
|
||||
}
|
||||
if !reflect.DeepEqual(filter.DisabledLibraryIDs, []int{7}) {
|
||||
t.Fatalf("DisabledLibraryIDs = %v, want [7]", filter.DisabledLibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAccessFilterFailsClosed(t *testing.T) {
|
||||
resolver := &stubScopeResolver{err: errors.New("boom")}
|
||||
|
||||
filter := NewScopeAccessFilter(resolver)(context.Background(), 7, "profile-1")
|
||||
|
||||
if filter.AllowedLibraryIDs == nil || len(filter.AllowedLibraryIDs) != 0 {
|
||||
t.Fatalf("AllowedLibraryIDs = %v, want empty non-nil allowlist (deny all)", filter.AllowedLibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
type stubFolderSource struct {
|
||||
enabled []*models.MediaFolder
|
||||
byIDs []*models.MediaFolder
|
||||
listByIDsArgs []int
|
||||
}
|
||||
|
||||
func (s *stubFolderSource) GetEnabled(context.Context) ([]*models.MediaFolder, error) {
|
||||
return s.enabled, nil
|
||||
}
|
||||
|
||||
func (s *stubFolderSource) ListByIDs(_ context.Context, ids []int) ([]*models.MediaFolder, error) {
|
||||
s.listByIDsArgs = ids
|
||||
return s.byIDs, nil
|
||||
}
|
||||
|
||||
func TestListUserLibrariesRestrictedAllowlist(t *testing.T) {
|
||||
folders := &stubFolderSource{
|
||||
byIDs: []*models.MediaFolder{
|
||||
{ID: 2, Name: "TV Shows", Type: "series"},
|
||||
{ID: 19, Name: "Movies", Type: "movies"},
|
||||
},
|
||||
}
|
||||
svc := &directContentService{
|
||||
folderRepo: folders,
|
||||
accessFilter: NewScopeAccessFilter(&stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
AllowedLibraryIDs: []int{2, 19},
|
||||
LibrariesRestricted: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 7, ProfileID: "profile-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserLibraries: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(folders.listByIDsArgs, []int{2, 19}) {
|
||||
t.Fatalf("ListByIDs called with %v, want [2 19]", folders.listByIDsArgs)
|
||||
}
|
||||
if len(libraries) != 2 {
|
||||
t.Fatalf("got %d libraries, want 2", len(libraries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUserLibrariesFiltersDisabled(t *testing.T) {
|
||||
svc := &directContentService{
|
||||
folderRepo: &stubFolderSource{
|
||||
enabled: []*models.MediaFolder{
|
||||
{ID: 2, Name: "TV Shows", Type: "series"},
|
||||
{ID: 7, Name: "XXX", Type: "movies"},
|
||||
{ID: 19, Name: "Movies", Type: "movies"},
|
||||
},
|
||||
},
|
||||
accessFilter: NewScopeAccessFilter(&stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
DisabledLibraryIDs: []int{7},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserLibraries: %v", err)
|
||||
}
|
||||
if len(libraries) != 2 {
|
||||
t.Fatalf("got %d libraries, want 2 (disabled library not filtered)", len(libraries))
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == 7 {
|
||||
t.Fatal("user-disabled library 7 still present in views list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUserLibrariesEmptyAllowlistDeniesAll(t *testing.T) {
|
||||
svc := &directContentService{
|
||||
accessFilter: NewScopeAccessFilter(&stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
AllowedLibraryIDs: []int{},
|
||||
LibrariesRestricted: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 7, ProfileID: "profile-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserLibraries: %v", err)
|
||||
}
|
||||
if len(libraries) != 0 {
|
||||
t.Fatalf("got %d libraries, want 0 for empty allowlist", len(libraries))
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,6 @@ func TestBuildPlaybackSource_SeedsRequestedAudioStreamIndex(t *testing.T) {
|
||||
version,
|
||||
DeviceProfile{},
|
||||
playbackInfoRequest{AudioStreamIndex: compatIntValuePtr(requestedAudioStreamIndex)},
|
||||
true,
|
||||
)
|
||||
|
||||
if source.SelectedAudioStreamIndex == nil {
|
||||
|
||||
@@ -46,14 +46,6 @@ type itemAccessSource interface {
|
||||
GetByIDs(ctx context.Context, contentIDs []string) ([]*models.MediaItem, error)
|
||||
}
|
||||
|
||||
// folderListSource is the subset of *catalog.FolderRepository that
|
||||
// directContentService relies on. Defined as an interface so tests can
|
||||
// substitute a stub without standing up a Postgres pool.
|
||||
type folderListSource interface {
|
||||
GetEnabled(ctx context.Context) ([]*models.MediaFolder, error)
|
||||
ListByIDs(ctx context.Context, ids []int) ([]*models.MediaFolder, error)
|
||||
}
|
||||
|
||||
// seasonListSource is the subset of *catalog.SeasonRepository that
|
||||
// directContentService relies on.
|
||||
type seasonListSource interface {
|
||||
@@ -69,7 +61,6 @@ type seasonListSource interface {
|
||||
type episodeListSource interface {
|
||||
ListBySeason(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error)
|
||||
ListBySeriesGroupedBySeason(ctx context.Context, seriesID string) (map[int][]*models.Episode, error)
|
||||
ListBySeriesIDs(ctx context.Context, seriesIDs []string) (map[string][]*models.Episode, error)
|
||||
}
|
||||
|
||||
// directContentService implements ContentService by calling catalog repos directly.
|
||||
@@ -79,7 +70,7 @@ type directContentService struct {
|
||||
seasonRepo seasonListSource
|
||||
episodeRepo episodeListSource
|
||||
detailSvc *catalog.DetailService
|
||||
folderRepo folderListSource
|
||||
folderRepo *catalog.FolderRepository
|
||||
storeProvider userstore.UserStoreProvider
|
||||
accessFilter AccessFilterResolver
|
||||
posterPresigner LibraryPosterPresigner
|
||||
@@ -92,7 +83,7 @@ func newDirectContentService(
|
||||
seasonRepo *catalog.SeasonRepository,
|
||||
episodeRepo *catalog.EpisodeRepository,
|
||||
detailSvc *catalog.DetailService,
|
||||
folderRepo folderListSource,
|
||||
folderRepo *catalog.FolderRepository,
|
||||
storeProvider userstore.UserStoreProvider,
|
||||
accessFilter AccessFilterResolver,
|
||||
) *directContentService {
|
||||
@@ -144,12 +135,7 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
|
||||
|
||||
var folders []*models.MediaFolder
|
||||
var err error
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
// A non-nil allowlist means the viewer is restricted; an empty
|
||||
// allowlist grants access to no libraries at all.
|
||||
if len(filter.AllowedLibraryIDs) == 0 {
|
||||
return []upstreamUserLibrary{}, nil
|
||||
}
|
||||
if len(filter.AllowedLibraryIDs) > 0 {
|
||||
folders, err = s.folderRepo.ListByIDs(ctx, filter.AllowedLibraryIDs)
|
||||
} else {
|
||||
folders, err = s.folderRepo.GetEnabled(ctx)
|
||||
@@ -158,16 +144,8 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
|
||||
return nil, fmt.Errorf("list libraries: %w", err)
|
||||
}
|
||||
|
||||
disabled := make(map[int]struct{}, len(filter.DisabledLibraryIDs))
|
||||
for _, id := range filter.DisabledLibraryIDs {
|
||||
disabled[id] = struct{}{}
|
||||
}
|
||||
|
||||
libraries := make([]upstreamUserLibrary, 0, len(folders))
|
||||
for _, f := range folders {
|
||||
if _, ok := disabled[f.ID]; ok {
|
||||
continue
|
||||
}
|
||||
lib := upstreamUserLibrary{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
@@ -287,18 +265,6 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
|
||||
if len(collected) > requestedLimit {
|
||||
collected = collected[:requestedLimit]
|
||||
}
|
||||
|
||||
// The handler's resolveUserStateForContentIDs overlay only covers items
|
||||
// with their own progress rows, which a series never has — aggregate
|
||||
// series watch state here so library views carry Played/UnplayedItemCount.
|
||||
// The is_played path already did this via enrichListItemsUserData.
|
||||
// That same absence is what keeps this safe from the handler overlay:
|
||||
// userDataDTO only overrides item-level UserData when a direct progress
|
||||
// row exists, and one never does for a series id.
|
||||
if isPlayedFilter == "" {
|
||||
s.enrichSeriesUserData(ctx, session, collected)
|
||||
}
|
||||
|
||||
if totalKnown {
|
||||
hasMore = totalFromCatalog > requestedOffset+requestedLimit
|
||||
}
|
||||
@@ -381,17 +347,6 @@ func (s *directContentService) GetItemDetail(ctx context.Context, session *Sessi
|
||||
IsInProgress: progress.PositionSeconds > 0 && !progress.Completed,
|
||||
}
|
||||
}
|
||||
|
||||
// A series never has a progress row of its own, so roll watch
|
||||
// state up from its episodes (mirrors applySeasonUserData) to
|
||||
// give clients Played/UnplayedItemCount at the series level.
|
||||
if result.UserData == nil && strings.EqualFold(result.Type, "series") && s.episodeRepo != nil {
|
||||
if episodesBySeries, epErr := s.episodeRepo.ListBySeriesIDs(ctx, []string{contentID}); epErr == nil {
|
||||
episodes := episodesBySeries[contentID]
|
||||
progressMap := chunkedProgressByMediaItems(ctx, store, session.ProfileID, modelEpisodeContentIDs(episodes))
|
||||
result.UserData = seriesUserDataFromEpisodes(episodes, progressMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,128 +545,6 @@ func (s *directContentService) enrichListItemsUserData(ctx context.Context, sess
|
||||
items[i].UserData = seasonUserDataFromProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
s.enrichSeriesListUserData(ctx, session, store, items)
|
||||
}
|
||||
|
||||
// enrichSeriesUserData is enrichSeriesListUserData with store acquisition,
|
||||
// for callers that haven't already resolved the user store.
|
||||
func (s *directContentService) enrichSeriesUserData(ctx context.Context, session *Session, items []upstreamListItem) {
|
||||
if s.storeProvider == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
store, err := s.userStore(ctx, session)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.enrichSeriesListUserData(ctx, session, store, items)
|
||||
}
|
||||
|
||||
// enrichSeriesListUserData fills aggregated user data for series rows in a
|
||||
// list response. A series never has a progress row of its own, so Played and
|
||||
// UnplayedItemCount are rolled up from episode progress, mirroring
|
||||
// applySeasonUserData semantics.
|
||||
func (s *directContentService) enrichSeriesListUserData(ctx context.Context, session *Session, store userstore.UserStore, items []upstreamListItem) {
|
||||
if s.episodeRepo == nil {
|
||||
return
|
||||
}
|
||||
seriesIDs := make([]string, 0, len(items))
|
||||
for i := range items {
|
||||
if items[i].UserData == nil && items[i].ContentID != "" && strings.EqualFold(items[i].Type, "series") {
|
||||
seriesIDs = append(seriesIDs, items[i].ContentID)
|
||||
}
|
||||
}
|
||||
if len(seriesIDs) == 0 {
|
||||
return
|
||||
}
|
||||
// Cap the rollup so a max-limit browse (compatBrowseMaxLimit) of a
|
||||
// series-only library can't materialize hundreds of thousands of episode
|
||||
// rows in one request. Series past the cap keep a nil UserData — the same
|
||||
// shape they had before series aggregation existed. Real clients page at
|
||||
// 20-100 items, well under the cap.
|
||||
const maxSeriesUserDataRollups = 250
|
||||
if len(seriesIDs) > maxSeriesUserDataRollups {
|
||||
seriesIDs = seriesIDs[:maxSeriesUserDataRollups]
|
||||
}
|
||||
episodesBySeries, err := s.episodeRepo.ListBySeriesIDs(ctx, seriesIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
episodeIDs := make([]string, 0, 256)
|
||||
for _, episodes := range episodesBySeries {
|
||||
episodeIDs = append(episodeIDs, modelEpisodeContentIDs(episodes)...)
|
||||
}
|
||||
progressMap := chunkedProgressByMediaItems(ctx, store, session.ProfileID, episodeIDs)
|
||||
for i := range items {
|
||||
if items[i].UserData != nil || !strings.EqualFold(items[i].Type, "series") {
|
||||
continue
|
||||
}
|
||||
if episodes, ok := episodesBySeries[items[i].ContentID]; ok {
|
||||
items[i].UserData = seriesUserDataFromEpisodes(episodes, progressMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seriesUserDataFromEpisodes computes WatchedCount/UnplayedCount/
|
||||
// InProgressCount/Played for a whole series from a pre-fetched progressMap.
|
||||
// Pure function — no I/O. Counting semantics match the native API's series
|
||||
// rollup (all episodes including specials; in-progress = started but not
|
||||
// completed).
|
||||
func seriesUserDataFromEpisodes(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) *catalog.SeasonUserData {
|
||||
watched := 0
|
||||
unplayed := 0
|
||||
inProgress := 0
|
||||
for _, ep := range episodes {
|
||||
if ep == nil {
|
||||
continue
|
||||
}
|
||||
progress, ok := progressMap[ep.ContentID]
|
||||
if ok && progress.Completed {
|
||||
watched++
|
||||
continue
|
||||
}
|
||||
if ok && progress.PositionSeconds > 0 {
|
||||
inProgress++
|
||||
}
|
||||
unplayed++
|
||||
}
|
||||
return &catalog.SeasonUserData{
|
||||
WatchedCount: watched,
|
||||
UnplayedCount: unplayed,
|
||||
InProgressCount: inProgress,
|
||||
Played: unplayed == 0 && len(episodes) > 0,
|
||||
}
|
||||
}
|
||||
|
||||
// modelEpisodeContentIDs returns the non-empty content ids of the given episodes.
|
||||
func modelEpisodeContentIDs(episodes []*models.Episode) []string {
|
||||
ids := make([]string, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep != nil && ep.ContentID != "" {
|
||||
ids = append(ids, ep.ContentID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// chunkedProgressByMediaItems batches ListProgressByMediaItems calls when a
|
||||
// series list expands to thousands of episode ids: per-user stores may be
|
||||
// SQLite-backed (999 bind-variable default), and chunking also keeps
|
||||
// Postgres IN-lists at a sane size. Returns an empty map (never nil); chunks
|
||||
// that error are skipped.
|
||||
func chunkedProgressByMediaItems(ctx context.Context, store userstore.UserStore, profileID string, mediaItemIDs []string) map[string]userstore.WatchProgress {
|
||||
const chunkSize = 500
|
||||
result := make(map[string]userstore.WatchProgress, len(mediaItemIDs))
|
||||
for start := 0; start < len(mediaItemIDs); start += chunkSize {
|
||||
chunk, err := store.ListProgressByMediaItems(ctx, profileID, mediaItemIDs[start:min(start+chunkSize, len(mediaItemIDs))])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for id, progress := range chunk {
|
||||
result[id] = progress
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// enrichSeasonUserData adds aggregated user data for a season. Used by
|
||||
|
||||
@@ -1183,45 +1183,27 @@ func (h *ItemsHandler) writeNextUpResponse(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// The scan stays on list-level data (per-item GetItemDetail fanout over
|
||||
// the FULL next-up list was 5N DB roundtrips for users with long lists);
|
||||
// only the sliced page below is upgraded to detail when the client asked
|
||||
// for detail-level Fields — see upgradeProgressPageToDetail for why the
|
||||
// listing's MediaSources are load-bearing for Infuse/SenPlayer.
|
||||
pageIDs := make([]string, 0, len(results))
|
||||
items := make([]baseItemDTO, 0, len(results))
|
||||
for _, res := range results {
|
||||
target, ok := episodeTargets[res.ContentID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// NextUp deliberately serves list-level data even when the client
|
||||
// requests Fields=People|Chapters|MediaStreams|MediaSources, mirroring
|
||||
// the Resume hydrator (handlers_items.go ~L1721). Per-item
|
||||
// GetItemDetail fanout was 5N DB roundtrips for users with long
|
||||
// next-up lists, and the detail data was never load-bearing for the
|
||||
// row UX — clients refetch MediaSources via /Items/{id}/PlaybackInfo
|
||||
// on play.
|
||||
dto := h.mapper.itemFromList(target.Item, favorites[res.ContentID], progress[res.ContentID], query.requestedFields)
|
||||
h.applyCompatEpisodeTarget(&dto, target)
|
||||
stubDetailListFields(&dto, query.requestedFields)
|
||||
pageIDs = append(pageIDs, res.ContentID)
|
||||
items = append(items, dto)
|
||||
}
|
||||
|
||||
total := len(items)
|
||||
items = sliceBaseItems(items, query.startIndex, query.limit)
|
||||
pageIDs = slicePageIDs(pageIDs, query.startIndex, query.limit)
|
||||
if query.needsDetailFields {
|
||||
for i := range items {
|
||||
if i >= maxDetailUpgrades || i >= len(pageIDs) {
|
||||
break
|
||||
}
|
||||
detail, detailErr := h.content.GetItemDetail(r.Context(), session, pageIDs[i], nil)
|
||||
if detailErr != nil {
|
||||
continue
|
||||
}
|
||||
h.rememberDetailImages(*detail)
|
||||
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[pageIDs[i]], progress[pageIDs[i]], query.requestedFields)
|
||||
if target, ok := episodeTargets[pageIDs[i]]; ok {
|
||||
h.applyCompatEpisodeTarget(&dto, target)
|
||||
}
|
||||
items[i] = dto
|
||||
}
|
||||
}
|
||||
applyImageTypeLimit(items, query.imageTypeLimit)
|
||||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||||
Items: items,
|
||||
@@ -1230,22 +1212,6 @@ func (h *ItemsHandler) writeNextUpResponse(w http.ResponseWriter, r *http.Reques
|
||||
})
|
||||
}
|
||||
|
||||
// slicePageIDs applies sliceBaseItems' bounds to the parallel content-id
|
||||
// slice so page positions stay aligned after StartIndex/Limit slicing.
|
||||
func slicePageIDs(ids []string, startIndex, limit int) []string {
|
||||
if startIndex < 0 {
|
||||
startIndex = 0
|
||||
}
|
||||
if startIndex >= len(ids) {
|
||||
return []string{}
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = len(ids)
|
||||
}
|
||||
end := min(startIndex+limit, len(ids))
|
||||
return ids[startIndex:end]
|
||||
}
|
||||
|
||||
// HandleUpcoming serves GET /Shows/Upcoming.
|
||||
//
|
||||
// Android TV's show-detail page calls this endpoint to populate a single
|
||||
@@ -1756,20 +1722,10 @@ func (h *ItemsHandler) handleResumeResponse(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
type progressHydratedItem struct {
|
||||
itemType string
|
||||
contentID string
|
||||
isFavorite bool
|
||||
entry upstreamProgress
|
||||
target *compatEpisodeTarget
|
||||
dto baseItemDTO
|
||||
itemType string
|
||||
dto baseItemDTO
|
||||
}
|
||||
|
||||
// maxDetailUpgrades caps how many returned-page items get re-mapped through
|
||||
// the per-item detail path when the client requested detail-level Fields.
|
||||
// Entries past the cap keep their stubbed list-level DTO. Guardrail against
|
||||
// absurd client limits — normal Resume/NextUp requests are 20-40 items.
|
||||
const maxDetailUpgrades = 100
|
||||
|
||||
func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, status string, query itemsQuery, typeSet map[string]bool, libraryID *int) ([]baseItemDTO, int, error) {
|
||||
if len(typeSet) == 0 && libraryID == nil && !query.enableTotalRecordCount {
|
||||
batchSize := min(max(query.limit*2, 48), 200)
|
||||
@@ -1777,7 +1733,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
batchSize = 48
|
||||
}
|
||||
|
||||
result := make([]progressHydratedItem, 0, query.limit)
|
||||
result := make([]baseItemDTO, 0, query.limit)
|
||||
offset := query.startIndex
|
||||
for {
|
||||
progressEntries, err := h.userData.ListProgress(ctx, session, status, batchSize, offset)
|
||||
@@ -1796,14 +1752,14 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
if len(result) >= query.limit {
|
||||
break
|
||||
}
|
||||
result = append(result, item)
|
||||
result = append(result, item.dto)
|
||||
}
|
||||
if len(result) >= query.limit || len(progressEntries) < batchSize {
|
||||
break
|
||||
}
|
||||
offset += len(progressEntries)
|
||||
}
|
||||
return h.finishProgressPage(ctx, session, result, query, libraryID), 0, nil
|
||||
return result, 0, nil
|
||||
}
|
||||
|
||||
batchSize := min(max(query.limit*3, 48), 200)
|
||||
@@ -1811,7 +1767,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
batchSize = 48
|
||||
}
|
||||
|
||||
items := make([]progressHydratedItem, 0, query.limit)
|
||||
items := make([]baseItemDTO, 0, query.limit)
|
||||
matchedCount := 0
|
||||
offset := 0
|
||||
|
||||
@@ -1833,7 +1789,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
continue
|
||||
}
|
||||
if matchedCount >= query.startIndex && len(items) < query.limit {
|
||||
items = append(items, item)
|
||||
items = append(items, item.dto)
|
||||
}
|
||||
matchedCount++
|
||||
}
|
||||
@@ -1851,49 +1807,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
if query.enableTotalRecordCount {
|
||||
total = matchedCount
|
||||
}
|
||||
return h.finishProgressPage(ctx, session, items, query, libraryID), total, nil
|
||||
}
|
||||
|
||||
// finishProgressPage upgrades the returned page to detail-mapped DTOs when
|
||||
// the client asked for detail-level Fields, then unwraps to the DTO slice.
|
||||
func (h *ItemsHandler) finishProgressPage(ctx context.Context, session *Session, page []progressHydratedItem, query itemsQuery, libraryID *int) []baseItemDTO {
|
||||
h.upgradeProgressPageToDetail(ctx, session, page, query, libraryID)
|
||||
dtos := make([]baseItemDTO, 0, len(page))
|
||||
for _, item := range page {
|
||||
dtos = append(dtos, item.dto)
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
// upgradeProgressPageToDetail re-maps the returned page of progress rows
|
||||
// through the detail path when the client requested detail-level Fields
|
||||
// (e.g. MediaSources). The scan loop in loadProgressPage stays on list-level
|
||||
// data so type filtering over long in-progress lists never fans out — the
|
||||
// per-ENTRY fanout was the 38s timeout in error-report-2026-05-08.md §6 —
|
||||
// but the returned page is bounded by the request limit, so this costs the
|
||||
// same as HandleLatest's existing per-item detail path. The listing's
|
||||
// MediaSources ARE load-bearing for some clients: Infuse and SenPlayer build
|
||||
// their Continue Watching rows from them and discard entries served only the
|
||||
// list-level stub.
|
||||
func (h *ItemsHandler) upgradeProgressPageToDetail(ctx context.Context, session *Session, page []progressHydratedItem, query itemsQuery, libraryID *int) {
|
||||
if !query.needsDetailFields {
|
||||
return
|
||||
}
|
||||
for i := range page {
|
||||
if i >= maxDetailUpgrades {
|
||||
break
|
||||
}
|
||||
detail, err := h.content.GetItemDetail(ctx, session, page[i].contentID, libraryID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h.rememberDetailImages(*detail)
|
||||
dto := h.mapper.itemFromDetailWithFields(*detail, page[i].isFavorite, &page[i].entry, query.requestedFields)
|
||||
if page[i].target != nil {
|
||||
h.applyCompatEpisodeTarget(&dto, *page[i].target)
|
||||
}
|
||||
page[i].dto = dto
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Session, entries []upstreamProgress, fields map[string]bool, libraryID *int) ([]progressHydratedItem, error) {
|
||||
@@ -1929,11 +1843,8 @@ func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Sessio
|
||||
dto := h.mapper.itemFromList(item, favorites[entry.MediaItemID], &entry, fields)
|
||||
stubDetailListFields(&dto, fields)
|
||||
result = append(result, progressHydratedItem{
|
||||
itemType: strings.ToLower(item.Type),
|
||||
contentID: entry.MediaItemID,
|
||||
isFavorite: favorites[entry.MediaItemID],
|
||||
entry: entry,
|
||||
dto: dto,
|
||||
itemType: strings.ToLower(item.Type),
|
||||
dto: dto,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -1942,12 +1853,8 @@ func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Sessio
|
||||
h.applyCompatEpisodeTarget(&dto, target)
|
||||
stubDetailListFields(&dto, fields)
|
||||
result = append(result, progressHydratedItem{
|
||||
itemType: "episode",
|
||||
contentID: entry.MediaItemID,
|
||||
isFavorite: favorites[entry.MediaItemID],
|
||||
entry: entry,
|
||||
target: &target,
|
||||
dto: dto,
|
||||
itemType: "episode",
|
||||
dto: dto,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -155,22 +154,6 @@ func (h *PlaybackHandler) playbackThresholds(ctx context.Context) userstore.Prog
|
||||
return t
|
||||
}
|
||||
|
||||
var errTranscode4KDisallowed = errors.New("4k video transcode disallowed by server settings")
|
||||
|
||||
// allow4KVideoTranscode reads the allow_4k_transcode server setting,
|
||||
// defaulting to deny like the native playback handler.
|
||||
func (h *PlaybackHandler) allow4KVideoTranscode(ctx context.Context) bool {
|
||||
if h.SettingsRepo == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := h.SettingsRepo.Get(ctx, "allow_4k_transcode")
|
||||
return v == "true"
|
||||
}
|
||||
|
||||
func is4KResolution(res string) bool {
|
||||
return access.CompareQuality(res, "2160p") >= 0
|
||||
}
|
||||
|
||||
// NewPlaybackHandler creates a playback handler.
|
||||
func NewPlaybackHandler(
|
||||
cfg *config.Config,
|
||||
@@ -317,9 +300,6 @@ func (h *PlaybackHandler) startRemoteTranscode(
|
||||
initialSeekSeconds float64,
|
||||
transcodeNodeURL string,
|
||||
) error {
|
||||
if !source.TranscodeAudio && is4KResolution(source.Version.Resolution) && !h.allow4KVideoTranscode(ctx) {
|
||||
return errTranscode4KDisallowed
|
||||
}
|
||||
if d := float64(source.Version.Duration); d > 0 && initialSeekSeconds > d {
|
||||
initialSeekSeconds = d
|
||||
}
|
||||
@@ -436,9 +416,8 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
|
||||
sources := make([]PlaybackMediaSource, 0, len(detail.Versions))
|
||||
sourceDTOs := make([]mediaSourceDTO, 0, len(detail.Versions))
|
||||
|
||||
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
|
||||
for _, version := range detail.Versions {
|
||||
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
||||
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req)
|
||||
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
|
||||
continue
|
||||
}
|
||||
@@ -534,7 +513,6 @@ func (h *PlaybackHandler) buildPlaybackSource(
|
||||
version catalog.FileVersion,
|
||||
profile DeviceProfile,
|
||||
req playbackInfoRequest,
|
||||
allow4KTranscode bool,
|
||||
) PlaybackMediaSource {
|
||||
sourceID := h.codec.EncodeIntID(EncodedIDMediaSource, int64(version.FileID))
|
||||
enableDirectPlay := boolDefault(req.EnableDirectPlay, true)
|
||||
@@ -553,12 +531,6 @@ func (h *PlaybackHandler) buildPlaybackSource(
|
||||
allowAudioCopy &&
|
||||
audioSupported
|
||||
supportsTranscoding := boolDefault(req.EnableTranscoding, true) && profile.SupportsTranscoding(version)
|
||||
// Don't offer full video encodes of 4K sources when allow_4k_transcode is
|
||||
// off. Audio-only transcodes (transcodeAudio) stream-copy the video and
|
||||
// stay available.
|
||||
if supportsTranscoding && !transcodeAudio && !allow4KTranscode && is4KResolution(version.Resolution) {
|
||||
supportsTranscoding = false
|
||||
}
|
||||
|
||||
audioIndex := defaultAudioStreamIndex(version)
|
||||
subtitleIndex := defaultSubtitleStreamIndex(version)
|
||||
|
||||
@@ -25,11 +25,11 @@ func (s *panicOnGetItemDetailContent) GetItemDetail(_ context.Context, _ *Sessio
|
||||
// Fields=People|Chapters|MediaStreams|MediaSources, producing the 38s timeout
|
||||
// observed in production for users with large in-progress lists.
|
||||
//
|
||||
// hydrateProgressItems is the SCAN phase and must stay detail-free: it runs
|
||||
// over up to 200 entries per loop iteration purely for type filtering. The
|
||||
// detail data clients need (real MediaSources — load-bearing for Infuse and
|
||||
// SenPlayer Continue Watching rows) is served by upgradeProgressPageToDetail,
|
||||
// which runs only on the returned page, bounded by the request limit.
|
||||
// The function now serves list-level data unconditionally and stubs the four
|
||||
// detail-only fields with empty (but non-nil) collections via
|
||||
// stubDetailListFields. Standard Jellyfin clients refetch MediaSources via
|
||||
// /Items/{id}/PlaybackInfo on play, so the detail data was never load-bearing
|
||||
// for the Resume row UX.
|
||||
func TestHydrateProgressItems_DoesNotCallGetItemDetail(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
|
||||
|
||||
@@ -65,10 +65,6 @@ func (c *countingEpisodeListSource) ListBySeriesGroupedBySeason(_ context.Contex
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *countingEpisodeListSource) ListBySeriesIDs(_ context.Context, _ []string) (map[string][]*models.Episode, error) {
|
||||
return map[string][]*models.Episode{}, nil
|
||||
}
|
||||
|
||||
// TestListSeasons_UsesBatchEpisodeFetch verifies ListSeasons makes exactly
|
||||
// one ListBySeriesGroupedBySeason call (replacing N per-season ListBySeason
|
||||
// calls) and exactly one ListProgressByMediaItems call (replacing N
|
||||
|
||||
@@ -204,11 +204,8 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
|
||||
// The values are minimal: each carries the required (non-omitempty) fields
|
||||
// only. Clients that consume this data downstream will see a single source
|
||||
// with a stub ID, a single Video stream at index 0, and a single placeholder
|
||||
// person. The stub is a FALLBACK, not a no-op: Infuse and SenPlayer build
|
||||
// their Continue Watching rows from the listing's MediaSources, so pages
|
||||
// within the detail-upgrade cap get real sources (upgradeProgressPageToDetail)
|
||||
// and only overflow/error entries see the stub — which must therefore still
|
||||
// look playable and serialize collections as empty, never null.
|
||||
// person; standard Jellyfin clients refetch real MediaSources via
|
||||
// /Items/{id}/PlaybackInfo on play, so this stub is never load-bearing.
|
||||
var (
|
||||
stubDetailPerson = personDTO{ID: "0", Name: ""}
|
||||
stubDetailMediaSource = mediaSourceDTO{ID: "0"}
|
||||
@@ -217,11 +214,10 @@ var (
|
||||
|
||||
// stubDetailListFields sets the four detail-only fields on a list-mapped DTO
|
||||
// to single-element placeholder slices when the client requested them via
|
||||
// Fields=People|Chapters|MediaStreams|MediaSources. Used by the Resume/NextUp
|
||||
// scan phase, which deliberately skips per-item GetItemDetail fanout; the
|
||||
// returned page is then re-mapped through the real detail path
|
||||
// (upgradeProgressPageToDetail), so these stubs reach clients only for
|
||||
// entries past the detail-upgrade cap or whose detail fetch failed.
|
||||
// Fields=People|Chapters|MediaStreams|MediaSources. Used by the Resume
|
||||
// hydrator, which deliberately skips per-item GetItemDetail fanout; clients
|
||||
// that need real MediaSources refetch them via /Items/{id}/PlaybackInfo on
|
||||
// play.
|
||||
func stubDetailListFields(dto *baseItemDTO, fields map[string]bool) {
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
@@ -238,31 +234,7 @@ func stubDetailListFields(dto *baseItemDTO, fields map[string]bool) {
|
||||
dto.MediaStreams = []mediaStreamDTO{stubDetailMediaStream}
|
||||
}
|
||||
if fields["mediasources"] && dto.MediaSources == nil {
|
||||
// The stub must still look PLAYABLE: some clients (SenPlayer) decide
|
||||
// whether to render a Resume row entry from the listing's
|
||||
// MediaSources, and an all-false playability stub makes them drop
|
||||
// every item. Keep the stub ID ("0" — never a registered media source
|
||||
// owner) but mirror the shape detailMediaSourceDTO produces; the real
|
||||
// source set is still refetched via /Items/{id}/PlaybackInfo on play.
|
||||
src := stubDetailMediaSource
|
||||
src.Protocol = "File"
|
||||
src.Type = "Default"
|
||||
src.VideoType = "VideoFile"
|
||||
src.Name = dto.Name
|
||||
src.RunTimeTicks = dto.RunTimeTicks
|
||||
src.SupportsTranscoding = true
|
||||
src.SupportsDirectStream = true
|
||||
src.SupportsDirectPlay = true
|
||||
src.SupportsProbing = true
|
||||
src.TranscodingSubProtocol = "hls"
|
||||
src.MediaStreams = []mediaStreamDTO{stubDetailMediaStream}
|
||||
// Real servers never serialize these as JSON null (always []/{});
|
||||
// strict client deserializers fail the whole response on a null
|
||||
// array, which empties the Resume/NextUp rows entirely.
|
||||
src.Formats = []string{}
|
||||
src.RequiredHTTPHeaders = map[string]string{}
|
||||
src.MediaAttachments = []map[string]any{}
|
||||
dto.MediaSources = []mediaSourceDTO{src}
|
||||
dto.MediaSources = []mediaSourceDTO{stubDetailMediaSource}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,59 +157,3 @@ func TestStubDetailListFields_FreshChapterMapPerCall(t *testing.T) {
|
||||
t.Errorf("mutating dto a's chapter map leaked into dto b: b.Chapters[0][Name] = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStubDetailListFields_MediaSourceStubLooksPlayable locks in that the
|
||||
// MediaSources stub advertises playability. Some clients (SenPlayer) decide
|
||||
// whether to render a Resume row entry from the listing's MediaSources; an
|
||||
// all-false playability stub made them drop every entry (the Continue
|
||||
// Watching row showed empty while item-page resume worked fine).
|
||||
func TestStubDetailListFields_MediaSourceStubLooksPlayable(t *testing.T) {
|
||||
dto := baseItemDTO{Name: "Pilot", RunTimeTicks: 34800000000}
|
||||
stubDetailListFields(&dto, map[string]bool{"mediasources": true})
|
||||
|
||||
if len(dto.MediaSources) != 1 {
|
||||
t.Fatalf("MediaSources = %v, want single-element slice", dto.MediaSources)
|
||||
}
|
||||
src := dto.MediaSources[0]
|
||||
if !src.SupportsDirectPlay || !src.SupportsDirectStream || !src.SupportsTranscoding || !src.SupportsProbing {
|
||||
t.Errorf("stub source must advertise playability; got %+v", src)
|
||||
}
|
||||
if src.ID != "0" {
|
||||
t.Errorf("stub source ID = %q, want \"0\" (never a registered media source owner)", src.ID)
|
||||
}
|
||||
if src.Name != "Pilot" || src.RunTimeTicks != 34800000000 {
|
||||
t.Errorf("stub source should mirror item Name/RunTimeTicks; got Name=%q ticks=%d", src.Name, src.RunTimeTicks)
|
||||
}
|
||||
if len(src.MediaStreams) != 1 {
|
||||
t.Errorf("stub source should nest a stream stub; got %v", src.MediaStreams)
|
||||
}
|
||||
// Null arrays (vs empty) break strict client deserializers — real servers
|
||||
// always serialize these as []/{}.
|
||||
if src.Formats == nil || src.RequiredHTTPHeaders == nil || src.MediaAttachments == nil {
|
||||
t.Errorf("stub source must not serialize null collections; got Formats=%v RequiredHTTPHeaders=%v MediaAttachments=%v",
|
||||
src.Formats, src.RequiredHTTPHeaders, src.MediaAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStubDetailListFields_FreshMediaSourcePerCall guards against reference
|
||||
// sharing across responses: the stub is built by copying the package-level
|
||||
// stubDetailMediaSource value, which only stays safe while that var's
|
||||
// reference-typed fields (slices/maps) remain nil. Mirrors the existing
|
||||
// chapter-map guard.
|
||||
func TestStubDetailListFields_FreshMediaSourcePerCall(t *testing.T) {
|
||||
a := baseItemDTO{}
|
||||
b := baseItemDTO{}
|
||||
fields := map[string]bool{"mediasources": true}
|
||||
|
||||
stubDetailListFields(&a, fields)
|
||||
stubDetailListFields(&b, fields)
|
||||
|
||||
a.MediaSources[0].RequiredHTTPHeaders["X-Mutated"] = "by-a"
|
||||
a.MediaSources[0].MediaStreams[0].Index = 99
|
||||
if len(b.MediaSources[0].RequiredHTTPHeaders) != 0 {
|
||||
t.Error("mutating dto a's RequiredHTTPHeaders leaked into dto b")
|
||||
}
|
||||
if b.MediaSources[0].MediaStreams[0].Index == 99 {
|
||||
t.Error("mutating dto a's nested stream leaked into dto b")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
type stubSettingsReader struct {
|
||||
values map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubSettingsReader) Get(_ context.Context, key string) (string, error) {
|
||||
if s.err != nil {
|
||||
return "", s.err
|
||||
}
|
||||
return s.values[key], nil
|
||||
}
|
||||
|
||||
func TestAllow4KVideoTranscode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repo SettingsReader
|
||||
want bool
|
||||
}{
|
||||
{name: "nil repo defaults to deny", repo: nil, want: false},
|
||||
{name: "unset defaults to deny", repo: stubSettingsReader{}, want: false},
|
||||
{name: "read error defaults to deny", repo: stubSettingsReader{err: errors.New("read failed")}, want: false},
|
||||
{name: "explicit false denies", repo: stubSettingsReader{values: map[string]string{"allow_4k_transcode": "false"}}, want: false},
|
||||
{name: "explicit true allows", repo: stubSettingsReader{values: map[string]string{"allow_4k_transcode": "true"}}, want: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := &PlaybackHandler{SettingsRepo: tt.repo}
|
||||
if got := h.allow4KVideoTranscode(context.Background()); got != tt.want {
|
||||
t.Errorf("allow4KVideoTranscode() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIs4KResolution(t *testing.T) {
|
||||
for res, want := range map[string]bool{
|
||||
"2160p": true,
|
||||
"4320p": true,
|
||||
"1080p": false,
|
||||
"720p": false,
|
||||
"": false,
|
||||
} {
|
||||
if got := is4KResolution(res); got != want {
|
||||
t.Errorf("is4KResolution(%q) = %v, want %v", res, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlaybackSource4KVideoTranscodeGate(t *testing.T) {
|
||||
version4K := catalog.FileVersion{
|
||||
FileID: 1,
|
||||
Resolution: "2160p",
|
||||
CodecVideo: "hevc",
|
||||
CodecAudio: "eac3",
|
||||
Container: "mkv",
|
||||
}
|
||||
version1080 := version4K
|
||||
version1080.Resolution = "1080p"
|
||||
|
||||
// Can only decode h264: HEVC versions need a full video transcode.
|
||||
h264Only := DeviceProfile{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Type: "Video", Container: "mp4", VideoCodec: "h264", AudioCodec: "aac"},
|
||||
},
|
||||
}
|
||||
// Decodes HEVC but not EAC3: transcoding path stream-copies the video.
|
||||
hevcNoEac3 := DeviceProfile{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Type: "Video", Container: "mp4", VideoCodec: "h264,hevc", AudioCodec: "aac"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
version catalog.FileVersion
|
||||
profile DeviceProfile
|
||||
allow4K bool
|
||||
wantTranscoding bool
|
||||
wantTranscodeAudio bool
|
||||
}{
|
||||
{
|
||||
name: "4K video transcode blocked when disallowed",
|
||||
version: version4K,
|
||||
profile: h264Only,
|
||||
allow4K: false,
|
||||
wantTranscoding: false,
|
||||
},
|
||||
{
|
||||
name: "4K video transcode offered when allowed",
|
||||
version: version4K,
|
||||
profile: h264Only,
|
||||
allow4K: true,
|
||||
wantTranscoding: true,
|
||||
},
|
||||
{
|
||||
name: "4K audio-only transcode (video copy) stays allowed",
|
||||
version: version4K,
|
||||
profile: hevcNoEac3,
|
||||
allow4K: false,
|
||||
wantTranscoding: true,
|
||||
wantTranscodeAudio: true,
|
||||
},
|
||||
{
|
||||
name: "non-4K video transcode unaffected",
|
||||
version: version1080,
|
||||
profile: h264Only,
|
||||
allow4K: false,
|
||||
wantTranscoding: true,
|
||||
},
|
||||
}
|
||||
|
||||
h := &PlaybackHandler{codec: NewResourceIDCodec()}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
source := h.buildPlaybackSource("item", "ps", tt.version, tt.profile, playbackInfoRequest{}, tt.allow4K)
|
||||
if source.SupportsTranscoding != tt.wantTranscoding {
|
||||
t.Errorf("SupportsTranscoding = %v, want %v", source.SupportsTranscoding, tt.wantTranscoding)
|
||||
}
|
||||
if source.TranscodeAudio != tt.wantTranscodeAudio {
|
||||
t.Errorf("TranscodeAudio = %v, want %v", source.TranscodeAudio, tt.wantTranscodeAudio)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureTranscodeSession4KGuard(t *testing.T) {
|
||||
h := &PlaybackHandler{} // nil SettingsRepo: 4K video transcodes denied
|
||||
|
||||
source := PlaybackMediaSource{
|
||||
FileID: 1,
|
||||
Version: catalog.FileVersion{FileID: 1, Resolution: "2160p", CodecVideo: "hevc"},
|
||||
}
|
||||
if _, err := h.ensureTranscodeSession(context.Background(), "ps", "session", source); !errors.Is(err, errTranscode4KDisallowed) {
|
||||
t.Errorf("ensureTranscodeSession() error = %v, want errTranscode4KDisallowed", err)
|
||||
}
|
||||
|
||||
// Video-copy sessions pass the guard (and fail later on the missing file
|
||||
// resolver, which is fine for this test).
|
||||
source.TranscodeAudio = true
|
||||
if _, err := h.ensureTranscodeSession(context.Background(), "ps", "session", source); errors.Is(err, errTranscode4KDisallowed) {
|
||||
t.Error("ensureTranscodeSession() blocked a video-copy session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRemoteTranscode4KGuard(t *testing.T) {
|
||||
h := &PlaybackHandler{} // nil SettingsRepo: 4K video transcodes denied
|
||||
|
||||
source := PlaybackMediaSource{
|
||||
FileID: 1,
|
||||
Version: catalog.FileVersion{FileID: 1, Resolution: "2160p", CodecVideo: "hevc"},
|
||||
}
|
||||
if err := h.startRemoteTranscode(context.Background(), "session", source, nil, 0, "http://node"); !errors.Is(err, errTranscode4KDisallowed) {
|
||||
t.Errorf("startRemoteTranscode() error = %v, want errTranscode4KDisallowed", err)
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
func ep(id string) *models.Episode {
|
||||
return &models.Episode{ContentID: id}
|
||||
}
|
||||
|
||||
// TestSeriesUserDataFromEpisodes covers the series watch-state rollup: a
|
||||
// series has no progress row of its own, so Played/UnplayedCount/
|
||||
// InProgressCount are aggregated from per-episode progress.
|
||||
func TestSeriesUserDataFromEpisodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
episodes []*models.Episode
|
||||
progress map[string]userstore.WatchProgress
|
||||
wantWatched int
|
||||
wantUnplayed int
|
||||
wantInProgress int
|
||||
wantPlayed bool
|
||||
}{
|
||||
{
|
||||
name: "no episodes",
|
||||
episodes: nil,
|
||||
progress: map[string]userstore.WatchProgress{},
|
||||
// Played must be false for an empty series — unplayed==0 alone
|
||||
// must not flip it.
|
||||
wantPlayed: false,
|
||||
},
|
||||
{
|
||||
name: "all watched",
|
||||
episodes: []*models.Episode{ep("a"), ep("b")},
|
||||
progress: map[string]userstore.WatchProgress{
|
||||
"a": {Completed: true},
|
||||
"b": {Completed: true},
|
||||
},
|
||||
wantWatched: 2,
|
||||
wantPlayed: true,
|
||||
},
|
||||
{
|
||||
name: "mixed watched, in-progress, untouched",
|
||||
episodes: []*models.Episode{ep("a"), ep("b"), ep("c")},
|
||||
progress: map[string]userstore.WatchProgress{
|
||||
"a": {Completed: true},
|
||||
"b": {PositionSeconds: 120},
|
||||
},
|
||||
wantWatched: 1,
|
||||
wantUnplayed: 2,
|
||||
wantInProgress: 1,
|
||||
wantPlayed: false,
|
||||
},
|
||||
{
|
||||
name: "no progress at all",
|
||||
episodes: []*models.Episode{ep("a")},
|
||||
progress: map[string]userstore.WatchProgress{},
|
||||
wantUnplayed: 1,
|
||||
wantPlayed: false,
|
||||
},
|
||||
{
|
||||
name: "nil episodes skipped",
|
||||
episodes: []*models.Episode{nil, ep("a"), nil},
|
||||
progress: map[string]userstore.WatchProgress{
|
||||
"a": {Completed: true},
|
||||
},
|
||||
wantWatched: 1,
|
||||
wantPlayed: true,
|
||||
},
|
||||
{
|
||||
name: "zero-position progress row is not in-progress",
|
||||
episodes: []*models.Episode{ep("a")},
|
||||
progress: map[string]userstore.WatchProgress{
|
||||
"a": {PositionSeconds: 0},
|
||||
},
|
||||
wantUnplayed: 1,
|
||||
wantInProgress: 0,
|
||||
wantPlayed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := seriesUserDataFromEpisodes(tc.episodes, tc.progress)
|
||||
if got.WatchedCount != tc.wantWatched {
|
||||
t.Errorf("WatchedCount = %d, want %d", got.WatchedCount, tc.wantWatched)
|
||||
}
|
||||
if got.UnplayedCount != tc.wantUnplayed {
|
||||
t.Errorf("UnplayedCount = %d, want %d", got.UnplayedCount, tc.wantUnplayed)
|
||||
}
|
||||
if got.InProgressCount != tc.wantInProgress {
|
||||
t.Errorf("InProgressCount = %d, want %d", got.InProgressCount, tc.wantInProgress)
|
||||
}
|
||||
if got.Played != tc.wantPlayed {
|
||||
t.Errorf("Played = %v, want %v", got.Played, tc.wantPlayed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelEpisodeContentIDs verifies nil episodes and empty content ids are
|
||||
// dropped — these ids feed SQL IN-lists.
|
||||
func TestModelEpisodeContentIDs(t *testing.T) {
|
||||
got := modelEpisodeContentIDs([]*models.Episode{nil, ep(""), ep("a"), ep("b")})
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Errorf("modelEpisodeContentIDs = %v, want [a b]", got)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSlicePageIDs locks slicePageIDs to sliceBaseItems' exact bounds
|
||||
// semantics — the two slice the same page in parallel in
|
||||
// writeNextUpResponse, and any divergence would attach detail upgrades to
|
||||
// the wrong DTOs.
|
||||
func TestSlicePageIDs(t *testing.T) {
|
||||
ids := []string{"a", "b", "c", "d"}
|
||||
cases := []struct {
|
||||
name string
|
||||
startIndex int
|
||||
limit int
|
||||
want []string
|
||||
}{
|
||||
{"plain page", 0, 2, []string{"a", "b"}},
|
||||
{"offset page", 1, 2, []string{"b", "c"}},
|
||||
{"limit past end clamps", 2, 10, []string{"c", "d"}},
|
||||
{"start past end is empty", 9, 2, []string{}},
|
||||
{"zero limit returns rest", 1, 0, []string{"b", "c", "d"}},
|
||||
{"negative start clamps to zero", -3, 2, []string{"a", "b"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := slicePageIDs(ids, tc.startIndex, tc.limit)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d (%v)", len(got), len(tc.want), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("got[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
// Mirror against sliceBaseItems on a same-length DTO slice.
|
||||
dtos := make([]baseItemDTO, len(ids))
|
||||
for i := range ids {
|
||||
dtos[i] = baseItemDTO{ID: ids[i]}
|
||||
}
|
||||
sliced := sliceBaseItems(dtos, tc.startIndex, tc.limit)
|
||||
if len(sliced) != len(got) {
|
||||
t.Fatalf("sliceBaseItems len %d diverges from slicePageIDs len %d", len(sliced), len(got))
|
||||
}
|
||||
for i := range sliced {
|
||||
if sliced[i].ID != got[i] {
|
||||
t.Errorf("alignment broken at %d: dto %q vs id %q", i, sliced[i].ID, got[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -165,10 +165,6 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
if err := h.startRemoteTranscode(r.Context(), playSession.UpstreamSessionID, *source, file, playSession.InitialSeekSeconds, tcNode.URL); err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, "TranscodeStartFailed", "Transcode node rejected the request")
|
||||
return
|
||||
}
|
||||
@@ -186,10 +182,6 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
// Ensure the transcode process is running.
|
||||
_, err = h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrManifestNotReady) {
|
||||
writeError(w, http.StatusServiceUnavailable, "NotReady", "Transcode playlist not ready")
|
||||
return
|
||||
@@ -236,10 +228,6 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque
|
||||
// Ensure the transcode process is running.
|
||||
_, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrManifestNotReady) {
|
||||
writeError(w, http.StatusServiceUnavailable, "NotReady", "Transcode playlist not ready")
|
||||
return
|
||||
@@ -822,9 +810,6 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
|
||||
if existing := h.getTranscodeSession(upstreamSessionID); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !source.TranscodeAudio && is4KResolution(source.Version.Resolution) && !h.allow4KVideoTranscode(ctx) {
|
||||
return nil, errTranscode4KDisallowed
|
||||
}
|
||||
if h.fileResolver == nil {
|
||||
return nil, fmt.Errorf("file resolver not available")
|
||||
}
|
||||
@@ -1034,9 +1019,8 @@ func (h *PlaybackHandler) createStaticPlaySession(ctx context.Context, session *
|
||||
|
||||
playSessionID := h.codec.EncodeStringID(EncodedIDPlaySession, uuidNewString())
|
||||
sources := make([]PlaybackMediaSource, 0, len(detail.Versions))
|
||||
allow4KTranscode := h.allow4KVideoTranscode(ctx)
|
||||
for _, version := range detail.Versions {
|
||||
source := h.buildPlaybackSource(routeID, playSessionID, version, DeviceProfile{}, playbackInfoRequest{}, allow4KTranscode)
|
||||
source := h.buildPlaybackSource(routeID, playSessionID, version, DeviceProfile{}, playbackInfoRequest{})
|
||||
sources = append(sources, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,10 +70,6 @@ type metadataItemRepo interface {
|
||||
ListUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]string, error)
|
||||
}
|
||||
|
||||
type metadataItemDeleteRepo interface {
|
||||
Delete(ctx context.Context, contentID string) ([]string, error)
|
||||
}
|
||||
|
||||
type metadataProviderIDRepo interface {
|
||||
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaItemProviderID, error)
|
||||
ReplaceByContentID(ctx context.Context, contentID string, providerIDs map[string]string) error
|
||||
@@ -3745,9 +3741,18 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
|
||||
return s.hooks.createOrFindSkeleton(ctx, file, folderID)
|
||||
}
|
||||
|
||||
libraryType, err := s.folderTypeForSkeleton(ctx, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
libraryType := ""
|
||||
if s != nil && s.folderRepo != nil && folderID > 0 {
|
||||
folder, err := s.folderRepo.GetByID(ctx, folderID)
|
||||
if err != nil {
|
||||
slog.Warn("metadata: failed to load folder context for path classification",
|
||||
"folder_id", folderID,
|
||||
"file_path", file.FilePath,
|
||||
"error", err,
|
||||
)
|
||||
} else if folder != nil {
|
||||
libraryType = folder.Type
|
||||
}
|
||||
}
|
||||
|
||||
contentRootPath := filepath.Dir(file.FilePath)
|
||||
@@ -4022,12 +4027,7 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
|
||||
// pre-confirmation ownership claims. Matching is driven by explicit IDs or
|
||||
// later provider confirmation to avoid cross-root false merges.
|
||||
|
||||
// No existing item — create skeleton. Re-check the folder state while still
|
||||
// under the dedup lock so a library disabled for deletion cannot receive new
|
||||
// skeleton items from a matcher that passed an older outer enabled check.
|
||||
if _, err := s.folderTypeForSkeleton(ctx, folderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// No existing item — create skeleton.
|
||||
contentID, err := generateContentID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate content id: %w", err)
|
||||
@@ -4054,53 +4054,10 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
|
||||
|
||||
// Link file, claim root, and create library membership.
|
||||
if err := s.fileRepo.UpdateContentID(ctx, file.ID, contentID); err != nil {
|
||||
if cleanupErr := s.deleteCreatedSkeleton(ctx, contentID); cleanupErr != nil {
|
||||
slog.Warn("metadata: failed to clean up unlinked skeleton",
|
||||
"content_id", contentID,
|
||||
"file_id", file.ID,
|
||||
"folder_id", folderID,
|
||||
"error", cleanupErr,
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("linking file to skeleton: %w", err)
|
||||
}
|
||||
if _, err := s.folderTypeForSkeleton(ctx, folderID); err != nil {
|
||||
if clearErr := s.fileRepo.UpdateContentID(ctx, file.ID, ""); clearErr != nil {
|
||||
slog.Warn("metadata: failed to clear file link after disabled skeleton folder",
|
||||
"content_id", contentID,
|
||||
"file_id", file.ID,
|
||||
"folder_id", folderID,
|
||||
"error", clearErr,
|
||||
)
|
||||
}
|
||||
if cleanupErr := s.deleteCreatedSkeleton(ctx, contentID); cleanupErr != nil {
|
||||
slog.Warn("metadata: failed to clean up skeleton for disabled folder",
|
||||
"content_id", contentID,
|
||||
"file_id", file.ID,
|
||||
"folder_id", folderID,
|
||||
"error", cleanupErr,
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("validating skeleton folder before membership: %w", err)
|
||||
}
|
||||
if err := s.upsertLibraryMembership(ctx, contentID, folderID); err != nil {
|
||||
if clearErr := s.fileRepo.UpdateContentID(ctx, file.ID, ""); clearErr != nil {
|
||||
slog.Warn("metadata: failed to clear file link after skeleton membership failure",
|
||||
"content_id", contentID,
|
||||
"file_id", file.ID,
|
||||
"folder_id", folderID,
|
||||
"error", clearErr,
|
||||
)
|
||||
}
|
||||
if cleanupErr := s.deleteCreatedSkeleton(ctx, contentID); cleanupErr != nil {
|
||||
slog.Warn("metadata: failed to clean up membershipless skeleton",
|
||||
"content_id", contentID,
|
||||
"file_id", file.ID,
|
||||
"folder_id", folderID,
|
||||
"error", cleanupErr,
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("upserting skeleton membership: %w", err)
|
||||
s.logLibraryMembershipError("upserting skeleton membership", contentID, folderID, err)
|
||||
}
|
||||
|
||||
res.ContentID = contentID
|
||||
@@ -4108,47 +4065,6 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) folderTypeForSkeleton(ctx context.Context, folderID int) (string, error) {
|
||||
if s == nil || s.folderRepo == nil || folderID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
folder, err := s.folderRepo.GetByID(ctx, folderID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("loading folder context for skeleton: %w", err)
|
||||
}
|
||||
if folder == nil {
|
||||
return "", fmt.Errorf("folder %d is unavailable for skeleton creation", folderID)
|
||||
}
|
||||
if !folder.Enabled {
|
||||
return "", fmt.Errorf("folder %d is disabled for skeleton creation", folderID)
|
||||
}
|
||||
return folder.Type, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) deleteCreatedSkeleton(ctx context.Context, contentID string) error {
|
||||
if s == nil || strings.TrimSpace(contentID) == "" {
|
||||
return nil
|
||||
}
|
||||
if repo, ok := s.itemRepo.(metadataItemDeleteRepo); ok {
|
||||
if _, err := repo.Delete(ctx, contentID); err != nil && !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if s.dbPool == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := s.dbPool.Exec(ctx, `
|
||||
DELETE FROM media_items
|
||||
WHERE content_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil
|
||||
WHERE mil.content_id = media_items.content_id
|
||||
)`, contentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// claimRoot records a canonical root path claim for dedup. Failures are logged
|
||||
// but not fatal — the root claim is an optimization, not a hard requirement.
|
||||
func (s *MetadataService) claimRoot(ctx context.Context, folderID int, rootPath, contentID string) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
@@ -82,16 +81,6 @@ func (r *fakeItemRepo) Upsert(_ context.Context, item *models.MediaItem) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeItemRepo) Delete(_ context.Context, contentID string) ([]string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.items[contentID]; !ok {
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
delete(r.items, contentID)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeItemRepo) IncrementRefreshFailure(_ context.Context, contentID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -270,8 +259,6 @@ type fakeFileRepo struct {
|
||||
groupContent map[string]string // "folderID:version:key" -> contentID
|
||||
groupFiles map[string][]*models.MediaFile
|
||||
matchStamps map[int]time.Time // fileID -> match_attempted_at
|
||||
updateErrors map[int]error
|
||||
afterUpdate func(fileID int, contentID string)
|
||||
claimUnmatchedCalls int
|
||||
claimNonSeriesCalls int
|
||||
claimMixedCalls int
|
||||
@@ -286,22 +273,13 @@ func newFakeFileRepo() *fakeFileRepo {
|
||||
groupContent: make(map[string]string),
|
||||
groupFiles: make(map[string][]*models.MediaFile),
|
||||
matchStamps: make(map[int]time.Time),
|
||||
updateErrors: make(map[int]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeFileRepo) UpdateContentID(_ context.Context, fileID int, contentID string) error {
|
||||
r.mu.Lock()
|
||||
if err := r.updateErrors[fileID]; err != nil {
|
||||
r.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
defer r.mu.Unlock()
|
||||
r.contentIDs[fileID] = contentID
|
||||
afterUpdate := r.afterUpdate
|
||||
r.mu.Unlock()
|
||||
if afterUpdate != nil {
|
||||
afterUpdate(fileID, contentID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1190,109 +1168,6 @@ func TestCreateOrFindSkeleton_NoFolderIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrFindSkeleton_DisabledFolderDoesNotCreateItem(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
h.service.folderRepo = &fakeMetadataFolderRepo{
|
||||
folders: map[int]*models.MediaFolder{
|
||||
10: {ID: 10, Type: "movies", Enabled: false},
|
||||
},
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
file := &models.MediaFile{
|
||||
ID: 1,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
|
||||
}
|
||||
|
||||
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected disabled folder error")
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("expected no result, got %+v", result)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "disabled") {
|
||||
t.Fatalf("expected disabled error, got %v", err)
|
||||
}
|
||||
if len(h.itemRepo.items) != 0 {
|
||||
t.Fatalf("expected no items to be created, got %d", len(h.itemRepo.items))
|
||||
}
|
||||
if cid := h.fileRepo.contentIDs[file.ID]; cid != "" {
|
||||
t.Fatalf("expected file to remain unlinked, got content_id %q", cid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrFindSkeleton_LinkFailureDeletesSkeleton(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
linkErr := errors.New("media file not found")
|
||||
h.fileRepo.updateErrors[1] = linkErr
|
||||
|
||||
file := &models.MediaFile{
|
||||
ID: 1,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
|
||||
}
|
||||
|
||||
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected link error")
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("expected no result, got %+v", result)
|
||||
}
|
||||
if !errors.Is(err, linkErr) {
|
||||
t.Fatalf("expected wrapped link error, got %v", err)
|
||||
}
|
||||
if len(h.itemRepo.items) != 0 {
|
||||
t.Fatalf("expected skeleton item to be deleted, got %d items", len(h.itemRepo.items))
|
||||
}
|
||||
if len(h.libraryRepo.memberships) != 0 {
|
||||
t.Fatalf("expected no library memberships, got %d", len(h.libraryRepo.memberships))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrFindSkeleton_DisabledBeforeMembershipDeletesSkeleton(t *testing.T) {
|
||||
h := newTestHarness()
|
||||
ctx := context.Background()
|
||||
folder := &models.MediaFolder{ID: 10, Type: "movies", Enabled: true}
|
||||
h.service.folderRepo = &fakeMetadataFolderRepo{
|
||||
folders: map[int]*models.MediaFolder{10: folder},
|
||||
}
|
||||
h.fileRepo.afterUpdate = func(_ int, contentID string) {
|
||||
if contentID != "" {
|
||||
folder.Enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
file := &models.MediaFile{
|
||||
ID: 1,
|
||||
MediaFolderID: 10,
|
||||
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
|
||||
}
|
||||
|
||||
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
|
||||
if err == nil {
|
||||
t.Fatal("expected disabled folder error")
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("expected no result, got %+v", result)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "disabled") {
|
||||
t.Fatalf("expected disabled error, got %v", err)
|
||||
}
|
||||
if len(h.itemRepo.items) != 0 {
|
||||
t.Fatalf("expected skeleton item to be deleted, got %d items", len(h.itemRepo.items))
|
||||
}
|
||||
if len(h.libraryRepo.memberships) != 0 {
|
||||
t.Fatalf("expected no library memberships, got %d", len(h.libraryRepo.memberships))
|
||||
}
|
||||
if cid := h.fileRepo.contentIDs[file.ID]; cid != "" {
|
||||
t.Fatalf("expected file link to be cleared, got content_id %q", cid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOrFindSkeleton_PendingItemGetsLibraryMembership verifies that
|
||||
// library membership is created immediately for pending items, not just for
|
||||
// matched items.
|
||||
|
||||
+19
-206
@@ -1,38 +1,15 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultDRIDir = "/dev/dri"
|
||||
defaultNVIDIAControlDevice = "/dev/nvidiactl"
|
||||
defaultNVIDIADeviceGlob = "/dev/nvidia[0-9]*"
|
||||
sysClassDRMDir = "/sys/class/drm"
|
||||
currentGOOS = runtime.GOOS
|
||||
nvencProbeCommandTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
type nvencProbeResult struct {
|
||||
available bool
|
||||
reason string
|
||||
}
|
||||
|
||||
var nvencProbeCache = struct {
|
||||
sync.Mutex
|
||||
byPath map[string]nvencProbeResult
|
||||
}{
|
||||
byPath: make(map[string]nvencProbeResult),
|
||||
}
|
||||
const defaultDRIDir = "/dev/dri"
|
||||
|
||||
// HWAccelInfo describes the detected hardware acceleration capability.
|
||||
type HWAccelInfo struct {
|
||||
@@ -45,11 +22,6 @@ type HWAccelInfo struct {
|
||||
|
||||
// DetectHWAccel probes this host's GPU hardware and returns structured info.
|
||||
func DetectHWAccel() HWAccelInfo {
|
||||
return DetectHWAccelWithFFmpeg("")
|
||||
}
|
||||
|
||||
// DetectHWAccelWithFFmpeg probes this host's GPU hardware and configured FFmpeg.
|
||||
func DetectHWAccelWithFFmpeg(ffmpegPath string) HWAccelInfo {
|
||||
devices := listRenderDevices(defaultDRIDir)
|
||||
intel := false
|
||||
for _, d := range devices {
|
||||
@@ -59,7 +31,7 @@ func DetectHWAccelWithFFmpeg(ffmpegPath string) HWAccelInfo {
|
||||
}
|
||||
}
|
||||
return HWAccelInfo{
|
||||
Resolved: ResolveHWAccelWithFFmpeg("auto", ffmpegPath),
|
||||
Resolved: ResolveHWAccel("auto"),
|
||||
RenderDevices: devices,
|
||||
IntelDetected: intel,
|
||||
Source: "local",
|
||||
@@ -81,163 +53,34 @@ func PickRenderDevice(explicit string) string {
|
||||
return dev
|
||||
}
|
||||
|
||||
// ResolveHWAccel resolves "auto" using the default FFmpeg binary.
|
||||
func ResolveHWAccel(hwAccel string) string {
|
||||
return ResolveHWAccelWithFFmpeg(hwAccel, "")
|
||||
}
|
||||
|
||||
// ResolveHWAccelWithFFmpeg resolves "auto" into a concrete acceleration method
|
||||
// by probing the system and the configured FFmpeg binary.
|
||||
// Preference order: nvenc > qsv > vaapi > none.
|
||||
// ResolveHWAccel resolves "auto" into a concrete acceleration method by
|
||||
// probing the system. Preference order: qsv > vaapi > none.
|
||||
// Non-"auto" values are returned unchanged.
|
||||
func ResolveHWAccelWithFFmpeg(hwAccel string, ffmpegPath string) string {
|
||||
func ResolveHWAccel(hwAccel string) string {
|
||||
if hwAccel != "auto" {
|
||||
return hwAccel
|
||||
}
|
||||
if currentGOOS != "linux" {
|
||||
if runtime.GOOS != "linux" {
|
||||
return "none"
|
||||
}
|
||||
|
||||
devices := listRenderDevices(defaultDRIDir)
|
||||
var intelDevice string
|
||||
var nvidiaDevice string
|
||||
var vaapiDevice string
|
||||
if len(devices) == 0 {
|
||||
slog.Info("hw_accel=auto: no render devices found, using software encoding")
|
||||
return "none"
|
||||
}
|
||||
|
||||
// Check for Intel GPU — enables QSV (preferred).
|
||||
for _, dev := range devices {
|
||||
switch {
|
||||
case isNVIDIADevice(dev):
|
||||
if nvidiaDevice == "" {
|
||||
nvidiaDevice = dev
|
||||
}
|
||||
case isIntelDevice(dev):
|
||||
if intelDevice == "" {
|
||||
intelDevice = dev
|
||||
}
|
||||
default:
|
||||
if vaapiDevice == "" {
|
||||
vaapiDevice = dev
|
||||
}
|
||||
if isIntelDevice(dev) {
|
||||
slog.Info("hw_accel=auto: Intel GPU detected, using QSV", "device", dev)
|
||||
return "qsv"
|
||||
}
|
||||
}
|
||||
|
||||
if nvidiaDevice != "" || hasNVIDIADevice() {
|
||||
if ok, reason := ffmpegSupportsNVENC(ffmpegPath); ok {
|
||||
if nvidiaDevice != "" {
|
||||
slog.Info("hw_accel=auto: NVIDIA GPU detected, using NVENC", "device", nvidiaDevice)
|
||||
} else {
|
||||
slog.Info("hw_accel=auto: NVIDIA device detected, using NVENC")
|
||||
}
|
||||
return "nvenc"
|
||||
} else {
|
||||
slog.Warn("hw_accel=auto: NVIDIA device detected but FFmpeg NVENC probe failed",
|
||||
"ffmpeg", normalizeFFmpegPath(ffmpegPath), "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
if intelDevice != "" {
|
||||
slog.Info("hw_accel=auto: Intel GPU detected, using QSV", "device", intelDevice)
|
||||
return "qsv"
|
||||
}
|
||||
|
||||
if vaapiDevice != "" {
|
||||
slog.Info("hw_accel=auto: non-Intel GPU detected, using VAAPI", "device", vaapiDevice)
|
||||
return "vaapi"
|
||||
}
|
||||
|
||||
slog.Info("hw_accel=auto: no compatible GPU devices found, using software encoding")
|
||||
return "none"
|
||||
}
|
||||
|
||||
func ffmpegSupportsNVENC(ffmpegPath string) (bool, string) {
|
||||
ffmpegPath = normalizeFFmpegPath(ffmpegPath)
|
||||
nvencProbeCache.Lock()
|
||||
if result, ok := nvencProbeCache.byPath[ffmpegPath]; ok {
|
||||
nvencProbeCache.Unlock()
|
||||
return result.available, result.reason
|
||||
}
|
||||
nvencProbeCache.Unlock()
|
||||
|
||||
result := probeFFmpegNVENC(ffmpegPath)
|
||||
nvencProbeCache.Lock()
|
||||
nvencProbeCache.byPath[ffmpegPath] = result
|
||||
nvencProbeCache.Unlock()
|
||||
return result.available, result.reason
|
||||
}
|
||||
|
||||
func normalizeFFmpegPath(ffmpegPath string) string {
|
||||
ffmpegPath = strings.TrimSpace(ffmpegPath)
|
||||
if ffmpegPath == "" {
|
||||
ffmpegPath = ffmpegBinary()
|
||||
}
|
||||
if strings.ContainsRune(ffmpegPath, os.PathSeparator) {
|
||||
return filepath.Clean(ffmpegPath)
|
||||
}
|
||||
return ffmpegPath
|
||||
}
|
||||
|
||||
func probeFFmpegNVENC(ffmpegPath string) nvencProbeResult {
|
||||
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-hwaccels"); err != nil {
|
||||
return nvencProbeResult{reason: "hwaccels probe failed: " + probeFailure(err, output)}
|
||||
} else if !ffmpegOutputHasToken(output, "cuda") {
|
||||
return nvencProbeResult{reason: "cuda hwaccel unavailable"}
|
||||
}
|
||||
|
||||
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-encoders"); err != nil {
|
||||
return nvencProbeResult{reason: "encoders probe failed: " + probeFailure(err, output)}
|
||||
} else if !ffmpegOutputHasToken(output, "h264_nvenc") {
|
||||
return nvencProbeResult{reason: "h264_nvenc encoder unavailable"}
|
||||
} else if !ffmpegOutputHasToken(output, "hevc_nvenc") {
|
||||
return nvencProbeResult{reason: "hevc_nvenc encoder unavailable"}
|
||||
}
|
||||
|
||||
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-filters"); err != nil {
|
||||
return nvencProbeResult{reason: "filters probe failed: " + probeFailure(err, output)}
|
||||
} else if !ffmpegOutputHasToken(output, "scale_cuda") {
|
||||
return nvencProbeResult{reason: "scale_cuda filter unavailable"}
|
||||
} else if !ffmpegOutputHasToken(output, "hwupload_cuda") {
|
||||
return nvencProbeResult{reason: "hwupload_cuda filter unavailable"}
|
||||
}
|
||||
|
||||
if output, err := runFFmpegProbe(ffmpegPath,
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc2=size=640x360:rate=1",
|
||||
"-frames:v", "1",
|
||||
"-an",
|
||||
"-c:v", "h264_nvenc",
|
||||
"-f", "null",
|
||||
"-",
|
||||
); err != nil {
|
||||
return nvencProbeResult{reason: "h264_nvenc smoke encode failed: " + probeFailure(err, output)}
|
||||
}
|
||||
|
||||
return nvencProbeResult{available: true}
|
||||
}
|
||||
|
||||
func runFFmpegProbe(ffmpegPath string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nvencProbeCommandTimeout)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, ffmpegPath, args...).CombinedOutput()
|
||||
}
|
||||
|
||||
func ffmpegOutputHasToken(output []byte, token string) bool {
|
||||
for _, field := range strings.Fields(string(output)) {
|
||||
if strings.EqualFold(field, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func probeFailure(err error, output []byte) string {
|
||||
message := strings.TrimSpace(err.Error())
|
||||
if trimmed := strings.TrimSpace(string(output)); trimmed != "" {
|
||||
if len(trimmed) > 240 {
|
||||
trimmed = trimmed[:240] + "..."
|
||||
}
|
||||
message += ": " + trimmed
|
||||
}
|
||||
return message
|
||||
// Any accessible render device supports VAAPI.
|
||||
slog.Info("hw_accel=auto: non-Intel GPU detected, using VAAPI", "device", devices[0])
|
||||
return "vaapi"
|
||||
}
|
||||
|
||||
// listRenderDevices returns all accessible /dev/dri/renderD* paths, sorted.
|
||||
@@ -264,7 +107,7 @@ func listRenderDevices(driDir string) []string {
|
||||
func isIntelDevice(renderDevPath string) bool {
|
||||
// /dev/dri/renderD128 → card name "renderD128"
|
||||
name := filepath.Base(renderDevPath)
|
||||
vendorPath := filepath.Join(sysClassDRMDir, name, "device", "vendor")
|
||||
vendorPath := filepath.Join("/sys/class/drm", name, "device", "vendor")
|
||||
data, err := os.ReadFile(vendorPath)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -272,36 +115,6 @@ func isIntelDevice(renderDevPath string) bool {
|
||||
return strings.TrimSpace(string(data)) == "0x8086"
|
||||
}
|
||||
|
||||
// isNVIDIADevice checks whether a render device belongs to an NVIDIA GPU by
|
||||
// reading the PCI vendor ID from sysfs. NVIDIA vendor ID is 0x10de.
|
||||
func isNVIDIADevice(renderDevPath string) bool {
|
||||
name := filepath.Base(renderDevPath)
|
||||
vendorPath := filepath.Join(sysClassDRMDir, name, "device", "vendor")
|
||||
data, err := os.ReadFile(vendorPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == "0x10de"
|
||||
}
|
||||
|
||||
func hasNVIDIADevice() bool {
|
||||
if file, err := os.Open(defaultNVIDIAControlDevice); err == nil {
|
||||
file.Close()
|
||||
return true
|
||||
}
|
||||
matches, err := filepath.Glob(defaultNVIDIADeviceGlob)
|
||||
if err != nil || len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, dev := range matches {
|
||||
if file, err := os.Open(dev); err == nil {
|
||||
file.Close()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectRenderDevice enumerates /dev/dri/renderD* and returns the first
|
||||
// available device, or empty string if none found.
|
||||
func detectRenderDevice(driDir string) string {
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hwAccelTestEnv struct {
|
||||
devDir string
|
||||
driDir string
|
||||
sysDir string
|
||||
}
|
||||
|
||||
type fakeFFmpegProbe struct {
|
||||
cuda bool
|
||||
h264NVENC bool
|
||||
hevcNVENC bool
|
||||
scaleCUDA bool
|
||||
uploadCUDA bool
|
||||
smokeOK bool
|
||||
hang bool
|
||||
}
|
||||
|
||||
type fakeFFmpegBinary struct {
|
||||
path string
|
||||
logPath string
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegAutoPrefersNVENCOverIntel(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x8086")
|
||||
env.addRenderDevice(t, "renderD129", "0x10de")
|
||||
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegFallsBackToIntelWhenNVENCProbeFails(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x8086")
|
||||
env.addRenderDevice(t, "renderD129", "0x10de")
|
||||
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "qsv" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want qsv", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegFallsBackToVAAPIWhenNVENCProbeFails(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x10de")
|
||||
env.addRenderDevice(t, "renderD129", "0x1002")
|
||||
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "vaapi" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want vaapi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegReturnsNoneWhenNVENCProbeFailsWithoutFallback(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x10de")
|
||||
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "none" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegUsesNVIDIADeviceNodesWithoutDRM(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addNVIDIADevice(t, "nvidia0")
|
||||
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitNVENCBypassesFFmpegProbe(t *testing.T) {
|
||||
setupHWAccelTest(t)
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("nvenc", "/does/not/exist/ffmpeg"); got != "nvenc" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegSupportsNVENCRequiresCUDAEncodersFiltersAndSmoke(t *testing.T) {
|
||||
setupHWAccelTest(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
probe fakeFFmpegProbe
|
||||
}{
|
||||
{
|
||||
name: "missing cuda hwaccel",
|
||||
probe: fakeFFmpegProbe{
|
||||
h264NVENC: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing h264 nvenc encoder",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing hevc nvenc encoder",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, h264NVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing scale cuda filter",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, h264NVENC: true, hevcNVENC: true, uploadCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing hwupload cuda filter",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, h264NVENC: true, hevcNVENC: true, scaleCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "smoke encode failure",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, h264NVENC: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "probe timeout",
|
||||
probe: fakeFFmpegProbe{hang: true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetNVENCProbeCacheForTest()
|
||||
ffmpeg := writeFakeFFmpeg(t, tt.probe)
|
||||
if ok, reason := ffmpegSupportsNVENC(ffmpeg.path); ok {
|
||||
t.Fatalf("ffmpegSupportsNVENC() = true, want false")
|
||||
} else if reason == "" {
|
||||
t.Fatalf("ffmpegSupportsNVENC() reason is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegSupportsNVENCCachesByFFmpegPath(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x10de")
|
||||
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() call %d = %q, want nvenc", i+1, got)
|
||||
}
|
||||
}
|
||||
|
||||
logData, err := os.ReadFile(ffmpeg.logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read ffmpeg probe log: %v", err)
|
||||
}
|
||||
if got := strings.Count(string(logData), "\n"); got != 4 {
|
||||
t.Fatalf("probe command count = %d, want 4; log:\n%s", got, logData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegSupportsNVENCSmokeProbeUsesSafeFrameDimensions(t *testing.T) {
|
||||
setupHWAccelTest(t)
|
||||
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
|
||||
|
||||
if ok, reason := ffmpegSupportsNVENC(ffmpeg.path); !ok {
|
||||
t.Fatalf("ffmpegSupportsNVENC() = false, want true (reason=%q)", reason)
|
||||
}
|
||||
|
||||
logData, err := os.ReadFile(ffmpeg.logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read ffmpeg probe log: %v", err)
|
||||
}
|
||||
logText := string(logData)
|
||||
if !strings.Contains(logText, "testsrc2=size=640x360:rate=1") {
|
||||
t.Fatalf("smoke probe should use 640x360 input; log:\n%s", logText)
|
||||
}
|
||||
}
|
||||
|
||||
func successfulNVENCProbe() fakeFFmpegProbe {
|
||||
return fakeFFmpegProbe{
|
||||
cuda: true,
|
||||
h264NVENC: true,
|
||||
hevcNVENC: true,
|
||||
scaleCUDA: true,
|
||||
uploadCUDA: true,
|
||||
smokeOK: true,
|
||||
}
|
||||
}
|
||||
|
||||
func setupHWAccelTest(t *testing.T) *hwAccelTestEnv {
|
||||
t.Helper()
|
||||
|
||||
oldDRIDir := defaultDRIDir
|
||||
oldNVIDIAControlDevice := defaultNVIDIAControlDevice
|
||||
oldNVIDIADeviceGlob := defaultNVIDIADeviceGlob
|
||||
oldSysClassDRMDir := sysClassDRMDir
|
||||
oldGOOS := currentGOOS
|
||||
oldProbeTimeout := nvencProbeCommandTimeout
|
||||
resetNVENCProbeCacheForTest()
|
||||
|
||||
tmp := t.TempDir()
|
||||
env := &hwAccelTestEnv{
|
||||
devDir: filepath.Join(tmp, "dev"),
|
||||
driDir: filepath.Join(tmp, "dev", "dri"),
|
||||
sysDir: filepath.Join(tmp, "sys", "class", "drm"),
|
||||
}
|
||||
defaultDRIDir = env.driDir
|
||||
defaultNVIDIAControlDevice = filepath.Join(env.devDir, "nvidiactl")
|
||||
defaultNVIDIADeviceGlob = filepath.Join(env.devDir, "nvidia[0-9]*")
|
||||
sysClassDRMDir = env.sysDir
|
||||
currentGOOS = "linux"
|
||||
nvencProbeCommandTimeout = 200 * time.Millisecond
|
||||
|
||||
if err := os.MkdirAll(env.driDir, 0o755); err != nil {
|
||||
t.Fatalf("create test dri dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(env.devDir, 0o755); err != nil {
|
||||
t.Fatalf("create test dev dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
defaultDRIDir = oldDRIDir
|
||||
defaultNVIDIAControlDevice = oldNVIDIAControlDevice
|
||||
defaultNVIDIADeviceGlob = oldNVIDIADeviceGlob
|
||||
sysClassDRMDir = oldSysClassDRMDir
|
||||
currentGOOS = oldGOOS
|
||||
nvencProbeCommandTimeout = oldProbeTimeout
|
||||
resetNVENCProbeCacheForTest()
|
||||
})
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *hwAccelTestEnv) addRenderDevice(t *testing.T, name string, vendor string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(e.driDir, name), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create render device: %v", err)
|
||||
}
|
||||
vendorPath := filepath.Join(e.sysDir, name, "device", "vendor")
|
||||
if err := os.MkdirAll(filepath.Dir(vendorPath), 0o755); err != nil {
|
||||
t.Fatalf("create vendor dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(vendorPath, []byte(vendor+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write vendor file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *hwAccelTestEnv) addNVIDIADevice(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(e.devDir, name), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create nvidia device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeFFmpeg(t *testing.T, probe fakeFFmpegProbe) fakeFFmpegBinary {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ffmpeg")
|
||||
logPath := filepath.Join(dir, "probe.log")
|
||||
|
||||
script := "#!/bin/sh\n"
|
||||
script += fmt.Sprintf("printf '%%s\\n' \"$*\" >> %q\n", logPath)
|
||||
if probe.hang {
|
||||
script += "sleep 5\n"
|
||||
}
|
||||
script += "case \"$*\" in\n"
|
||||
script += " *-hwaccels*)\n"
|
||||
script += " echo 'Hardware acceleration methods:'\n"
|
||||
if probe.cuda {
|
||||
script += " echo 'cuda'\n"
|
||||
}
|
||||
script += " exit 0 ;;\n"
|
||||
script += " *-encoders*)\n"
|
||||
if probe.h264NVENC {
|
||||
script += " echo ' V..... h264_nvenc NVIDIA NVENC H.264 encoder'\n"
|
||||
}
|
||||
if probe.hevcNVENC {
|
||||
script += " echo ' V..... hevc_nvenc NVIDIA NVENC hevc encoder'\n"
|
||||
}
|
||||
script += " exit 0 ;;\n"
|
||||
script += " *-filters*)\n"
|
||||
if probe.scaleCUDA {
|
||||
script += " echo ' ... scale_cuda V->V GPU video scaling'\n"
|
||||
}
|
||||
if probe.uploadCUDA {
|
||||
script += " echo ' ... hwupload_cuda V->V upload CUDA frames'\n"
|
||||
}
|
||||
script += " exit 0 ;;\n"
|
||||
script += " *)\n"
|
||||
if probe.smokeOK {
|
||||
script += " exit 0 ;;\n"
|
||||
} else {
|
||||
script += " echo 'no capable devices found' >&2\n"
|
||||
script += " exit 1 ;;\n"
|
||||
}
|
||||
script += "esac\n"
|
||||
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake ffmpeg: %v", err)
|
||||
}
|
||||
return fakeFFmpegBinary{path: path, logPath: logPath}
|
||||
}
|
||||
|
||||
func resetNVENCProbeCacheForTest() {
|
||||
nvencProbeCache.Lock()
|
||||
defer nvencProbeCache.Unlock()
|
||||
nvencProbeCache.byPath = make(map[string]nvencProbeResult)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ type TranscodeOpts struct {
|
||||
SegmentDuration int // seconds, default 6
|
||||
StartSegmentNumber int // -hls_segment_start_number, default 0
|
||||
FFmpegPath string // optional explicit ffmpeg binary path
|
||||
HWAccel string // auto, qsv, vaapi, nvenc, none
|
||||
HWAccel string // auto, qsv, vaapi, none
|
||||
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
|
||||
SubtitleTrackIndex int // -1 = no subtitles
|
||||
SubtitleBurnIn bool
|
||||
@@ -294,9 +294,6 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
|
||||
} else if opts.HWAccel == "vaapi" {
|
||||
scale := vaapiScaleFilter(opts.TargetResolution)
|
||||
args = append(args, "-vf", scale)
|
||||
} else if opts.HWAccel == "nvenc" {
|
||||
scale := nvencScaleFilter(opts.TargetResolution)
|
||||
args = append(args, "-vf", scale)
|
||||
} else if opts.TargetResolution != "" {
|
||||
scale := resolutionToScale(opts.TargetResolution)
|
||||
if scale != "" {
|
||||
@@ -354,7 +351,7 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
|
||||
}
|
||||
|
||||
func resolveEffectiveTranscodeHWAccel(opts TranscodeOpts) string {
|
||||
hwAccel := ResolveHWAccelWithFFmpeg(opts.HWAccel, opts.FFmpegPath)
|
||||
hwAccel := ResolveHWAccel(opts.HWAccel)
|
||||
if hwAccel == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -425,12 +422,12 @@ func appendSegmentBoundaryArgs(args []string, opts TranscodeOpts) []string {
|
||||
fmt.Sprintf("expr:gte(t,n_forced*%d)", opts.SegmentDuration))
|
||||
}
|
||||
|
||||
// Hardware encoders (QSV, VAAPI, NVENC) may not reliably honor
|
||||
// Hardware encoders (QSV, VAAPI) may not reliably honor
|
||||
// force_key_frames expressions. Set explicit GOP size so segment
|
||||
// boundaries always start with an IDR frame. We assume 30 fps as a
|
||||
// safe ceiling — the GOP will be at most segmentDuration * 30 frames.
|
||||
// Matches Jellyfin's approach for hardware encoders.
|
||||
if opts.HWAccel == "qsv" || opts.HWAccel == "vaapi" || opts.HWAccel == "nvenc" {
|
||||
if opts.HWAccel == "qsv" || opts.HWAccel == "vaapi" {
|
||||
gopSize := fmt.Sprintf("%d", opts.SegmentDuration*30)
|
||||
args = append(args, "-g", gopSize, "-keyint_min", gopSize)
|
||||
}
|
||||
@@ -468,15 +465,6 @@ func appendHWAccelArgs(args []string, opts TranscodeOpts) []string {
|
||||
"-hwaccel", "vaapi",
|
||||
"-hwaccel_output_format", "vaapi",
|
||||
)
|
||||
case "nvenc":
|
||||
args = append(args,
|
||||
"-hwaccel", "cuda",
|
||||
"-hwaccel_output_format", "cuda",
|
||||
"-noautorotate",
|
||||
)
|
||||
if hwDevice := strings.TrimSpace(opts.HWDevice); hwDevice != "" {
|
||||
args = append(args, "-hwaccel_device", hwDevice)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -542,26 +530,6 @@ func appendVideoArgs(args []string, opts TranscodeOpts) []string {
|
||||
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
|
||||
}
|
||||
case opts.HWAccel == "nvenc" && codec == "h264":
|
||||
args = append(args, "-c:v", "h264_nvenc", "-rc:v", "vbr")
|
||||
if hasBitrateCap {
|
||||
args = append(args,
|
||||
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
|
||||
} else {
|
||||
args = append(args, "-cq:v", "23", "-b:v", "0")
|
||||
}
|
||||
case opts.HWAccel == "nvenc" && codec == "hevc":
|
||||
args = append(args, "-c:v", "hevc_nvenc", "-rc:v", "vbr")
|
||||
if hasBitrateCap {
|
||||
args = append(args,
|
||||
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
|
||||
} else {
|
||||
args = append(args, "-cq:v", "28", "-b:v", "0")
|
||||
}
|
||||
default:
|
||||
// CPU fallback — match Jellyfin's proven browser-compatible settings.
|
||||
// Force yuv420p to ensure 8-bit output (10-bit sources produce High 10
|
||||
@@ -641,10 +609,6 @@ func appendSubtitleBurnInArgs(args []string, opts TranscodeOpts) []string {
|
||||
// VAAPI-only: download, apply CPU filters, convert to nv12, upload back.
|
||||
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload"
|
||||
args = append(args, "-vf", vf)
|
||||
case "nvenc":
|
||||
// NVENC/CUDA: download to CPU for subtitle rendering, then upload back.
|
||||
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload_cuda"
|
||||
args = append(args, "-vf", vf)
|
||||
default:
|
||||
// CPU encoding: filters run directly on decoded frames.
|
||||
args = append(args, "-vf", cpuFilters)
|
||||
@@ -715,25 +679,6 @@ func vaapiScaleFilter(res string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func nvencScaleFilter(res string) string {
|
||||
switch res {
|
||||
case "2160p":
|
||||
return "scale_cuda=w=-2:h=2160:format=nv12"
|
||||
case "1080p":
|
||||
return "scale_cuda=w=-2:h=1080:format=nv12"
|
||||
case "720p":
|
||||
return "scale_cuda=w=-2:h=720:format=nv12"
|
||||
case "480p":
|
||||
return "scale_cuda=w=-2:h=480:format=nv12"
|
||||
case "420p":
|
||||
return "scale_cuda=w=-2:h=420:format=nv12"
|
||||
case "328p":
|
||||
return "scale_cuda=w=-2:h=328:format=nv12"
|
||||
default:
|
||||
return "scale_cuda=format=nv12"
|
||||
}
|
||||
}
|
||||
|
||||
// filterPathReplacer escapes special characters in file paths for ffmpeg filter syntax.
|
||||
var filterPathReplacer = strings.NewReplacer(
|
||||
"'", "'\\''",
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestBuildFFmpegArgs_CopyVideoSeekPreservesCodecCopy(t *testing.T) {
|
||||
t.Fatalf("copy-mode seek should preserve -c:v copy: %s", joined)
|
||||
}
|
||||
// Must not contain any video encoder.
|
||||
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc"} {
|
||||
for _, enc := range []string{"h264_qsv", "h264_vaapi", "libx264", "hevc_qsv"} {
|
||||
if strings.Contains(joined, enc) {
|
||||
t.Fatalf("copy-mode seek should not use encoder %s: %s", enc, joined)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestBuildFFmpegArgs_MPEG2CopyVideoUsesMPEGTS(t *testing.T) {
|
||||
if strings.Contains(joined, "movflags=+frag_discont") {
|
||||
t.Fatalf("mpeg2 MPEG-TS copy-mode should not use fMP4 movflags: %s", joined)
|
||||
}
|
||||
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc", "libx265"} {
|
||||
for _, enc := range []string{"h264_qsv", "h264_vaapi", "libx264", "hevc_qsv", "libx265"} {
|
||||
if strings.Contains(joined, enc) {
|
||||
t.Fatalf("mpeg2 copy-mode should not use encoder %s: %s", enc, joined)
|
||||
}
|
||||
@@ -234,11 +234,6 @@ func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
|
||||
opts: TranscodeOpts{HWAccel: "vaapi", SourceVideoCodec: "mpeg4", TargetCodecVideo: "h264"},
|
||||
want: "none",
|
||||
},
|
||||
{
|
||||
name: "nvenc passthrough",
|
||||
opts: TranscodeOpts{HWAccel: "nvenc", SourceVideoCodec: "h264", TargetCodecVideo: "h264"},
|
||||
want: "nvenc",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -252,38 +247,6 @@ func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgs_NVENCH264UsesCudaPipeline(t *testing.T) {
|
||||
args := buildFFmpegArgs(TranscodeOpts{
|
||||
InputPath: "/media/movie.mkv",
|
||||
OutputDir: "/tmp/out",
|
||||
SessionID: "session-nvenc",
|
||||
SourceVideoCodec: "h264",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
HWAccel: "nvenc",
|
||||
TargetResolution: "720p",
|
||||
TargetBitrateKbps: 2000,
|
||||
})
|
||||
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-hwaccel cuda") {
|
||||
t.Fatalf("nvenc args should enable cuda hwaccel: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-c:v h264_nvenc") {
|
||||
t.Fatalf("nvenc args should use h264_nvenc encoder: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-vf scale_cuda=w=-2:h=720:format=nv12") {
|
||||
t.Fatalf("nvenc args should use scale_cuda, not software scale: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "-vf scale=-2:720") {
|
||||
t.Fatalf("nvenc args must not use software scale on cuda frames: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-b:v 2000k -maxrate 2000k -bufsize 4000k") {
|
||||
t.Fatalf("nvenc args should include bitrate cap controls: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgs_VAAPIScalingUsesHardwareFilter(t *testing.T) {
|
||||
args := buildFFmpegArgs(TranscodeOpts{
|
||||
InputPath: "/media/movie.mkv",
|
||||
|
||||
@@ -482,53 +482,6 @@ func (s *Service) ScanSourceClient(
|
||||
return client.ScanSource(capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) ScanSourceClientByPluginID(
|
||||
ctx context.Context,
|
||||
pluginID string,
|
||||
capabilityID string,
|
||||
) (*pluginhost.ScanSourceClient, error) {
|
||||
if s == nil || s.installations == nil {
|
||||
return nil, fmt.Errorf("scan source plugin resolver is not configured")
|
||||
}
|
||||
installations, err := s.installations.ListByPluginID(ctx, pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(installations) == 0 {
|
||||
return nil, fmt.Errorf("scan source plugin %q is not installed", pluginID)
|
||||
}
|
||||
if len(installations) > 1 {
|
||||
return nil, fmt.Errorf("scan source plugin %q is ambiguous across %d installations", pluginID, len(installations))
|
||||
}
|
||||
|
||||
var matches []*Installation
|
||||
for _, installation := range installations {
|
||||
if installation == nil {
|
||||
continue
|
||||
}
|
||||
capabilities, err := s.installations.ListCapabilities(ctx, installation.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, capability := range capabilities {
|
||||
if capability == nil {
|
||||
continue
|
||||
}
|
||||
if capability.Type == "scan_source.v1" && capability.ID == capabilityID {
|
||||
matches = append(matches, installation)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("scan source capability %q is not installed for plugin %q", capabilityID, pluginID)
|
||||
}
|
||||
if !matches[0].Enabled {
|
||||
return nil, fmt.Errorf("scan source plugin %q is disabled", pluginID)
|
||||
}
|
||||
return s.ScanSourceClient(ctx, matches[0].ID, capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) EventConsumerClient(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"github.com/Silo-Server/silo-server/internal/pluginhost"
|
||||
)
|
||||
|
||||
func TestScanSourceClientByPluginIDResolvesCurrentInstallation(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.autoscan.cephfs", "0.2.2")
|
||||
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
|
||||
{Type: "scan_source.v1", Id: "cephfs", DisplayName: "CephFS"},
|
||||
}
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
host := &fakeServiceHost{
|
||||
clientErr: pluginhost.ErrClientNotFound,
|
||||
startResult: &fakePluginClient{manifest: manifest},
|
||||
}
|
||||
service := &Service{
|
||||
installations: &fakeServiceInstallationStore{
|
||||
byID: map[int]*Installation{
|
||||
8: {
|
||||
ID: 8,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
Version: "0.2.2",
|
||||
InstallPath: installPath,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
byPluginID: map[string][]*Installation{
|
||||
"silo.autoscan.cephfs": {
|
||||
{ID: 8, PluginID: "silo.autoscan.cephfs", Version: "0.2.2", InstallPath: installPath, Enabled: true},
|
||||
},
|
||||
},
|
||||
listCapabilities: []*Capability{{InstallationID: 8, Type: "scan_source.v1", ID: "cephfs"}},
|
||||
},
|
||||
host: host,
|
||||
}
|
||||
|
||||
if _, err := service.ScanSourceClientByPluginID(context.Background(), "silo.autoscan.cephfs", "cephfs"); err != nil {
|
||||
t.Fatalf("ScanSourceClientByPluginID: %v", err)
|
||||
}
|
||||
if len(host.started) != 1 || host.started[0].InstallationID != 8 {
|
||||
t.Fatalf("started installations = %+v, want installation 8", host.started)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSourceClientByPluginIDRejectsAmbiguousInstallations(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.autoscan.cephfs", "0.2.2")
|
||||
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
|
||||
{Type: "scan_source.v1", Id: "cephfs", DisplayName: "CephFS"},
|
||||
}
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
service := &Service{
|
||||
installations: &fakeServiceInstallationStore{
|
||||
byID: map[int]*Installation{
|
||||
8: {ID: 8, PluginID: "silo.autoscan.cephfs", Version: "0.2.2", InstallPath: installPath, Enabled: true},
|
||||
9: {ID: 9, PluginID: "silo.autoscan.cephfs", Version: "0.2.3", InstallPath: installPath, Enabled: true},
|
||||
},
|
||||
byPluginID: map[string][]*Installation{
|
||||
"silo.autoscan.cephfs": {
|
||||
{ID: 8, PluginID: "silo.autoscan.cephfs", Version: "0.2.2", InstallPath: installPath, Enabled: true},
|
||||
{ID: 9, PluginID: "silo.autoscan.cephfs", Version: "0.2.3", InstallPath: installPath, Enabled: true},
|
||||
},
|
||||
},
|
||||
listCapabilities: []*Capability{{Type: "scan_source.v1", ID: "cephfs"}},
|
||||
},
|
||||
host: &fakeServiceHost{},
|
||||
}
|
||||
|
||||
_, err := service.ScanSourceClientByPluginID(context.Background(), "silo.autoscan.cephfs", "cephfs")
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
||||
t.Fatalf("ScanSourceClientByPluginID error = %v, want ambiguous", err)
|
||||
}
|
||||
}
|
||||
@@ -39,35 +39,6 @@ func ensureCanonicalDimensions(vec []float32) ([]float32, error) {
|
||||
const embeddingLockSettingKey = "recommendations.embedding_lock"
|
||||
const minHNSWEfSearch = 200
|
||||
|
||||
const tasteSeedCandidateQuery = `
|
||||
WITH watched_counts AS (
|
||||
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
|
||||
COUNT(DISTINCT wp.user_id::text || ':' || COALESCE(wp.profile_id, '')) AS watch_count
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND wp.updated_at > NOW() - INTERVAL '180 days'
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
|
||||
WHERE mi.status = 'matched'
|
||||
AND mi.type IN ('movie', 'series')
|
||||
AND mi.poster_path IS NOT NULL
|
||||
AND mi.poster_path <> ''
|
||||
ORDER BY COALESCE(wc.watch_count, 0) DESC,
|
||||
CASE
|
||||
WHEN mi.rating_imdb IS NOT NULL THEN 2
|
||||
WHEN mi.rating_tmdb IS NOT NULL AND mi.rating_tmdb < 9.5 THEN 1
|
||||
ELSE 0
|
||||
END DESC,
|
||||
mi.rating_imdb DESC NULLS LAST,
|
||||
CASE WHEN mi.rating_tmdb < 9.5 THEN mi.rating_tmdb END DESC NULLS LAST,
|
||||
mi.year DESC NULLS LAST,
|
||||
mi.content_id ASC
|
||||
LIMIT $1 OFFSET $2`
|
||||
|
||||
// Repo provides database operations for the recommendation system.
|
||||
type Repo struct {
|
||||
pool *pgxpool.Pool
|
||||
@@ -1327,12 +1298,34 @@ func (r *Repo) GetTopRatedItems(ctx context.Context, minRatings, limit int) ([]S
|
||||
|
||||
// GetTasteSeedCandidates returns movie/series content IDs ordered for the
|
||||
// taste-seeding picker: server engagement first (most-watched in the last
|
||||
// 180 days), then rating reliability and rating score, then recency. This keeps
|
||||
// fresh servers from front-loading single-vote TMDB 10.0 obscurities while
|
||||
// established servers prioritize what users actually watch. Episodes are resolved
|
||||
// to their parent series. Items without a poster are excluded.
|
||||
// 180 days), then TMDB rating, then recency. This blend ensures fresh servers
|
||||
// (no watch history) still surface meaningful posters via TMDB ratings while
|
||||
// established servers prioritize what users actually watch. Episodes are
|
||||
// resolved to their parent series. Items without a poster are excluded.
|
||||
func (r *Repo) GetTasteSeedCandidates(ctx context.Context, limit, offset int) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, tasteSeedCandidateQuery, limit, offset)
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH watched_counts AS (
|
||||
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
|
||||
COUNT(DISTINCT wp.user_id::text || ':' || COALESCE(wp.profile_id, '')) AS watch_count
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND wp.updated_at > NOW() - INTERVAL '180 days'
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
|
||||
WHERE mi.status = 'matched'
|
||||
AND mi.type IN ('movie', 'series')
|
||||
AND mi.poster_path IS NOT NULL
|
||||
AND mi.poster_path <> ''
|
||||
ORDER BY COALESCE(wc.watch_count, 0) DESC,
|
||||
mi.rating_tmdb DESC NULLS LAST,
|
||||
mi.year DESC NULLS LAST,
|
||||
mi.content_id ASC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get taste seed candidates: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTasteSeedCandidateQueryOrdersByReliableColdStartSignals(t *testing.T) {
|
||||
query := strings.Join(strings.Fields(tasteSeedCandidateQuery), " ")
|
||||
|
||||
assertQueryTermsInOrder(t, query,
|
||||
"ORDER BY COALESCE(wc.watch_count, 0) DESC",
|
||||
"WHEN mi.rating_imdb IS NOT NULL THEN 2",
|
||||
"WHEN mi.rating_tmdb IS NOT NULL AND mi.rating_tmdb < 9.5 THEN 1",
|
||||
"ELSE 0 END DESC",
|
||||
"mi.rating_imdb DESC NULLS LAST",
|
||||
"CASE WHEN mi.rating_tmdb < 9.5 THEN mi.rating_tmdb END DESC NULLS LAST",
|
||||
"mi.year DESC NULLS LAST",
|
||||
"mi.content_id ASC",
|
||||
)
|
||||
|
||||
if strings.Contains(query, "ORDER BY COALESCE(wc.watch_count, 0) DESC, mi.rating_tmdb DESC NULLS LAST") {
|
||||
t.Fatal("taste seed query must not rank cold-start candidates by raw TMDB rating directly")
|
||||
}
|
||||
}
|
||||
|
||||
func assertQueryTermsInOrder(t *testing.T, query string, terms ...string) {
|
||||
t.Helper()
|
||||
|
||||
searchFrom := 0
|
||||
for _, term := range terms {
|
||||
idx := strings.Index(query[searchFrom:], term)
|
||||
if idx < 0 {
|
||||
t.Fatalf("query term %q missing or out of order in query:\n%s", term, query)
|
||||
}
|
||||
searchFrom += idx + len(term)
|
||||
}
|
||||
}
|
||||
@@ -652,8 +652,7 @@ func (s *Scanner) upsertAudiobookMediaFiles(
|
||||
folderPath string,
|
||||
book *parsedAudiobook,
|
||||
) error {
|
||||
partTotal := len(book.Files)
|
||||
for idx, af := range book.Files {
|
||||
for _, af := range book.Files {
|
||||
chapters := make([]models.MediaChapter, len(af.Chapters))
|
||||
for i, ch := range af.Chapters {
|
||||
chapters[i] = models.MediaChapter{
|
||||
@@ -685,12 +684,6 @@ func (s *Scanner) upsertAudiobookMediaFiles(
|
||||
Container: af.Container,
|
||||
AudioChannels: af.AudioChannels,
|
||||
}
|
||||
if partTotal > 1 {
|
||||
mf.PresentationKind = "multipart"
|
||||
mf.PresentationGroupKey = contentID
|
||||
mf.PresentationPartIndex = idx + 1
|
||||
mf.PresentationPartTotal = partTotal
|
||||
}
|
||||
|
||||
if _, err := s.fileRepo.Upsert(ctx, mf); err != nil {
|
||||
return fmt.Errorf("upsert media file %s: %w", af.Path, err)
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxEPUBMetadataEntrySize = 8 * 1024 * 1024
|
||||
|
||||
var ebookExtensions = map[string]bool{
|
||||
".epub": true,
|
||||
".pdf": true,
|
||||
".mobi": true,
|
||||
".azw": true,
|
||||
".azw3": true,
|
||||
".fb2": true,
|
||||
}
|
||||
|
||||
type parsedEbook struct {
|
||||
Format string
|
||||
Title string
|
||||
Authors []string
|
||||
Description string
|
||||
Publisher string
|
||||
PublishedAt time.Time
|
||||
Year int
|
||||
Language string
|
||||
ISBN string
|
||||
Series string
|
||||
SeriesIndex string
|
||||
Genres []string
|
||||
PageCount int
|
||||
Cover *parsedEbookCover
|
||||
}
|
||||
|
||||
type parsedEbookCover struct {
|
||||
ContentType string
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
func SupportsEbookFile(filePath string) bool {
|
||||
return ebookExtensions[strings.ToLower(filepath.Ext(filePath))]
|
||||
}
|
||||
|
||||
func parseEbookFile(path string) (book parsedEbook, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic parsing ebook %s: %v", path, r)
|
||||
}
|
||||
}()
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".epub":
|
||||
book, err = parseEbookEPUB(path)
|
||||
case ".fb2":
|
||||
book, err = parseEbookFB2(path)
|
||||
case ".pdf", ".mobi", ".azw", ".azw3":
|
||||
book = parsedEbook{Format: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")}
|
||||
default:
|
||||
err = fmt.Errorf("unsupported ebook format: %s", filepath.Ext(path))
|
||||
}
|
||||
book.sanitize()
|
||||
return book, err
|
||||
}
|
||||
|
||||
func (b *parsedEbook) sanitize() {
|
||||
b.Format = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(b.Format)), ".")
|
||||
b.Title = strings.TrimSpace(b.Title)
|
||||
b.Description = strings.TrimSpace(b.Description)
|
||||
b.Publisher = strings.TrimSpace(b.Publisher)
|
||||
b.Language = strings.TrimSpace(b.Language)
|
||||
b.ISBN = normalizeEbookISBN(b.ISBN)
|
||||
b.Series = strings.TrimSpace(b.Series)
|
||||
b.SeriesIndex = strings.TrimSpace(b.SeriesIndex)
|
||||
b.Authors = uniqueTrimmedStrings(b.Authors)
|
||||
b.Genres = uniqueTrimmedStrings(b.Genres)
|
||||
if b.PageCount < 0 {
|
||||
b.PageCount = 0
|
||||
}
|
||||
if b.Year == 0 && !b.PublishedAt.IsZero() {
|
||||
b.Year = b.PublishedAt.Year()
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueTrimmedStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
key := strings.ToLower(trimmed)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeEbookISBN(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
for _, prefix := range []string{"ISBN-13", "ISBN-10", "ISBN"} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
value = strings.TrimSpace(strings.TrimPrefix(value, prefix))
|
||||
value = strings.TrimLeft(value, ": ")
|
||||
break
|
||||
}
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, r := range value {
|
||||
if r >= '0' && r <= '9' {
|
||||
out.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r == 'X' {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
candidate := out.String()
|
||||
switch len(candidate) {
|
||||
case 10:
|
||||
if validISBN10(candidate) {
|
||||
return candidate
|
||||
}
|
||||
case 13:
|
||||
if validISBN13(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validISBN10(candidate string) bool {
|
||||
if len(candidate) != 10 {
|
||||
return false
|
||||
}
|
||||
sum := 0
|
||||
for i, r := range candidate {
|
||||
value := 0
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
value = int(r - '0')
|
||||
case r == 'X' && i == 9:
|
||||
value = 10
|
||||
default:
|
||||
return false
|
||||
}
|
||||
sum += value * (10 - i)
|
||||
}
|
||||
return sum%11 == 0
|
||||
}
|
||||
|
||||
func validISBN13(candidate string) bool {
|
||||
if len(candidate) != 13 {
|
||||
return false
|
||||
}
|
||||
sum := 0
|
||||
for i, r := range candidate {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
value := int(r - '0')
|
||||
if i%2 == 1 {
|
||||
value *= 3
|
||||
}
|
||||
sum += value
|
||||
}
|
||||
return sum%10 == 0
|
||||
}
|
||||
|
||||
func parseEbookEPUB(path string) (parsedEbook, error) {
|
||||
book := parsedEbook{Format: "epub"}
|
||||
reader, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
container, err := readEPUBZipEntry(&reader.Reader, "META-INF/container.xml")
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
opfPath, err := epubOPFPath(container)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
opf, err := readEPUBZipEntry(&reader.Reader, opfPath)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
if err := parseEPUBOPFMetadata(opf, &book); err != nil {
|
||||
return book, err
|
||||
}
|
||||
return book, nil
|
||||
}
|
||||
|
||||
func parseEbookFB2(path string) (parsedEbook, error) {
|
||||
book := parsedEbook{Format: "fb2"}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return book, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var fb2 struct {
|
||||
Description struct {
|
||||
TitleInfo struct {
|
||||
Genres []string `xml:"genre"`
|
||||
Authors []struct {
|
||||
FirstName string `xml:"first-name"`
|
||||
MiddleName string `xml:"middle-name"`
|
||||
LastName string `xml:"last-name"`
|
||||
Nickname string `xml:"nickname"`
|
||||
} `xml:"author"`
|
||||
BookTitle string `xml:"book-title"`
|
||||
Lang string `xml:"lang"`
|
||||
Date struct {
|
||||
Value string `xml:"value,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
} `xml:"date"`
|
||||
Sequences []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Number string `xml:"number,attr"`
|
||||
} `xml:"sequence"`
|
||||
} `xml:"title-info"`
|
||||
PublishInfo struct {
|
||||
ISBN string `xml:"isbn"`
|
||||
Publisher string `xml:"publisher"`
|
||||
Year string `xml:"year"`
|
||||
} `xml:"publish-info"`
|
||||
} `xml:"description"`
|
||||
}
|
||||
if err := xml.NewDecoder(file).Decode(&fb2); err != nil {
|
||||
return book, err
|
||||
}
|
||||
|
||||
book.Title = fb2.Description.TitleInfo.BookTitle
|
||||
book.Language = fb2.Description.TitleInfo.Lang
|
||||
book.Genres = fb2.Description.TitleInfo.Genres
|
||||
if t, ok := parseEbookDate(firstNonEmpty(fb2.Description.TitleInfo.Date.Value, fb2.Description.TitleInfo.Date.Text)); ok {
|
||||
book.PublishedAt = t
|
||||
}
|
||||
for _, author := range fb2.Description.TitleInfo.Authors {
|
||||
name := strings.Join(uniqueTrimmedStrings([]string{
|
||||
author.FirstName,
|
||||
author.MiddleName,
|
||||
author.LastName,
|
||||
}), " ")
|
||||
if name == "" {
|
||||
name = author.Nickname
|
||||
}
|
||||
book.Authors = append(book.Authors, name)
|
||||
}
|
||||
if len(fb2.Description.TitleInfo.Sequences) > 0 {
|
||||
book.Series = fb2.Description.TitleInfo.Sequences[0].Name
|
||||
book.SeriesIndex = fb2.Description.TitleInfo.Sequences[0].Number
|
||||
}
|
||||
book.ISBN = fb2.Description.PublishInfo.ISBN
|
||||
book.Publisher = fb2.Description.PublishInfo.Publisher
|
||||
if year, err := strconv.Atoi(strings.TrimSpace(fb2.Description.PublishInfo.Year)); err == nil {
|
||||
book.Year = year
|
||||
}
|
||||
return book, nil
|
||||
}
|
||||
|
||||
func readEPUBZipEntry(reader *zip.Reader, name string) ([]byte, error) {
|
||||
for _, file := range reader.File {
|
||||
if file.Name != name {
|
||||
continue
|
||||
}
|
||||
entry, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer entry.Close()
|
||||
limited := io.LimitReader(entry, maxEPUBMetadataEntrySize+1)
|
||||
data, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxEPUBMetadataEntrySize {
|
||||
return nil, fmt.Errorf("epub entry too large: %s", name)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("epub entry not found: %s", name)
|
||||
}
|
||||
|
||||
func epubOPFPath(container []byte) (string, error) {
|
||||
var parsed struct {
|
||||
Rootfiles []struct {
|
||||
FullPath string `xml:"full-path,attr"`
|
||||
} `xml:"rootfiles>rootfile"`
|
||||
}
|
||||
if err := xml.Unmarshal(container, &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, rootfile := range parsed.Rootfiles {
|
||||
if strings.TrimSpace(rootfile.FullPath) != "" {
|
||||
return rootfile.FullPath, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("epub container has no rootfile")
|
||||
}
|
||||
|
||||
func parseEPUBOPFMetadata(opf []byte, book *parsedEbook) error {
|
||||
var parsed struct {
|
||||
Metadata struct {
|
||||
Titles []string `xml:"title"`
|
||||
Creators []string `xml:"creator"`
|
||||
Identifiers []string `xml:"identifier"`
|
||||
Publisher string `xml:"publisher"`
|
||||
Dates []string `xml:"date"`
|
||||
Language string `xml:"language"`
|
||||
Subjects []string `xml:"subject"`
|
||||
Descriptions []string `xml:"description"`
|
||||
Meta []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Property string `xml:"property,attr"`
|
||||
Content string `xml:"content,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:"meta"`
|
||||
} `xml:"metadata"`
|
||||
}
|
||||
if err := xml.Unmarshal(opf, &parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
book.Title = firstNonEmpty(parsed.Metadata.Titles...)
|
||||
book.Authors = append(book.Authors, parsed.Metadata.Creators...)
|
||||
book.Publisher = parsed.Metadata.Publisher
|
||||
book.Language = parsed.Metadata.Language
|
||||
book.Genres = append(book.Genres, parsed.Metadata.Subjects...)
|
||||
book.Description = firstNonEmpty(parsed.Metadata.Descriptions...)
|
||||
for _, identifier := range parsed.Metadata.Identifiers {
|
||||
if isbn := normalizeEbookISBN(identifier); isbn != "" {
|
||||
book.ISBN = isbn
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, date := range parsed.Metadata.Dates {
|
||||
if t, ok := parseEbookDate(date); ok {
|
||||
book.PublishedAt = t
|
||||
book.Year = t.Year()
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, meta := range parsed.Metadata.Meta {
|
||||
name := strings.ToLower(strings.TrimSpace(firstNonEmpty(meta.Name, meta.Property)))
|
||||
value := strings.TrimSpace(firstNonEmpty(meta.Content, meta.Value))
|
||||
switch name {
|
||||
case "calibre:series", "belongs-to-collection":
|
||||
book.Series = value
|
||||
case "calibre:series_index", "group-position":
|
||||
book.SeriesIndex = value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseEbookDate(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -1,686 +0,0 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/idgen"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/titleutil"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type ebookSQLExecutor interface {
|
||||
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
type filesystemMediaItemReader interface {
|
||||
GetByIDs(ctx context.Context, ids []string) ([]*models.MediaItem, error)
|
||||
}
|
||||
|
||||
func ebookScanWorkers() int {
|
||||
if v := os.Getenv("SILO_EBOOK_SCAN_WORKERS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return audiobookScanWorkers()
|
||||
}
|
||||
|
||||
func (s *Scanner) ScanEbookFolder(ctx context.Context, folder *models.MediaFolder) error {
|
||||
if s == nil || folder == nil {
|
||||
return fmt.Errorf("ScanEbookFolder: nil scanner or folder")
|
||||
}
|
||||
|
||||
var candidates []string
|
||||
for _, root := range folder.Paths {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if walkErr != nil {
|
||||
slog.Warn("ebook scan: walk error", "path", path, "error", walkErr)
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if SupportsEbookFile(path) {
|
||||
candidates = append(candidates, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
slog.Warn("ebook scan: walk root failed", "root", root, "error", walkErr)
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
workers := ebookScanWorkers()
|
||||
slog.Info("ebook scan: starting",
|
||||
"folder_id", folder.ID,
|
||||
"candidates", len(candidates),
|
||||
"workers", workers,
|
||||
)
|
||||
|
||||
ch := make(chan string, workers*2)
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
processed int64
|
||||
failed int64
|
||||
skipped int64
|
||||
failMu sync.Mutex
|
||||
failures []error
|
||||
cancelErr error
|
||||
)
|
||||
start := time.Now()
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for path := range ch {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := s.reconcileEbookFile(ctx, folder, path, &skipped); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
failMu.Lock()
|
||||
if cancelErr == nil {
|
||||
cancelErr = err
|
||||
}
|
||||
failMu.Unlock()
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&failed, 1)
|
||||
failMu.Lock()
|
||||
failures = append(failures, fmt.Errorf("%s: %w", path, err))
|
||||
failMu.Unlock()
|
||||
slog.Warn("ebook scan: file failed",
|
||||
"folder_id", folder.ID,
|
||||
"path", path,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
n := atomic.AddInt64(&processed, 1)
|
||||
if n%500 == 0 {
|
||||
slog.Info("ebook scan: progress",
|
||||
"folder_id", folder.ID,
|
||||
"processed", n,
|
||||
"failed", atomic.LoadInt64(&failed),
|
||||
"skipped", atomic.LoadInt64(&skipped),
|
||||
"total", len(candidates),
|
||||
"elapsed_sec", int(time.Since(start).Seconds()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, p := range candidates {
|
||||
select {
|
||||
case ch <- p:
|
||||
case <-ctx.Done():
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cancelErr != nil {
|
||||
return cancelErr
|
||||
}
|
||||
|
||||
slog.Info("ebook scan: completed",
|
||||
"folder_id", folder.ID,
|
||||
"processed", atomic.LoadInt64(&processed),
|
||||
"failed", atomic.LoadInt64(&failed),
|
||||
"skipped", atomic.LoadInt64(&skipped),
|
||||
"elapsed_sec", int(time.Since(start).Seconds()),
|
||||
)
|
||||
if processedCount := atomic.LoadInt64(&processed); processedCount > 0 {
|
||||
failedCount := atomic.LoadInt64(&failed)
|
||||
skippedCount := atomic.LoadInt64(&skipped)
|
||||
if failedCount > 0 && skippedCount == 0 && failedCount == processedCount {
|
||||
return fmt.Errorf("ebook scan failed for every attempted folder_id=%d: %w", folder.ID, errors.Join(failures...))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFolder, filePath string, skipped *int64) error {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat ebook file %s: %w", filePath, err)
|
||||
}
|
||||
size := info.Size()
|
||||
modifiedAt := normalizeFileModifiedAt(info.ModTime())
|
||||
|
||||
isUnchanged, skipErr := s.ebookFileShouldSkip(ctx, folder, filePath, size, modifiedAt)
|
||||
if skipErr != nil {
|
||||
slog.Warn("ebook scan: skip-check failed, falling through",
|
||||
"folder_id", folder.ID,
|
||||
"path", filePath,
|
||||
"error", skipErr,
|
||||
)
|
||||
} else if isUnchanged {
|
||||
atomic.AddInt64(skipped, 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := parseEbookFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse ebook file %s: %w", filePath, err)
|
||||
}
|
||||
if parsed.Title == "" {
|
||||
parsed.Title = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
|
||||
contentID, err := s.upsertEbookMediaItem(ctx, folder.ID, filePath, &parsed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert ebook item: %w", err)
|
||||
}
|
||||
if err := s.upsertEbookMediaFile(ctx, folder, contentID, filePath, size, modifiedAt, &parsed); err != nil {
|
||||
return fmt.Errorf("upsert ebook file: %w", err)
|
||||
}
|
||||
if err := s.upsertEbookPeople(ctx, contentID, &parsed); err != nil {
|
||||
return fmt.Errorf("upsert ebook people: %w", err)
|
||||
}
|
||||
if err := s.upsertEbookSeries(ctx, contentID, &parsed); err != nil {
|
||||
return fmt.Errorf("upsert ebook series: %w", err)
|
||||
}
|
||||
if err := insertEbookLibraryMembership(ctx, s.fileRepo.Pool(), contentID, folder.ID); err != nil {
|
||||
return fmt.Errorf("upsert ebook library membership: %w", err)
|
||||
}
|
||||
if parsed.ISBN != "" {
|
||||
if err := insertEbookISBNProviderID(ctx, s.fileRepo.Pool(), contentID, parsed.ISBN); err != nil {
|
||||
return fmt.Errorf("upsert ebook ISBN provider id: %w", err)
|
||||
}
|
||||
}
|
||||
slog.Info("ebook scan: indexed",
|
||||
"folder_id", folder.ID,
|
||||
"content_id", contentID,
|
||||
"title", parsed.Title,
|
||||
"authors", parsed.Authors,
|
||||
"path", filePath,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) ebookFileShouldSkip(ctx context.Context, folder *models.MediaFolder, filePath string, size int64, modifiedAt time.Time) (bool, error) {
|
||||
if s.fileRepo == nil || s.itemRepo == nil {
|
||||
return false, nil
|
||||
}
|
||||
existing, err := s.fileRepo.ListByObservedRootPath(ctx, folder.ID, filePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("list existing files: %w", err)
|
||||
}
|
||||
if len(existing) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
mf := existing[0]
|
||||
if mf.FilePath != filePath || mf.FileSize != size || mf.FileModifiedAt == nil || !sameFileModifiedAt(mf.FileModifiedAt, modifiedAt) {
|
||||
return false, nil
|
||||
}
|
||||
if mf.ContentID == "" {
|
||||
return false, nil
|
||||
}
|
||||
statuses, err := s.itemRepo.GetStatusByIDs(ctx, []string{mf.ContentID})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get item status: %w", err)
|
||||
}
|
||||
return !strings.EqualFold(strings.TrimSpace(statuses[mf.ContentID]), "unmatched"), nil
|
||||
}
|
||||
|
||||
func (s *Scanner) upsertEbookMediaItem(ctx context.Context, folderID int, filePath string, book *parsedEbook) (string, error) {
|
||||
if s.itemRepo == nil {
|
||||
return "", fmt.Errorf("itemRepo not configured on Scanner")
|
||||
}
|
||||
if s.fileRepo == nil {
|
||||
return "", fmt.Errorf("fileRepo not configured on Scanner")
|
||||
}
|
||||
|
||||
existingID, err := s.fileRepo.FindContentIDByRootPath(ctx, folderID, filePath, "ebook")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find ebook by root path: %w", err)
|
||||
}
|
||||
if existingID != "" {
|
||||
if err := updateExistingEbookMediaItem(ctx, s.itemRepo, s.itemRepo, existingID, book); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
if existing := s.findEbookByFilePath(ctx, filePath); existing != nil {
|
||||
applyEbookToMediaItem(existing, book)
|
||||
if existing.SortTitle == "" {
|
||||
existing.SortTitle = titleutil.DeriveDefaultSortTitle(existing.Title)
|
||||
}
|
||||
if err := s.itemRepo.Upsert(ctx, existing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return existing.ContentID, nil
|
||||
}
|
||||
return resolveEbookMediaItem(ctx, s.fileRepo, s.itemRepo, folderID, filePath, book)
|
||||
}
|
||||
|
||||
func resolveEbookMediaItem(
|
||||
ctx context.Context,
|
||||
rootFinder filesystemRootContentFinder,
|
||||
itemWriter filesystemMediaItemWriter,
|
||||
folderID int,
|
||||
filePath string,
|
||||
book *parsedEbook,
|
||||
) (string, error) {
|
||||
if rootFinder == nil {
|
||||
return "", fmt.Errorf("root content finder not configured")
|
||||
}
|
||||
if itemWriter == nil {
|
||||
return "", fmt.Errorf("media item writer not configured")
|
||||
}
|
||||
existingID, err := rootFinder.FindContentIDByRootPath(ctx, folderID, filePath, "ebook")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find ebook by root path: %w", err)
|
||||
}
|
||||
if existingID != "" {
|
||||
return existingID, nil
|
||||
}
|
||||
|
||||
cleanTitle := strings.TrimSpace(book.Title)
|
||||
if cleanTitle == "" {
|
||||
cleanTitle = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
return createEbookMediaItem(ctx, itemWriter, book, cleanTitle)
|
||||
}
|
||||
|
||||
func createEbookMediaItem(ctx context.Context, itemWriter filesystemMediaItemWriter, book *parsedEbook, cleanTitle string) (string, error) {
|
||||
id, err := idgen.NextID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate content_id: %w", err)
|
||||
}
|
||||
item := &models.MediaItem{
|
||||
ContentID: id,
|
||||
Type: "ebook",
|
||||
Title: cleanTitle,
|
||||
SortTitle: titleutil.DeriveDefaultSortTitle(cleanTitle),
|
||||
Year: book.Year,
|
||||
}
|
||||
applyEbookToMediaItem(item, book)
|
||||
if item.SortTitle == "" {
|
||||
item.SortTitle = titleutil.DeriveDefaultSortTitle(item.Title)
|
||||
}
|
||||
if err := itemWriter.Upsert(ctx, item); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func updateExistingEbookMediaItem(ctx context.Context, itemReader filesystemMediaItemReader, itemWriter filesystemMediaItemWriter, contentID string, book *parsedEbook) error {
|
||||
if itemReader == nil {
|
||||
return fmt.Errorf("media item reader not configured")
|
||||
}
|
||||
if itemWriter == nil {
|
||||
return fmt.Errorf("media item writer not configured")
|
||||
}
|
||||
items, err := itemReader.GetByIDs(ctx, []string{contentID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get ebook media item %s: %w", contentID, err)
|
||||
}
|
||||
if len(items) == 0 || items[0] == nil {
|
||||
return fmt.Errorf("ebook media item %s not found", contentID)
|
||||
}
|
||||
item := items[0]
|
||||
applyEbookToMediaItem(item, book)
|
||||
if item.SortTitle == "" {
|
||||
item.SortTitle = titleutil.DeriveDefaultSortTitle(item.Title)
|
||||
}
|
||||
return itemWriter.Upsert(ctx, item)
|
||||
}
|
||||
|
||||
func applyEbookToMediaItem(item *models.MediaItem, book *parsedEbook) {
|
||||
item.Type = "ebook"
|
||||
if title := strings.TrimSpace(book.Title); title != "" {
|
||||
item.Title = title
|
||||
}
|
||||
if book.Year > 0 {
|
||||
item.Year = book.Year
|
||||
}
|
||||
if book.Description != "" && item.Overview == "" {
|
||||
item.Overview = book.Description
|
||||
}
|
||||
if book.Publisher != "" {
|
||||
item.Studios = mergeUniqueStrings(item.Studios, []string{book.Publisher})
|
||||
}
|
||||
if len(book.Genres) > 0 {
|
||||
item.Genres = mergeUniqueStrings(item.Genres, book.Genres)
|
||||
}
|
||||
if !book.PublishedAt.IsZero() && item.ReleaseDate == nil {
|
||||
rd := book.PublishedAt.UTC().Format("2006-01-02")
|
||||
item.ReleaseDate = &rd
|
||||
}
|
||||
if book.Language != "" && item.OriginalLanguage == "" {
|
||||
item.OriginalLanguage = book.Language
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scanner) findEbookByFilePath(ctx context.Context, filePath string) *models.MediaItem {
|
||||
if s.fileRepo == nil {
|
||||
return nil
|
||||
}
|
||||
var existingID string
|
||||
err := s.fileRepo.Pool().QueryRow(ctx, `
|
||||
SELECT mf.content_id
|
||||
FROM media_files mf
|
||||
JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.file_path = $1
|
||||
AND mi.type = 'ebook'
|
||||
LIMIT 1
|
||||
`, filePath).Scan(&existingID)
|
||||
if err != nil || existingID == "" {
|
||||
return nil
|
||||
}
|
||||
items, err := s.itemRepo.GetByIDs(ctx, []string{existingID})
|
||||
if err != nil || len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return items[0]
|
||||
}
|
||||
|
||||
func (s *Scanner) upsertEbookMediaFile(ctx context.Context, folder *models.MediaFolder, contentID string, filePath string, size int64, modifiedAt time.Time, book *parsedEbook) error {
|
||||
mf := buildEbookMediaFile(folder, contentID, filePath, size, modifiedAt, book)
|
||||
if _, err := s.fileRepo.Upsert(ctx, mf); err != nil {
|
||||
return fmt.Errorf("upsert media file %s: %w", filePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEbookMediaFile(folder *models.MediaFolder, contentID string, filePath string, size int64, modifiedAt time.Time, book *parsedEbook) models.MediaFile {
|
||||
return models.MediaFile{
|
||||
ContentID: contentID,
|
||||
MediaFolderID: folder.ID,
|
||||
CanonicalRootPath: filePath,
|
||||
ObservedRootPath: filePath,
|
||||
ContentGroupKey: contentID,
|
||||
GroupKeyVersion: 1,
|
||||
BaseTitle: book.Title,
|
||||
BaseYear: book.Year,
|
||||
BaseType: "ebook",
|
||||
IdentityConfidence: ebookIdentityConfidence(book),
|
||||
FilePath: filePath,
|
||||
FileSize: size,
|
||||
FileModifiedAt: &modifiedAt,
|
||||
Container: book.Format,
|
||||
ProbeSource: "local",
|
||||
}
|
||||
}
|
||||
|
||||
func insertEbookLibraryMembership(ctx context.Context, exec ebookSQLExecutor, contentID string, folderID int) error {
|
||||
_, err := exec.Exec(ctx, `
|
||||
INSERT INTO media_item_libraries (content_id, media_folder_id, first_seen_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (content_id, media_folder_id) DO NOTHING
|
||||
`, contentID, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func insertEbookISBNProviderID(ctx context.Context, exec ebookSQLExecutor, contentID string, isbn string) error {
|
||||
_, err := exec.Exec(ctx, `
|
||||
INSERT INTO media_item_provider_ids (content_id, provider, provider_id, item_type)
|
||||
VALUES ($1, 'isbn', $2, 'ebook')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, contentID, isbn)
|
||||
return err
|
||||
}
|
||||
|
||||
type ebookCredit struct {
|
||||
Name string
|
||||
Kind models.PersonKind
|
||||
}
|
||||
|
||||
func ebookPeopleCreditsEqual(existing []models.ItemPerson, desired []ebookCredit) bool {
|
||||
existingAuthors := make([]models.ItemPerson, 0, len(existing))
|
||||
for _, p := range existing {
|
||||
if p.Kind == models.PersonKindAuthor {
|
||||
existingAuthors = append(existingAuthors, p)
|
||||
}
|
||||
}
|
||||
if len(existingAuthors) != len(desired) {
|
||||
return false
|
||||
}
|
||||
type key struct {
|
||||
name string
|
||||
kind models.PersonKind
|
||||
}
|
||||
have := make(map[key]struct{}, len(existingAuthors))
|
||||
for _, p := range existingAuthors {
|
||||
have[key{strings.ToLower(strings.TrimSpace(p.Person.Name)), p.Kind}] = struct{}{}
|
||||
}
|
||||
for _, d := range desired {
|
||||
k := key{strings.ToLower(strings.TrimSpace(d.Name)), d.Kind}
|
||||
if _, ok := have[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type ebookResolvedAuthor struct {
|
||||
ID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
func mergeEbookPeople(existing []models.ItemPerson, authors []ebookResolvedAuthor) []models.ItemPerson {
|
||||
people := make([]models.ItemPerson, 0, len(existing)+len(authors))
|
||||
for _, p := range existing {
|
||||
if p.Kind == models.PersonKindAuthor {
|
||||
continue
|
||||
}
|
||||
p.SortOrder = len(people)
|
||||
people = append(people, p)
|
||||
}
|
||||
for _, author := range authors {
|
||||
people = append(people, models.ItemPerson{
|
||||
Person: models.Person{ID: author.ID, Name: author.Name},
|
||||
Kind: models.PersonKindAuthor,
|
||||
SortOrder: len(people),
|
||||
})
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
func ebookPeopleForReplace(existing []models.ItemPerson, getErr error, authors []ebookResolvedAuthor) ([]models.ItemPerson, error) {
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("get ebook people: %w", getErr)
|
||||
}
|
||||
return mergeEbookPeople(existing, authors), nil
|
||||
}
|
||||
|
||||
func (s *Scanner) upsertEbookPeople(ctx context.Context, contentID string, book *parsedEbook) error {
|
||||
if s.personRepo == nil {
|
||||
return fmt.Errorf("personRepo not configured on Scanner")
|
||||
}
|
||||
if s.itemRepo == nil {
|
||||
return fmt.Errorf("itemRepo not configured on Scanner")
|
||||
}
|
||||
|
||||
var desired []ebookCredit
|
||||
for _, author := range book.Authors {
|
||||
if name := strings.TrimSpace(author); name != "" {
|
||||
desired = append(desired, ebookCredit{Name: name, Kind: models.PersonKindAuthor})
|
||||
}
|
||||
}
|
||||
if len(desired) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
existing, err := s.itemRepo.GetPeople(ctx, contentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get ebook people: %w", err)
|
||||
}
|
||||
if ebookPeopleCreditsEqual(existing, desired) {
|
||||
return nil
|
||||
}
|
||||
|
||||
authors := make([]ebookResolvedAuthor, 0, len(desired))
|
||||
for _, c := range desired {
|
||||
personID, err := s.personRepo.FindOrCreate(ctx, models.Person{Name: c.Name})
|
||||
if err != nil {
|
||||
return fmt.Errorf("find-or-create person %q: %w", c.Name, err)
|
||||
}
|
||||
authors = append(authors, ebookResolvedAuthor{ID: personID, Name: c.Name})
|
||||
}
|
||||
people, err := ebookPeopleForReplace(existing, nil, authors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.itemRepo.ReplacePeople(ctx, contentID, people)
|
||||
}
|
||||
|
||||
func (s *Scanner) upsertEbookSeries(ctx context.Context, contentID string, book *parsedEbook) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("Scanner not configured")
|
||||
}
|
||||
if s.fileRepo == nil {
|
||||
return fmt.Errorf("fileRepo not configured on Scanner")
|
||||
}
|
||||
|
||||
var currentName *string
|
||||
var currentIdx *float64
|
||||
err := s.fileRepo.Pool().QueryRow(ctx, `
|
||||
SELECT series_name, series_index FROM ebook_series WHERE content_id = $1
|
||||
`, contentID).Scan(¤tName, ¤tIdx)
|
||||
|
||||
plan, err := planEbookSeriesWrite(book, currentName, currentIdx, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch plan.Kind {
|
||||
case ebookSeriesWriteNone:
|
||||
return nil
|
||||
case ebookSeriesWriteDelete:
|
||||
if _, delErr := s.fileRepo.Pool().Exec(ctx,
|
||||
`DELETE FROM ebook_series WHERE content_id = $1`, contentID); delErr != nil {
|
||||
return fmt.Errorf("delete ebook_series row: %w", delErr)
|
||||
}
|
||||
return nil
|
||||
case ebookSeriesWriteUpsert:
|
||||
var idx any
|
||||
if plan.Index != nil {
|
||||
idx = *plan.Index
|
||||
}
|
||||
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
||||
INSERT INTO ebook_series (content_id, series_name, series_index, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (content_id) DO UPDATE SET
|
||||
series_name = EXCLUDED.series_name,
|
||||
series_index = EXCLUDED.series_index,
|
||||
updated_at = NOW()
|
||||
`, contentID, plan.Name, idx); err != nil {
|
||||
return fmt.Errorf("upsert ebook_series row: %w", err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown ebook_series write kind: %d", plan.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func ebookSeriesDesired(book *parsedEbook) (string, *float64) {
|
||||
if book == nil {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(book.Series), parseSeriesIndex(book.SeriesIndex)
|
||||
}
|
||||
|
||||
type ebookSeriesWriteKind int
|
||||
|
||||
const (
|
||||
ebookSeriesWriteNone ebookSeriesWriteKind = iota
|
||||
ebookSeriesWriteDelete
|
||||
ebookSeriesWriteUpsert
|
||||
)
|
||||
|
||||
type ebookSeriesWritePlan struct {
|
||||
Kind ebookSeriesWriteKind
|
||||
Name string
|
||||
Index *float64
|
||||
}
|
||||
|
||||
func planEbookSeriesWrite(book *parsedEbook, currentName *string, currentIdx *float64, queryErr error) (ebookSeriesWritePlan, error) {
|
||||
if queryErr != nil {
|
||||
if !errors.Is(queryErr, pgx.ErrNoRows) {
|
||||
return ebookSeriesWritePlan{}, fmt.Errorf("query ebook_series: %w", queryErr)
|
||||
}
|
||||
currentName = nil
|
||||
currentIdx = nil
|
||||
}
|
||||
|
||||
desiredName, desiredIdx := ebookSeriesDesired(book)
|
||||
if desiredName == "" {
|
||||
if currentName == nil {
|
||||
return ebookSeriesWritePlan{Kind: ebookSeriesWriteNone}, nil
|
||||
}
|
||||
return ebookSeriesWritePlan{Kind: ebookSeriesWriteDelete}, nil
|
||||
}
|
||||
|
||||
if currentName != nil && *currentName == desiredName && floatPtrEqual(currentIdx, desiredIdx) {
|
||||
return ebookSeriesWritePlan{Kind: ebookSeriesWriteNone}, nil
|
||||
}
|
||||
|
||||
return ebookSeriesWritePlan{
|
||||
Kind: ebookSeriesWriteUpsert,
|
||||
Name: desiredName,
|
||||
Index: desiredIdx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ebookIdentityConfidence(book *parsedEbook) string {
|
||||
if book == nil {
|
||||
return "low"
|
||||
}
|
||||
|
||||
score := 0
|
||||
if book.Title != "" {
|
||||
score++
|
||||
}
|
||||
if len(book.Authors) > 0 {
|
||||
score++
|
||||
}
|
||||
if book.Year > 0 {
|
||||
score++
|
||||
}
|
||||
if book.ISBN != "" {
|
||||
score++
|
||||
}
|
||||
|
||||
switch {
|
||||
case score >= 4:
|
||||
return "high"
|
||||
case score > 0:
|
||||
return "medium"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
@@ -1,590 +0,0 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type recordingEbookExecutor struct {
|
||||
queries []string
|
||||
args [][]any
|
||||
}
|
||||
|
||||
func (r *recordingEbookExecutor) Exec(_ context.Context, query string, args ...any) (pgconn.CommandTag, error) {
|
||||
r.queries = append(r.queries, query)
|
||||
r.args = append(r.args, append([]any(nil), args...))
|
||||
return pgconn.CommandTag{}, nil
|
||||
}
|
||||
|
||||
type fakeFilesystemItemReader struct {
|
||||
items []*models.MediaItem
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFilesystemItemReader) GetByIDs(_ context.Context, _ []string) ([]*models.MediaItem, error) {
|
||||
return f.items, f.err
|
||||
}
|
||||
|
||||
func TestSupportsEbookFile(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"book.epub", true},
|
||||
{"book.PDF", true},
|
||||
{"book.mobi", true},
|
||||
{"book.azw", true},
|
||||
{"book.azw3", true},
|
||||
{"book.FB2", true},
|
||||
{"book.mp3", false},
|
||||
{"movie.mkv", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := SupportsEbookFile(tc.path); got != tc.want {
|
||||
t.Errorf("SupportsEbookFile(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEbookISBNHandlesCommonLabels(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ISBN-13: 978-0-306-40615-7", "9780306406157"},
|
||||
{"ISBN-10: 0-9752298-0-x", "097522980X"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := normalizeEbookISBN(tc.in); got != tc.want {
|
||||
t.Errorf("normalizeEbookISBN(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEbookEPUBMetadata(t *testing.T) {
|
||||
path := writeTestEPUB(t, []string{"ISBN: 978-0-306-40615-7"})
|
||||
|
||||
got, err := parseEbookFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEbookFile: %v", err)
|
||||
}
|
||||
|
||||
if got.Format != "epub" {
|
||||
t.Fatalf("Format = %q, want epub", got.Format)
|
||||
}
|
||||
if got.Title != "The Test Ebook" {
|
||||
t.Fatalf("Title = %q, want The Test Ebook", got.Title)
|
||||
}
|
||||
if strings.Join(got.Authors, ", ") != "Ada Writer, Ben Author" {
|
||||
t.Fatalf("Authors = %v, want Ada Writer and Ben Author", got.Authors)
|
||||
}
|
||||
if got.ISBN != "9780306406157" {
|
||||
t.Fatalf("ISBN = %q, want normalized ISBN", got.ISBN)
|
||||
}
|
||||
if got.Year != 2024 {
|
||||
t.Fatalf("Year = %d, want 2024", got.Year)
|
||||
}
|
||||
if got.Publisher != "Silo Press" {
|
||||
t.Fatalf("Publisher = %q, want Silo Press", got.Publisher)
|
||||
}
|
||||
wantPublishedAt := time.Date(2024, 3, 10, 0, 0, 0, 0, time.UTC)
|
||||
if !got.PublishedAt.Equal(wantPublishedAt) {
|
||||
t.Fatalf("PublishedAt = %v, want %v", got.PublishedAt, wantPublishedAt)
|
||||
}
|
||||
if got.Language != "en" {
|
||||
t.Fatalf("Language = %q, want en", got.Language)
|
||||
}
|
||||
if strings.Join(got.Genres, ", ") != "Fiction, Adventure" {
|
||||
t.Fatalf("Genres = %v, want Fiction and Adventure", got.Genres)
|
||||
}
|
||||
if got.Series != "Test Series" || got.SeriesIndex != "2" {
|
||||
t.Fatalf("Series = %q/%q, want Test Series/2", got.Series, got.SeriesIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEbookEPUBSkipsUUIDIdentifierBeforeISBN(t *testing.T) {
|
||||
path := writeTestEPUB(t, []string{
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"ISBN: 978-0-306-40615-7",
|
||||
})
|
||||
|
||||
got, err := parseEbookFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEbookFile: %v", err)
|
||||
}
|
||||
if got.ISBN != "9780306406157" {
|
||||
t.Fatalf("ISBN = %q, want normalized ISBN after skipping UUID", got.ISBN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEPUBZipEntryRejectsOversizedEntry(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "large.epub")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create epub: %v", err)
|
||||
}
|
||||
zw := zip.NewWriter(file)
|
||||
w, err := zw.Create("OPS/content.opf")
|
||||
if err != nil {
|
||||
t.Fatalf("create zip entry: %v", err)
|
||||
}
|
||||
if _, err := w.Write([]byte(strings.Repeat("x", maxEPUBMetadataEntrySize+1))); err != nil {
|
||||
t.Fatalf("write zip entry: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
_, err = readEPUBZipEntry(&reader.Reader, "OPS/content.opf")
|
||||
if err == nil || !strings.Contains(err.Error(), "epub entry too large") {
|
||||
t.Fatalf("readEPUBZipEntry error = %v, want size limit error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEbookFB2Metadata(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "book.fb2")
|
||||
if err := os.WriteFile(path, []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<FictionBook>
|
||||
<description>
|
||||
<title-info>
|
||||
<genre>science fiction</genre>
|
||||
<author><first-name>Ada</first-name><last-name>Writer</last-name></author>
|
||||
<book-title>FB2 Test Ebook</book-title>
|
||||
<date value="2022-04-05">5 April 2022</date>
|
||||
<lang>en</lang>
|
||||
<sequence name="FB2 Series" number="3"/>
|
||||
</title-info>
|
||||
<publish-info>
|
||||
<publisher>FB2 Press</publisher>
|
||||
<year>2022</year>
|
||||
<isbn>978-0-306-40615-7</isbn>
|
||||
</publish-info>
|
||||
</description>
|
||||
</FictionBook>`), 0o644); err != nil {
|
||||
t.Fatalf("write fb2: %v", err)
|
||||
}
|
||||
|
||||
got, err := parseEbookFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEbookFile: %v", err)
|
||||
}
|
||||
|
||||
if got.Format != "fb2" || got.Title != "FB2 Test Ebook" {
|
||||
t.Fatalf("Format/Title = %q/%q, want fb2/FB2 Test Ebook", got.Format, got.Title)
|
||||
}
|
||||
if strings.Join(got.Authors, ", ") != "Ada Writer" {
|
||||
t.Fatalf("Authors = %v, want Ada Writer", got.Authors)
|
||||
}
|
||||
if got.ISBN != "9780306406157" {
|
||||
t.Fatalf("ISBN = %q, want normalized ISBN", got.ISBN)
|
||||
}
|
||||
if got.Publisher != "FB2 Press" {
|
||||
t.Fatalf("Publisher = %q, want FB2 Press", got.Publisher)
|
||||
}
|
||||
if got.Year != 2022 {
|
||||
t.Fatalf("Year = %d, want 2022", got.Year)
|
||||
}
|
||||
wantPublishedAt := time.Date(2022, 4, 5, 0, 0, 0, 0, time.UTC)
|
||||
if !got.PublishedAt.Equal(wantPublishedAt) {
|
||||
t.Fatalf("PublishedAt = %v, want %v", got.PublishedAt, wantPublishedAt)
|
||||
}
|
||||
if got.Language != "en" {
|
||||
t.Fatalf("Language = %q, want en", got.Language)
|
||||
}
|
||||
if strings.Join(got.Genres, ", ") != "science fiction" {
|
||||
t.Fatalf("Genres = %v, want science fiction", got.Genres)
|
||||
}
|
||||
if got.Series != "FB2 Series" || got.SeriesIndex != "3" {
|
||||
t.Fatalf("Series = %q/%q, want FB2 Series/3", got.Series, got.SeriesIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookSeriesDesiredParsesIndex(t *testing.T) {
|
||||
name, idx := ebookSeriesDesired(&parsedEbook{
|
||||
Series: " The Expanse ",
|
||||
SeriesIndex: "2 of 9",
|
||||
})
|
||||
|
||||
if name != "The Expanse" {
|
||||
t.Fatalf("series name = %q, want The Expanse", name)
|
||||
}
|
||||
if idx == nil || *idx != 2 {
|
||||
t.Fatalf("series index = %v, want 2", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertEbookSeriesNilScannerReturnsError(t *testing.T) {
|
||||
var s *Scanner
|
||||
if err := s.upsertEbookSeries(context.Background(), "content-1", &parsedEbook{Series: "Series"}); err == nil {
|
||||
t.Fatal("upsertEbookSeries nil scanner error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertEbookSeriesNilFileRepoReturnsError(t *testing.T) {
|
||||
s := &Scanner{}
|
||||
if err := s.upsertEbookSeries(context.Background(), "content-1", &parsedEbook{Series: "Series"}); err == nil {
|
||||
t.Fatal("upsertEbookSeries nil fileRepo error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEbookSeriesWriteInsertsWhenRowAbsent(t *testing.T) {
|
||||
plan, err := planEbookSeriesWrite(&parsedEbook{Series: " Series ", SeriesIndex: "2 of 9"}, nil, nil, pgx.ErrNoRows)
|
||||
if err != nil {
|
||||
t.Fatalf("planEbookSeriesWrite: %v", err)
|
||||
}
|
||||
if plan.Kind != ebookSeriesWriteUpsert || plan.Name != "Series" {
|
||||
t.Fatalf("plan = %+v, want upsert Series", plan)
|
||||
}
|
||||
if plan.Index == nil || *plan.Index != 2 {
|
||||
t.Fatalf("index = %v, want 2", plan.Index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEbookSeriesWriteBlankSeriesDeletesExistingAndSkipsAbsent(t *testing.T) {
|
||||
currentName := "Series"
|
||||
plan, err := planEbookSeriesWrite(&parsedEbook{Series: " "}, ¤tName, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("planEbookSeriesWrite delete: %v", err)
|
||||
}
|
||||
if plan.Kind != ebookSeriesWriteDelete {
|
||||
t.Fatalf("blank existing plan = %+v, want delete", plan)
|
||||
}
|
||||
|
||||
plan, err = planEbookSeriesWrite(&parsedEbook{Series: ""}, nil, nil, pgx.ErrNoRows)
|
||||
if err != nil {
|
||||
t.Fatalf("planEbookSeriesWrite absent: %v", err)
|
||||
}
|
||||
if plan.Kind != ebookSeriesWriteNone {
|
||||
t.Fatalf("blank absent plan = %+v, want none", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEbookSeriesWriteSkipsIdenticalRow(t *testing.T) {
|
||||
currentName := "Series"
|
||||
currentIdx := 3.5
|
||||
plan, err := planEbookSeriesWrite(&parsedEbook{Series: "Series", SeriesIndex: "3.5"}, ¤tName, ¤tIdx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("planEbookSeriesWrite: %v", err)
|
||||
}
|
||||
if plan.Kind != ebookSeriesWriteNone {
|
||||
t.Fatalf("identical plan = %+v, want none", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEbookSeriesWriteAllowsNullNumericIndex(t *testing.T) {
|
||||
plan, err := planEbookSeriesWrite(&parsedEbook{Series: "Series", SeriesIndex: "appendix"}, nil, nil, pgx.ErrNoRows)
|
||||
if err != nil {
|
||||
t.Fatalf("planEbookSeriesWrite: %v", err)
|
||||
}
|
||||
if plan.Kind != ebookSeriesWriteUpsert || plan.Index != nil {
|
||||
t.Fatalf("plan = %+v, want upsert with nil index", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEbookSeriesWriteReturnsQueryError(t *testing.T) {
|
||||
queryErr := errors.New("query failed")
|
||||
if _, err := planEbookSeriesWrite(&parsedEbook{Series: "Series"}, nil, nil, queryErr); !errors.Is(err, queryErr) {
|
||||
t.Fatalf("planEbookSeriesWrite error = %v, want query error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookIdentityConfidenceReflectsMetadataCompleteness(t *testing.T) {
|
||||
book := &parsedEbook{Title: "Tagged Ebook", Authors: []string{"Author"}, Year: 2024, ISBN: "9780306406157"}
|
||||
if got := ebookIdentityConfidence(book); got != "high" {
|
||||
t.Fatalf("complete metadata confidence = %q, want high", got)
|
||||
}
|
||||
|
||||
book = &parsedEbook{Title: "Tagged Ebook", Authors: []string{"Author"}, Year: 2024}
|
||||
if got := ebookIdentityConfidence(book); got != "medium" {
|
||||
t.Fatalf("partial metadata confidence = %q, want medium", got)
|
||||
}
|
||||
|
||||
book = &parsedEbook{}
|
||||
if got := ebookIdentityConfidence(book); got != "low" {
|
||||
t.Fatalf("empty metadata confidence = %q, want low", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEbookMediaItemCreatesNewWhenRootHasNoClaim(t *testing.T) {
|
||||
finder := &fakeRootContentFinder{}
|
||||
writer := &fakeFilesystemItemWriter{}
|
||||
|
||||
got, err := resolveEbookMediaItem(context.Background(), finder, writer, 7, "/library/Author/Book.epub", &parsedEbook{
|
||||
Title: "Book", Year: 2024, Authors: []string{"Author"}, Description: "Overview", Publisher: "Publisher", Genres: []string{"Fiction"}, Language: "en",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveEbookMediaItem: %v", err)
|
||||
}
|
||||
if got == "" || len(writer.upserts) != 1 {
|
||||
t.Fatalf("contentID/upserts = %q/%d, want id and one upsert", got, len(writer.upserts))
|
||||
}
|
||||
item := writer.upserts[0]
|
||||
if item.Type != "ebook" || item.Title != "Book" || item.Year != 2024 || item.Overview != "Overview" || item.OriginalLanguage != "en" {
|
||||
t.Fatalf("upserted ebook item = %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEbookMediaItemReusesRootScopedContentID(t *testing.T) {
|
||||
finder := &fakeRootContentFinder{contentID: "ebook-root-id"}
|
||||
writer := &fakeFilesystemItemWriter{}
|
||||
|
||||
got, err := resolveEbookMediaItem(context.Background(), finder, writer, 7, "/library/Author/Book.epub", &parsedEbook{Title: "Book"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveEbookMediaItem: %v", err)
|
||||
}
|
||||
if got != "ebook-root-id" {
|
||||
t.Fatalf("contentID = %q, want root-scoped id", got)
|
||||
}
|
||||
if len(writer.upserts) != 0 {
|
||||
t.Fatalf("unexpected item upsert for existing root: %d", len(writer.upserts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEbookExistingRootAppliesParsedMetadata(t *testing.T) {
|
||||
reader := &fakeFilesystemItemReader{items: []*models.MediaItem{{
|
||||
ContentID: "ebook-root-id",
|
||||
Type: "ebook",
|
||||
Title: "Old Title",
|
||||
Year: 2020,
|
||||
OriginalLanguage: "",
|
||||
}}}
|
||||
writer := &fakeFilesystemItemWriter{}
|
||||
|
||||
err := updateExistingEbookMediaItem(context.Background(), reader, writer, "ebook-root-id", &parsedEbook{
|
||||
Title: "New Title",
|
||||
Year: 2026,
|
||||
Publisher: "New Press",
|
||||
Genres: []string{"Fiction"},
|
||||
Language: "en",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("updateExistingEbookMediaItem: %v", err)
|
||||
}
|
||||
if len(writer.upserts) != 1 {
|
||||
t.Fatalf("upserts = %d, want 1", len(writer.upserts))
|
||||
}
|
||||
item := writer.upserts[0]
|
||||
if item.ContentID != "ebook-root-id" || item.Type != "ebook" || item.Title != "New Title" || item.Year != 2026 || item.OriginalLanguage != "en" {
|
||||
t.Fatalf("updated item = %+v", item)
|
||||
}
|
||||
if strings.Join(item.Studios, ",") != "New Press" || strings.Join(item.Genres, ",") != "Fiction" {
|
||||
t.Fatalf("updated item studios/genres = %+v/%+v", item.Studios, item.Genres)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEbookToMediaItemPreservesExistingYearWhenParsedYearMissing(t *testing.T) {
|
||||
item := &models.MediaItem{
|
||||
Type: "ebook",
|
||||
Title: "Existing",
|
||||
Year: 1999,
|
||||
}
|
||||
|
||||
applyEbookToMediaItem(item, &parsedEbook{Title: "Updated", Year: 0})
|
||||
|
||||
if item.Title != "Updated" {
|
||||
t.Fatalf("Title = %q, want Updated", item.Title)
|
||||
}
|
||||
if item.Year != 1999 {
|
||||
t.Fatalf("Year = %d, want preserved 1999", item.Year)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookPeopleCreditsEqualAuthorsOnly(t *testing.T) {
|
||||
existing := []models.ItemPerson{
|
||||
{Person: models.Person{Name: "Ada Writer"}, Kind: models.PersonKindAuthor, SortOrder: 0},
|
||||
{Person: models.Person{Name: "Ben Author"}, Kind: models.PersonKindAuthor, SortOrder: 1},
|
||||
{Person: models.Person{Name: "Manual Contributor"}, Kind: models.PersonKindWriter, SortOrder: 2},
|
||||
}
|
||||
desired := []ebookCredit{
|
||||
{Name: "Ada Writer", Kind: models.PersonKindAuthor},
|
||||
{Name: "Ben Author", Kind: models.PersonKindAuthor},
|
||||
}
|
||||
if !ebookPeopleCreditsEqual(existing, desired) {
|
||||
t.Fatal("expected matching author credits to compare equal while ignoring non-authors")
|
||||
}
|
||||
|
||||
existing[1] = models.ItemPerson{Person: models.Person{Name: "Other Author"}, Kind: models.PersonKindAuthor, SortOrder: 1}
|
||||
if ebookPeopleCreditsEqual(existing, desired) {
|
||||
t.Fatal("expected different author credit to make ebook author set differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookPeopleMergePreservesExistingNonAuthorCredits(t *testing.T) {
|
||||
existing := []models.ItemPerson{
|
||||
{Person: models.Person{ID: 10, Name: "Old Author"}, Kind: models.PersonKindAuthor, SortOrder: 0},
|
||||
{Person: models.Person{ID: 20, Name: "Manual Writer"}, Kind: models.PersonKindWriter, SortOrder: 1, Character: "essay"},
|
||||
{Person: models.Person{ID: 30, Name: "Manual Narrator"}, Kind: models.PersonKindNarrator, SortOrder: 2},
|
||||
}
|
||||
authors := []ebookResolvedAuthor{
|
||||
{ID: 40, Name: "New Author"},
|
||||
}
|
||||
|
||||
got := mergeEbookPeople(existing, authors)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("merged people len = %d, want 3: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Person.ID != 20 || got[0].Kind != models.PersonKindWriter || got[0].Character != "essay" {
|
||||
t.Fatalf("first preserved non-author = %+v", got[0])
|
||||
}
|
||||
if got[1].Person.ID != 30 || got[1].Kind != models.PersonKindNarrator {
|
||||
t.Fatalf("second preserved non-author = %+v", got[1])
|
||||
}
|
||||
if got[2].Person.ID != 40 || got[2].Kind != models.PersonKindAuthor || got[2].SortOrder != 2 {
|
||||
t.Fatalf("new author credit = %+v", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookPeopleReplacePlanReturnsGetPeopleError(t *testing.T) {
|
||||
wantErr := errors.New("get people failed")
|
||||
_, err := ebookPeopleForReplace(nil, wantErr, []ebookResolvedAuthor{{ID: 40, Name: "New Author"}})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanEbookBuildMediaFileSetsCorePersistenceFields(t *testing.T) {
|
||||
modifiedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
book := &parsedEbook{Format: "epub", Title: "Book", Authors: []string{"Author"}, Year: 2024, ISBN: "9780306406157"}
|
||||
|
||||
got := buildEbookMediaFile(&models.MediaFolder{ID: 44}, "content-1", "/library/Book.epub", 1234, modifiedAt, book)
|
||||
if got.ContentID != "content-1" || got.MediaFolderID != 44 {
|
||||
t.Fatalf("ids = %q/%d, want content-1/44", got.ContentID, got.MediaFolderID)
|
||||
}
|
||||
if got.BaseType != "ebook" || got.BaseTitle != "Book" || got.BaseYear != 2024 || got.Container != "epub" || got.ProbeSource != "local" {
|
||||
t.Fatalf("ebook file metadata = %+v", got)
|
||||
}
|
||||
if got.CanonicalRootPath != "/library/Book.epub" || got.ObservedRootPath != "/library/Book.epub" || got.FilePath != "/library/Book.epub" {
|
||||
t.Fatalf("ebook paths = canonical %q observed %q file %q", got.CanonicalRootPath, got.ObservedRootPath, got.FilePath)
|
||||
}
|
||||
if got.FileSize != 1234 || got.FileModifiedAt == nil || !got.FileModifiedAt.Equal(modifiedAt) {
|
||||
t.Fatalf("file facts = size %d modified %v", got.FileSize, got.FileModifiedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanEbookPersistenceSQLWritesLibraryMembershipAndISBNOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
exec := &recordingEbookExecutor{}
|
||||
|
||||
if err := insertEbookLibraryMembership(ctx, exec, "content-1", 44); err != nil {
|
||||
t.Fatalf("insertEbookLibraryMembership: %v", err)
|
||||
}
|
||||
if err := insertEbookISBNProviderID(ctx, exec, "content-1", "9780306406157"); err != nil {
|
||||
t.Fatalf("insertEbookISBNProviderID: %v", err)
|
||||
}
|
||||
if len(exec.queries) != 2 {
|
||||
t.Fatalf("queries = %d, want 2", len(exec.queries))
|
||||
}
|
||||
if !strings.Contains(exec.queries[0], "media_item_libraries") || exec.args[0][0] != "content-1" || exec.args[0][1] != 44 {
|
||||
t.Fatalf("library membership write = query %q args %+v", exec.queries[0], exec.args[0])
|
||||
}
|
||||
if !strings.Contains(exec.queries[1], "media_item_provider_ids") || !strings.Contains(exec.queries[1], "'isbn'") || !strings.Contains(exec.queries[1], "'ebook'") {
|
||||
t.Fatalf("ISBN provider write query = %q", exec.queries[1])
|
||||
}
|
||||
if strings.Contains(exec.queries[1], "asin") || exec.args[1][0] != "content-1" || exec.args[1][1] != "9780306406157" {
|
||||
t.Fatalf("ISBN provider write args/query = query %q args %+v", exec.queries[1], exec.args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanEbookFolderReturnsErrorWhenEveryReconcileFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "bad.epub"), []byte("not a real epub"), 0o644); err != nil {
|
||||
t.Fatalf("write fake ebook: %v", err)
|
||||
}
|
||||
|
||||
s := &Scanner{}
|
||||
err := s.ScanEbookFolder(context.Background(), &models.MediaFolder{ID: 44, Paths: []string{root}})
|
||||
if err == nil {
|
||||
t.Fatal("ScanEbookFolder returned nil, want aggregate failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "folder_id=44") {
|
||||
t.Fatalf("error = %q, want folder id", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanEbookFolderReturnsCanceledContext(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
s := &Scanner{}
|
||||
err := s.ScanEbookFolder(ctx, &models.MediaFolder{ID: 44, Paths: []string{root}})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("ScanEbookFolder error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestEPUB(t *testing.T, identifiers []string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "book.epub")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create epub: %v", err)
|
||||
}
|
||||
zw := zip.NewWriter(file)
|
||||
add := func(name, body string) {
|
||||
t.Helper()
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("create zip entry %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatalf("write zip entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
add("META-INF/container.xml", `<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`)
|
||||
var identifierXML strings.Builder
|
||||
for _, identifier := range identifiers {
|
||||
identifierXML.WriteString(" <dc:identifier>")
|
||||
identifierXML.WriteString(identifier)
|
||||
identifierXML.WriteString("</dc:identifier>\n")
|
||||
}
|
||||
add("OPS/content.opf", `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<metadata>
|
||||
<dc:title>The Test Ebook</dc:title>
|
||||
<dc:creator>Ada Writer</dc:creator>
|
||||
<dc:creator>Ben Author</dc:creator>
|
||||
`+identifierXML.String()+`
|
||||
<dc:publisher>Silo Press</dc:publisher>
|
||||
<dc:date>2024-03-10</dc:date>
|
||||
<dc:language>en</dc:language>
|
||||
<dc:subject>Fiction</dc:subject>
|
||||
<dc:subject>Adventure</dc:subject>
|
||||
<dc:description>Back cover copy</dc:description>
|
||||
<meta name="calibre:series" content="Test Series"/>
|
||||
<meta name="calibre:series_index" content="2"/>
|
||||
</metadata>
|
||||
</package>`)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close epub: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -2645,16 +2645,10 @@ func (r *FileRepository) ListByEpisodeIDs(ctx context.Context, episodeIDs []stri
|
||||
// UpdateContentID sets the content_id on a media file, linking it to a matched
|
||||
// media item. This is called by the matcher after a successful resolution.
|
||||
func (r *FileRepository) UpdateContentID(ctx context.Context, fileID int, contentID string) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
_, err := r.pool.Exec(ctx,
|
||||
"UPDATE media_files SET content_id = $1, updated_at = NOW() WHERE id = $2",
|
||||
contentID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrFileNotFound
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplaceContentID reassigns all files linked to one content item to another.
|
||||
|
||||
@@ -225,9 +225,6 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
|
||||
if err := s.ScanAudiobookFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
@@ -235,33 +232,18 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
|
||||
if err := s.ScanPodcastFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
if isEbookLibraryType(folder.Type) {
|
||||
if err := s.ScanEbookFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
return s.scanPaths(watchCtx, folder, folder.Paths, folder.Paths, true)
|
||||
}
|
||||
|
||||
var errEbookScanningNotImplemented = errors.New("ebook scanning is not implemented")
|
||||
|
||||
// ScanSubtree walks a single subtree within a media folder and reconciles only
|
||||
// files that live beneath that subtree.
|
||||
func (s *Scanner) ScanSubtree(ctx context.Context, folder *models.MediaFolder, subtreePath string) (*ScanResult, error) {
|
||||
cleanSubtree := filepath.Clean(subtreePath)
|
||||
watchCtx, stopWatch := s.watchFolderContext(ctx, folder.ID)
|
||||
defer stopWatch()
|
||||
if isEbookLibraryType(folder.Type) {
|
||||
return nil, errEbookScanningNotImplemented
|
||||
}
|
||||
return s.scanPaths(watchCtx, folder, []string{cleanSubtree}, []string{cleanSubtree}, false)
|
||||
}
|
||||
|
||||
@@ -290,15 +272,6 @@ func isPodcastLibraryType(libraryType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isEbookLibraryType(libraryType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(libraryType)) {
|
||||
case "ebook", "ebooks":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// walkMode tells walkLogicalTree which file extensions to surface and
|
||||
// which library-specific filename heuristics (sample/extra skipping)
|
||||
// to apply.
|
||||
@@ -309,7 +282,6 @@ const (
|
||||
walkModeMovie // movie library: video extensions + sample/extra skipping
|
||||
walkModeAudiobook // audiobook library: audio extensions, no skipping
|
||||
walkModePodcast // podcast library: audio extensions, no skipping
|
||||
walkModeEbook // ebook library: ebook extensions, no skipping
|
||||
)
|
||||
|
||||
// walkModeFor derives a walkMode from a media_folders.type string.
|
||||
@@ -323,8 +295,6 @@ func walkModeFor(folderType string) walkMode {
|
||||
return walkModeAudiobook
|
||||
case isPodcastLibraryType(folderType):
|
||||
return walkModePodcast
|
||||
case isEbookLibraryType(folderType):
|
||||
return walkModeEbook
|
||||
default:
|
||||
return walkModeVideo
|
||||
}
|
||||
@@ -336,8 +306,6 @@ func (m walkMode) acceptsExt(ext string) bool {
|
||||
switch m {
|
||||
case walkModeAudiobook, walkModePodcast:
|
||||
return audioExtensions[ext]
|
||||
case walkModeEbook:
|
||||
return ebookExtensions[ext]
|
||||
default:
|
||||
return videoExtensions[ext]
|
||||
}
|
||||
@@ -1389,31 +1357,6 @@ func (s *Scanner) syncPresentLibraryState(ctx context.Context, folderID int) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) syncFolderScopedAudioLibraryState(ctx context.Context, folderID int) error {
|
||||
if err := s.syncPresentLibraryState(ctx, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
||||
INSERT INTO media_item_roots (media_folder_id, canonical_root_path, content_id)
|
||||
SELECT DISTINCT mf.media_folder_id, mf.canonical_root_path, mf.content_id
|
||||
FROM media_files mf
|
||||
JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.content_id IS NOT NULL
|
||||
AND COALESCE(mf.canonical_root_path, '') <> ''
|
||||
AND mi.type IN ('audiobook', 'podcast')
|
||||
ON CONFLICT (media_folder_id, canonical_root_path)
|
||||
DO UPDATE SET content_id = EXCLUDED.content_id,
|
||||
last_seen_at = NOW()
|
||||
`, folderID); err != nil {
|
||||
return fmt.Errorf("restoring folder-scoped audio roots: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) reconcileLibraryMemberships(ctx context.Context, folderID int) (int, int, []string, error) {
|
||||
if s.episodeLibraryRepo != nil {
|
||||
if _, err := s.episodeLibraryRepo.ReconcileFolderMembership(ctx, folderID); err != nil {
|
||||
|
||||
@@ -2,7 +2,6 @@ package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -201,82 +200,3 @@ func TestIsPodcastLibraryType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEbookLibraryType(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"ebooks", true},
|
||||
{"ebook", true},
|
||||
{"Ebook", true},
|
||||
{" EBOOKS ", true},
|
||||
{"audiobooks", false},
|
||||
{"movies", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isEbookLibraryType(tc.in); got != tc.want {
|
||||
t.Errorf("isEbookLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkModeForEbookLibraryTypes(t *testing.T) {
|
||||
for _, libraryType := range []string{"ebook", "ebooks", " EBOOKS "} {
|
||||
if got := walkModeFor(libraryType); got != walkModeEbook {
|
||||
t.Fatalf("walkModeFor(%q) = %v, want %v", libraryType, got, walkModeEbook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkModeEbookAcceptsEbookExtensionsOnly(t *testing.T) {
|
||||
if !walkModeEbook.acceptsExt(".epub") {
|
||||
t.Fatal("walkModeEbook should accept .epub")
|
||||
}
|
||||
if !walkModeEbook.acceptsExt(".pdf") {
|
||||
t.Fatal("walkModeEbook should accept .pdf")
|
||||
}
|
||||
if walkModeEbook.acceptsExt(".mp4") {
|
||||
t.Fatal("walkModeEbook should reject video extensions")
|
||||
}
|
||||
if walkModeEbook.acceptsExt(".mp3") {
|
||||
t.Fatal("walkModeEbook should reject audio extensions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFolderEbookLibraryRoutesToEbookScanner(t *testing.T) {
|
||||
scanner := &Scanner{}
|
||||
result, err := scanner.ScanFolder(context.Background(), &models.MediaFolder{
|
||||
ID: 0,
|
||||
Type: " ebooks ",
|
||||
Paths: []string{t.TempDir()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFolder ebook error = %v, want nil", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("ScanFolder ebook result = nil, want empty result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSubtreeEbookLibraryReturnsNotImplemented(t *testing.T) {
|
||||
subtree := t.TempDir()
|
||||
ebookPath := filepath.Join(subtree, "Book.epub")
|
||||
if err := os.WriteFile(ebookPath, []byte("not a real epub"), 0o644); err != nil {
|
||||
t.Fatalf("write ebook fixture: %v", err)
|
||||
}
|
||||
|
||||
scanner := &Scanner{}
|
||||
result, err := scanner.ScanSubtree(context.Background(), &models.MediaFolder{
|
||||
ID: 0,
|
||||
Type: "ebook",
|
||||
Paths: []string{subtree},
|
||||
}, subtree)
|
||||
if !errors.Is(err, errEbookScanningNotImplemented) {
|
||||
t.Fatalf("ScanSubtree ebook error = %v, want %v", err, errEbookScanningNotImplemented)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("ScanSubtree ebook result = %+v, want nil", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +167,6 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe
|
||||
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-random-tv", Scope: "library", LibraryID: libraryID, Position: 5, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true},
|
||||
}
|
||||
case "audiobooks", "audiobook":
|
||||
return []*PageSection{
|
||||
{ID: "default-continue-listening", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Listening", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-recently-added-audiobooks", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added Audiobooks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
|
||||
{ID: "default-recently-released-audiobooks", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released Audiobooks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
|
||||
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-random-audiobooks", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
|
||||
}
|
||||
default:
|
||||
return DefaultLibrarySections(libraryID)
|
||||
}
|
||||
|
||||
@@ -213,68 +213,6 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultLibrarySectionsForTypeAudiobooks(t *testing.T) {
|
||||
libraryID := 10
|
||||
got := DefaultLibrarySectionsForType(&libraryID, "audiobooks")
|
||||
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("expected 5 audiobook default sections, got %d", len(got))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
index int
|
||||
id string
|
||||
sectionType SectionType
|
||||
title string
|
||||
position int
|
||||
}{
|
||||
{index: 0, id: "default-continue-listening", sectionType: SectionContinueWatching, title: "Continue Listening", position: 0},
|
||||
{index: 1, id: "default-recently-added-audiobooks", sectionType: SectionRecentlyAdded, title: "Recently Added Audiobooks", position: 1},
|
||||
{index: 2, id: "default-recently-released-audiobooks", sectionType: SectionRecentlyReleased, title: "Recently Released Audiobooks", position: 2},
|
||||
{index: 3, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 3},
|
||||
{index: 4, id: "default-random-audiobooks", sectionType: SectionRandom, title: "Random Picks", position: 4},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
section := got[tt.index]
|
||||
if section.ID != tt.id {
|
||||
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
|
||||
}
|
||||
if section.SectionType != tt.sectionType {
|
||||
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
|
||||
}
|
||||
if section.Title != tt.title {
|
||||
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
|
||||
}
|
||||
if section.Position != tt.position {
|
||||
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
|
||||
}
|
||||
if section.Featured {
|
||||
t.Fatalf("section %d featured = true, want false", tt.index)
|
||||
}
|
||||
}
|
||||
|
||||
assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{
|
||||
MediaScope: "audiobook",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
})
|
||||
assertQueryDefinition(t, got[2].Config, catalog.QueryDefinition{
|
||||
MediaScope: "audiobook",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
})
|
||||
assertEmptyJSON(t, got[3].Config)
|
||||
assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{
|
||||
MediaScope: "audiobook",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultLibrarySectionsForTypeMixed(t *testing.T) {
|
||||
libraryID := 99
|
||||
got := DefaultLibrarySectionsForType(&libraryID, "mixed")
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
)
|
||||
|
||||
const orphanedMediaCleanupBatchSize = 1000
|
||||
|
||||
type OrphanedMediaItemCleaner interface {
|
||||
Cleanup(ctx context.Context, batchSize int) (catalog.OrphanedProvisionalCleanupStats, error)
|
||||
}
|
||||
|
||||
type CleanupOrphanedMediaItemsTask struct {
|
||||
cleaner OrphanedMediaItemCleaner
|
||||
}
|
||||
|
||||
type cleanupOrphanedMediaItemsResult struct {
|
||||
Candidates int `json:"candidates"`
|
||||
Deleted int `json:"deleted"`
|
||||
}
|
||||
|
||||
func NewCleanupOrphanedMediaItemsTask(cleaner OrphanedMediaItemCleaner) *CleanupOrphanedMediaItemsTask {
|
||||
return &CleanupOrphanedMediaItemsTask{cleaner: cleaner}
|
||||
}
|
||||
|
||||
func (t *CleanupOrphanedMediaItemsTask) Key() string { return "cleanup_orphaned_media_items" }
|
||||
func (t *CleanupOrphanedMediaItemsTask) Name() string {
|
||||
return "Clean Orphaned Media Items"
|
||||
}
|
||||
func (t *CleanupOrphanedMediaItemsTask) Description() string {
|
||||
return "Deletes detached provisional media items that have no files, library memberships, or durable user/sync references."
|
||||
}
|
||||
func (t *CleanupOrphanedMediaItemsTask) Category() taskmanager.TaskCategory {
|
||||
return taskmanager.TaskCategoryLibrary
|
||||
}
|
||||
func (t *CleanupOrphanedMediaItemsTask) IsHidden() bool { return false }
|
||||
|
||||
func (t *CleanupOrphanedMediaItemsTask) DefaultTriggers() []taskmanager.TriggerConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *CleanupOrphanedMediaItemsTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
|
||||
if t == nil || t.cleaner == nil {
|
||||
progress.Report(100, "Orphaned media cleanup is not configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
progress.Report(0, "Checking for orphaned provisional media items")
|
||||
stats, err := t.cleaner.Cleanup(ctx, orphanedMediaCleanupBatchSize)
|
||||
setCleanupOrphanedMediaItemsResult(progress, cleanupOrphanedMediaItemsResult{
|
||||
Candidates: stats.Candidates,
|
||||
Deleted: stats.Deleted,
|
||||
})
|
||||
if err != nil {
|
||||
progress.Report(100, fmt.Sprintf("Orphaned media cleanup failed after deleting %d of %d candidates", stats.Deleted, stats.Candidates))
|
||||
return err
|
||||
}
|
||||
|
||||
if stats.Candidates == 0 {
|
||||
progress.Report(100, "No orphaned provisional media items found")
|
||||
return nil
|
||||
}
|
||||
progress.Report(100, fmt.Sprintf("Deleted %d orphaned provisional media items (%d candidates)", stats.Deleted, stats.Candidates))
|
||||
return nil
|
||||
}
|
||||
|
||||
func setCleanupOrphanedMediaItemsResult(progress taskmanager.ProgressReporter, result cleanupOrphanedMediaItemsResult) {
|
||||
if progress == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
progress.SetResultData(data)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
)
|
||||
|
||||
type fakeOrphanedMediaCleaner struct {
|
||||
stats catalog.OrphanedProvisionalCleanupStats
|
||||
err error
|
||||
calls int
|
||||
batchSize int
|
||||
}
|
||||
|
||||
func (f *fakeOrphanedMediaCleaner) Cleanup(_ context.Context, batchSize int) (catalog.OrphanedProvisionalCleanupStats, error) {
|
||||
f.calls++
|
||||
f.batchSize = batchSize
|
||||
return f.stats, f.err
|
||||
}
|
||||
|
||||
type orphanedMediaCleanupProgress struct {
|
||||
reports []string
|
||||
result json.RawMessage
|
||||
}
|
||||
|
||||
func (p *orphanedMediaCleanupProgress) Report(_ float64, message string) {
|
||||
p.reports = append(p.reports, message)
|
||||
}
|
||||
|
||||
func (p *orphanedMediaCleanupProgress) SetResultData(data json.RawMessage) {
|
||||
p.result = append(p.result[:0], data...)
|
||||
}
|
||||
|
||||
func TestCleanupOrphanedMediaItemsTask(t *testing.T) {
|
||||
cleaner := &fakeOrphanedMediaCleaner{
|
||||
stats: catalog.OrphanedProvisionalCleanupStats{Candidates: 5, Deleted: 5},
|
||||
}
|
||||
task := NewCleanupOrphanedMediaItemsTask(cleaner)
|
||||
|
||||
if task.Key() != "cleanup_orphaned_media_items" {
|
||||
t.Fatalf("Key() = %q, want cleanup_orphaned_media_items", task.Key())
|
||||
}
|
||||
if task.Category() != taskmanager.TaskCategoryLibrary {
|
||||
t.Fatalf("Category() = %q, want %q", task.Category(), taskmanager.TaskCategoryLibrary)
|
||||
}
|
||||
if task.IsHidden() {
|
||||
t.Fatal("IsHidden() = true, want false")
|
||||
}
|
||||
if triggers := task.DefaultTriggers(); len(triggers) != 0 {
|
||||
t.Fatalf("DefaultTriggers() = %#v, want none", triggers)
|
||||
}
|
||||
|
||||
progress := &orphanedMediaCleanupProgress{}
|
||||
if err := task.Execute(context.Background(), progress); err != nil {
|
||||
t.Fatalf("Execute returned error: %v", err)
|
||||
}
|
||||
if cleaner.calls != 1 {
|
||||
t.Fatalf("cleaner calls = %d, want 1", cleaner.calls)
|
||||
}
|
||||
if cleaner.batchSize != orphanedMediaCleanupBatchSize {
|
||||
t.Fatalf("batchSize = %d, want %d", cleaner.batchSize, orphanedMediaCleanupBatchSize)
|
||||
}
|
||||
last := progress.reports[len(progress.reports)-1]
|
||||
if !strings.Contains(last, "Deleted 5 orphaned provisional media items") {
|
||||
t.Fatalf("last progress report = %q", last)
|
||||
}
|
||||
var result cleanupOrphanedMediaItemsResult
|
||||
if err := json.Unmarshal(progress.result, &result); err != nil {
|
||||
t.Fatalf("unmarshal result data: %v", err)
|
||||
}
|
||||
if result.Candidates != 5 || result.Deleted != 5 {
|
||||
t.Fatalf("result = %#v, want 5 candidates and 5 deleted", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOrphanedMediaItemsTaskNoCandidates(t *testing.T) {
|
||||
task := NewCleanupOrphanedMediaItemsTask(&fakeOrphanedMediaCleaner{})
|
||||
progress := &orphanedMediaCleanupProgress{}
|
||||
|
||||
if err := task.Execute(context.Background(), progress); err != nil {
|
||||
t.Fatalf("Execute returned error: %v", err)
|
||||
}
|
||||
last := progress.reports[len(progress.reports)-1]
|
||||
if last != "No orphaned provisional media items found" {
|
||||
t.Fatalf("last progress report = %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOrphanedMediaItemsTaskReturnsCleanupError(t *testing.T) {
|
||||
wantErr := errors.New("cleanup failed")
|
||||
cleaner := &fakeOrphanedMediaCleaner{
|
||||
stats: catalog.OrphanedProvisionalCleanupStats{Candidates: 5, Deleted: 2},
|
||||
err: wantErr,
|
||||
}
|
||||
task := NewCleanupOrphanedMediaItemsTask(cleaner)
|
||||
progress := &orphanedMediaCleanupProgress{}
|
||||
|
||||
err := task.Execute(context.Background(), progress)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Execute error = %v, want %v", err, wantErr)
|
||||
}
|
||||
last := progress.reports[len(progress.reports)-1]
|
||||
if !strings.Contains(last, "failed after deleting 2 of 5 candidates") {
|
||||
t.Fatalf("last progress report = %q", last)
|
||||
}
|
||||
}
|
||||
@@ -112,11 +112,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleHWCapabilities(w http.ResponseWriter, _ *http.Request) {
|
||||
ffmpegPath := ""
|
||||
if cfg := s.watcher.Config(); cfg != nil {
|
||||
ffmpegPath = cfg.Playback.FFmpegPath
|
||||
}
|
||||
info := playback.DetectHWAccelWithFFmpeg(ffmpegPath)
|
||||
info := playback.DetectHWAccel()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCleanupOrphanedProvisionalItemsPreservesDurableReferences(t *testing.T) {
|
||||
migrationBytes, err := FS.ReadFile("sql/20260607220000_cleanup_orphaned_provisional_items.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read migration: %v", err)
|
||||
}
|
||||
migration := normalizeSQL(string(migrationBytes))
|
||||
for _, want := range []string{
|
||||
"public.abs_bookmarks ab WHERE ab.library_item_id = mi.content_id",
|
||||
"public.abs_playback_sessions aps WHERE aps.content_id = mi.content_id",
|
||||
"public.abs_rss_feeds arf WHERE arf.library_item_id = mi.content_id",
|
||||
"public.episodes e WHERE e.series_id = mi.content_id",
|
||||
"public.podcast_feeds pf WHERE pf.media_item_id = mi.content_id",
|
||||
"public.seasons s WHERE s.series_id = mi.content_id",
|
||||
"public.user_audio_preferences uap WHERE uap.series_id = mi.content_id",
|
||||
"public.user_home_item_dismissals uhid WHERE uhid.media_item_id = mi.content_id",
|
||||
"public.user_home_item_dismissals uhid_series WHERE uhid_series.series_id = mi.content_id",
|
||||
"public.user_personal_collection_items upci WHERE upci.media_item_id = mi.content_id",
|
||||
"public.user_series_playback_preferences uspp WHERE uspp.series_id = mi.content_id",
|
||||
"public.user_subtitle_preferences usp WHERE usp.series_id = mi.content_id",
|
||||
} {
|
||||
if !strings.Contains(migration, normalizeSQL(want)) {
|
||||
t.Fatalf("cleanup migration missing durable reference guard %q", want)
|
||||
}
|
||||
}
|
||||
for _, droppedTable := range []string{
|
||||
"abs_collection_items",
|
||||
"abs_playlist_items",
|
||||
"abs_playlists",
|
||||
} {
|
||||
if strings.Contains(migration, droppedTable) {
|
||||
t.Fatalf("cleanup migration references dropped table %q", droppedTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
INSERT INTO server_settings (key, value)
|
||||
VALUES (
|
||||
'audiobookshelf_compat.enabled',
|
||||
COALESCE(
|
||||
(SELECT value FROM server_settings WHERE key = 'audiobooks.enabled'),
|
||||
'false'
|
||||
)
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DELETE FROM server_settings WHERE key = 'audiobookshelf_compat.enabled';
|
||||
-- +goose StatementEnd
|
||||
@@ -1,6 +0,0 @@
|
||||
-- +goose Up
|
||||
DELETE FROM server_settings
|
||||
WHERE key IN ('audiobookshelf_compat.listen', 'audiobookshelf_compat.public_url');
|
||||
|
||||
-- +goose Down
|
||||
SELECT 1;
|
||||
@@ -1,145 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.autoscan_sources
|
||||
ADD COLUMN plugin_id text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ADD COLUMN plugin_id text NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE public.autoscan_sources s
|
||||
SET plugin_id = pi.plugin_id
|
||||
FROM public.plugin_installations pi
|
||||
WHERE s.installation_id = pi.id
|
||||
AND s.plugin_id = '';
|
||||
|
||||
WITH inferred AS (
|
||||
SELECT s.id, min(pi.plugin_id) AS plugin_id, count(*) AS matches
|
||||
FROM public.autoscan_sources s
|
||||
JOIN public.plugin_capabilities pc
|
||||
ON pc.capability_type = 'scan_source.v1'
|
||||
AND pc.capability_id = s.capability_id
|
||||
JOIN public.plugin_installations pi
|
||||
ON pi.id = pc.plugin_installation_id
|
||||
WHERE s.plugin_id = ''
|
||||
GROUP BY s.id
|
||||
)
|
||||
UPDATE public.autoscan_sources s
|
||||
SET plugin_id = inferred.plugin_id
|
||||
FROM inferred
|
||||
WHERE s.id = inferred.id
|
||||
AND inferred.matches = 1;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.autoscan_sources
|
||||
WHERE enabled = true
|
||||
AND btrim(plugin_id) = ''
|
||||
) THEN
|
||||
RAISE EXCEPTION 'enabled autoscan source could not be migrated to plugin_id';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
UPDATE public.autoscan_events e
|
||||
SET plugin_id = s.plugin_id
|
||||
FROM public.autoscan_sources s
|
||||
WHERE e.source_id = s.id
|
||||
AND e.plugin_id = ''
|
||||
AND btrim(s.plugin_id) <> '';
|
||||
|
||||
UPDATE public.autoscan_events e
|
||||
SET plugin_id = pi.plugin_id
|
||||
FROM public.plugin_installations pi
|
||||
WHERE e.installation_id = pi.id
|
||||
AND e.plugin_id = '';
|
||||
|
||||
WITH inferred AS (
|
||||
SELECT e.id, min(pi.plugin_id) AS plugin_id, count(*) AS matches
|
||||
FROM public.autoscan_events e
|
||||
JOIN public.plugin_capabilities pc
|
||||
ON pc.capability_type = 'scan_source.v1'
|
||||
AND pc.capability_id = e.capability_id
|
||||
JOIN public.plugin_installations pi
|
||||
ON pi.id = pc.plugin_installation_id
|
||||
WHERE e.plugin_id = ''
|
||||
GROUP BY e.id
|
||||
)
|
||||
UPDATE public.autoscan_events e
|
||||
SET plugin_id = inferred.plugin_id
|
||||
FROM inferred
|
||||
WHERE e.id = inferred.id
|
||||
AND inferred.matches = 1;
|
||||
|
||||
CREATE INDEX idx_autoscan_sources_plugin_capability
|
||||
ON public.autoscan_sources (plugin_id, capability_id);
|
||||
|
||||
CREATE INDEX idx_autoscan_events_plugin_capability_completed
|
||||
ON public.autoscan_events (plugin_id, capability_id, completed_at DESC);
|
||||
|
||||
ALTER TABLE public.autoscan_sources
|
||||
ALTER COLUMN plugin_id DROP DEFAULT;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ALTER COLUMN plugin_id DROP DEFAULT;
|
||||
|
||||
ALTER TABLE public.autoscan_sources
|
||||
DROP COLUMN installation_id;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
DROP COLUMN installation_id;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.autoscan_sources
|
||||
ADD COLUMN installation_id integer NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ADD COLUMN installation_id integer NOT NULL DEFAULT 0;
|
||||
|
||||
WITH resolved AS (
|
||||
SELECT s.id, min(pc.plugin_installation_id)::integer AS installation_id, count(*) AS matches
|
||||
FROM public.autoscan_sources s
|
||||
JOIN public.plugin_installations pi
|
||||
ON pi.plugin_id = s.plugin_id
|
||||
JOIN public.plugin_capabilities pc
|
||||
ON pc.plugin_installation_id = pi.id
|
||||
AND pc.capability_type = 'scan_source.v1'
|
||||
AND pc.capability_id = s.capability_id
|
||||
WHERE btrim(s.plugin_id) <> ''
|
||||
GROUP BY s.id
|
||||
)
|
||||
UPDATE public.autoscan_sources s
|
||||
SET installation_id = resolved.installation_id
|
||||
FROM resolved
|
||||
WHERE s.id = resolved.id
|
||||
AND resolved.matches = 1;
|
||||
|
||||
WITH resolved AS (
|
||||
SELECT e.id, min(pc.plugin_installation_id)::integer AS installation_id, count(*) AS matches
|
||||
FROM public.autoscan_events e
|
||||
JOIN public.plugin_installations pi
|
||||
ON pi.plugin_id = e.plugin_id
|
||||
JOIN public.plugin_capabilities pc
|
||||
ON pc.plugin_installation_id = pi.id
|
||||
AND pc.capability_type = 'scan_source.v1'
|
||||
AND pc.capability_id = e.capability_id
|
||||
WHERE btrim(e.plugin_id) <> ''
|
||||
GROUP BY e.id
|
||||
)
|
||||
UPDATE public.autoscan_events e
|
||||
SET installation_id = resolved.installation_id
|
||||
FROM resolved
|
||||
WHERE e.id = resolved.id
|
||||
AND resolved.matches = 1;
|
||||
|
||||
DROP INDEX IF EXISTS public.idx_autoscan_events_plugin_capability_completed;
|
||||
DROP INDEX IF EXISTS public.idx_autoscan_sources_plugin_capability;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
DROP COLUMN plugin_id;
|
||||
|
||||
ALTER TABLE public.autoscan_sources
|
||||
DROP COLUMN plugin_id;
|
||||
-- +goose StatementEnd
|
||||
@@ -1,36 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.autoscan_events
|
||||
DROP CONSTRAINT IF EXISTS autoscan_events_status_check;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ADD CONSTRAINT autoscan_events_status_check
|
||||
CHECK (status = ANY (ARRAY[
|
||||
'running'::text,
|
||||
'success'::text,
|
||||
'error'::text,
|
||||
'unresolved'::text
|
||||
]));
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
UPDATE public.autoscan_events
|
||||
SET status = 'error',
|
||||
error_message = CASE
|
||||
WHEN error_message = '' THEN 'poll started but did not finish'
|
||||
ELSE error_message
|
||||
END
|
||||
WHERE status = 'running';
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
DROP CONSTRAINT IF EXISTS autoscan_events_status_check;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ADD CONSTRAINT autoscan_events_status_check
|
||||
CHECK (status = ANY (ARRAY[
|
||||
'success'::text,
|
||||
'error'::text,
|
||||
'unresolved'::text
|
||||
]));
|
||||
-- +goose StatementEnd
|
||||
@@ -1,142 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- Clean up provisional catalog rows left behind by library-delete/matcher races.
|
||||
-- Stale external IDs and other item-owned metadata cascade from media_items.
|
||||
-- Durable user/sync references do not all have media_items foreign keys, so
|
||||
-- keep any provisional row that is still referenced outside derived metadata.
|
||||
DELETE FROM public.media_items mi
|
||||
WHERE mi.status IN ('pending', 'unmatched', 'ambiguous')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.media_item_libraries mil
|
||||
WHERE mil.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.media_files mf
|
||||
WHERE mf.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.episodes e
|
||||
WHERE e.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.seasons s
|
||||
WHERE s.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.library_collection_items lci
|
||||
WHERE lci.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.abs_bookmarks ab
|
||||
WHERE ab.library_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.abs_playback_sessions aps
|
||||
WHERE aps.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.abs_rss_feeds arf
|
||||
WHERE arf.library_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.downloads d
|
||||
WHERE d.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.playback_history_admin pha
|
||||
WHERE pha.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.plex_sync_item_bindings psib
|
||||
WHERE psib.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.plex_sync_item_state psis
|
||||
WHERE psis.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.podcast_feeds pf
|
||||
WHERE pf.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_favorites uf
|
||||
WHERE uf.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_downloads ud
|
||||
WHERE ud.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_history_hidden_items uhhi
|
||||
WHERE uhhi.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_home_item_dismissals uhid
|
||||
WHERE uhid.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_home_item_dismissals uhid_series
|
||||
WHERE uhid_series.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_audio_preferences uap
|
||||
WHERE uap.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_personal_collection_items upci
|
||||
WHERE upci.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_ratings ur
|
||||
WHERE ur.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_series_playback_preferences uspp
|
||||
WHERE uspp.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_subtitle_preferences usp
|
||||
WHERE usp.series_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_watch_history uwh
|
||||
WHERE uwh.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_watch_progress uwp
|
||||
WHERE uwp.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.user_watchlist uwl
|
||||
WHERE uwl.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_provider_favorite_items wpfi
|
||||
WHERE wpfi.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_provider_history_exports wphe
|
||||
WHERE wphe.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_provider_scrobble_sessions wpss
|
||||
WHERE wpss.media_item_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_together_rooms wtr
|
||||
WHERE wtr.selected_content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.watch_together_suggestions wts
|
||||
WHERE wts.content_id = mi.content_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.webhook_sync_item_state wsis
|
||||
WHERE wsis.media_item_id = mi.content_id
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
-- Data cleanup only; deleted orphaned provisional rows cannot be reconstructed.
|
||||
-- +goose StatementEnd
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user