Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17ec6646a1 | ||
|
|
c200ecc061 | ||
|
|
bcaa6dc58d | ||
|
|
cfc0d4ac0d | ||
|
|
5adcbbd1e2 | ||
|
|
ca55d6f146 | ||
|
|
28c56a7e59 | ||
|
|
2908fc9a9a | ||
|
|
60542f8768 | ||
|
|
f1e3a8df95 | ||
|
|
527ccd64e3 | ||
|
|
45c79f4d82 | ||
|
|
78ac6b0551 | ||
|
|
ae42516a91 | ||
|
|
958abefe53 | ||
|
|
7a28a8fde9 | ||
|
|
3f6105ae00 | ||
|
|
998174c8f7 | ||
|
|
48422f0fe1 | ||
|
|
463b21c683 | ||
|
|
d40dca9022 | ||
|
|
9676e616ab | ||
|
|
544a1e1332 | ||
|
|
be18357469 | ||
|
|
ecde6d6039 | ||
|
|
9d6216da3a | ||
|
|
2fbc268124 | ||
|
|
3c70c0aaae | ||
|
|
5eb34cbeda | ||
|
|
a7569e6d40 | ||
|
|
a8afc04580 | ||
|
|
8edb5c761a | ||
|
|
6ab4458efa | ||
|
|
f305e4c6a1 | ||
|
|
c406b56b98 | ||
|
|
900f7adb77 | ||
|
|
783d6751dd | ||
|
|
5ad2fe9b9e | ||
|
|
c809d8139b | ||
|
|
9f238f2b8e | ||
|
|
b249d53aaf | ||
|
|
c35fa8bbee | ||
|
|
2a30d8a517 | ||
|
|
45acc59196 | ||
|
|
92ffbfd9ce | ||
|
|
d083dd44c2 | ||
|
|
ffec643a90 | ||
|
|
5ac4f812b2 | ||
|
|
67d5bf7dfe | ||
|
|
433535593f | ||
|
|
11cc37faf9 | ||
|
|
9b2ce9588a | ||
|
|
52a21e48da | ||
|
|
fb5af3ea00 | ||
|
|
70ba2f132d | ||
|
|
c41d8f1641 | ||
|
|
a3486f7671 | ||
|
|
48566514fc | ||
|
|
7b599970af | ||
|
|
f014f55e6f | ||
|
|
7ab24d85e7 | ||
|
|
3b3e1fbbde | ||
|
|
0bebbf96c7 | ||
|
|
3db6babfad | ||
|
|
95c1802528 | ||
|
|
2b29356e76 | ||
|
|
f4ed50c896 | ||
|
|
9a8babdb75 | ||
|
|
dad9e21563 | ||
|
|
648e65bdc3 | ||
|
|
4f139a195a | ||
|
|
c53fbd3442 | ||
|
|
72650c147f | ||
|
|
776c2ce034 | ||
|
|
a67015a808 | ||
|
|
e34d1ff47a | ||
|
|
82df5d063a | ||
|
|
94f5dd8ac2 | ||
|
|
46af6746b3 | ||
|
|
53a78d1290 | ||
|
|
08d13552d7 | ||
|
|
8ad14501cd | ||
|
|
8161a70a09 | ||
|
|
b7d05cdee9 | ||
|
|
025009c40a | ||
|
|
82f6e97e66 | ||
|
|
f8e3f2dac8 | ||
|
|
334e2f7b06 | ||
|
|
cd6b0afb34 | ||
|
|
f734d7a01c | ||
|
|
ddd579767b | ||
|
|
3018622e5e | ||
|
|
9e7189f074 | ||
|
|
21170cfcf3 | ||
|
|
0e1c2995a3 | ||
|
|
98d213204a | ||
|
|
5ea8868047 | ||
|
|
0b97d2bd6d | ||
|
|
293db1f255 | ||
|
|
1119cef018 | ||
|
|
c91a1d5b29 | ||
|
|
f15423d505 | ||
|
|
6de2b19c07 | ||
|
|
adf595cbf9 | ||
|
|
0b44952816 | ||
|
|
96bae2f346 | ||
|
|
978e1b4954 | ||
|
|
c4e67effc3 | ||
|
|
6b1554a86c | ||
|
|
480ca44306 | ||
|
|
ecd85f9ca1 | ||
|
|
f9bb94a299 | ||
|
|
eb6024573e | ||
|
|
c469550fa0 | ||
|
|
3353456d46 | ||
|
|
826f0d0b9a | ||
|
|
0b3723c7f2 |
@@ -1,6 +1,12 @@
|
||||
# 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
|
||||
@@ -40,6 +46,9 @@ 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
|
||||
|
||||
@@ -3,6 +3,7 @@ FROM node:22-slim AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/pnpm-lock.yaml ./
|
||||
COPY web/vendor/foliate-js ./vendor/foliate-js
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY web/ .
|
||||
|
||||
@@ -34,6 +34,29 @@ 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.
|
||||
|
||||
+49
-48
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -18,6 +17,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +47,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/database"
|
||||
"github.com/Silo-Server/silo-server/internal/ebooks"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"github.com/Silo-Server/silo-server/internal/imagecache"
|
||||
@@ -425,6 +426,8 @@ 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)
|
||||
@@ -525,14 +528,20 @@ 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})
|
||||
audiobooksEnabled, err := audiobooksService.Enabled(appCtx)
|
||||
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
|
||||
if err != nil {
|
||||
slog.Warn("audiobooks feature disabled; failed to read setting", "err", err)
|
||||
audiobooksEnabled = false
|
||||
slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err)
|
||||
absCompatEnabled = false
|
||||
}
|
||||
deps.AudiobooksEnabled = audiobooksEnabled
|
||||
adminJobCancelRegistry := adminjob.NewCancelRegistry()
|
||||
deps.AdminJobCancelRegistry = adminJobCancelRegistry
|
||||
if needsWorkers && deps.DB != nil {
|
||||
@@ -880,6 +889,7 @@ func main() {
|
||||
var seasonRepo *catalog.SeasonRepository
|
||||
var episodeRepo *catalog.EpisodeRepository
|
||||
var audiobookEnricher *audiobooks.Enricher
|
||||
var ebookEnricher *ebooks.Enricher
|
||||
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
|
||||
chainRepo := metadata.NewChainRepository(deps.DB)
|
||||
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
|
||||
@@ -961,6 +971,14 @@ func main() {
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
ebookEnricher = ebooks.NewEnricher(
|
||||
deps.DB,
|
||||
chainRepo,
|
||||
pluginResolver,
|
||||
itemRepo,
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
|
||||
// Always wire the image resolver so plugin-prefixed URLs (e.g.
|
||||
// metadb://) can be resolved to presigned HTTP URLs in API responses.
|
||||
@@ -983,6 +1001,9 @@ func main() {
|
||||
audiobookEnricher.SetImageCacher(imageCacher)
|
||||
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
ebookEnricher.SetImageCacher(imageCacher)
|
||||
}
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
@@ -1435,6 +1456,7 @@ 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))
|
||||
}
|
||||
@@ -1490,6 +1512,9 @@ 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,
|
||||
@@ -1519,9 +1544,12 @@ 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 audiobooksEnabled && audiobookEnricher != nil {
|
||||
if audiobookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
|
||||
if err != nil {
|
||||
@@ -1542,7 +1570,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 audiobooksEnabled && deps.DB != nil {
|
||||
if absCompatEnabled && deps.DB != nil {
|
||||
absUserRepo := auth.NewUserRepository(deps.DB)
|
||||
absSessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
absJWTService := auth.NewJWTService(
|
||||
@@ -1884,47 +1912,17 @@ func main() {
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
|
||||
|
||||
// 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
|
||||
// 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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2016,6 +2014,9 @@ 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)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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}}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}: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
|
||||
|
||||
@@ -33,6 +33,7 @@ require (
|
||||
github.com/zishang520/socket.io/v2 v2.5.0
|
||||
go.n16f.net/thumbhash v1.1.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/net v0.53.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
@@ -71,7 +72,6 @@ require (
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ type PluginScanSourceAdapter struct {
|
||||
Svc *plugins.Service
|
||||
}
|
||||
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClient(ctx, installationID, capabilityID)
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
|
||||
}
|
||||
|
||||
// scanSourceCapabilityType is the plugin capability type autoscan discovery
|
||||
@@ -75,15 +75,6 @@ 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
|
||||
}
|
||||
@@ -104,10 +95,9 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
|
||||
continue
|
||||
}
|
||||
out = append(out, autoscan.DiscoveredSource{
|
||||
InstallationID: c.InstallationID,
|
||||
CapabilityID: c.ID,
|
||||
PluginID: inst.PluginID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
PluginID: inst.PluginID,
|
||||
CapabilityID: c.ID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ 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"`
|
||||
@@ -127,13 +129,15 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
}
|
||||
|
||||
var (
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalShows int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalMovieFiles int64
|
||||
totalShows int64
|
||||
totalShowFiles int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
)
|
||||
|
||||
row := pool.QueryRow(ctx, `
|
||||
@@ -151,8 +155,34 @@ 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 media_files
|
||||
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
|
||||
),
|
||||
session_stats AS (
|
||||
SELECT COUNT(*)::bigint AS active_streams
|
||||
@@ -163,7 +193,9 @@ 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
|
||||
@@ -176,7 +208,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
&totalItems,
|
||||
&totalFiles,
|
||||
&totalMovies,
|
||||
&totalMovieFiles,
|
||||
&totalShows,
|
||||
&totalShowFiles,
|
||||
&activeStreams,
|
||||
&totalStorage,
|
||||
); err != nil {
|
||||
@@ -194,7 +228,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
TotalItems: int(totalItems),
|
||||
TotalFiles: int(totalFiles),
|
||||
TotalMovies: int(totalMovies),
|
||||
TotalMovieFiles: int(totalMovieFiles),
|
||||
TotalShows: int(totalShows),
|
||||
TotalShowFiles: int(totalShowFiles),
|
||||
ActiveStreams: int(activeStreams),
|
||||
TotalStorageBytes: totalStorage,
|
||||
WatchProviderActivity: activity,
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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,6 +36,7 @@ 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)
|
||||
}
|
||||
@@ -309,7 +310,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"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -331,7 +332,7 @@ func sourceResponse(s autoscan.Source) autoscanSourceResponse {
|
||||
config := normalizeSourceConfig(s.SourceConfig)
|
||||
return autoscanSourceResponse{
|
||||
ID: s.ID,
|
||||
InstallationID: s.InstallationID,
|
||||
PluginID: s.PluginID,
|
||||
CapabilityID: s.CapabilityID,
|
||||
ConnectionID: s.ConnectionID,
|
||||
Enabled: s.Enabled,
|
||||
@@ -364,10 +365,9 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
|
||||
// --- Available scan-source plugins (Add-source picker) ---
|
||||
|
||||
type autoscanScanSourcePluginResponse struct {
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// HandleListAvailableScanSources returns every installed scan_source capability
|
||||
@@ -381,10 +381,9 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
out := make([]autoscanScanSourcePluginResponse, 0, len(available))
|
||||
for _, a := range available {
|
||||
out = append(out, autoscanScanSourcePluginResponse{
|
||||
InstallationID: a.InstallationID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
PluginID: a.PluginID,
|
||||
DisplayName: a.DisplayName,
|
||||
PluginID: a.PluginID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
DisplayName: a.DisplayName,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
@@ -394,12 +393,12 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
|
||||
// --- Create source ---
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
type autoscanCreateSourceInput struct {
|
||||
InstallationID int `json:"installation_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -420,6 +419,11 @@ 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
|
||||
@@ -432,7 +436,7 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
// Validate the (installation_id, capability_id) is a currently-installed
|
||||
// Validate the (plugin_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())
|
||||
@@ -440,13 +444,13 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
if !scanSourceInstalled(available, in.InstallationID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "installation_id + capability_id is not a currently-installed scan_source capability")
|
||||
if !scanSourceInstalled(available, pluginID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "plugin_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{
|
||||
InstallationID: in.InstallationID,
|
||||
PluginID: pluginID,
|
||||
CapabilityID: capID,
|
||||
ConnectionID: connArg,
|
||||
Enabled: in.Enabled,
|
||||
@@ -462,18 +466,18 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusCreated, sourceResponse(created))
|
||||
}
|
||||
|
||||
// scanSourceInstalled reports whether (installationID, capabilityID) is in the
|
||||
// scanSourceInstalled reports whether (pluginID, capabilityID) is in the
|
||||
// set of currently-installed scan_source capabilities.
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, installationID int, capabilityID string) bool {
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, pluginID, capabilityID string) bool {
|
||||
for _, a := range available {
|
||||
if a.InstallationID == installationID && a.CapabilityID == capabilityID {
|
||||
if a.PluginID == pluginID && a.CapabilityID == capabilityID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoscanSourceInput is the source write payload. The (installation_id,
|
||||
// autoscanSourceInput is the source write payload. The (plugin_id,
|
||||
// capability_id) identity is read from the existing source row, so only the
|
||||
// schedulable/binding fields are accepted.
|
||||
//
|
||||
@@ -548,7 +552,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 (installation_id, capability_id) is immutable and
|
||||
// Update by id; identity (plugin_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,
|
||||
@@ -581,9 +585,8 @@ 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): the
|
||||
// poll loop skips such rows quietly, and this endpoint clears them. An unknown
|
||||
// id maps to 404 via autoscan.ErrNotFound.
|
||||
// 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.
|
||||
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 {
|
||||
@@ -710,7 +713,7 @@ type autoscanEventScanRunResponse struct {
|
||||
type autoscanEventResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID *string `json:"source_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
@@ -753,7 +756,7 @@ func eventResponse(event autoscan.EventWithRuns) autoscanEventResponse {
|
||||
return autoscanEventResponse{
|
||||
ID: e.ID,
|
||||
SourceID: e.SourceID,
|
||||
InstallationID: e.InstallationID,
|
||||
PluginID: e.PluginID,
|
||||
CapabilityID: e.CapabilityID,
|
||||
StartedAt: e.StartedAt,
|
||||
CompletedAt: e.CompletedAt,
|
||||
@@ -794,9 +797,9 @@ func (h *AutoscanHandler) HandleListEvents(w http.ResponseWriter, r *http.Reques
|
||||
filter.Offset = offset
|
||||
}
|
||||
switch filter.Status {
|
||||
case "", autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
case "", autoscan.EventStatusRunning, autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be success, error, or unresolved")
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be running, success, error, or unresolved")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -835,7 +838,7 @@ type autoscanScanResponse struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
AutoscanEventID *int64 `json:"autoscan_event_id,omitempty"`
|
||||
SourceID *string `json:"source_id,omitempty"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
CapabilityID string `json:"capability_id,omitempty"`
|
||||
EventStatus string `json:"event_status,omitempty"`
|
||||
EventCompletedAt *time.Time `json:"event_completed_at,omitempty"`
|
||||
@@ -862,7 +865,7 @@ func autoscanScanRunResponse(scan autoscan.ScanWithEvent) autoscanScanResponse {
|
||||
CompletedAt: scan.CompletedAt,
|
||||
AutoscanEventID: scan.AutoscanEventID,
|
||||
SourceID: scan.SourceID,
|
||||
InstallationID: scan.InstallationID,
|
||||
PluginID: scan.PluginID,
|
||||
CapabilityID: scan.CapabilityID,
|
||||
EventStatus: string(scan.EventStatus),
|
||||
EventCompletedAt: scan.EventCompletedAt,
|
||||
@@ -923,24 +926,35 @@ func (h *AutoscanHandler) HandleListScans(w http.ResponseWriter, r *http.Request
|
||||
// --- Status ---
|
||||
|
||||
type autoscanStatusSource struct {
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type autoscanStatusResponse struct {
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -960,6 +974,11 @@ 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)
|
||||
@@ -972,20 +991,38 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
rewrites = []autoscan.PathRewrite{}
|
||||
}
|
||||
trimmed = append(trimmed, autoscanStatusSource{
|
||||
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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, autoscanStatusResponse{
|
||||
Enabled: s.Enabled,
|
||||
Sources: trimmed,
|
||||
RunningPolls: runningPolls,
|
||||
ActiveScans: queue.Active,
|
||||
AcceptedScans: queue.Accepted,
|
||||
RunningScans: queue.Running,
|
||||
|
||||
@@ -31,6 +31,7 @@ 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)
|
||||
}
|
||||
@@ -140,6 +141,13 @@ 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()
|
||||
@@ -273,7 +281,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
listSourcesFn: func() ([]autoscan.Source, error) {
|
||||
listed = true
|
||||
return []autoscan.Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr"}}, nil
|
||||
return []autoscan.Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr"}}, nil
|
||||
},
|
||||
}
|
||||
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
|
||||
@@ -300,7 +308,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
|
||||
func TestAutoscanHandleListAvailableScanSources(t *testing.T) {
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{InstallationID: 1, CapabilityID: "arr", PluginID: "sonarr", DisplayName: "Sonarr"},
|
||||
{PluginID: "sonarr", CapabilityID: "arr", DisplayName: "Sonarr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
|
||||
|
||||
@@ -331,19 +339,19 @@ func TestAutoscanHandleCreateSourceSucceeds(t *testing.T) {
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"installation_id":1,"capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
|
||||
`{"plugin_id":"silo.autoscan.arr","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.InstallationID != 1 || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
if got.PluginID != "silo.autoscan.arr" || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
t.Fatalf("unexpected created source: %+v", got)
|
||||
}
|
||||
var body autoscanSourceResponse
|
||||
@@ -363,14 +371,14 @@ func TestAutoscanHandleCreateSourceRejectsUnknownCapability(t *testing.T) {
|
||||
return s, nil
|
||||
},
|
||||
}
|
||||
// Installed set does NOT include (1, "arr").
|
||||
// Installed set does NOT include ("silo.autoscan.arr", "arr").
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{InstallationID: 2, CapabilityID: "other"},
|
||||
{PluginID: "silo.autoscan.other", CapabilityID: "other"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":false}`, "")
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":false}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -394,12 +402,12 @@ func TestAutoscanHandleCreateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":true}`, "")
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":true}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -615,7 +623,7 @@ func TestAutoscanHandleUpdateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -641,7 +649,7 @@ func TestAutoscanHandleUpdateSourceEnableWithConnectionSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -669,7 +677,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, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -697,7 +705,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, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -727,7 +735,7 @@ func TestAutoscanHandleUpdateSourceUnbindWhileEnabledSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -925,7 +933,7 @@ func TestAutoscanHandleUpdateSourceRoundTripsPathRewrites(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -990,7 +998,7 @@ func TestAutoscanHandleUpdateSourceRejectsBlankRewrite(t *testing.T) {
|
||||
upserted := false
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
upserted = true
|
||||
@@ -1020,7 +1028,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
eventCompleted := completed.Add(-1 * time.Second)
|
||||
eventID := int64(42)
|
||||
sourceID := "src-1"
|
||||
installationID := 7
|
||||
pluginID := "silo.autoscan.cephfs"
|
||||
var gotFilter autoscan.ScanListFilter
|
||||
var gotCountFilter autoscan.ScanListFilter
|
||||
store := &fakeAutoscanStore{
|
||||
@@ -1040,7 +1048,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
},
|
||||
AutoscanEventID: &eventID,
|
||||
SourceID: &sourceID,
|
||||
InstallationID: &installationID,
|
||||
PluginID: pluginID,
|
||||
CapabilityID: "cephfs",
|
||||
EventStatus: autoscan.EventStatusSuccess,
|
||||
EventCompletedAt: &eventCompleted,
|
||||
@@ -1084,7 +1092,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.InstallationID == nil || *scan.InstallationID != installationID || scan.CapabilityID != "cephfs" {
|
||||
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.PluginID != pluginID || scan.CapabilityID != "cephfs" {
|
||||
t.Fatalf("source context = %+v", scan)
|
||||
}
|
||||
}
|
||||
@@ -1128,7 +1136,7 @@ func TestAutoscanHandleListEventsSerializesFiltersAndRuns(t *testing.T) {
|
||||
Event: autoscan.Event{
|
||||
ID: 42,
|
||||
SourceID: &sourceID,
|
||||
InstallationID: 7,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
CapabilityID: "cephfs",
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
@@ -1258,18 +1266,30 @@ 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", InstallationID: 7, CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
|
||||
{ID: "src-1", PluginID: "silo.autoscan.cephfs", 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
|
||||
},
|
||||
@@ -1296,6 +1316,16 @@ 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)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
@@ -89,6 +90,7 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
|
||||
h.itemsH.accessFilter(r),
|
||||
overlaySummaries,
|
||||
store,
|
||||
apimw.GetUserID(r.Context()),
|
||||
profileID,
|
||||
)
|
||||
items := make([]itemListResponse, 0, len(result.Items))
|
||||
@@ -221,6 +223,9 @@ 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,8 +497,8 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
|
||||
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
|
||||
}
|
||||
}
|
||||
case "movie", "episode":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
|
||||
case "movie", "episode", "audiobook", "ebook":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID, detail.Type)
|
||||
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// EbookReaderProgress is the persisted reader position for one profile and ebook.
|
||||
type EbookReaderProgress struct {
|
||||
UserID int `json:"-"`
|
||||
ProfileID string `json:"-"`
|
||||
ContentID string `json:"content_id"`
|
||||
FileID int `json:"file_id"`
|
||||
Location string `json:"location"`
|
||||
Progress float64 `json:"progress"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type EbookReaderProgressStore interface {
|
||||
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
|
||||
Upsert(ctx context.Context, progress EbookReaderProgress) error
|
||||
}
|
||||
|
||||
// EbookReaderHandler serves ebook files for the in-app reader.
|
||||
type EbookReaderHandler struct {
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
ProgressStore EbookReaderProgressStore
|
||||
}
|
||||
|
||||
func NewEbookReaderHandler(authorizer *MediaFileAuthorizer) *EbookReaderHandler {
|
||||
return &EbookReaderHandler{FileAuthorizer: authorizer}
|
||||
}
|
||||
|
||||
// HandleReadFile serves an ebook file inline with byte-range support.
|
||||
func (h *EbookReaderHandler) HandleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||
if apimw.GetUserID(r.Context()) == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
if h == nil || h.FileAuthorizer == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
fileID, err := strconv.Atoi(chi.URLParam(r, "file_id"))
|
||||
if contentID == "" || err != nil || fileID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id and file_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.FileAuthorizer.Authorize(r, fileID)
|
||||
if err != nil {
|
||||
h.writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
if file == nil || file.ContentID != contentID || !isEbookFile(file) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := serveEbookInline(w, r, file); err != nil {
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve ebook file")
|
||||
}
|
||||
}
|
||||
|
||||
type ebookReaderProgressRequest struct {
|
||||
FileID int `json:"file_id"`
|
||||
Location string `json:"location"`
|
||||
Progress float64 `json:"progress"`
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleGetProgress(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
if h == nil || h.ProgressStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader progress is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
|
||||
h.writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
progress, err := h.ProgressStore.Get(r.Context(), userID, profileID, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load ebook progress")
|
||||
return
|
||||
}
|
||||
if progress == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, progress)
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
if h == nil || h.ProgressStore == nil || h.FileAuthorizer == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader progress is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req ebookReaderProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
req.Location = strings.TrimSpace(req.Location)
|
||||
if req.FileID <= 0 || req.Location == "" || req.Progress < 0 || req.Progress > 1 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "file_id, location, and progress are required")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.FileAuthorizer.Authorize(r, req.FileID)
|
||||
if err != nil {
|
||||
h.writeReadError(w, err)
|
||||
return
|
||||
}
|
||||
if file == nil || file.ContentID != contentID || !isEbookFile(file) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
|
||||
return
|
||||
}
|
||||
|
||||
progress := EbookReaderProgress{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: contentID,
|
||||
FileID: req.FileID,
|
||||
Location: req.Location,
|
||||
Progress: req.Progress,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := h.ProgressStore.Upsert(r.Context(), progress); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save ebook progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, progress)
|
||||
}
|
||||
|
||||
func (h *EbookReaderHandler) writeReadError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize ebook file")
|
||||
}
|
||||
}
|
||||
|
||||
func serveEbookInline(w http.ResponseWriter, r *http.Request, file *models.MediaFile) error {
|
||||
f, err := os.Open(file.FilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return catalog.ErrItemNotFound
|
||||
}
|
||||
return fmt.Errorf("opening ebook file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat ebook file: %w", err)
|
||||
}
|
||||
|
||||
filename := sanitizeInlineFilename(filepath.Base(file.FilePath))
|
||||
w.Header().Set("Content-Type", ebookMimeType(file.FilePath, file.Container))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename))
|
||||
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEbookFile(file *models.MediaFile) bool {
|
||||
if file == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(file.BaseType, "ebook") {
|
||||
return false
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(file.FilePath), ".fb2.zip") {
|
||||
return true
|
||||
}
|
||||
return isEbookReaderFormat(file.Container) || isEbookReaderFormat(filepath.Ext(file.FilePath))
|
||||
}
|
||||
|
||||
func isEbookReaderFormat(value string) bool {
|
||||
format := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), ".")
|
||||
switch format {
|
||||
case "epub", "pdf", "mobi", "azw", "azw3", "cbz", "cbr", "fb2", "fbz", "md":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ebookMimeType(path, container string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if strings.HasSuffix(strings.ToLower(path), ".fb2.zip") {
|
||||
ext = ".fbz"
|
||||
} else if ext == "" && container != "" {
|
||||
ext = "." + strings.TrimPrefix(strings.ToLower(container), ".")
|
||||
}
|
||||
switch ext {
|
||||
case ".epub":
|
||||
return "application/epub+zip"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".mobi":
|
||||
return "application/x-mobipocket-ebook"
|
||||
case ".azw":
|
||||
return "application/vnd.amazon.ebook"
|
||||
case ".azw3":
|
||||
return "application/vnd.amazon.mobi8-ebook"
|
||||
case ".cbz":
|
||||
return "application/vnd.comicbook+zip"
|
||||
case ".cbr":
|
||||
return "application/vnd.comicbook-rar"
|
||||
case ".fb2":
|
||||
return "application/x-fictionbook+xml"
|
||||
case ".fbz":
|
||||
return "application/x-zip-compressed-fb2"
|
||||
case ".md":
|
||||
return "text/markdown; charset=utf-8"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeInlineFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "." || name == string(filepath.Separator) {
|
||||
return "ebook"
|
||||
}
|
||||
name = strings.ReplaceAll(name, `"`, "")
|
||||
name = strings.ReplaceAll(name, "\n", " ")
|
||||
name = strings.ReplaceAll(name, "\r", " ")
|
||||
return name
|
||||
}
|
||||
|
||||
type PGEbookReaderProgressStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGEbookReaderProgressStore(pool *pgxpool.Pool) *PGEbookReaderProgressStore {
|
||||
return &PGEbookReaderProgressStore{pool: pool}
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderProgressStore) Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
var progress EbookReaderProgress
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT user_id, profile_id, content_id, file_id, location, progress, updated_at
|
||||
FROM ebook_reader_progress
|
||||
WHERE user_id = $1 AND profile_id = $2 AND content_id = $3`,
|
||||
userID, profileID, contentID,
|
||||
).Scan(
|
||||
&progress.UserID,
|
||||
&progress.ProfileID,
|
||||
&progress.ContentID,
|
||||
&progress.FileID,
|
||||
&progress.Location,
|
||||
&progress.Progress,
|
||||
&progress.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ebook reader progress: %w", err)
|
||||
}
|
||||
return &progress, nil
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderProgressStore) ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
if userID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT user_id, profile_id, content_id, file_id, location, progress, updated_at
|
||||
FROM ebook_reader_progress
|
||||
WHERE user_id = $1 AND profile_id = $2 AND content_id = ANY($3::text[])`,
|
||||
userID, profileID, contentIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list ebook reader progress: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
progressByContentID := make(map[string]EbookReaderProgress, len(contentIDs))
|
||||
for rows.Next() {
|
||||
var progress EbookReaderProgress
|
||||
if err := rows.Scan(
|
||||
&progress.UserID,
|
||||
&progress.ProfileID,
|
||||
&progress.ContentID,
|
||||
&progress.FileID,
|
||||
&progress.Location,
|
||||
&progress.Progress,
|
||||
&progress.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan ebook reader progress: %w", err)
|
||||
}
|
||||
progressByContentID[progress.ContentID] = progress
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate ebook reader progress: %w", err)
|
||||
}
|
||||
return progressByContentID, nil
|
||||
}
|
||||
|
||||
func (s *PGEbookReaderProgressStore) Upsert(ctx context.Context, progress EbookReaderProgress) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO ebook_reader_progress
|
||||
(user_id, profile_id, content_id, file_id, location, progress, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE SET
|
||||
file_id = EXCLUDED.file_id,
|
||||
location = EXCLUDED.location,
|
||||
progress = EXCLUDED.progress,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
progress.UserID,
|
||||
progress.ProfileID,
|
||||
progress.ContentID,
|
||||
progress.FileID,
|
||||
progress.Location,
|
||||
progress.Progress,
|
||||
progress.UpdatedAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("upsert ebook reader progress: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func newEbookReaderAuthRequest(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withEbookReaderRouteParams(req *http.Request, contentID, fileID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
routeCtx.URLParams.Add("file_id", fileID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func withEbookReaderContentRouteParam(req *http.Request, contentID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
type fakeEbookProgressStore struct {
|
||||
progress *EbookReaderProgress
|
||||
upserted *EbookReaderProgress
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeEbookProgressStore) Get(
|
||||
context.Context,
|
||||
int,
|
||||
string,
|
||||
string,
|
||||
) (*EbookReaderProgress, error) {
|
||||
return s.progress, s.err
|
||||
}
|
||||
|
||||
func (s *fakeEbookProgressStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.upserted = &progress
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEbookReaderServesEbookInlineWithRangeSupport(t *testing.T) {
|
||||
filePath := writePlaybackTestMediaFile(t, "book.epub")
|
||||
if err := os.WriteFile(filePath, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatalf("write ebook: %v", err)
|
||||
}
|
||||
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: filePath,
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req.Header.Set("Range", "bytes=2-5")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
|
||||
t.Fatalf("content-type = %q", got)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Disposition"); got != `inline; filename="book.epub"` {
|
||||
t.Fatalf("content-disposition = %q", got)
|
||||
}
|
||||
if rr.Body.String() != "2345" {
|
||||
t.Fatalf("body = %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsNonEbookFile(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: "/tmp/movie.mkv",
|
||||
Container: "mkv",
|
||||
BaseType: "movie",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/movie-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "movie-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRecognizesReadestFormats(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"book.epub": "application/epub+zip",
|
||||
"book.pdf": "application/pdf",
|
||||
"book.mobi": "application/x-mobipocket-ebook",
|
||||
"book.azw": "application/vnd.amazon.ebook",
|
||||
"book.azw3": "application/vnd.amazon.mobi8-ebook",
|
||||
"book.cbz": "application/vnd.comicbook+zip",
|
||||
"book.cbr": "application/vnd.comicbook-rar",
|
||||
"book.fb2": "application/x-fictionbook+xml",
|
||||
"book.fbz": "application/x-zip-compressed-fb2",
|
||||
"book.fb2.zip": "application/x-zip-compressed-fb2",
|
||||
"book.md": "text/markdown; charset=utf-8",
|
||||
"book.unknown": "application/octet-stream",
|
||||
}
|
||||
|
||||
for name, wantMime := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
container := name
|
||||
if name == "book.fb2.zip" {
|
||||
container = "fbz"
|
||||
}
|
||||
file := &models.MediaFile{FilePath: "/library/" + name, Container: container}
|
||||
if name == "book.unknown" {
|
||||
if isEbookFile(file) {
|
||||
t.Fatal("unknown extension should not be treated as an ebook reader format")
|
||||
}
|
||||
return
|
||||
}
|
||||
file.BaseType = "ebook"
|
||||
if !isEbookFile(file) {
|
||||
t.Fatal("expected ebook reader format")
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
|
||||
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsPlainText(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
FilePath: "/library/book.txt",
|
||||
BaseType: "ebook",
|
||||
}
|
||||
|
||||
if isEbookFile(file) {
|
||||
t.Fatal("plain text should not be treated as an ebook reader format")
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got == "text/plain; charset=utf-8" {
|
||||
t.Fatal("plain text should not have an ebook reader MIME type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRecognizesFBZFromCompoundFilenameWithoutContainer(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
FilePath: "/library/book.fb2.zip",
|
||||
BaseType: "ebook",
|
||||
}
|
||||
|
||||
if !isEbookFile(file) {
|
||||
t.Fatal("expected .fb2.zip path to be treated as an ebook reader format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderMapsAccessFailureToNotFound(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: "/tmp/book.epub",
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsInternalErrorForUnexpectedAuthorizeFailure(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: errors.New("db down")},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
|
||||
store := &fakeEbookProgressStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: "/tmp/book.epub",
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/progress",
|
||||
strings.NewReader(`{"file_id":42,"location":"epubcfi(/6/4)","progress":0.42}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.upserted == nil {
|
||||
t.Fatal("expected progress to be saved")
|
||||
}
|
||||
if store.upserted.UserID != 1 || store.upserted.ProfileID != "profile-9" {
|
||||
t.Fatalf("scope = user %d profile %q", store.upserted.UserID, store.upserted.ProfileID)
|
||||
}
|
||||
if store.upserted.ContentID != "ebook-1" || store.upserted.FileID != 42 {
|
||||
t.Fatalf("target = content %q file %d", store.upserted.ContentID, store.upserted.FileID)
|
||||
}
|
||||
if store.upserted.Location != "epubcfi(/6/4)" || store.upserted.Progress != 0.42 {
|
||||
t.Fatalf("progress = %+v", store.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsSavedProgress(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = &fakeEbookProgressStore{
|
||||
progress: &EbookReaderProgress{
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
FileID: 42,
|
||||
Location: "epubcfi(/6/8)",
|
||||
Progress: 0.75,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/progress")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
`"content_id":"ebook-1"`,
|
||||
`"file_id":42`,
|
||||
`"location":"epubcfi(/6/8)"`,
|
||||
`"progress":0.75`,
|
||||
`"updated_at":"2026-06-08T12:00:00Z"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response %s missing %s", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type PersonalDataHandler struct {
|
||||
localFavoriteDispatcher LocalFavoriteEventDispatcher
|
||||
profileStaler ProfileStaler
|
||||
profileRefreshRequester ProfileRefreshRequester
|
||||
ebookProgressStore EbookReaderProgressLister
|
||||
}
|
||||
|
||||
// NewPersonalDataHandler creates a new PersonalDataHandler.
|
||||
@@ -55,6 +56,10 @@ func (h *PersonalDataHandler) SetDetailService(svc *catalog.DetailService) {
|
||||
h.detailSvc = svc
|
||||
}
|
||||
|
||||
func (h *PersonalDataHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
|
||||
h.ebookProgressStore = store
|
||||
}
|
||||
|
||||
// SetProfileStaler configures an optional staleness trigger for taste profiles.
|
||||
func (h *PersonalDataHandler) SetProfileStaler(ps ProfileStaler) {
|
||||
h.profileStaler = ps
|
||||
@@ -552,7 +557,10 @@ func resolveItemsByIDs(h *PersonalDataHandler, r *http.Request, ids []string) ([
|
||||
|
||||
userStates := map[string]*itemUserStateResponse{}
|
||||
if store, profileID, ok := h.userStoreForRequest(r); ok {
|
||||
if resolvedStates, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, accessibleItems); err == nil {
|
||||
if resolvedStates, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, accessibleItems, itemUserStateOptions{
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
EbookProgressStore: h.ebookProgressStore,
|
||||
}); err == nil {
|
||||
userStates = resolvedStates
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ type LocalWatchEventDispatcher interface {
|
||||
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
|
||||
}
|
||||
|
||||
type EbookReaderProgressLister interface {
|
||||
ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error)
|
||||
}
|
||||
|
||||
// ItemsHandler handles browse, search, item detail, and series endpoints.
|
||||
type ItemsHandler struct {
|
||||
browseRepo *catalog.BrowseRepository
|
||||
@@ -62,6 +66,7 @@ type ItemsHandler struct {
|
||||
profileRefreshRequester ProfileRefreshRequester
|
||||
metadataRefreshRequester MetadataRefreshRequester
|
||||
localWatchDispatcher LocalWatchEventDispatcher
|
||||
ebookProgressStore EbookReaderProgressLister
|
||||
EventsHub *evt.Hub
|
||||
UserRepo *auth.UserRepository
|
||||
}
|
||||
@@ -114,6 +119,10 @@ func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDi
|
||||
h.localWatchDispatcher = dispatcher
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
|
||||
h.ebookProgressStore = store
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) {
|
||||
if h == nil || detail == nil || h.metadataRefreshRequester == nil {
|
||||
return
|
||||
@@ -373,8 +382,8 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
if detail.Type == "movie" || detail.Type == "episode" {
|
||||
detail.UserData = h.getLeafUserData(r, detail.ContentID)
|
||||
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" {
|
||||
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
|
||||
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
@@ -739,7 +748,10 @@ func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.Media
|
||||
if !ok {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
states, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, items)
|
||||
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
EbookProgressStore: h.ebookProgressStore,
|
||||
})
|
||||
if err != nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
@@ -850,6 +862,7 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
filter catalog.AccessFilter,
|
||||
overlaySummaries map[string]*models.OverlaySummary,
|
||||
store userstore.UserStore,
|
||||
userID int,
|
||||
profileID string,
|
||||
) map[string]*sortMetricsResponse {
|
||||
metrics := make(map[string]*sortMetricsResponse, len(items))
|
||||
@@ -890,7 +903,7 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
}
|
||||
}
|
||||
case "progress", "date_viewed", "plays":
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
@@ -900,6 +913,7 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
store userstore.UserStore,
|
||||
userID int,
|
||||
profileID string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
@@ -915,6 +929,10 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch sortField {
|
||||
case "progress":
|
||||
@@ -923,10 +941,16 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
continue
|
||||
}
|
||||
progress, ok := progressMap[item.ContentID]
|
||||
if !ok || progress.Completed || progress.PositionSeconds <= 0 || progress.DurationSeconds <= 0 {
|
||||
if ok && !progress.Completed && progress.PositionSeconds > 0 && progress.DurationSeconds > 0 {
|
||||
ratio := progress.PositionSeconds / progress.DurationSeconds
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
continue
|
||||
}
|
||||
ratio := progress.PositionSeconds / progress.DurationSeconds
|
||||
ebookProgress, ok := ebookProgressMap[item.ContentID]
|
||||
if !ok || ebookProgress.Progress <= 0 || ebookProgress.Progress >= 0.9 {
|
||||
continue
|
||||
}
|
||||
ratio := ebookProgress.Progress
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
}
|
||||
case "date_viewed", "plays":
|
||||
@@ -955,6 +979,12 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt {
|
||||
viewedAt = progress.UpdatedAt
|
||||
}
|
||||
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= 0.9 {
|
||||
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
if ebookViewedAt > viewedAt {
|
||||
viewedAt = ebookViewedAt
|
||||
}
|
||||
}
|
||||
if viewedAt == "" {
|
||||
continue
|
||||
}
|
||||
@@ -964,6 +994,9 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= 0.9 && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if playCount <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -974,6 +1007,18 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listEbookReaderProgressByContentIDs(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
profileID string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
if h == nil || h.ebookProgressStore == nil || userID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return h.ebookProgressStore.ListByContentIDs(ctx, userID, profileID, contentIDs)
|
||||
}
|
||||
|
||||
func uniqueItemContentIDs(items []*models.MediaItem) []string {
|
||||
contentIDs := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
@@ -1110,7 +1155,11 @@ func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *mod
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
|
||||
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemType ...string) *catalog.SeasonUserData {
|
||||
if len(itemType) > 0 && itemType[0] == "ebook" {
|
||||
return h.getEbookLeafUserData(r, contentID)
|
||||
}
|
||||
|
||||
store, profileID, ok := h.userStoreForRequest(r)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -1134,6 +1183,36 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catal
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getEbookLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
|
||||
if h == nil || h.ebookProgressStore == nil {
|
||||
return nil
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if userID <= 0 || profileID == "" || contentID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
progress, err := h.ebookProgressStore.ListByContentIDs(r.Context(), userID, profileID, []string{contentID})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
row, ok := progress[contentID]
|
||||
if !ok || row.Progress <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &catalog.SeasonUserData{
|
||||
PositionSeconds: row.Progress,
|
||||
DurationSeconds: 1,
|
||||
IsInProgress: row.Progress < 0.9,
|
||||
Played: row.Progress >= 0.9,
|
||||
}
|
||||
}
|
||||
|
||||
func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) {
|
||||
if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" {
|
||||
return
|
||||
|
||||
@@ -40,20 +40,86 @@ func TestListSortMetricsIncludesUserStateSorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
progress := progressMetrics["movie-progress"]
|
||||
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.25 {
|
||||
t.Fatalf("progress metrics = %#v", progress)
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
if got := viewedMetrics["movie-viewed"]; got == nil || got.ViewedAt != "2026-05-29T18:30:00Z" {
|
||||
t.Fatalf("viewed metrics = %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
plays := playsMetrics["movie-plays"]
|
||||
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 2 {
|
||||
t.Fatalf("plays metrics = %#v", plays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSortMetricsIncludesEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-progress": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.37,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
"ebook-complete": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-progress", Type: "ebook", Title: "Progress"},
|
||||
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete"},
|
||||
}
|
||||
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
progress := progressMetrics["ebook-progress"]
|
||||
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.37 {
|
||||
t.Fatalf("progress metrics = %#v", progress)
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := viewedMetrics["ebook-complete"]; got == nil || got.ViewedAt != "2026-06-03T10:15:00Z" {
|
||||
t.Fatalf("viewed metrics = %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
plays := playsMetrics["ebook-complete"]
|
||||
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 1 {
|
||||
t.Fatalf("plays metrics = %#v", plays)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEbookReaderProgressLister struct {
|
||||
progress map[string]EbookReaderProgress
|
||||
}
|
||||
|
||||
func (f *fakeEbookReaderProgressLister) ListByContentIDs(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
result := make(map[string]EbookReaderProgress, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
if progress, ok := f.progress[contentID]; ok {
|
||||
result[contentID] = progress
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-progress": {
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.42,
|
||||
UpdatedAt: time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
"ebook-complete": {
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.93,
|
||||
UpdatedAt: time.Date(2026, 6, 5, 11, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/items/ebook-progress", nil)
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
progress := handler.getLeafUserData(req, "ebook-progress", "ebook")
|
||||
if progress == nil || progress.Played || !progress.IsInProgress || progress.PositionSeconds != 0.42 || progress.DurationSeconds != 1 {
|
||||
t.Fatalf("partial ebook user data = %#v", progress)
|
||||
}
|
||||
|
||||
complete := handler.getLeafUserData(req, "ebook-complete", "ebook")
|
||||
if complete == nil || !complete.Played || complete.IsInProgress {
|
||||
t.Fatalf("completed ebook user data = %#v", complete)
|
||||
}
|
||||
}
|
||||
@@ -2029,16 +2029,8 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine which content levels apply.
|
||||
var levels []string
|
||||
switch libraryType {
|
||||
case "series":
|
||||
levels = []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
levels = []string{"movie"}
|
||||
case "mixed":
|
||||
levels = []string{"movie", "series", "season", "episode"}
|
||||
default:
|
||||
levels := metadataContentLevelsForLibraryType(libraryType)
|
||||
if len(levels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2091,6 +2083,23 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
return entries
|
||||
}
|
||||
|
||||
func metadataContentLevelsForLibraryType(libraryType string) []string {
|
||||
switch libraryType {
|
||||
case "series":
|
||||
return []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
return []string{"movie"}
|
||||
case "audiobooks", "audiobook":
|
||||
return []string{"audiobook"}
|
||||
case "ebooks", "ebook":
|
||||
return []string{"ebook"}
|
||||
case "mixed":
|
||||
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListStaleIDs handles GET /libraries/stale-ids.
|
||||
func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) {
|
||||
if h.StaleIDRepo == nil {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
libraryType string
|
||||
want []string
|
||||
}{
|
||||
{name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}},
|
||||
{name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}},
|
||||
{name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := metadataContentLevelsForLibraryType(tc.libraryType); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("metadataContentLevelsForLibraryType(%q) = %#v, want %#v", tc.libraryType, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type RecommendationsHandler struct {
|
||||
EpisodeRepo *catalog.EpisodeRepository
|
||||
WatchTonightFetcher watchTonightSectionFetcher
|
||||
CastFetcher cardsCastFetcher
|
||||
EbookProgress EbookReaderProgressLister
|
||||
// RecWorker enqueues asynchronous taste-profile refreshes after writes
|
||||
// (taste seeding). Optional — when nil, refresh is simply skipped.
|
||||
RecWorker ProfileRefreshRequester
|
||||
@@ -643,7 +644,10 @@ func (h *RecommendationsHandler) loadItemEnrichment(
|
||||
if h.storeProvider != nil {
|
||||
store, storeErr := h.storeProvider.ForUser(ctx, userID)
|
||||
if storeErr == nil && store != nil {
|
||||
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
|
||||
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
if stateErr == nil {
|
||||
out.states = states
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type SectionHandler struct {
|
||||
DetailSvc *catalog.DetailService
|
||||
Settings *catalog.ServerSettingsRepo
|
||||
CollectionRepo *catalog.LibraryCollectionRepository
|
||||
EbookProgress EbookReaderProgressLister
|
||||
}
|
||||
|
||||
// NewSectionHandler creates a new SectionHandler.
|
||||
@@ -1377,7 +1378,10 @@ func (h *SectionHandler) listSectionItemUserStates(r *http.Request, items []*mod
|
||||
if err != nil || store == nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
states, err := resolveItemUserStates(r.Context(), store, profileID, h.EpisodeRepo, items)
|
||||
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.EpisodeRepo, items, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
if err != nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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,14 +17,16 @@ 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) *SystemHandler {
|
||||
func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string, ffmpegPath string) *SystemHandler {
|
||||
return &SystemHandler{
|
||||
transcodePool: transcodePool,
|
||||
jwtSecret: jwtSecret,
|
||||
ffmpegPath: ffmpegPath,
|
||||
buildInfo: buildinfo.Current(),
|
||||
}
|
||||
}
|
||||
@@ -45,7 +47,7 @@ func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
info := playback.DetectHWAccel()
|
||||
info := playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)
|
||||
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 "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.
|
||||
// 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.
|
||||
func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r *http.Request) {
|
||||
if h.recsRepo == nil || h.Fetcher == nil {
|
||||
writeJSON(w, http.StatusOK, tasteSeedItemsResponse{Items: []sectionItemResponse{}})
|
||||
@@ -181,7 +181,10 @@ func (h *RecommendationsHandler) resolveTasteSeedUserStates(ctx context.Context,
|
||||
if err != nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
states, err := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,12 +14,28 @@ type itemUserStateResponse struct {
|
||||
InWatchlist bool `json:"in_watchlist"`
|
||||
}
|
||||
|
||||
type itemUserStateOptions struct {
|
||||
UserID int
|
||||
EbookProgressStore EbookReaderProgressLister
|
||||
}
|
||||
|
||||
func resolveItemUserStates(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
episodeRepo *catalog.EpisodeRepository,
|
||||
items []*models.MediaItem,
|
||||
) (map[string]*itemUserStateResponse, error) {
|
||||
return resolveItemUserStatesWithOptions(ctx, store, profileID, episodeRepo, items, itemUserStateOptions{})
|
||||
}
|
||||
|
||||
func resolveItemUserStatesWithOptions(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
episodeRepo *catalog.EpisodeRepository,
|
||||
items []*models.MediaItem,
|
||||
options itemUserStateOptions,
|
||||
) (map[string]*itemUserStateResponse, error) {
|
||||
result := make(map[string]*itemUserStateResponse, len(items))
|
||||
if store == nil || profileID == "" || len(items) == 0 {
|
||||
@@ -111,6 +127,10 @@ func resolveItemUserStates(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ebookProgressMap, err := resolveEbookProgressForUserStates(ctx, options, profileID, contentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item == nil || item.ContentID == "" {
|
||||
@@ -125,6 +145,8 @@ func resolveItemUserStates(
|
||||
state.Played = allEpisodesCompleted(seriesEpisodes[item.ContentID], progressMap)
|
||||
case "season":
|
||||
state.Played = allEpisodesCompleted(seasonEpisodes[item.ContentID], progressMap)
|
||||
case "ebook":
|
||||
state.Played = ebookProgressMap[item.ContentID].Progress >= 0.9
|
||||
default:
|
||||
state.Played = progressMap[item.ContentID].Completed
|
||||
}
|
||||
@@ -134,6 +156,18 @@ func resolveItemUserStates(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveEbookProgressForUserStates(
|
||||
ctx context.Context,
|
||||
options itemUserStateOptions,
|
||||
profileID string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
if options.EbookProgressStore == nil || options.UserID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return options.EbookProgressStore.ListByContentIDs(ctx, options.UserID, profileID, contentIDs)
|
||||
}
|
||||
|
||||
func allEpisodesCompleted(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) bool {
|
||||
if len(episodes) == 0 {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestResolveItemUserStatesIncludesCompletedEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete Ebook"},
|
||||
{ContentID: "ebook-progress", Type: "ebook", Title: "Partial Ebook"},
|
||||
}
|
||||
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
|
||||
UserID: 42,
|
||||
EbookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-complete": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
"ebook-progress": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.45,
|
||||
UpdatedAt: time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
|
||||
}
|
||||
|
||||
if states["ebook-complete"] == nil || !states["ebook-complete"].Played {
|
||||
t.Fatalf("completed ebook state = %+v, want played", states["ebook-complete"])
|
||||
}
|
||||
if states["ebook-progress"] == nil || states["ebook-progress"].Played {
|
||||
t.Fatalf("partial ebook state = %+v, want not played", states["ebook-progress"])
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,10 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil
|
||||
if h.storeProvider != nil {
|
||||
store, storeErr := h.storeProvider.ForUser(ctx, userID)
|
||||
if storeErr == nil && store != nil {
|
||||
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
|
||||
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
if stateErr == nil {
|
||||
stateMap = states
|
||||
}
|
||||
|
||||
+44
-18
@@ -143,6 +143,7 @@ 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.
|
||||
@@ -164,9 +165,6 @@ 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.
|
||||
@@ -370,7 +368,10 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
var webhookSyncHandler *handlers.WebhookSyncHandler
|
||||
var requestHandler *handlers.RequestsHandler
|
||||
var autoscanHandler *handlers.AutoscanHandler
|
||||
var ebookReaderHandler *handlers.EbookReaderHandler
|
||||
var ebookProgressStore *handlers.PGEbookReaderProgressStore
|
||||
if deps.DB != nil {
|
||||
ebookProgressStore = handlers.NewPGEbookReaderProgressStore(deps.DB)
|
||||
browseRepo := catalog.NewBrowseRepository(deps.DB)
|
||||
itemRepo = catalog.NewItemRepository(deps.DB)
|
||||
episodeRepo = catalog.NewEpisodeRepository(deps.DB)
|
||||
@@ -421,6 +422,19 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalWatchEventDispatcher); ok {
|
||||
itemsHandler.SetLocalWatchEventDispatcher(dispatcher)
|
||||
}
|
||||
if ebookProgressStore != nil {
|
||||
itemsHandler.SetEbookReaderProgressStore(ebookProgressStore)
|
||||
}
|
||||
if deps.FileRepo != nil {
|
||||
ebookReaderHandler = handlers.NewEbookReaderHandler(&handlers.MediaFileAuthorizer{
|
||||
FileResolver: deps.FileRepo,
|
||||
ItemAccess: itemRepo,
|
||||
EpisodeLookup: episodeRepo,
|
||||
})
|
||||
if ebookProgressStore != nil {
|
||||
ebookReaderHandler.ProgressStore = ebookProgressStore
|
||||
}
|
||||
}
|
||||
catalogResourceHandler = handlers.NewCatalogResourceHandler(itemsHandler)
|
||||
catalogHandler = handlers.NewCatalogHandler(
|
||||
catalog.NewCatalogResolver(browseRepo, itemRepo).
|
||||
@@ -500,6 +514,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
if detailSvc != nil {
|
||||
personalDataHandler.SetDetailService(detailSvc)
|
||||
}
|
||||
if ebookProgressStore != nil {
|
||||
personalDataHandler.SetEbookReaderProgressStore(ebookProgressStore)
|
||||
}
|
||||
personalDataHandler.SetEpisodeRepo(episodeRepo)
|
||||
personalDataHandler.SetSeasonRepo(seasonRepo)
|
||||
personalDataHandler.EventsHub = deps.EventsHub
|
||||
@@ -579,6 +596,7 @@ 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 {
|
||||
@@ -662,6 +680,7 @@ 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,6 +719,8 @@ 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
|
||||
@@ -935,6 +956,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
sectionHandler.EpisodeRepo = episodeRepo
|
||||
sectionHandler.DetailSvc = detailSvc
|
||||
if ebookProgressStore != nil {
|
||||
sectionHandler.EbookProgress = ebookProgressStore
|
||||
}
|
||||
if userRepo != nil {
|
||||
sectionHandler.UserRepo = userRepo
|
||||
}
|
||||
@@ -1115,6 +1139,9 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
recsHandler.CalendarRepo = calendarRepo
|
||||
recsHandler.EpisodeRepo = episodeRepo
|
||||
if ebookProgressStore != nil {
|
||||
recsHandler.EbookProgress = ebookProgressStore
|
||||
}
|
||||
if deps.PersonRepo != nil {
|
||||
recsHandler.CastFetcher = deps.PersonRepo
|
||||
}
|
||||
@@ -1701,6 +1728,16 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
if ebookReaderHandler != nil {
|
||||
r.Route("/ebooks", func(r chi.Router) {
|
||||
r.Use(apimw.RequireProfile)
|
||||
r.Get("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
|
||||
r.Head("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
|
||||
r.Get("/{content_id}/progress", ebookReaderHandler.HandleGetProgress)
|
||||
r.Put("/{content_id}/progress", ebookReaderHandler.HandleSaveProgress)
|
||||
})
|
||||
}
|
||||
|
||||
// Subtitle search + AI translation routes.
|
||||
if subtitleSearchHandler != nil {
|
||||
if deps.FileRepo != nil && itemRepo != nil {
|
||||
@@ -1809,20 +1846,6 @@ 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) {
|
||||
@@ -1920,6 +1943,7 @@ 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 {
|
||||
@@ -2113,10 +2137,12 @@ 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)
|
||||
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret, sysFFmpegPath)
|
||||
r.Route("/system", func(r chi.Router) {
|
||||
r.Get("/build", systemHandler.HandleBuildInfo)
|
||||
r.Get("/hw-accel", systemHandler.HandleHWAccel)
|
||||
|
||||
@@ -18,7 +18,8 @@ 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 {
|
||||
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
|
||||
slog.Warn("abs author access resolution failed", "err", err, "id", id)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
author, err := h.deps.MediaStore.GetAuthorByID(r.Context(), id, access)
|
||||
@@ -47,7 +48,8 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
access, err := h.accessFilterForAuth(r.Context(), a)
|
||||
if err != nil {
|
||||
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
|
||||
slog.Warn("abs series access resolution failed", "err", err, "id", id)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
series, err := h.deps.MediaStore.GetSeriesByName(r.Context(), id, access)
|
||||
|
||||
@@ -44,7 +44,9 @@ func TestAuthor_Detail_ReturnsBooks(t *testing.T) {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if got["name"] != "Brandon Sanderson" {
|
||||
t.Errorf("name = %v", got["name"])
|
||||
}
|
||||
@@ -78,7 +80,9 @@ func TestSeries_Detail_ReturnsBooks(t *testing.T) {
|
||||
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if got["name"] != "Mistborn" {
|
||||
t.Errorf("name = %v", got["name"])
|
||||
}
|
||||
|
||||
@@ -61,7 +61,12 @@ func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
|
||||
if err != nil || item == nil {
|
||||
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 {
|
||||
http.Error(w, "item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
@@ -157,10 +158,14 @@ 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")
|
||||
known map[string]*models.MediaItem // itemID → row (nil means "exists but no row needed")
|
||||
lookupErr error
|
||||
}
|
||||
|
||||
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
|
||||
@@ -411,6 +416,19 @@ 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,6 +6,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
@@ -39,7 +40,12 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == nil || *body.Name == "" {
|
||||
if body.Name == nil {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(*body.Name)
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -48,7 +54,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
ID: ulid.Make().String(),
|
||||
UserID: a.UserID,
|
||||
ProfileID: a.ProfileID,
|
||||
Name: *body.Name,
|
||||
Name: name,
|
||||
}
|
||||
if body.Description != nil {
|
||||
c.Description = *body.Description
|
||||
@@ -267,7 +273,12 @@ func (h *Handler) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if body.Name != nil {
|
||||
c.Name = *body.Name
|
||||
name := strings.TrimSpace(*body.Name)
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.Name = 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,8 +281,16 @@ 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
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
list, _ := env["collections"].([]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())
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("user 2 sees %d collections, want 0", len(list))
|
||||
}
|
||||
@@ -295,8 +303,16 @@ 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
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
list, _ := env["collections"].([]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())
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("profile B sees %d collections, want 0", len(list))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,12 @@ 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 || item == nil {
|
||||
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 {
|
||||
http.Error(w, "item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package abs
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -73,3 +74,18 @@ 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,14 +151,18 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,8 +186,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,10 +21,8 @@ type SettingsReader interface {
|
||||
GetString(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Service owns the Audiobookshelf compatibility adapters. Native audiobook
|
||||
// libraries are normal catalog media_items and do not depend on this service.
|
||||
type Service struct {
|
||||
settings SettingsReader
|
||||
ABSHandler *abs.Handler
|
||||
@@ -181,17 +179,16 @@ func buildABSRecommender(deps ABSHandlerDeps) abs.Recommender {
|
||||
return &ABSRecommender{Pool: deps.Pool, Recs: deps.Recs}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
if s == nil || s.settings == nil {
|
||||
return false, nil
|
||||
}
|
||||
value, err := s.settings.GetString(ctx, "audiobooks.enabled")
|
||||
value, err := s.settings.GetString(ctx, "audiobookshelf_compat.enabled")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read audiobooks.enabled: %w", err)
|
||||
return false, fmt.Errorf("read audiobookshelf_compat.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 != "audiobooks.enabled" {
|
||||
if key != "audiobookshelf_compat.enabled" {
|
||||
return "", errors.New("unexpected key: " + key)
|
||||
}
|
||||
return f.value, f.err
|
||||
}
|
||||
|
||||
func TestServiceEnabledReadsFlag(t *testing.T) {
|
||||
func TestServiceABSCompatEnabledReadsFlag(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stored string
|
||||
@@ -33,44 +33,44 @@ func TestServiceEnabledReadsFlag(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := New(&fakeSettingsReader{value: tc.stored})
|
||||
got, err := svc.Enabled(context.Background())
|
||||
got, err := svc.ABSCompatEnabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Enabled returned error: %v", err)
|
||||
t.Fatalf("ABSCompatEnabled returned error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("Enabled = %v, want %v", got, tc.want)
|
||||
t.Fatalf("ABSCompatEnabled = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceEnabledPropagatesError(t *testing.T) {
|
||||
func TestServiceABSCompatEnabledPropagatesError(t *testing.T) {
|
||||
wantErr := errors.New("db down")
|
||||
svc := New(&fakeSettingsReader{err: wantErr})
|
||||
_, err := svc.Enabled(context.Background())
|
||||
_, err := svc.ABSCompatEnabled(context.Background())
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Enabled error = %v, want %v wrapped", err, wantErr)
|
||||
t.Fatalf("ABSCompatEnabled error = %v, want %v wrapped", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceEnabledNilReceiverReturnsFalse(t *testing.T) {
|
||||
func TestServiceABSCompatEnabledNilReceiverReturnsFalse(t *testing.T) {
|
||||
var svc *Service
|
||||
got, err := svc.Enabled(context.Background())
|
||||
got, err := svc.ABSCompatEnabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Enabled returned error: %v", err)
|
||||
t.Fatalf("ABSCompatEnabled returned error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatal("Enabled = true, want false")
|
||||
t.Fatal("ABSCompatEnabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceEnabledNilSettingsReturnsFalse(t *testing.T) {
|
||||
func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
|
||||
svc := New(nil)
|
||||
got, err := svc.Enabled(context.Background())
|
||||
got, err := svc.ABSCompatEnabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Enabled returned error: %v", err)
|
||||
t.Fatalf("ABSCompatEnabled returned error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatal("Enabled = true, want false")
|
||||
t.Fatal("ABSCompatEnabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,17 @@ 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
|
||||
PluginID string
|
||||
CapabilityID 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 (a) offer them in the Add-source picker and (b) detect orphaned
|
||||
// source rows whose plugin has been uninstalled.
|
||||
// engine can offer them in the Add-source picker.
|
||||
type ScanSourceLister interface {
|
||||
// ListScanSources returns one entry per installed scan_source.v1 capability,
|
||||
// enriched with plugin id + display name.
|
||||
@@ -31,17 +29,9 @@ 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 {
|
||||
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
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// ListAvailableScanSources enumerates every installed scan_source capability so
|
||||
@@ -59,30 +49,10 @@ func (s *Service) ListAvailableScanSources(ctx context.Context) ([]AvailableScan
|
||||
out := make([]AvailableScanSource, 0, len(discovered))
|
||||
for _, d := range discovered {
|
||||
out = append(out, AvailableScanSource{
|
||||
InstallationID: d.InstallationID,
|
||||
CapabilityID: d.CapabilityID,
|
||||
PluginID: d.PluginID,
|
||||
DisplayName: d.DisplayName,
|
||||
PluginID: d.PluginID,
|
||||
CapabilityID: d.CapabilityID,
|
||||
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{
|
||||
{InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"},
|
||||
{InstallationID: 2, CapabilityID: "arr-b", PluginID: "radarr", DisplayName: "Radarr"},
|
||||
{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"},
|
||||
{PluginID: "radarr", CapabilityID: "arr-b", 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{InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"}) {
|
||||
if available[0] != (AvailableScanSource{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"}) {
|
||||
t.Fatalf("unexpected first available: %+v", available[0])
|
||||
}
|
||||
}
|
||||
@@ -41,32 +41,3 @@ 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,3 +5,7 @@ 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", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1")},
|
||||
{ID: "src-1", PluginID: "silo.autoscan.arr", 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", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}},
|
||||
sources: []Source{{ID: "src-1", PluginID: "silo.autoscan.arr", 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, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) (changes []Change, nextMarker string, err error)
|
||||
PollChanges(ctx context.Context, pluginID, 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-(installation, capability) scan-source client.
|
||||
// ScanSourceResolver yields a per-(plugin, capability) scan-source client.
|
||||
// Exported for the same cross-package adapter reason as PollChangesClient.
|
||||
type ScanSourceResolver interface {
|
||||
ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (PollChangesClient, error)
|
||||
ScanSourceClient(ctx context.Context, pluginID, 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, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
client, err := p.resolver.ScanSourceClient(ctx, installationID, capabilityID)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -17,10 +17,17 @@ 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.
|
||||
type capturingResolver struct{ client PollChangesClient }
|
||||
// capturingResolver yields a fixed PollChangesClient and records the requested
|
||||
// stable scan-source identity.
|
||||
type capturingResolver struct {
|
||||
client PollChangesClient
|
||||
lastPluginID string
|
||||
lastCapID string
|
||||
}
|
||||
|
||||
func (r capturingResolver) ScanSourceClient(context.Context, int, string) (PollChangesClient, error) {
|
||||
func (r *capturingResolver) ScanSourceClient(_ context.Context, pluginID, capabilityID string) (PollChangesClient, error) {
|
||||
r.lastPluginID = pluginID
|
||||
r.lastCapID = capabilityID
|
||||
return r.client, nil
|
||||
}
|
||||
|
||||
@@ -28,11 +35,12 @@ func (r capturingResolver) ScanSourceClient(context.Context, int, string) (PollC
|
||||
// delivered to the plugin on the PollChangesRequest.
|
||||
func TestPluginProviderPopulatesConnection(t *testing.T) {
|
||||
client := &capturingClient{}
|
||||
prov := NewPluginProvider(capturingResolver{client})
|
||||
resolver := &capturingResolver{client: client}
|
||||
prov := NewPluginProvider(resolver)
|
||||
|
||||
conn := ResolvedConnection{BaseURL: "https://arr.example", APIKey: "secret-key"}
|
||||
sourceConfig := map[string]string{"exclusions": ".downloads"}
|
||||
changes, next, err := prov.PollChanges(context.Background(), 1, "cap", "m1", conn, sourceConfig)
|
||||
changes, next, err := prov.PollChanges(context.Background(), "silo.autoscan.arr", "cap", "m1", conn, sourceConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("PollChanges: %v", err)
|
||||
}
|
||||
@@ -45,6 +53,9 @@ 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")
|
||||
@@ -59,9 +70,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(), 1, "cap", "m1", ResolvedConnection{}, nil)
|
||||
changes, next, err := prov.PollChanges(context.Background(), "silo.autoscan.arr", "cap", "m1", ResolvedConnection{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PollChanges: %v", err)
|
||||
}
|
||||
|
||||
+124
-26
@@ -175,14 +175,14 @@ func (r *Repository) GetConnection(ctx context.Context, id string) (Connection,
|
||||
|
||||
// --- Sources ---
|
||||
|
||||
const sourceColumns = `id, installation_id, capability_id, connection_id, enabled,
|
||||
const sourceColumns = `id, plugin_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.InstallationID, &s.CapabilityID, &s.ConnectionID,
|
||||
if err := row.Scan(&s.ID, &s.PluginID, &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 (
|
||||
installation_id, capability_id, connection_id, enabled, poll_interval_seconds, path_rewrites, source_config, label
|
||||
plugin_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.InstallationID, s.CapabilityID, connectionIDArg(s.ConnectionID), s.Enabled, s.PollIntervalSeconds, rewrites, sourceConfig, s.Label)
|
||||
s.PluginID, 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
|
||||
// (installation_id, capability_id) and bookkeeping fields (marker/last_run_at/
|
||||
// (plugin_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 installation_id, capability_id`)
|
||||
FROM autoscan_sources ORDER BY plugin_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 installation_id, capability_id`)
|
||||
ORDER BY plugin_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, installation_id, capability_id, started_at, completed_at,
|
||||
const eventColumns = `id, source_id, plugin_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.InstallationID,
|
||||
&e.PluginID,
|
||||
&e.CapabilityID,
|
||||
&e.StartedAt,
|
||||
&e.CompletedAt,
|
||||
@@ -499,25 +499,80 @@ func (r *Repository) CreateEvent(ctx context.Context, in EventCreate) (int64, er
|
||||
if started.IsZero() {
|
||||
started = time.Now()
|
||||
}
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
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, `
|
||||
INSERT INTO autoscan_events (
|
||||
source_id, installation_id, capability_id, started_at, completed_at,
|
||||
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`,
|
||||
nullable(in.SourceID),
|
||||
in.InstallationID,
|
||||
sourceID,
|
||||
in.PluginID,
|
||||
in.CapabilityID,
|
||||
started,
|
||||
string(EventStatusError),
|
||||
"poll started but did not finish",
|
||||
string(EventStatusRunning),
|
||||
"",
|
||||
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
|
||||
}
|
||||
|
||||
@@ -565,6 +620,25 @@ 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 {
|
||||
@@ -758,6 +832,30 @@ 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
|
||||
@@ -780,15 +878,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,
|
||||
e.installation_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,
|
||||
COALESCE(e.plugin_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 ")+`
|
||||
@@ -818,7 +916,7 @@ func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilte
|
||||
&scan.CompletedAt,
|
||||
&scan.AutoscanEventID,
|
||||
&scan.SourceID,
|
||||
&scan.InstallationID,
|
||||
&scan.PluginID,
|
||||
&scan.CapabilityID,
|
||||
&eventStatus,
|
||||
&scan.EventCompletedAt,
|
||||
@@ -853,7 +951,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`).Scan(&latest)
|
||||
err := r.pool.QueryRow(ctx, `SELECT max(completed_at) FROM autoscan_events WHERE status <> $1`, string(EventStatusRunning)).Scan(&latest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get latest autoscan event time: %w", err)
|
||||
}
|
||||
|
||||
@@ -91,8 +91,7 @@ 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 and PollOnce can skip orphaned source rows; it may be nil
|
||||
// (the picker then returns an empty list and orphan-pruning is disabled).
|
||||
// can offer them; it may be nil (the picker then returns an empty list).
|
||||
func NewService(
|
||||
store Store,
|
||||
provider ScanSourceProvider,
|
||||
@@ -113,24 +112,15 @@ func NewService(
|
||||
}
|
||||
}
|
||||
|
||||
// PollOnce runs one autoscan cycle. Per-source failures are logged and skipped;
|
||||
// only settings/listing errors propagate. The opaque next marker returned by the
|
||||
// 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
|
||||
// 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
|
||||
@@ -146,16 +136,6 @@ 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.
|
||||
@@ -170,7 +150,10 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
if src.Marker != nil {
|
||||
marker = *src.Marker
|
||||
}
|
||||
eventID := s.createEvent(ctx, src, marker, time.Now())
|
||||
eventID, started := s.createEvent(ctx, src, marker, time.Now())
|
||||
if !started {
|
||||
continue
|
||||
}
|
||||
// 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
|
||||
@@ -195,7 +178,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
}
|
||||
conn = resolved
|
||||
}
|
||||
changes, next, perr := s.provider.PollChanges(ctx, src.InstallationID, src.CapabilityID, marker, conn, src.SourceConfig)
|
||||
changes, next, perr := s.provider.PollChanges(ctx, src.PluginID, 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 {
|
||||
@@ -295,22 +278,26 @@ 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 {
|
||||
func (s *Service) createEvent(ctx context.Context, src Source, marker string, startedAt time.Time) (int64, bool) {
|
||||
if s == nil || s.store == nil {
|
||||
return 0
|
||||
return 0, true
|
||||
}
|
||||
id, err := s.store.CreateEvent(ctx, EventCreate{
|
||||
SourceID: src.ID,
|
||||
InstallationID: src.InstallationID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
StartedAt: startedAt,
|
||||
MarkerBefore: marker,
|
||||
SourceID: src.ID,
|
||||
PluginID: src.PluginID,
|
||||
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
|
||||
return 0, true
|
||||
}
|
||||
return id
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (s *Service) finishEvent(ctx context.Context, eventID int64, finish EventFinish) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package autoscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -12,13 +13,14 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetSettings(context.Context) (Settings, error) { return f.settings, nil }
|
||||
@@ -51,6 +53,9 @@ 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
|
||||
}
|
||||
@@ -65,14 +70,20 @@ 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, _ int, capabilityID, _ string, _ ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
func (f *fakeProvider) PollChanges(_ context.Context, _ string, capabilityID, _ string, _ ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
f.calls++
|
||||
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
|
||||
}
|
||||
@@ -87,7 +98,7 @@ func TestPollOncePassesSourceConfigToProvider(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
SourceConfig: map[string]string{"exclusions": ".downloads\n.recyclebin"},
|
||||
}},
|
||||
}
|
||||
@@ -101,6 +112,38 @@ 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{}
|
||||
@@ -210,7 +253,7 @@ func TestPollOnceEnqueuesDedupedFolders(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -241,7 +284,7 @@ func TestPollOnceRecordsSuccessfulEvent(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, Marker: &marker,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, Marker: &marker,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -283,7 +326,7 @@ func TestPollOnceRecordsReusedScanCounts(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -318,7 +361,7 @@ func TestPollOnceAppliesSourceRewritesBeforeEnqueue(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}},
|
||||
}},
|
||||
}
|
||||
@@ -347,7 +390,7 @@ func TestPollOnceStructuredFileChangeEnqueuesExactFile(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/ceph/tv", To: "/mnt/media/tv"}},
|
||||
}},
|
||||
}
|
||||
@@ -380,7 +423,7 @@ func TestPollOnceStructuredSubtreeChangeEnqueuesExactSubtree(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/ceph/movies", To: "/mnt/media/movies"}},
|
||||
}},
|
||||
}
|
||||
@@ -416,7 +459,7 @@ func TestPollOnceScansDistinctPathsUnderSameFolder(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -446,7 +489,7 @@ func TestPollOnceStoresOpaqueMarkerVerbatim(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
const opaque = "eyJjdXJzb3IiOiJhYmMxMjMifQ==|2026-06-02T14:10:00Z"
|
||||
@@ -470,7 +513,7 @@ func TestPollOnceHoldsMarkerWhenPathsReturnedButNoneResolve(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -523,7 +566,7 @@ func TestPollOnceAdvancesMarkerWhenResolvedButAllSuppressed(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", 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"}},
|
||||
}},
|
||||
@@ -556,7 +599,7 @@ func TestPollOnceAdvancesMarkerWhenZeroPathsReturned(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"arr": {}}, nextMarker: "m1"}
|
||||
@@ -588,7 +631,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", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
sources: []Source{{ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
}
|
||||
prov := &fakeProvider{err: context.DeadlineExceeded}
|
||||
q := &recordingQueuer{}
|
||||
@@ -607,7 +650,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", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
sources: []Source{{ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"}
|
||||
sup := &recordingSuppressor{}
|
||||
@@ -640,7 +683,7 @@ func TestPollOncePollsConnectionlessSource(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil, Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil, Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"}
|
||||
@@ -666,7 +709,7 @@ func TestPollOnceSkipsSourcePolledTooRecently(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
PollIntervalSeconds: &interval, LastRunAt: &recent,
|
||||
}},
|
||||
}
|
||||
@@ -690,7 +733,7 @@ func TestPollOnceRunsSourcePastItsInterval(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
PollIntervalSeconds: &interval, LastRunAt: &old,
|
||||
}},
|
||||
}
|
||||
@@ -708,25 +751,23 @@ func TestPollOnceRunsSourcePastItsInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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.
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{
|
||||
{ID: "live", InstallationID: 1, CapabilityID: "arr-live", ConnectionID: strptr("c1"), Enabled: true},
|
||||
{ID: "gone", InstallationID: 2, CapabilityID: "arr-gone", ConnectionID: strptr("c1"), Enabled: true},
|
||||
{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},
|
||||
},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
"arr-live": {"/mnt/media/Show/S01/E01.mkv"},
|
||||
"arr-gone": {"/mnt/media/Other/S01/E01.mkv"},
|
||||
}, errByCap: map[string]error{
|
||||
"arr-gone": errors.New("scan source plugin \"silo.autoscan.gone\" is not installed"),
|
||||
}, nextMarker: "m1"}
|
||||
q := &recordingQueuer{}
|
||||
// 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)
|
||||
svc := newService(store, prov, q, allowSuppressor{})
|
||||
|
||||
if err := svc.PollOnce(context.Background()); err != nil {
|
||||
t.Fatalf("PollOnce: %v", err)
|
||||
@@ -738,9 +779,15 @@ func TestPollOnceSkipsOrphanedSourceQuietly(t *testing.T) {
|
||||
t.Fatalf("expected live source marker advanced")
|
||||
}
|
||||
if _, ok := store.advanced["gone"]; ok {
|
||||
t.Fatalf("orphaned source must NOT poll/advance")
|
||||
t.Fatalf("failed source must NOT advance")
|
||||
}
|
||||
if _, ok := store.recorded["gone"]; ok {
|
||||
t.Fatalf("orphaned source must be skipped quietly, but an error was recorded: %q", store.recorded["gone"])
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ type Change struct {
|
||||
Scope ChangeScope
|
||||
}
|
||||
|
||||
// Source ties a scan_source plugin capability instance to a connection plus the
|
||||
// Source ties a scan_source plugin capability to a connection plus the
|
||||
// host-owned scheduling/bookkeeping state.
|
||||
type Source struct {
|
||||
ID string
|
||||
InstallationID int
|
||||
PluginID string
|
||||
CapabilityID string
|
||||
ConnectionID *string // nil until an operator binds a connection
|
||||
Enabled bool
|
||||
@@ -65,6 +65,7 @@ type Source struct {
|
||||
type EventStatus string
|
||||
|
||||
const (
|
||||
EventStatusRunning EventStatus = "running"
|
||||
EventStatusSuccess EventStatus = "success"
|
||||
EventStatusError EventStatus = "error"
|
||||
EventStatusUnresolved EventStatus = "unresolved"
|
||||
@@ -73,7 +74,7 @@ const (
|
||||
type Event struct {
|
||||
ID int64
|
||||
SourceID *string
|
||||
InstallationID int
|
||||
PluginID string
|
||||
CapabilityID string
|
||||
StartedAt time.Time
|
||||
CompletedAt time.Time
|
||||
@@ -91,11 +92,11 @@ type Event struct {
|
||||
}
|
||||
|
||||
type EventCreate struct {
|
||||
SourceID string
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
StartedAt time.Time
|
||||
MarkerBefore string
|
||||
SourceID string
|
||||
PluginID string
|
||||
CapabilityID string
|
||||
StartedAt time.Time
|
||||
MarkerBefore string
|
||||
}
|
||||
|
||||
type EventFinish struct {
|
||||
@@ -154,7 +155,7 @@ type ScanWithEvent struct {
|
||||
ScanRunSummary
|
||||
AutoscanEventID *int64
|
||||
SourceID *string
|
||||
InstallationID *int
|
||||
PluginID string
|
||||
CapabilityID string
|
||||
EventStatus EventStatus
|
||||
EventCompletedAt *time.Time
|
||||
|
||||
@@ -759,9 +759,9 @@ func listDistinctPeopleByKindWithSource(
|
||||
}
|
||||
|
||||
// listDistinctAudiobookSeriesWithSource returns distinct series_name values
|
||||
// from audiobook_series joined onto the scoped result set. Names are trimmed
|
||||
// from the active book series table joined onto the scoped result set. Names are trimmed
|
||||
// and case-folded for sort so the picker doesn't show duplicates that differ
|
||||
// only by whitespace or casing — the literal trimmed series_name is still
|
||||
// only by whitespace or casing; the literal trimmed series_name is still
|
||||
// returned so existing rules continue to match.
|
||||
//
|
||||
// The DISTINCT happens in an inline subquery (rather than directly on the
|
||||
@@ -783,14 +783,14 @@ func listDistinctAudiobookSeriesWithSource(
|
||||
SELECT name FROM (
|
||||
SELECT DISTINCT BTRIM(s.series_name) AS name
|
||||
FROM %s
|
||||
JOIN audiobook_series s ON s.content_id = mi.content_id
|
||||
JOIN %s s ON s.content_id = mi.content_id
|
||||
%s
|
||||
AND s.series_name IS NOT NULL
|
||||
AND BTRIM(s.series_name) <> ''
|
||||
) names
|
||||
ORDER BY LOWER(name) ASC
|
||||
LIMIT %d
|
||||
`, fromClause, browseFilterPrefix(whereClause), catalogFacetMaxValues)
|
||||
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), catalogFacetMaxValues)
|
||||
return queryDistinctStrings(ctx, pool, query, args)
|
||||
}
|
||||
|
||||
@@ -946,7 +946,7 @@ func searchDistinctAudiobookSeriesWithSource(
|
||||
SELECT name FROM (
|
||||
SELECT DISTINCT BTRIM(s.series_name) AS name
|
||||
FROM %s
|
||||
JOIN audiobook_series s ON s.content_id = mi.content_id
|
||||
JOIN %s s ON s.content_id = mi.content_id
|
||||
%s
|
||||
AND s.series_name IS NOT NULL
|
||||
AND BTRIM(s.series_name) <> ''
|
||||
@@ -954,10 +954,17 @@ func searchDistinctAudiobookSeriesWithSource(
|
||||
) names
|
||||
ORDER BY LOWER(name) ASC
|
||||
LIMIT %d
|
||||
`, fromClause, browseFilterPrefix(whereClause), prefixIdx, limit+1)
|
||||
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), prefixIdx, limit+1)
|
||||
return queryFacetSearchResults(ctx, pool, query, args, limit)
|
||||
}
|
||||
|
||||
func bookSeriesTableForMediaScope(mediaScope string) string {
|
||||
if mediaScope == "ebook" {
|
||||
return "ebook_series"
|
||||
}
|
||||
return "audiobook_series"
|
||||
}
|
||||
|
||||
// queryFacetSearchResults executes a facet search query that was built
|
||||
// with LIMIT N+1 and returns the first N rows plus a hasMore flag
|
||||
// indicating whether an additional row was present (i.e. the result set
|
||||
|
||||
@@ -390,7 +390,7 @@ func normalizeCatalogMatch(raw string) string {
|
||||
|
||||
func parseCatalogMediaScope(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "movie", "series", "episode", "audiobook":
|
||||
case "movie", "series", "episode", "audiobook", "ebook":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCatalogMediaScope_AllowsEbook(t *testing.T) {
|
||||
if got := parseCatalogMediaScope(" ebook "); got != "ebook" {
|
||||
t.Fatalf("expected ebook media scope, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,9 @@ type CatalogFiltersResult struct {
|
||||
Resolutions []string
|
||||
AudioLanguages []string
|
||||
SubtitleLanguages []string
|
||||
// Authors, Narrators and Series are audiobook-native facets. Populated
|
||||
// whenever the scope contains audiobook items; empty for video-only
|
||||
// scopes. They share the item_people / audiobook_series tables so
|
||||
// callers don't need to switch on media type.
|
||||
// Authors, Narrators and Series are book-native facets. Authors and
|
||||
// Narrators use item_people; Series uses the active book series table.
|
||||
// Video-only scopes return these empty.
|
||||
Authors []string
|
||||
Narrators []string
|
||||
Series []string
|
||||
@@ -64,8 +63,8 @@ type facetFetcher interface {
|
||||
// PeopleByKind returns distinct people names for the given PersonKind
|
||||
// across the scoped result set. Used for Authors / Narrators facets.
|
||||
PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
|
||||
// AudiobookSeries returns distinct series_name values from
|
||||
// audiobook_series joined onto the scoped result set.
|
||||
// AudiobookSeries returns distinct series_name values from the active
|
||||
// book series table joined onto the scoped result set.
|
||||
AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
|
||||
// SearchDistinctArrayColumn prefix-searches an array-column facet
|
||||
// (genres, studios, networks, countries). Returns up to limit
|
||||
@@ -710,7 +709,7 @@ func (r *CatalogResolver) ListFiltersWithOptions(ctx context.Context, req Catalo
|
||||
return r.listFiltersForSource(ctx, filters, options, episodeCatalogBaseRelation, req.Query.MediaScope)
|
||||
}
|
||||
|
||||
return r.listFiltersForSource(ctx, filters, options, "media_items mi", "")
|
||||
return r.listFiltersForSource(ctx, filters, options, "media_items mi", req.Query.MediaScope)
|
||||
}
|
||||
|
||||
// CatalogFacetSearchResult is the typed return for SearchFacet. Matches
|
||||
@@ -798,10 +797,9 @@ func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, a
|
||||
}
|
||||
|
||||
baseRelation := "media_items mi"
|
||||
mediaScope := ""
|
||||
mediaScope := req.Query.MediaScope
|
||||
if isEpisodeCatalogScope(req.Query.MediaScope) {
|
||||
baseRelation = episodeCatalogBaseRelation
|
||||
mediaScope = req.Query.MediaScope
|
||||
}
|
||||
|
||||
facets := r.facets
|
||||
@@ -837,6 +835,9 @@ func dispatchFacetSearch(ctx context.Context, facets facetFetcher, facet string,
|
||||
case "author":
|
||||
return facets.SearchPeopleByKind(ctx, models.PersonKindAuthor, filters, baseRelation, mediaScope, prefix, limit)
|
||||
case "narrator":
|
||||
if mediaScope == "ebook" {
|
||||
return nil, false, fmt.Errorf("%w: narrator facet is not available for ebook scope", ErrInvalidCatalogRequest)
|
||||
}
|
||||
return facets.SearchPeopleByKind(ctx, models.PersonKindNarrator, filters, baseRelation, mediaScope, prefix, limit)
|
||||
case "series":
|
||||
return facets.SearchAudiobookSeries(ctx, filters, baseRelation, mediaScope, prefix, limit)
|
||||
@@ -958,14 +959,16 @@ func (r *CatalogResolver) listFiltersForSource(
|
||||
authors = out
|
||||
return nil
|
||||
}))
|
||||
eg.Go(withLimit(func() error {
|
||||
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing catalog narrators: %w", err)
|
||||
}
|
||||
narrators = out
|
||||
return nil
|
||||
}))
|
||||
if mediaScope != "ebook" {
|
||||
eg.Go(withLimit(func() error {
|
||||
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing catalog narrators: %w", err)
|
||||
}
|
||||
narrators = out
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
eg.Go(withLimit(func() error {
|
||||
out, err := facets.AudiobookSeries(gctx, filters, baseRelation, mediaScope)
|
||||
if err != nil {
|
||||
@@ -1089,8 +1092,10 @@ func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFi
|
||||
if def.MediaScope != "" &&
|
||||
def.MediaScope != "movie" &&
|
||||
def.MediaScope != "series" &&
|
||||
def.MediaScope != "episode" {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', or 'episode'", ErrInvalidCatalogRequest)
|
||||
def.MediaScope != "episode" &&
|
||||
def.MediaScope != "audiobook" &&
|
||||
def.MediaScope != "ebook" {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'ebook'", ErrInvalidCatalogRequest)
|
||||
}
|
||||
if def.Match != "" && def.Match != "all" && def.Match != "any" {
|
||||
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
|
||||
|
||||
@@ -39,6 +39,21 @@ func TestValidateCatalogQueryRequest_AllowsEpisodeMediaScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogQueryRequest_AllowsEbookMediaScope(t *testing.T) {
|
||||
req := CatalogRequest{
|
||||
Source: CatalogSourceQuery,
|
||||
Query: QueryDefinition{
|
||||
MediaScope: "ebook",
|
||||
Match: "all",
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := validateCatalogQueryRequest(req, true); err != nil {
|
||||
t.Fatalf("expected ebook media scope to be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogQueryRequest_AllowsAddedAtFilter(t *testing.T) {
|
||||
req := CatalogRequest{
|
||||
Source: CatalogSourceQuery,
|
||||
@@ -244,6 +259,67 @@ func (s *stubFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters Br
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
type recordingFacetFetcher struct {
|
||||
mu sync.Mutex
|
||||
peopleKinds []models.PersonKind
|
||||
searchPeopleKind models.PersonKind
|
||||
searchMediaScope string
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) DistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) DistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) Resolutions(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) JSONBLanguages(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) SubtitleLanguages(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
|
||||
s.mu.Lock()
|
||||
s.peopleKinds = append(s.peopleKinds, kind)
|
||||
s.mu.Unlock()
|
||||
if kind == models.PersonKindAuthor {
|
||||
return []string{"Author"}, nil
|
||||
}
|
||||
return []string{"Narrator"}, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
|
||||
s.mu.Lock()
|
||||
s.searchPeopleKind = kind
|
||||
s.searchMediaScope = mediaScope
|
||||
s.mu.Unlock()
|
||||
return []string{"Author"}, false, nil
|
||||
}
|
||||
|
||||
func (s *recordingFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// countingExecutor is a previewExecutor stub that records the number of
|
||||
// PreviewPage calls and the AccessFilter passed in (so tests can confirm
|
||||
// NamePrefix made it through). It returns an empty result set so the resolver
|
||||
@@ -327,3 +403,92 @@ func TestListFiltersWithOptions_RunsFacetQueriesConcurrently(t *testing.T) {
|
||||
|
||||
barrier.assertConcurrent(t)
|
||||
}
|
||||
|
||||
func TestListFiltersWithOptions_EbookScopeSkipsNarratorFacet(t *testing.T) {
|
||||
facets := &recordingFacetFetcher{}
|
||||
resolver := &CatalogResolver{
|
||||
browseRepo: &BrowseRepository{},
|
||||
facets: facets,
|
||||
}
|
||||
|
||||
result, err := resolver.ListFiltersWithOptions(
|
||||
context.Background(),
|
||||
CatalogRequest{
|
||||
Source: CatalogSourceQuery,
|
||||
Query: QueryDefinition{MediaScope: "ebook"},
|
||||
},
|
||||
AccessFilter{},
|
||||
CatalogFilterOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ListFiltersWithOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
facets.mu.Lock()
|
||||
defer facets.mu.Unlock()
|
||||
for _, kind := range facets.peopleKinds {
|
||||
if kind == models.PersonKindNarrator {
|
||||
t.Fatal("ebook filters should not request narrator facets")
|
||||
}
|
||||
}
|
||||
if len(result.Narrators) != 0 {
|
||||
t.Fatalf("ebook filters returned narrators = %v, want empty", result.Narrators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFacet_EbookScopeForwardsMediaScope(t *testing.T) {
|
||||
facets := &recordingFacetFetcher{}
|
||||
resolver := &CatalogResolver{
|
||||
browseRepo: &BrowseRepository{},
|
||||
facets: facets,
|
||||
}
|
||||
|
||||
result, err := resolver.SearchFacet(
|
||||
context.Background(),
|
||||
CatalogRequest{
|
||||
Source: CatalogSourceQuery,
|
||||
Query: QueryDefinition{MediaScope: "ebook"},
|
||||
},
|
||||
AccessFilter{},
|
||||
"author",
|
||||
"Au",
|
||||
20,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchFacet returned error: %v", err)
|
||||
}
|
||||
if len(result.Matches) != 1 || result.Matches[0] != "Author" {
|
||||
t.Fatalf("matches = %v, want Author", result.Matches)
|
||||
}
|
||||
|
||||
facets.mu.Lock()
|
||||
defer facets.mu.Unlock()
|
||||
if facets.searchPeopleKind != models.PersonKindAuthor {
|
||||
t.Fatalf("searchPeopleKind = %v, want author", facets.searchPeopleKind)
|
||||
}
|
||||
if facets.searchMediaScope != "ebook" {
|
||||
t.Fatalf("search mediaScope = %q, want ebook", facets.searchMediaScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFacet_EbookScopeRejectsNarratorFacet(t *testing.T) {
|
||||
resolver := &CatalogResolver{
|
||||
browseRepo: &BrowseRepository{},
|
||||
facets: &recordingFacetFetcher{},
|
||||
}
|
||||
|
||||
_, err := resolver.SearchFacet(
|
||||
context.Background(),
|
||||
CatalogRequest{
|
||||
Source: CatalogSourceQuery,
|
||||
Query: QueryDefinition{MediaScope: "ebook"},
|
||||
},
|
||||
AccessFilter{},
|
||||
"narrator",
|
||||
"Na",
|
||||
20,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected narrator facet to be rejected for ebook scope")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,61 @@ 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"`
|
||||
|
||||
// Ebook-specific detail. Present only when Type == "ebook".
|
||||
Ebook *EbookDetailExtension `json:"ebook,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"`
|
||||
}
|
||||
|
||||
type EbookDetailExtension struct {
|
||||
Authors []AudiobookPerson `json:"authors"`
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
Series *AudiobookSeriesGroup `json:"series,omitempty"`
|
||||
Related AudiobookRelatedContent `json:"related"`
|
||||
}
|
||||
|
||||
// ItemUserState is per-profile viewer state included in item detail responses.
|
||||
@@ -224,6 +279,7 @@ 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"`
|
||||
@@ -833,6 +889,9 @@ 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,
|
||||
@@ -843,6 +902,13 @@ 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)
|
||||
}
|
||||
if item.Type == "ebook" {
|
||||
detail.Ebook = s.buildEbookExtension(ctx, item, 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
|
||||
@@ -952,6 +1018,400 @@ 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 (s *DetailService) buildEbookExtension(
|
||||
ctx context.Context,
|
||||
item *models.MediaItem,
|
||||
crew []CrewCredit,
|
||||
filter AccessFilter,
|
||||
) *EbookDetailExtension {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &EbookDetailExtension{
|
||||
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
|
||||
Publisher: firstNonEmptyString(item.Studios),
|
||||
Series: s.fetchEbookSeries(ctx, item.ContentID, filter),
|
||||
Related: AudiobookRelatedContent{
|
||||
AlsoByAuthor: s.fetchEbookAlsoByAuthor(ctx, item.ContentID, filter),
|
||||
Similar: s.fetchEbookSimilarByGenres(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 {
|
||||
return s.fetchBookAlsoByAuthor(ctx, contentID, "audiobook", filter)
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchEbookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
||||
return s.fetchBookAlsoByAuthor(ctx, contentID, "ebook", filter)
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchBookAlsoByAuthor(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
args := []any{contentID, models.PersonKindAuthor, mediaType}
|
||||
argIdx := 4
|
||||
conditions := []string{
|
||||
"ip1.content_id = $1",
|
||||
"ip1.kind = $2",
|
||||
"m2.type = $3",
|
||||
}
|
||||
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 {
|
||||
return s.fetchBookSimilarByGenres(ctx, contentID, "audiobook", filter)
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchEbookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
||||
return s.fetchBookSimilarByGenres(ctx, contentID, "ebook", filter)
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchBookSimilarByGenres(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
args := []any{contentID, models.PersonKindAuthor, mediaType}
|
||||
argIdx := 4
|
||||
conditions := []string{
|
||||
"m.type = $3",
|
||||
"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 {
|
||||
return s.fetchBookSeries(ctx, contentID, "audiobook", "audiobook_series", filter)
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchEbookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
|
||||
return s.fetchBookSeries(ctx, contentID, "ebook", "ebook_series", filter)
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchBookSeries(ctx context.Context, contentID string, mediaType string, tableName string, filter AccessFilter) *AudiobookSeriesGroup {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return nil
|
||||
}
|
||||
switch tableName {
|
||||
case "audiobook_series", "ebook_series":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
args := []any{contentID, mediaType}
|
||||
argIdx := 3
|
||||
conditions := []string{
|
||||
"root.content_id = $1",
|
||||
"m.type = $2",
|
||||
}
|
||||
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 %s root
|
||||
JOIN %s 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
|
||||
`, tableName, tableName, 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
|
||||
@@ -1567,6 +2027,30 @@ 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,
|
||||
@@ -1622,6 +2106,7 @@ 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),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEbookExtensionUsesAuthorsAndPublisherWithoutNarrators(t *testing.T) {
|
||||
crew := []CrewCredit{
|
||||
{Name: "Becky Chambers", Job: models.PersonKindAuthor.String(), PersonID: "10"},
|
||||
{Name: "Narrator Should Not Apply", Job: models.PersonKindNarrator.String(), PersonID: "20"},
|
||||
}
|
||||
item := &models.MediaItem{
|
||||
ContentID: "ebook-1",
|
||||
Type: "ebook",
|
||||
Studios: []string{"Tor"},
|
||||
}
|
||||
|
||||
extension := (&DetailService{}).buildEbookExtension(nil, item, crew, AccessFilter{})
|
||||
|
||||
if extension == nil {
|
||||
t.Fatal("buildEbookExtension returned nil")
|
||||
}
|
||||
if len(extension.Authors) != 1 || extension.Authors[0].Name != "Becky Chambers" {
|
||||
t.Fatalf("Authors = %+v, want Becky Chambers", extension.Authors)
|
||||
}
|
||||
if extension.Publisher != "Tor" {
|
||||
t.Fatalf("Publisher = %q, want Tor", extension.Publisher)
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,10 @@ type RatingFilter struct {
|
||||
Filter AccessFilter
|
||||
}
|
||||
|
||||
// ListByRatingThreshold returns media items whose rating_imdb is >= f.Min,
|
||||
// ordered by rating_imdb DESC NULLS LAST. Items without an IMDb rating are
|
||||
// always excluded.
|
||||
func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f RatingFilter) ([]*models.MediaItem, error) {
|
||||
// buildRatingThresholdQuery builds the SQL statement and bind args for ListByRatingThreshold.
|
||||
// It returns an empty query string when access rules exclude every
|
||||
// library, signalling the caller to skip the query and return no rows.
|
||||
func buildRatingThresholdQuery(f RatingFilter) (string, []any) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
argIdx := 1
|
||||
@@ -60,7 +60,7 @@ func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f Ratin
|
||||
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
|
||||
if f.Filter.AllowedLibraryIDs != nil {
|
||||
if len(f.Filter.AllowedLibraryIDs) == 0 {
|
||||
return []*models.MediaItem{}, nil
|
||||
return "", nil
|
||||
}
|
||||
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
|
||||
for i, id := range f.Filter.AllowedLibraryIDs {
|
||||
@@ -85,7 +85,7 @@ func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f Ratin
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT %s FROM %s WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
|
||||
itemColumns,
|
||||
qualifiedItemColumns("mi"),
|
||||
fromClause,
|
||||
strings.Join(conditions, " AND "),
|
||||
)
|
||||
@@ -95,6 +95,18 @@ func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f Ratin
|
||||
args = append(args, f.Limit)
|
||||
}
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
// ListByRatingThreshold returns media items whose rating_imdb is >= f.Min,
|
||||
// ordered by rating_imdb DESC NULLS LAST. Items without an IMDb rating are
|
||||
// always excluded.
|
||||
func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f RatingFilter) ([]*models.MediaItem, error) {
|
||||
query, args := buildRatingThresholdQuery(f)
|
||||
if query == "" {
|
||||
return []*models.MediaItem{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing items by rating threshold: %w", err)
|
||||
@@ -122,17 +134,10 @@ type UnplayedFilter struct {
|
||||
Filter AccessFilter
|
||||
}
|
||||
|
||||
// ListUnplayedHighRated returns high-rated items that the given user/profile has
|
||||
// never started watching. "Never started" means no row exists in
|
||||
// user_watch_history for (user_id, profile_id, media_item_id), regardless of
|
||||
// completion status. Items without an IMDb rating are excluded.
|
||||
//
|
||||
// Results are ordered by rating_imdb DESC NULLS LAST.
|
||||
func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f UnplayedFilter) ([]*models.MediaItem, error) {
|
||||
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
|
||||
return nil, fmt.Errorf("ListUnplayedHighRated: UserID and ProfileID are required")
|
||||
}
|
||||
|
||||
// buildUnplayedHighRatedQuery builds the SQL statement and bind args for ListUnplayedHighRated.
|
||||
// It returns an empty query string when access rules exclude every
|
||||
// library, signalling the caller to skip the query and return no rows.
|
||||
func buildUnplayedHighRatedQuery(f UnplayedFilter) (string, []any) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
argIdx := 1
|
||||
@@ -161,7 +166,7 @@ func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f Unpla
|
||||
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
|
||||
if f.Filter.AllowedLibraryIDs != nil {
|
||||
if len(f.Filter.AllowedLibraryIDs) == 0 {
|
||||
return []*models.MediaItem{}, nil
|
||||
return "", nil
|
||||
}
|
||||
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
|
||||
for i, id := range f.Filter.AllowedLibraryIDs {
|
||||
@@ -186,7 +191,7 @@ func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f Unpla
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT %s FROM %s WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
|
||||
itemColumns,
|
||||
qualifiedItemColumns("mi"),
|
||||
fromClause,
|
||||
strings.Join(conditions, " AND "),
|
||||
)
|
||||
@@ -196,6 +201,25 @@ func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f Unpla
|
||||
args = append(args, f.Limit)
|
||||
}
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
// ListUnplayedHighRated returns high-rated items that the given user/profile has
|
||||
// never started watching. "Never started" means no row exists in
|
||||
// user_watch_history for (user_id, profile_id, media_item_id), regardless of
|
||||
// completion status. Items without an IMDb rating are excluded.
|
||||
//
|
||||
// Results are ordered by rating_imdb DESC NULLS LAST.
|
||||
func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f UnplayedFilter) ([]*models.MediaItem, error) {
|
||||
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
|
||||
return nil, fmt.Errorf("ListUnplayedHighRated: UserID and ProfileID are required")
|
||||
}
|
||||
|
||||
query, args := buildUnplayedHighRatedQuery(f)
|
||||
if query == "" {
|
||||
return []*models.MediaItem{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing unplayed high-rated items: %w", err)
|
||||
@@ -225,13 +249,10 @@ type ForgottenFavoritesFilter struct {
|
||||
Filter AccessFilter
|
||||
}
|
||||
|
||||
// ListForgottenFavorites returns high-rated items (rating_imdb >= 7.0) that the
|
||||
// user/profile either has never watched OR last watched more than LookbackDays
|
||||
// ago. Results are ordered by rating_imdb DESC NULLS LAST.
|
||||
func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f ForgottenFavoritesFilter) ([]*models.MediaItem, error) {
|
||||
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
|
||||
return nil, fmt.Errorf("ListForgottenFavorites: UserID and ProfileID are required")
|
||||
}
|
||||
// buildForgottenFavoritesQuery builds the SQL statement and bind args for ListForgottenFavorites.
|
||||
// It returns an empty query string when access rules exclude every
|
||||
// library, signalling the caller to skip the query and return no rows.
|
||||
func buildForgottenFavoritesQuery(f ForgottenFavoritesFilter) (string, []any) {
|
||||
if f.LookbackDays <= 0 {
|
||||
f.LookbackDays = 365
|
||||
}
|
||||
@@ -261,7 +282,7 @@ func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f Forg
|
||||
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
|
||||
if f.Filter.AllowedLibraryIDs != nil {
|
||||
if len(f.Filter.AllowedLibraryIDs) == 0 {
|
||||
return []*models.MediaItem{}, nil
|
||||
return "", nil
|
||||
}
|
||||
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
|
||||
for i, id := range f.Filter.AllowedLibraryIDs {
|
||||
@@ -286,7 +307,7 @@ func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f Forg
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT %s FROM %s WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
|
||||
itemColumns,
|
||||
qualifiedItemColumns("mi"),
|
||||
fromClause,
|
||||
strings.Join(conditions, " AND "),
|
||||
)
|
||||
@@ -296,6 +317,21 @@ func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f Forg
|
||||
args = append(args, f.Limit)
|
||||
}
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
// ListForgottenFavorites returns high-rated items (rating_imdb >= 7.0) that the
|
||||
// user/profile either has never watched OR last watched more than LookbackDays
|
||||
// ago. Results are ordered by rating_imdb DESC NULLS LAST.
|
||||
func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f ForgottenFavoritesFilter) ([]*models.MediaItem, error) {
|
||||
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
|
||||
return nil, fmt.Errorf("ListForgottenFavorites: UserID and ProfileID are required")
|
||||
}
|
||||
query, args := buildForgottenFavoritesQuery(f)
|
||||
if query == "" {
|
||||
return []*models.MediaItem{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing forgotten favorites: %w", err)
|
||||
|
||||
@@ -13,130 +13,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildRatingThresholdQuery is the testable extraction of the SQL generation
|
||||
// logic from ListByRatingThreshold so we can inspect it without a pool.
|
||||
func buildRatingThresholdQuery(f RatingFilter) (string, []any) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
argIdx := 1
|
||||
|
||||
conditions = append(conditions, "mi.rating_imdb >= $1")
|
||||
args = append(args, f.Min)
|
||||
argIdx++
|
||||
|
||||
fromClause := "media_items mi"
|
||||
if f.LibraryID != nil {
|
||||
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
|
||||
conditions = append(conditions, "mil.media_folder_id = $2")
|
||||
args = append(args, *f.LibraryID)
|
||||
argIdx++
|
||||
} else if f.Filter.AllowedLibraryIDs != nil || len(f.Filter.DisabledLibraryIDs) > 0 {
|
||||
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
|
||||
if f.Filter.AllowedLibraryIDs != nil {
|
||||
if len(f.Filter.AllowedLibraryIDs) == 0 {
|
||||
return "", nil // early empty
|
||||
}
|
||||
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
|
||||
for i, id := range f.Filter.AllowedLibraryIDs {
|
||||
placeholders[i] = "$" + itoa(argIdx)
|
||||
args = append(args, id)
|
||||
argIdx++
|
||||
}
|
||||
conditions = append(conditions, "mil.media_folder_id IN ("+strings.Join(placeholders, ", ")+")")
|
||||
}
|
||||
if len(f.Filter.DisabledLibraryIDs) > 0 {
|
||||
placeholders := make([]string, len(f.Filter.DisabledLibraryIDs))
|
||||
for i, id := range f.Filter.DisabledLibraryIDs {
|
||||
placeholders[i] = "$" + itoa(argIdx)
|
||||
args = append(args, id)
|
||||
argIdx++
|
||||
}
|
||||
conditions = append(conditions, "mil.media_folder_id NOT IN ("+strings.Join(placeholders, ", ")+")")
|
||||
}
|
||||
}
|
||||
|
||||
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
|
||||
|
||||
query := "SELECT " + itemColumns + " FROM " + fromClause +
|
||||
" WHERE " + strings.Join(conditions, " AND ") +
|
||||
" ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC"
|
||||
|
||||
if f.Limit > 0 {
|
||||
query += " LIMIT $" + itoa(argIdx)
|
||||
args = append(args, f.Limit)
|
||||
}
|
||||
return query, args
|
||||
}
|
||||
|
||||
// buildUnplayedHighRatedQuery mirrors the SQL-building portion of
|
||||
// ListUnplayedHighRated for unit testing.
|
||||
func buildUnplayedHighRatedQuery(f UnplayedFilter) (string, []any) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
argIdx := 1
|
||||
|
||||
conditions = append(conditions, "mi.rating_imdb >= $1")
|
||||
args = append(args, f.MinRating)
|
||||
argIdx++
|
||||
|
||||
conditions = append(conditions, "NOT EXISTS (\n\t\tSELECT 1\n\t\tFROM user_watch_history uwh\n\t\tWHERE uwh.user_id = $"+itoa(argIdx)+"\n\t\t AND uwh.profile_id = $"+itoa(argIdx+1)+"\n\t\t AND uwh.media_item_id = mi.content_id\n\t)")
|
||||
args = append(args, f.UserID, f.ProfileID)
|
||||
argIdx += 2
|
||||
|
||||
fromClause := "media_items mi"
|
||||
if f.Filter.AllowedLibraryIDs != nil || len(f.Filter.DisabledLibraryIDs) > 0 {
|
||||
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
|
||||
if f.Filter.AllowedLibraryIDs != nil {
|
||||
if len(f.Filter.AllowedLibraryIDs) == 0 {
|
||||
return "", nil // early empty
|
||||
}
|
||||
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
|
||||
for i, id := range f.Filter.AllowedLibraryIDs {
|
||||
placeholders[i] = "$" + itoa(argIdx)
|
||||
args = append(args, id)
|
||||
argIdx++
|
||||
}
|
||||
conditions = append(conditions, "mil.media_folder_id IN ("+strings.Join(placeholders, ", ")+")")
|
||||
}
|
||||
if len(f.Filter.DisabledLibraryIDs) > 0 {
|
||||
placeholders := make([]string, len(f.Filter.DisabledLibraryIDs))
|
||||
for i, id := range f.Filter.DisabledLibraryIDs {
|
||||
placeholders[i] = "$" + itoa(argIdx)
|
||||
args = append(args, id)
|
||||
argIdx++
|
||||
}
|
||||
conditions = append(conditions, "mil.media_folder_id NOT IN ("+strings.Join(placeholders, ", ")+")")
|
||||
}
|
||||
}
|
||||
|
||||
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
|
||||
|
||||
query := "SELECT " + itemColumns + " FROM " + fromClause +
|
||||
" WHERE " + strings.Join(conditions, " AND ") +
|
||||
" ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC"
|
||||
|
||||
if f.Limit > 0 {
|
||||
query += " LIMIT $" + itoa(argIdx)
|
||||
args = append(args, f.Limit)
|
||||
}
|
||||
return query, args
|
||||
}
|
||||
|
||||
// itoa is a local helper to avoid importing strconv.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
buf := [20]byte{}
|
||||
pos := len(buf)
|
||||
for n > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListByRatingThreshold SQL generation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -370,62 +246,6 @@ func TestUnplayedHighRated_ContentRatingFilter(t *testing.T) {
|
||||
// ListForgottenFavorites SQL generation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildForgottenFavoritesQuery is the testable extraction of the SQL
|
||||
// generation logic from ListForgottenFavorites.
|
||||
func buildForgottenFavoritesQuery(f ForgottenFavoritesFilter) (string, []any) {
|
||||
if f.LookbackDays <= 0 {
|
||||
f.LookbackDays = 365
|
||||
}
|
||||
|
||||
var conditions []string
|
||||
var args []any
|
||||
argIdx := 1
|
||||
|
||||
conditions = append(conditions, "mi.rating_imdb >= 7.0")
|
||||
|
||||
conditions = append(conditions, "NOT EXISTS (\n\t\tSELECT 1\n\t\tFROM user_watch_history uwh\n\t\tWHERE uwh.user_id = $"+itoa(argIdx)+"\n\t\t AND uwh.profile_id = $"+itoa(argIdx+1)+"\n\t\t AND uwh.media_item_id = mi.content_id\n\t\t AND uwh.watched_at >= NOW() - ($"+itoa(argIdx+2)+" || ' days')::interval\n\t)")
|
||||
args = append(args, f.UserID, f.ProfileID, f.LookbackDays)
|
||||
argIdx += 3
|
||||
|
||||
fromClause := "media_items mi"
|
||||
if f.Filter.AllowedLibraryIDs != nil || len(f.Filter.DisabledLibraryIDs) > 0 {
|
||||
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
|
||||
if f.Filter.AllowedLibraryIDs != nil {
|
||||
if len(f.Filter.AllowedLibraryIDs) == 0 {
|
||||
return "", nil // early empty
|
||||
}
|
||||
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
|
||||
for i, id := range f.Filter.AllowedLibraryIDs {
|
||||
placeholders[i] = "$" + itoa(argIdx)
|
||||
args = append(args, id)
|
||||
argIdx++
|
||||
}
|
||||
conditions = append(conditions, "mil.media_folder_id IN ("+strings.Join(placeholders, ", ")+")")
|
||||
}
|
||||
if len(f.Filter.DisabledLibraryIDs) > 0 {
|
||||
placeholders := make([]string, len(f.Filter.DisabledLibraryIDs))
|
||||
for i, id := range f.Filter.DisabledLibraryIDs {
|
||||
placeholders[i] = "$" + itoa(argIdx)
|
||||
args = append(args, id)
|
||||
argIdx++
|
||||
}
|
||||
conditions = append(conditions, "mil.media_folder_id NOT IN ("+strings.Join(placeholders, ", ")+")")
|
||||
}
|
||||
}
|
||||
|
||||
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
|
||||
|
||||
query := "SELECT " + itemColumns + " FROM " + fromClause +
|
||||
" WHERE " + strings.Join(conditions, " AND ") +
|
||||
" ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC"
|
||||
|
||||
if f.Limit > 0 {
|
||||
query += " LIMIT $" + itoa(argIdx)
|
||||
args = append(args, f.Limit)
|
||||
}
|
||||
return query, args
|
||||
}
|
||||
|
||||
func TestForgottenFavorites_BasicQuery(t *testing.T) {
|
||||
query, args := buildForgottenFavoritesQuery(ForgottenFavoritesFilter{
|
||||
LookbackDays: 365,
|
||||
@@ -540,3 +360,41 @@ func TestForgottenFavorites_DefaultLookbackApplied(t *testing.T) {
|
||||
t.Fatalf("expected lookback default 365, got %v", args[2])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryQueries_QualifyContentIDUnderLibraryJoin is a regression guard
|
||||
// for the "column reference \"content_id\" is ambiguous" (SQLSTATE 42702)
|
||||
// failure. When a viewer has library restrictions, each discovery query joins
|
||||
// media_item_libraries (which also has a content_id column). The SELECT list
|
||||
// must therefore qualify its columns with the mi alias; a bare column list
|
||||
// makes content_id ambiguous and the query fails at runtime.
|
||||
func TestDiscoveryQueries_QualifyContentIDUnderLibraryJoin(t *testing.T) {
|
||||
restricted := AccessFilter{AllowedLibraryIDs: []int{1, 2}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{"rating_threshold", mustQuery(buildRatingThresholdQuery(RatingFilter{Min: 7, Filter: restricted}))},
|
||||
{"unplayed_high_rated", mustQuery(buildUnplayedHighRatedQuery(UnplayedFilter{MinRating: 7, UserID: 1, ProfileID: "p", Filter: restricted}))},
|
||||
{"forgotten_favorites", mustQuery(buildForgottenFavoritesQuery(ForgottenFavoritesFilter{UserID: 1, ProfileID: "p", LookbackDays: 365, Filter: restricted}))},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Precondition: the join that introduces the ambiguity is present.
|
||||
if !strings.Contains(tc.query, "JOIN media_item_libraries mil") {
|
||||
t.Fatalf("expected library join in query, got:\n%s", tc.query)
|
||||
}
|
||||
// The SELECT list must be alias-qualified.
|
||||
if !strings.HasPrefix(tc.query, "SELECT mi.content_id,") {
|
||||
t.Fatalf("SELECT list is not qualified with the mi alias (ambiguous content_id), got:\n%s", tc.query)
|
||||
}
|
||||
// And must not contain a bare, unqualified content_id column.
|
||||
if strings.Contains(tc.query, "SELECT content_id,") {
|
||||
t.Fatalf("SELECT list uses an unqualified content_id, got:\n%s", tc.query)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustQuery(query string, _ []any) string { return query }
|
||||
|
||||
@@ -3,6 +3,7 @@ package catalog
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -136,3 +137,15 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+142
-22
@@ -563,16 +563,6 @@ 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")
|
||||
@@ -592,23 +582,30 @@ func (r *FolderRepository) DeleteWithStats(
|
||||
}
|
||||
|
||||
// Phase 3: delete remaining folder memberships (shared items kept; only the
|
||||
// membership in this folder is removed).
|
||||
// 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.
|
||||
if progress != nil {
|
||||
progress(orphanTotal, orphanTotal, "Removing library memberships")
|
||||
}
|
||||
if _, err := deleteInBatches(ctx, folderChildDeleteBatch, func(ctx context.Context) (int64, error) {
|
||||
tag, e := r.pool.Exec(ctx, `
|
||||
DELETE FROM media_item_libraries
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2
|
||||
)`, id, folderChildDeleteBatch)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}); err != nil {
|
||||
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
|
||||
}
|
||||
stats.OrphanedImageDirs = filtered
|
||||
}
|
||||
|
||||
// Phase 4: delete the now-lightweight folder row. Tolerate 0 rows so a
|
||||
// resumed run that already removed it still succeeds.
|
||||
@@ -656,6 +653,129 @@ 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 ` + orphanedProvisionalMediaItemConditions
|
||||
|
||||
const deleteOrphanedProvisionalIDsByContentIDSQL = `
|
||||
DELETE FROM public.media_items mi
|
||||
WHERE mi.content_id = ANY($1)
|
||||
AND ` + orphanedProvisionalMediaItemConditions
|
||||
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,9 +159,10 @@ func (qb *QueryBuilder) UserHistoryCTEArgs() []any {
|
||||
// UserHistoryCTESQL returns the user_last_watched CTE definition. The two
|
||||
// placeholder positions (user_id, profile_id) are bound to the args returned
|
||||
// by UserHistoryCTEArgs and should be the first two entries in the final
|
||||
// statement's arg list. The CTE aggregates user_watch_history watch_at and
|
||||
// user_watch_progress.completed=TRUE updated_at into one row per
|
||||
// media_item_id, filtering out items the user has hidden via
|
||||
// statement's arg list. The CTE aggregates user_watch_history watched_at,
|
||||
// user_watch_progress.completed=TRUE updated_at, and completed ebook reader
|
||||
// progress updated_at into one row per media_item_id, filtering out videos the
|
||||
// user has hidden via
|
||||
// user_history_hidden_items. The single GROUP BY replaces the two
|
||||
// correlated MAX subqueries the previous lastWatchedExpr emitted (audit
|
||||
// 2026-05-01 §3.1 Pattern B).
|
||||
@@ -187,6 +188,10 @@ func UserHistoryCTESQL(argIdx int) string {
|
||||
SELECT uwp.media_item_id, NULL::timestamptz, uwp.updated_at
|
||||
FROM user_watch_progress uwp
|
||||
WHERE uwp.user_id = $%d AND uwp.profile_id = $%d AND uwp.completed = TRUE
|
||||
UNION ALL
|
||||
SELECT erp.content_id AS media_item_id, NULL::timestamptz, erp.updated_at
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.user_id = $%d AND erp.profile_id = $%d AND erp.progress >= 0.9
|
||||
) src
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM user_history_hidden_items hhi
|
||||
@@ -195,7 +200,7 @@ func UserHistoryCTESQL(argIdx int) string {
|
||||
AND COALESCE(src.uwh_at, src.uwp_at) <= hhi.hidden_before
|
||||
)
|
||||
GROUP BY src.media_item_id
|
||||
)`, argIdx, argIdx+1, argIdx, argIdx+1, argIdx, argIdx+1)
|
||||
)`, argIdx, argIdx+1, argIdx, argIdx+1, argIdx, argIdx+1, argIdx, argIdx+1)
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) BuildSortClause(sortConfig QuerySort) (string, []any, error) {
|
||||
@@ -298,7 +303,8 @@ func (qb *QueryBuilder) BuildSortPlan(sortConfig QuerySort) (QuerySortPlan, erro
|
||||
return plan, nil
|
||||
case "series":
|
||||
plan.Joins = []string{fmt.Sprintf(
|
||||
"LEFT JOIN audiobook_series sort_series ON sort_series.content_id = %s.content_id",
|
||||
"LEFT JOIN %s sort_series ON sort_series.content_id = %s.content_id",
|
||||
qb.bookSeriesTable(),
|
||||
qb.alias,
|
||||
)}
|
||||
// Sort by series name primarily, then by series_index so books
|
||||
@@ -385,7 +391,7 @@ func (qb *QueryBuilder) buildRule(rule QueryRule) (string, error) {
|
||||
case "narrator":
|
||||
return qb.buildPersonClause(models.PersonKindNarrator, rule)
|
||||
case "series":
|
||||
return qb.buildAudiobookSeriesClause(rule)
|
||||
return qb.buildBookSeriesClause(rule)
|
||||
case "watched":
|
||||
return qb.buildWatchedClause(rule)
|
||||
case "favorited":
|
||||
@@ -502,14 +508,21 @@ func (qb *QueryBuilder) personKindSortJoin(kind models.PersonKind, alias string)
|
||||
)
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) buildAudiobookSeriesClause(rule QueryRule) (string, error) {
|
||||
func (qb *QueryBuilder) bookSeriesTable() string {
|
||||
if qb.mediaScope == "ebook" {
|
||||
return "ebook_series"
|
||||
}
|
||||
return "audiobook_series"
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) buildBookSeriesClause(rule QueryRule) (string, error) {
|
||||
qb.args = append(qb.args, rule.Value)
|
||||
clause := fmt.Sprintf(`EXISTS (
|
||||
SELECT 1
|
||||
FROM audiobook_series s
|
||||
FROM %s s
|
||||
WHERE s.content_id = %s.content_id
|
||||
AND LOWER(BTRIM(s.series_name)) = LOWER(BTRIM($%d))
|
||||
)`, qb.alias, qb.argIdx)
|
||||
)`, qb.bookSeriesTable(), qb.alias, qb.argIdx)
|
||||
qb.argIdx++
|
||||
if rule.Op == "is_not" {
|
||||
return "NOT (" + clause + ")", nil
|
||||
@@ -947,6 +960,9 @@ func (qb *QueryBuilder) buildInProgressClause(rule QueryRule) (string, error) {
|
||||
if !ok {
|
||||
return "", fmt.Errorf("in_progress requires a boolean value")
|
||||
}
|
||||
if qb.mediaScope == "ebook" {
|
||||
return qb.buildEbookInProgressClause(value), nil
|
||||
}
|
||||
qb.args = append(qb.args, qb.userID, qb.profileID)
|
||||
clause := fmt.Sprintf(`EXISTS (
|
||||
SELECT 1
|
||||
@@ -972,6 +988,24 @@ func (qb *QueryBuilder) buildInProgressClause(rule QueryRule) (string, error) {
|
||||
return clause, nil
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) buildEbookInProgressClause(value bool) string {
|
||||
qb.args = append(qb.args, qb.userID, qb.profileID)
|
||||
clause := fmt.Sprintf(`EXISTS (
|
||||
SELECT 1
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.user_id = $%d
|
||||
AND erp.profile_id = $%d
|
||||
AND erp.content_id = %s.content_id
|
||||
AND erp.progress > 0
|
||||
AND erp.progress < 0.9
|
||||
)`, qb.argIdx, qb.argIdx+1, qb.alias)
|
||||
qb.argIdx += 2
|
||||
if !value {
|
||||
return "NOT (" + clause + ")"
|
||||
}
|
||||
return clause
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) buildLastWatchedClause(rule QueryRule) (string, error) {
|
||||
if err := qb.ensureUserScope(rule.Field); err != nil {
|
||||
return "", err
|
||||
@@ -1070,6 +1104,9 @@ func (qb *QueryBuilder) buildReleaseDateClause(rule QueryRule) (string, error) {
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) userStateCompletionClause() string {
|
||||
if qb.mediaScope == "ebook" {
|
||||
return qb.ebookUserStateCompletionClause()
|
||||
}
|
||||
qb.args = append(qb.args, qb.userID, qb.profileID)
|
||||
clause := fmt.Sprintf(`(
|
||||
EXISTS (
|
||||
@@ -1120,6 +1157,20 @@ func (qb *QueryBuilder) userStateCompletionClause() string {
|
||||
return clause
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) ebookUserStateCompletionClause() string {
|
||||
qb.args = append(qb.args, qb.userID, qb.profileID)
|
||||
clause := fmt.Sprintf(`EXISTS (
|
||||
SELECT 1
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.user_id = $%d
|
||||
AND erp.profile_id = $%d
|
||||
AND erp.content_id = %s.content_id
|
||||
AND erp.progress >= 0.9
|
||||
)`, qb.argIdx, qb.argIdx+1, qb.alias)
|
||||
qb.argIdx += 2
|
||||
return clause
|
||||
}
|
||||
|
||||
// lastWatchedExpr returns the SQL expression the executor binds to the
|
||||
// per-user history aggregate. It signals the executor (via
|
||||
// requireUserHistoryCTE) that the user_last_watched CTE and a LEFT JOIN
|
||||
@@ -1343,6 +1394,9 @@ func (qb *QueryBuilder) consumeIntArgs(values []int) ([]string, []any) {
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) progressSortPlan() (string, []string, []any) {
|
||||
if qb.mediaScope == "ebook" {
|
||||
return qb.ebookProgressSortPlan()
|
||||
}
|
||||
joinSQL := fmt.Sprintf(
|
||||
`LEFT JOIN (
|
||||
SELECT uwp.media_item_id AS content_id,
|
||||
@@ -1371,7 +1425,31 @@ func (qb *QueryBuilder) progressSortPlan() (string, []string, []any) {
|
||||
return "sort_progress.progress_ratio", []string{joinSQL}, []any{qb.userID, qb.profileID}
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) ebookProgressSortPlan() (string, []string, []any) {
|
||||
joinSQL := fmt.Sprintf(
|
||||
`LEFT JOIN (
|
||||
SELECT erp.content_id,
|
||||
CASE
|
||||
WHEN erp.progress > 0
|
||||
AND erp.progress < 0.9
|
||||
THEN erp.progress::double precision
|
||||
ELSE NULL
|
||||
END AS progress_ratio
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.user_id = $%d
|
||||
AND erp.profile_id = $%d
|
||||
) sort_progress ON sort_progress.content_id = %s.content_id`,
|
||||
qb.argIdx,
|
||||
qb.argIdx+1,
|
||||
qb.alias,
|
||||
)
|
||||
return "sort_progress.progress_ratio", []string{joinSQL}, []any{qb.userID, qb.profileID}
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) dateViewedSortPlan() (string, []string, []any) {
|
||||
if qb.mediaScope == "ebook" {
|
||||
return qb.ebookDateViewedSortPlan()
|
||||
}
|
||||
historyJoin := fmt.Sprintf(
|
||||
`LEFT JOIN (
|
||||
SELECT uwh.media_item_id AS content_id, MAX(uwh.watched_at) AS viewed_at
|
||||
@@ -1422,7 +1500,30 @@ func (qb *QueryBuilder) dateViewedSortPlan() (string, []string, []any) {
|
||||
return expr, []string{historyJoin, progressJoin}, []any{qb.userID, qb.profileID}
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) ebookDateViewedSortPlan() (string, []string, []any) {
|
||||
joinSQL := fmt.Sprintf(
|
||||
`LEFT JOIN (
|
||||
SELECT erp.content_id,
|
||||
CASE
|
||||
WHEN erp.progress >= 0.9
|
||||
THEN erp.updated_at
|
||||
ELSE NULL
|
||||
END AS viewed_at
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.user_id = $%d
|
||||
AND erp.profile_id = $%d
|
||||
) sort_ebook_viewed ON sort_ebook_viewed.content_id = %s.content_id`,
|
||||
qb.argIdx,
|
||||
qb.argIdx+1,
|
||||
qb.alias,
|
||||
)
|
||||
return "sort_ebook_viewed.viewed_at", []string{joinSQL}, []any{qb.userID, qb.profileID}
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) playsSortPlan() (string, []string, []any) {
|
||||
if qb.mediaScope == "ebook" {
|
||||
return qb.ebookPlaysSortPlan()
|
||||
}
|
||||
historyJoin := fmt.Sprintf(
|
||||
`LEFT JOIN (
|
||||
SELECT uwh.media_item_id AS content_id, COUNT(*) AS play_count
|
||||
@@ -1473,6 +1574,26 @@ func (qb *QueryBuilder) playsSortPlan() (string, []string, []any) {
|
||||
return expr, []string{historyJoin, progressJoin}, []any{qb.userID, qb.profileID}
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) ebookPlaysSortPlan() (string, []string, []any) {
|
||||
joinSQL := fmt.Sprintf(
|
||||
`LEFT JOIN (
|
||||
SELECT erp.content_id,
|
||||
CASE
|
||||
WHEN erp.progress >= 0.9
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS completed_play_count
|
||||
FROM ebook_reader_progress erp
|
||||
WHERE erp.user_id = $%d
|
||||
AND erp.profile_id = $%d
|
||||
) sort_ebook_plays ON sort_ebook_plays.content_id = %s.content_id`,
|
||||
qb.argIdx,
|
||||
qb.argIdx+1,
|
||||
qb.alias,
|
||||
)
|
||||
return "NULLIF(sort_ebook_plays.completed_play_count, 0)", []string{joinSQL}, []any{qb.userID, qb.profileID}
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) lastAirDateSortPlan() (string, []string) {
|
||||
expr := fmt.Sprintf("%s.last_air_date_at", qb.alias)
|
||||
return expr, nil // denormalized column — no JOIN required
|
||||
|
||||
@@ -390,6 +390,132 @@ func TestBuildSortClause_PersonalizedComputedSorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSortClause_EbookPersonalizedComputedSortsUseReaderProgress(t *testing.T) {
|
||||
builder := NewQueryBuilder("mi").WithMediaScope("ebook").WithUserScope(42, "profile-1")
|
||||
tests := []struct {
|
||||
field string
|
||||
expectJoin []string
|
||||
expectExpr []string
|
||||
}{
|
||||
{
|
||||
field: "progress",
|
||||
expectJoin: []string{"FROM ebook_reader_progress erp", "erp.progress > 0", "erp.progress < 0.9"},
|
||||
expectExpr: []string{"sort_progress.progress_ratio"},
|
||||
},
|
||||
{
|
||||
field: "date_viewed",
|
||||
expectJoin: []string{"FROM ebook_reader_progress erp", "erp.progress >= 0.9"},
|
||||
expectExpr: []string{"sort_ebook_viewed.viewed_at"},
|
||||
},
|
||||
{
|
||||
field: "plays",
|
||||
expectJoin: []string{"FROM ebook_reader_progress erp", "erp.progress >= 0.9", "completed_play_count"},
|
||||
expectExpr: []string{"sort_ebook_plays.completed_play_count"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
plan, err := builder.BuildSortPlan(QuerySort{Field: tt.field, Order: "desc"})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: BuildSortPlan returned error: %v", tt.field, err)
|
||||
}
|
||||
if len(plan.Args) != 2 || plan.Args[0] != 42 || plan.Args[1] != "profile-1" {
|
||||
t.Fatalf("%s: expected user scope args, got %v", tt.field, plan.Args)
|
||||
}
|
||||
joined := strings.Join(plan.Joins, "\n")
|
||||
for _, fragment := range tt.expectJoin {
|
||||
if !strings.Contains(joined, fragment) {
|
||||
t.Fatalf("%s: expected joins to contain %q, got %q", tt.field, fragment, joined)
|
||||
}
|
||||
}
|
||||
for _, fragment := range tt.expectExpr {
|
||||
if !strings.Contains(plan.OrderBy, fragment) {
|
||||
t.Fatalf("%s: expected order by to contain %q, got %q", tt.field, fragment, plan.OrderBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_EbookUserStateRulesUseReaderProgress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rule QueryRule
|
||||
expectSQL []string
|
||||
unexpectSQL []string
|
||||
}{
|
||||
{
|
||||
name: "watched",
|
||||
rule: QueryRule{Field: "watched", Op: "is", Value: true},
|
||||
expectSQL: []string{
|
||||
"FROM ebook_reader_progress erp",
|
||||
"erp.progress >= 0.9",
|
||||
},
|
||||
unexpectSQL: []string{"FROM user_watch_progress uwp", "FROM user_watch_history uwh"},
|
||||
},
|
||||
{
|
||||
name: "unread",
|
||||
rule: QueryRule{Field: "watched", Op: "is", Value: false},
|
||||
expectSQL: []string{
|
||||
"NOT (EXISTS",
|
||||
"FROM ebook_reader_progress erp",
|
||||
"erp.progress >= 0.9",
|
||||
},
|
||||
unexpectSQL: []string{"FROM user_watch_progress uwp", "FROM user_watch_history uwh"},
|
||||
},
|
||||
{
|
||||
name: "in progress",
|
||||
rule: QueryRule{Field: "in_progress", Op: "is", Value: true},
|
||||
expectSQL: []string{
|
||||
"FROM ebook_reader_progress erp",
|
||||
"erp.progress > 0",
|
||||
"erp.progress < 0.9",
|
||||
},
|
||||
unexpectSQL: []string{"FROM user_watch_progress uwp"},
|
||||
},
|
||||
{
|
||||
name: "not in progress",
|
||||
rule: QueryRule{Field: "in_progress", Op: "is", Value: false},
|
||||
expectSQL: []string{
|
||||
"NOT (EXISTS",
|
||||
"FROM ebook_reader_progress erp",
|
||||
"erp.progress > 0",
|
||||
"erp.progress < 0.9",
|
||||
},
|
||||
unexpectSQL: []string{"FROM user_watch_progress uwp"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
clause, args, err := NewQueryBuilder("mi").
|
||||
WithMediaScope("ebook").
|
||||
WithUserScope(42, "profile-1").
|
||||
Build(QueryDefinition{
|
||||
MediaScope: "ebook",
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{{
|
||||
Match: "all",
|
||||
Rules: []QueryRule{tt.rule},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Build returned error: %v", tt.name, err)
|
||||
}
|
||||
if len(args) != 2 || args[0] != 42 || args[1] != "profile-1" {
|
||||
t.Fatalf("%s: expected user scope args, got %v", tt.name, args)
|
||||
}
|
||||
for _, fragment := range tt.expectSQL {
|
||||
if !strings.Contains(clause, fragment) {
|
||||
t.Fatalf("%s: expected clause to contain %q, got %q", tt.name, fragment, clause)
|
||||
}
|
||||
}
|
||||
for _, fragment := range tt.unexpectSQL {
|
||||
if strings.Contains(clause, fragment) {
|
||||
t.Fatalf("%s: did not expect clause to contain %q, got %q", tt.name, fragment, clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_ResolutionNormalizes4KAndScopesMediaFiles(t *testing.T) {
|
||||
clause, args, err := NewQueryBuilder("mi").
|
||||
WithLibraryScope([]int{3}).
|
||||
@@ -531,6 +657,26 @@ func TestBuild_SeriesClauseJoinsAudiobookSeries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_SeriesClauseJoinsEbookSeriesForEbookScope(t *testing.T) {
|
||||
clause, args, err := NewQueryBuilder("mi").
|
||||
WithMediaScope("ebook").
|
||||
Build(QueryDefinition{
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{
|
||||
{Match: "all", Rules: []QueryRule{{Field: "series", Op: "is", Value: "Wayfarers"}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build returned error: %v", err)
|
||||
}
|
||||
if len(args) != 1 || args[0] != "Wayfarers" {
|
||||
t.Fatalf("expected series arg, got %v", args)
|
||||
}
|
||||
if !strings.Contains(clause, "FROM ebook_series s") {
|
||||
t.Fatalf("expected ebook_series join in series clause, got %q", clause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSortPlan_AuthorJoinsLateralOnPersonKindAuthor(t *testing.T) {
|
||||
plan, err := NewQueryBuilder("mi").BuildSortPlan(QuerySort{Field: "author", Order: "asc"})
|
||||
if err != nil {
|
||||
@@ -576,6 +722,18 @@ func TestBuildSortPlan_SeriesOrdersByNameThenIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSortPlan_SeriesUsesEbookSeriesForEbookScope(t *testing.T) {
|
||||
plan, err := NewQueryBuilder("mi").
|
||||
WithMediaScope("ebook").
|
||||
BuildSortPlan(QuerySort{Field: "series", Order: "asc"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSortPlan(series) returned error: %v", err)
|
||||
}
|
||||
if len(plan.Joins) != 1 || !strings.Contains(plan.Joins[0], "ebook_series sort_series") {
|
||||
t.Fatalf("expected LEFT JOIN ebook_series, got %v", plan.Joins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_SeriesClauseNegationWrapsInNOT(t *testing.T) {
|
||||
clause, _, err := NewQueryBuilder("mi").Build(QueryDefinition{
|
||||
Match: "all",
|
||||
@@ -713,3 +871,18 @@ func TestQueryBuilder_LastWatched_RequiresCTE(t *testing.T) {
|
||||
t.Fatalf("after lastWatchedExpr, builder should require CTE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHistoryCTESQLIncludesCompletedEbookReaderProgress(t *testing.T) {
|
||||
sql := UserHistoryCTESQL(1)
|
||||
expectedFragments := []string{
|
||||
"FROM ebook_reader_progress erp",
|
||||
"erp.progress >= 0.9",
|
||||
"erp.content_id",
|
||||
"erp.updated_at",
|
||||
}
|
||||
for _, fragment := range expectedFragments {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("expected CTE to contain %q, got:\n%s", fragment, sql)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +185,10 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
|
||||
if normalized.MediaScope != "" &&
|
||||
normalized.MediaScope != "movie" &&
|
||||
normalized.MediaScope != "series" &&
|
||||
normalized.MediaScope != "episode" {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', or 'episode'")
|
||||
normalized.MediaScope != "episode" &&
|
||||
normalized.MediaScope != "audiobook" &&
|
||||
normalized.MediaScope != "ebook" {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'ebook'")
|
||||
}
|
||||
|
||||
if normalized.Match != "all" && normalized.Match != "any" {
|
||||
@@ -205,6 +207,9 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
|
||||
if def.personalized && !allowPersonalizedFields {
|
||||
return fmt.Errorf("groups[%d].rules[%d].field %q requires profile scope", i, j, rule.Field)
|
||||
}
|
||||
if normalized.MediaScope == "ebook" && rule.Field == "narrator" {
|
||||
return fmt.Errorf("groups[%d].rules[%d].field %q is not supported for ebook media_scope", i, j, rule.Field)
|
||||
}
|
||||
if !def.validOps[rule.Op] {
|
||||
return fmt.Errorf("groups[%d].rules[%d].op %q is not supported for field %q", i, j, rule.Op, rule.Field)
|
||||
}
|
||||
@@ -219,6 +224,9 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
|
||||
if def.personalized && !allowPersonalizedSorts {
|
||||
return fmt.Errorf("sort.field %q requires profile scope", normalized.Sort.Field)
|
||||
}
|
||||
if normalized.MediaScope == "ebook" && normalized.Sort.Field == "narrator" {
|
||||
return fmt.Errorf("sort.field %q is not supported for ebook media_scope", normalized.Sort.Field)
|
||||
}
|
||||
}
|
||||
if normalized.Sort.Order != "" && normalized.Sort.Order != "asc" && normalized.Sort.Order != "desc" {
|
||||
return fmt.Errorf("sort.order must be 'asc' or 'desc'")
|
||||
|
||||
@@ -60,6 +60,57 @@ 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 TestValidate_EbookMediaScope(t *testing.T) {
|
||||
qd := QueryDefinition{
|
||||
MediaScope: "ebook",
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{},
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
}
|
||||
if err := qd.Validate(); err != nil {
|
||||
t.Fatalf("expected ebook media scope to be valid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_EbookMediaScopeRejectsNarratorRule(t *testing.T) {
|
||||
qd := QueryDefinition{
|
||||
MediaScope: "ebook",
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{{
|
||||
Match: "all",
|
||||
Rules: []QueryRule{{Field: "narrator", Op: "is", Value: "Narrator"}},
|
||||
}},
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
}
|
||||
if err := qd.Validate(); err == nil {
|
||||
t.Fatal("expected ebook narrator rule to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_EbookMediaScopeRejectsNarratorSort(t *testing.T) {
|
||||
qd := QueryDefinition{
|
||||
MediaScope: "ebook",
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{},
|
||||
Sort: QuerySort{Field: "narrator", Order: "asc"},
|
||||
}
|
||||
if err := qd.Validate(); err == nil {
|
||||
t.Fatal("expected ebook narrator sort to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
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.ResolveHWAccel(opts.HWAccel)
|
||||
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
|
||||
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.ResolveHWAccel(s.hwAccel)
|
||||
s.resolvedHWAccel = playback.ResolveHWAccelWithFFmpeg(s.hwAccel, s.ffmpegPath)
|
||||
s.resolvedHWDevice = s.hwDevice
|
||||
if s.resolvedHWDevice == "" && (s.resolvedHWAccel == "qsv" || s.resolvedHWAccel == "vaapi") {
|
||||
s.resolvedHWDevice = playback.PickRenderDevice("")
|
||||
|
||||
@@ -198,13 +198,12 @@ type authConfigRaw struct {
|
||||
RefreshTokenExpiry string `yaml:"refresh_token_expiry"`
|
||||
}
|
||||
|
||||
// AudiobookshelfCompatConfig holds the dedicated ABS-compat listener
|
||||
// settings. The listener binds its own port (default :13378, ABS's
|
||||
// AudiobookshelfCompatConfig holds the dedicated ABS-compat listener setting.
|
||||
// The listener binds its own port (: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"`
|
||||
PublicURL string `yaml:"public_url"`
|
||||
Listen string `yaml:"-"`
|
||||
}
|
||||
|
||||
// JellyfinCompatConfig holds compatibility proxy settings.
|
||||
|
||||
@@ -356,15 +356,13 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
|
||||
|
||||
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
|
||||
// listener off unless the audiobooks feature flag is explicitly enabled.
|
||||
audiobooksEnabled, err := boolOr(m, "audiobooks.enabled", false)
|
||||
// listener off unless ABS compatibility is explicitly enabled.
|
||||
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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")
|
||||
if absCompatEnabled {
|
||||
cfg.AudiobookshelfCompat.Listen = ":13378"
|
||||
}
|
||||
|
||||
// JellyfinCompat
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDBAudiobooksFeatureFlagGatesCompatListener(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{"audiobooks.enabled": "false"})
|
||||
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromDB() returned error: %v", err)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func TestLoadFromDBAudiobooksFeatureFlagGatesCompatListener(t *testing.T) {
|
||||
t.Fatalf("disabled audiobooks listener = %q, want empty", cfg.AudiobookshelfCompat.Listen)
|
||||
}
|
||||
|
||||
cfg, err = LoadFromDB(map[string]string{"audiobooks.enabled": "true"})
|
||||
cfg, err = LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "true"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromDB() returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
package ebooks
|
||||
|
||||
// Enricher periodically enriches ebook media_items that are missing metadata
|
||||
// by querying the configured metadata-provider chain for each item's library
|
||||
// folder.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/metadata"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
ebookMetadataImageProviderID = "ebook-metadata"
|
||||
|
||||
defaultEnrichBatchSize = 50
|
||||
defaultEnrichWorkers = 4
|
||||
)
|
||||
|
||||
func ebookContentType() string {
|
||||
return "ebook"
|
||||
}
|
||||
|
||||
func ebookEnrichWorkers() int {
|
||||
n := defaultEnrichWorkers
|
||||
if v := os.Getenv("SILO_EBOOK_ENRICH_WORKERS"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
if n > defaultEnrichBatchSize {
|
||||
n = defaultEnrichBatchSize
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type enrichmentItemRow struct {
|
||||
ContentID string
|
||||
Title string
|
||||
Year int
|
||||
FolderID int
|
||||
Language string
|
||||
Author string
|
||||
ProviderIDs map[string]string
|
||||
}
|
||||
|
||||
// Enricher drives the ebook metadata enrichment sweep.
|
||||
type Enricher struct {
|
||||
pool *pgxpool.Pool
|
||||
chainRepo *metadata.ChainRepository
|
||||
resolver *metadata.PluginResolverAdapter
|
||||
itemRepo *catalog.ItemRepository
|
||||
personRepo *catalog.PersonRepository
|
||||
providerIDs *catalog.ProviderIDRepository
|
||||
imageCacher metadata.ImageCacher
|
||||
batchSize int
|
||||
workers int
|
||||
}
|
||||
|
||||
func NewEnricher(
|
||||
pool *pgxpool.Pool,
|
||||
chainRepo *metadata.ChainRepository,
|
||||
resolver *metadata.PluginResolverAdapter,
|
||||
itemRepo *catalog.ItemRepository,
|
||||
personRepo *catalog.PersonRepository,
|
||||
providerIDs *catalog.ProviderIDRepository,
|
||||
) *Enricher {
|
||||
return &Enricher{
|
||||
pool: pool,
|
||||
chainRepo: chainRepo,
|
||||
resolver: resolver,
|
||||
itemRepo: itemRepo,
|
||||
personRepo: personRepo,
|
||||
providerIDs: providerIDs,
|
||||
batchSize: defaultEnrichBatchSize,
|
||||
workers: ebookEnrichWorkers(),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Enricher) SetImageCacher(cacher metadata.ImageCacher) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.imageCacher = cacher
|
||||
}
|
||||
|
||||
func (e *Enricher) Run(ctx context.Context) (int, error) {
|
||||
if e == nil || e.pool == nil || e.chainRepo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
items, err := e.claimBatch(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ebook enrichment: claim batch: %w", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
slog.Info("ebook enrichment: sweep started",
|
||||
"count", len(items),
|
||||
"workers", e.workers,
|
||||
)
|
||||
|
||||
enriched := e.runBatch(ctx, items, e.enrichItem)
|
||||
|
||||
slog.Info("ebook enrichment: sweep complete",
|
||||
"attempted", len(items),
|
||||
"enriched", enriched,
|
||||
)
|
||||
return enriched, nil
|
||||
}
|
||||
|
||||
func (e *Enricher) runBatch(ctx context.Context, items []enrichmentItemRow, enrichFn func(context.Context, enrichmentItemRow) error) int {
|
||||
workers := e.workers
|
||||
if workers <= 0 {
|
||||
workers = 1
|
||||
}
|
||||
if workers > len(items) {
|
||||
workers = len(items)
|
||||
}
|
||||
|
||||
ch := make(chan enrichmentItemRow, workers)
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
enriched int64
|
||||
)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for item := range ch {
|
||||
if ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
if err := enrichFn(ctx, item); err != nil {
|
||||
slog.Warn("ebook enrichment: item failed",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
atomic.AddInt64(&enriched, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, item := range items {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
ch <- item
|
||||
}
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
return int(enriched)
|
||||
}
|
||||
|
||||
func (e *Enricher) claimBatch(ctx context.Context) ([]enrichmentItemRow, error) {
|
||||
rows, err := e.pool.Query(ctx, `
|
||||
SELECT
|
||||
mi.content_id,
|
||||
mi.title,
|
||||
mi.year,
|
||||
COALESCE(mil.media_folder_id, 0) AS folder_id,
|
||||
COALESCE(mf.metadata_language, 'en') AS language,
|
||||
COALESCE(
|
||||
(SELECT p.name
|
||||
FROM item_people ip
|
||||
JOIN people p ON p.id = ip.person_id
|
||||
WHERE ip.content_id = mi.content_id
|
||||
AND ip.kind = 7
|
||||
ORDER BY ip.sort_order, ip.id
|
||||
LIMIT 1),
|
||||
''
|
||||
) AS author
|
||||
FROM media_items mi
|
||||
LEFT JOIN media_item_libraries mil ON mil.content_id = mi.content_id
|
||||
LEFT JOIN media_folders mf ON mf.id = mil.media_folder_id
|
||||
WHERE mi.type = 'ebook'
|
||||
AND (mi.poster_path IS NULL OR mi.poster_path = '')
|
||||
AND mi.last_refreshed IS NULL
|
||||
ORDER BY mi.created_at ASC
|
||||
LIMIT $1
|
||||
`, e.batchSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying unenriched ebooks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []enrichmentItemRow
|
||||
seen := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var item enrichmentItemRow
|
||||
if err := rows.Scan(
|
||||
&item.ContentID,
|
||||
&item.Title,
|
||||
&item.Year,
|
||||
&item.FolderID,
|
||||
&item.Language,
|
||||
&item.Author,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning ebook enrichment row: %w", err)
|
||||
}
|
||||
if _, dup := seen[item.ContentID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[item.ContentID] = struct{}{}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating ebook enrichment rows: %w", err)
|
||||
}
|
||||
|
||||
if e.providerIDs != nil {
|
||||
for i := range items {
|
||||
pids, err := e.providerIDs.GetByContentID(ctx, items[i].ContentID)
|
||||
if err == nil {
|
||||
items[i].ProviderIDs = providerIDMapFromRows(pids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error {
|
||||
if item.FolderID == 0 {
|
||||
slog.Debug("ebook enrichment: item has no library folder, skipping",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
)
|
||||
return e.stampLastRefreshed(ctx, item.ContentID)
|
||||
}
|
||||
|
||||
providers, err := metadata.ResolveChain(ctx, item.FolderID, ebookContentType(), e.chainRepo, e.resolver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving ebook chain for folder %d: %w", item.FolderID, err)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
slog.Debug("ebook enrichment: no providers in chain",
|
||||
"content_id", item.ContentID,
|
||||
"folder_id", item.FolderID,
|
||||
)
|
||||
return e.stampLastRefreshed(ctx, item.ContentID)
|
||||
}
|
||||
|
||||
searchQuery, accumulatedIDs := buildEbookSearchQuery(item)
|
||||
|
||||
for _, p := range providers {
|
||||
sp, ok := p.(metadata.SearchProvider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
results, searchErr := sp.Search(ctx, searchQuery)
|
||||
if searchErr != nil {
|
||||
slog.Warn("ebook enrichment: search error",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"error", searchErr,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if len(results) == 0 {
|
||||
continue
|
||||
}
|
||||
for k, v := range results[0].ProviderIDs {
|
||||
if v != "" {
|
||||
if _, exists := accumulatedIDs[k]; !exists {
|
||||
accumulatedIDs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Debug("ebook enrichment: search result",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"matched_ids", accumulatedIDs,
|
||||
)
|
||||
}
|
||||
|
||||
accumulator := &metadata.MetadataResult{
|
||||
ProviderIDs: accumulatedIDs,
|
||||
}
|
||||
|
||||
for _, p := range providers {
|
||||
mp, ok := p.(metadata.MetadataProvider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result, getErr := mp.GetMetadata(ctx, buildEbookMetadataRequest(accumulatedIDs, item.Language))
|
||||
if getErr != nil {
|
||||
slog.Warn("ebook enrichment: GetMetadata error",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"error", getErr,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if result == nil || !result.HasMetadata {
|
||||
continue
|
||||
}
|
||||
mergeEnrichmentProviderIDs(accumulator, result)
|
||||
accumulatedIDs = accumulator.ProviderIDs
|
||||
metadata.MergeMetadata(result, accumulator, nil, metadata.MergeFillEmpty)
|
||||
|
||||
slog.Debug("ebook enrichment: metadata received",
|
||||
"provider", p.Slug(),
|
||||
"content_id", item.ContentID,
|
||||
"has_poster", result.PosterPath != "",
|
||||
"has_overview", result.Overview != "",
|
||||
)
|
||||
}
|
||||
|
||||
if !accumulator.HasMetadata && accumulator.PosterPath == "" && accumulator.Overview == "" {
|
||||
slog.Info("ebook enrichment: no metadata found",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
)
|
||||
return e.stampLastRefreshed(ctx, item.ContentID)
|
||||
}
|
||||
|
||||
e.cacheRemotePoster(ctx, item.ContentID, accumulator)
|
||||
|
||||
if err := e.persist(ctx, item.ContentID, accumulatedIDs, accumulator); err != nil {
|
||||
return fmt.Errorf("persisting enrichment for %s: %w", item.ContentID, err)
|
||||
}
|
||||
|
||||
slog.Info("ebook enrichment: enriched",
|
||||
"content_id", item.ContentID,
|
||||
"title", item.Title,
|
||||
"poster", accumulator.PosterPath != "",
|
||||
"overview", accumulator.Overview != "",
|
||||
"people", len(filterEbookPeople(accumulator.People)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, result *metadata.MetadataResult) {
|
||||
if e == nil || result == nil || result.PosterPath == "" {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(result.PosterPath, "http://") && !strings.HasPrefix(result.PosterPath, "https://") {
|
||||
return
|
||||
}
|
||||
if isNilImageCacher(e.imageCacher) {
|
||||
return
|
||||
}
|
||||
|
||||
cached, err := e.imageCacher.CacheImage(ctx, metadata.CacheImageRequest{
|
||||
SourceURL: result.PosterPath,
|
||||
ProviderID: ebookMetadataImageProviderID,
|
||||
ContentType: "ebooks",
|
||||
ContentID: contentID,
|
||||
ImageType: metadata.ImagePoster,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("ebook enrichment: poster cache failed, keeping provider URL",
|
||||
"content_id", contentID,
|
||||
"url", result.PosterPath,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if cached == nil {
|
||||
slog.Warn("ebook enrichment: poster cache returned no result, keeping provider URL",
|
||||
"content_id", contentID,
|
||||
"url", result.PosterPath,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if storedPath := cachedOriginalImagePath(cached.BasePath, cached.Ext); storedPath != "" {
|
||||
result.PosterPath = storedPath
|
||||
}
|
||||
if cached.Thumbhash != "" {
|
||||
result.PosterThumbhash = cached.Thumbhash
|
||||
}
|
||||
}
|
||||
|
||||
func isNilImageCacher(cacher metadata.ImageCacher) bool {
|
||||
if cacher == nil {
|
||||
return true
|
||||
}
|
||||
value := reflect.ValueOf(cacher)
|
||||
switch value.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
|
||||
return value.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func cachedOriginalImagePath(basePath, ext string) string {
|
||||
if basePath == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(basePath, "/original.") {
|
||||
return basePath
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
return strings.TrimRight(basePath, "/") + "/original" + ext
|
||||
}
|
||||
|
||||
func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs map[string]string, result *metadata.MetadataResult) error {
|
||||
upd := &catalog.MetadataUpdate{}
|
||||
|
||||
if result.PosterPath != "" {
|
||||
upd.PosterPath = &result.PosterPath
|
||||
}
|
||||
if result.PosterThumbhash != "" {
|
||||
upd.PosterThumbhash = &result.PosterThumbhash
|
||||
}
|
||||
if result.BackdropPath != "" {
|
||||
upd.BackdropPath = &result.BackdropPath
|
||||
}
|
||||
if result.BackdropThumbhash != "" {
|
||||
upd.BackdropThumbhash = &result.BackdropThumbhash
|
||||
}
|
||||
if result.LogoPath != "" {
|
||||
upd.LogoPath = &result.LogoPath
|
||||
}
|
||||
if result.Overview != "" {
|
||||
upd.Overview = &result.Overview
|
||||
}
|
||||
if result.Tagline != "" {
|
||||
upd.Tagline = &result.Tagline
|
||||
}
|
||||
if result.ReleaseDate != "" {
|
||||
upd.ReleaseDate = &result.ReleaseDate
|
||||
}
|
||||
if len(result.Genres) > 0 {
|
||||
genres := append([]string(nil), result.Genres...)
|
||||
upd.Genres = &genres
|
||||
}
|
||||
if len(result.Studios) > 0 {
|
||||
studios := append([]string(nil), result.Studios...)
|
||||
upd.Studios = &studios
|
||||
}
|
||||
if result.ContentRating != "" {
|
||||
upd.ContentRating = &result.ContentRating
|
||||
}
|
||||
if result.Runtime > 0 {
|
||||
upd.Runtime = &result.Runtime
|
||||
}
|
||||
if result.Year > 0 {
|
||||
upd.Year = &result.Year
|
||||
}
|
||||
|
||||
providerIDs = filterEbookProviderIDs(providerIDs)
|
||||
if e.providerIDs != nil && len(providerIDs) > 0 {
|
||||
if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil {
|
||||
slog.Warn("ebook enrichment: failed to persist provider IDs",
|
||||
"content_id", contentID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.updateMetadataAndTimestamps(ctx, contentID, upd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authors := filterEbookPeople(result.People)
|
||||
if len(authors) > 0 && e.personRepo != nil && e.itemRepo != nil {
|
||||
if err := e.persistPeople(ctx, contentID, authors); err != nil {
|
||||
slog.Warn("ebook enrichment: failed to persist people",
|
||||
"content_id", contentID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Enricher) updateMetadataAndTimestamps(ctx context.Context, contentID string, upd *catalog.MetadataUpdate) error {
|
||||
if e.itemRepo == nil {
|
||||
return nil
|
||||
}
|
||||
if err := e.itemRepo.UpdateMetadata(ctx, contentID, upd); err != nil {
|
||||
return fmt.Errorf("UpdateMetadata: %w", err)
|
||||
}
|
||||
return e.stampLastRefreshed(ctx, contentID)
|
||||
}
|
||||
|
||||
func (e *Enricher) stampLastRefreshed(ctx context.Context, contentID string) error {
|
||||
if e.pool == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err := e.pool.Exec(ctx, `
|
||||
UPDATE media_items
|
||||
SET last_refreshed = $1,
|
||||
matched_at = COALESCE(matched_at, $1),
|
||||
status = CASE WHEN status = 'pending' THEN 'matched' ELSE status END
|
||||
WHERE content_id = $2
|
||||
`, now, contentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Enricher) persistPeople(ctx context.Context, contentID string, people []models.ItemPerson) error {
|
||||
people = filterEbookPeople(people)
|
||||
if len(people) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
persons := make([]models.Person, len(people))
|
||||
for i := range people {
|
||||
persons[i] = people[i].Person
|
||||
}
|
||||
|
||||
personIDs, err := e.personRepo.BatchFindOrCreate(ctx, persons)
|
||||
if err != nil {
|
||||
return fmt.Errorf("BatchFindOrCreate people: %w", err)
|
||||
}
|
||||
|
||||
linked := make([]models.ItemPerson, 0, len(people))
|
||||
for i := range people {
|
||||
if i >= len(personIDs) || personIDs[i] == 0 {
|
||||
continue
|
||||
}
|
||||
ip := people[i]
|
||||
ip.Person.ID = personIDs[i]
|
||||
linked = append(linked, ip)
|
||||
}
|
||||
|
||||
if len(linked) == 0 {
|
||||
return nil
|
||||
}
|
||||
return e.itemRepo.ReplacePeople(ctx, contentID, linked)
|
||||
}
|
||||
|
||||
func filterEbookPeople(people []models.ItemPerson) []models.ItemPerson {
|
||||
authors := make([]models.ItemPerson, 0, len(people))
|
||||
for _, person := range people {
|
||||
if person.Kind != models.PersonKindAuthor {
|
||||
continue
|
||||
}
|
||||
person.SortOrder = len(authors)
|
||||
authors = append(authors, person)
|
||||
}
|
||||
return authors
|
||||
}
|
||||
|
||||
func buildEbookSearchQuery(item enrichmentItemRow) (metadata.SearchQuery, map[string]string) {
|
||||
accumulatedIDs := filterEbookProviderIDs(item.ProviderIDs)
|
||||
if accumulatedIDs == nil {
|
||||
accumulatedIDs = map[string]string{}
|
||||
}
|
||||
return metadata.SearchQuery{
|
||||
Title: item.Title,
|
||||
Year: item.Year,
|
||||
ContentType: ebookContentType(),
|
||||
ProviderIDs: accumulatedIDs,
|
||||
Language: item.Language,
|
||||
}, accumulatedIDs
|
||||
}
|
||||
|
||||
func buildEbookMetadataRequest(providerIDs map[string]string, language string) metadata.MetadataRequest {
|
||||
return metadata.MetadataRequest{
|
||||
ProviderIDs: filterEbookProviderIDs(providerIDs),
|
||||
ContentType: ebookContentType(),
|
||||
Language: language,
|
||||
}
|
||||
}
|
||||
|
||||
func mergeEnrichmentProviderIDs(dst *metadata.MetadataResult, src *metadata.MetadataResult) {
|
||||
if src == nil || len(src.ProviderIDs) == 0 {
|
||||
return
|
||||
}
|
||||
if dst.ProviderIDs == nil {
|
||||
dst.ProviderIDs = make(map[string]string, len(src.ProviderIDs))
|
||||
}
|
||||
for k, v := range filterEbookProviderIDs(src.ProviderIDs) {
|
||||
if v != "" {
|
||||
if _, exists := dst.ProviderIDs[k]; !exists {
|
||||
dst.ProviderIDs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterEbookProviderIDs(providerIDs map[string]string) map[string]string {
|
||||
if len(providerIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make(map[string]string, len(providerIDs))
|
||||
for provider, providerID := range providerIDs {
|
||||
provider = strings.TrimSpace(provider)
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if provider == "" || providerID == "" {
|
||||
continue
|
||||
}
|
||||
provider = strings.ToLower(provider)
|
||||
if isEbookASINProvider(provider) {
|
||||
continue
|
||||
}
|
||||
filtered[provider] = providerID
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isEbookASINProvider(provider string) bool {
|
||||
normalized := strings.ReplaceAll(strings.ReplaceAll(provider, "_", ""), "-", "")
|
||||
return normalized == "asin" || normalized == "audibleasin"
|
||||
}
|
||||
|
||||
func providerIDMapFromRows(rows []*models.MediaItemProviderID) map[string]string {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
if r != nil {
|
||||
for provider, providerID := range filterEbookProviderIDs(map[string]string{
|
||||
r.Provider: r.ProviderID,
|
||||
}) {
|
||||
m[provider] = providerID
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package ebooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/metadata"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestEbookContentType(t *testing.T) {
|
||||
if got := ebookContentType(); got != "ebook" {
|
||||
t.Fatalf("ebookContentType() = %q, want ebook", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEbookPeopleKeepsAuthorsOnly(t *testing.T) {
|
||||
people := []models.ItemPerson{
|
||||
{Person: models.Person{Name: "Author One"}, Kind: models.PersonKindAuthor, SortOrder: 7},
|
||||
{Person: models.Person{Name: "Narrator One"}, Kind: models.PersonKindNarrator, SortOrder: 8},
|
||||
{Person: models.Person{Name: "Writer One"}, Kind: models.PersonKindWriter, SortOrder: 9},
|
||||
{Person: models.Person{Name: "Author Two"}, Kind: models.PersonKindAuthor, SortOrder: 10},
|
||||
}
|
||||
|
||||
got := filterEbookPeople(people)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("filtered people len = %d, want 2: %+v", len(got), got)
|
||||
}
|
||||
for i, p := range got {
|
||||
if p.Kind != models.PersonKindAuthor {
|
||||
t.Fatalf("filtered[%d].Kind = %v, want author", i, p.Kind)
|
||||
}
|
||||
if p.SortOrder != i {
|
||||
t.Fatalf("filtered[%d].SortOrder = %d, want %d", i, p.SortOrder, i)
|
||||
}
|
||||
}
|
||||
if got[0].Person.Name != "Author One" || got[1].Person.Name != "Author Two" {
|
||||
t.Fatalf("filtered author order = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookEnrichWorkersFromEnv(t *testing.T) {
|
||||
t.Setenv("SILO_EBOOK_ENRICH_WORKERS", "12")
|
||||
if got := ebookEnrichWorkers(); got != 12 {
|
||||
t.Fatalf("ebookEnrichWorkers() = %d, want 12", got)
|
||||
}
|
||||
|
||||
t.Setenv("SILO_EBOOK_ENRICH_WORKERS", "0")
|
||||
if got := ebookEnrichWorkers(); got != defaultEnrichWorkers {
|
||||
t.Fatalf("ebookEnrichWorkers() with zero = %d, want default %d", got, defaultEnrichWorkers)
|
||||
}
|
||||
|
||||
t.Setenv("SILO_EBOOK_ENRICH_WORKERS", "999")
|
||||
if got := ebookEnrichWorkers(); got != defaultEnrichBatchSize {
|
||||
t.Fatalf("ebookEnrichWorkers() capped = %d, want %d", got, defaultEnrichBatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnricherRunFansOut(t *testing.T) {
|
||||
const wantWorkers = 4
|
||||
const itemCount = 16
|
||||
|
||||
items := make([]enrichmentItemRow, itemCount)
|
||||
for i := range items {
|
||||
items[i] = enrichmentItemRow{ContentID: "test", Title: "t"}
|
||||
}
|
||||
|
||||
var inFlight int32
|
||||
var maxInFlight int32
|
||||
var signaled int32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(wantWorkers)
|
||||
gate := make(chan struct{})
|
||||
|
||||
enrich := func(ctx context.Context, item enrichmentItemRow) error {
|
||||
cur := atomic.AddInt32(&inFlight, 1)
|
||||
defer atomic.AddInt32(&inFlight, -1)
|
||||
for {
|
||||
prev := atomic.LoadInt32(&maxInFlight)
|
||||
if cur <= prev || atomic.CompareAndSwapInt32(&maxInFlight, prev, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if atomic.AddInt32(&signaled, 1) <= wantWorkers {
|
||||
wg.Done()
|
||||
}
|
||||
select {
|
||||
case <-gate:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(gate)
|
||||
}()
|
||||
|
||||
e := &Enricher{workers: wantWorkers, batchSize: itemCount}
|
||||
e.runBatch(context.Background(), items, enrich)
|
||||
|
||||
if got := atomic.LoadInt32(&maxInFlight); got < wantWorkers {
|
||||
t.Errorf("max in-flight = %d, want >= %d", got, wantWorkers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
|
||||
cacher := &fakeEbookImageCacher{}
|
||||
e := &Enricher{imageCacher: cacher}
|
||||
result := &metadata.MetadataResult{
|
||||
PosterPath: "https://example.test/book.jpg",
|
||||
}
|
||||
|
||||
e.cacheRemotePoster(context.Background(), "content-1", result)
|
||||
|
||||
if cacher.calls != 1 {
|
||||
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
|
||||
}
|
||||
if cacher.req.ProviderID != ebookMetadataImageProviderID {
|
||||
t.Fatalf("ProviderID = %q, want %q", cacher.req.ProviderID, ebookMetadataImageProviderID)
|
||||
}
|
||||
if cacher.req.ContentType != "ebooks" || cacher.req.ContentID != "content-1" {
|
||||
t.Fatalf("cache target = %q/%q", cacher.req.ContentType, cacher.req.ContentID)
|
||||
}
|
||||
if result.PosterPath != "ebook-metadata/ebooks/content-1/poster/original.webp" {
|
||||
t.Fatalf("PosterPath = %q", result.PosterPath)
|
||||
}
|
||||
if result.PosterThumbhash != "thumb" {
|
||||
t.Fatalf("PosterThumbhash = %q", result.PosterThumbhash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRemotePosterSkipsNilCacher(t *testing.T) {
|
||||
e := &Enricher{}
|
||||
result := &metadata.MetadataResult{
|
||||
PosterPath: "https://example.test/book.jpg",
|
||||
}
|
||||
|
||||
e.cacheRemotePoster(context.Background(), "content-1", result)
|
||||
|
||||
if result.PosterPath != "https://example.test/book.jpg" {
|
||||
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRemotePosterSkipsTypedNilCacher(t *testing.T) {
|
||||
var cacher *fakeEbookImageCacher
|
||||
e := &Enricher{imageCacher: cacher}
|
||||
result := &metadata.MetadataResult{
|
||||
PosterPath: "https://example.test/book.jpg",
|
||||
}
|
||||
|
||||
e.cacheRemotePoster(context.Background(), "content-1", result)
|
||||
|
||||
if result.PosterPath != "https://example.test/book.jpg" {
|
||||
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRemotePosterSkipsAlreadyCachedPath(t *testing.T) {
|
||||
cacher := &fakeEbookImageCacher{}
|
||||
e := &Enricher{imageCacher: cacher}
|
||||
result := &metadata.MetadataResult{
|
||||
PosterPath: "local/ebooks/content-1/poster/original.webp",
|
||||
}
|
||||
|
||||
e.cacheRemotePoster(context.Background(), "content-1", result)
|
||||
|
||||
if cacher.calls != 0 {
|
||||
t.Fatalf("CacheImage calls = %d, want 0", cacher.calls)
|
||||
}
|
||||
if result.PosterPath != "local/ebooks/content-1/poster/original.webp" {
|
||||
t.Fatalf("PosterPath = %q", result.PosterPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRemotePosterPreservesProviderURLOnCacheError(t *testing.T) {
|
||||
cacher := &fakeEbookImageCacher{err: errors.New("cache failed")}
|
||||
e := &Enricher{imageCacher: cacher}
|
||||
result := &metadata.MetadataResult{
|
||||
PosterPath: "https://example.test/book.jpg",
|
||||
}
|
||||
|
||||
e.cacheRemotePoster(context.Background(), "content-1", result)
|
||||
|
||||
if cacher.calls != 1 {
|
||||
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
|
||||
}
|
||||
if result.PosterPath != "https://example.test/book.jpg" {
|
||||
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRemotePosterPreservesProviderURLOnNilCacheResult(t *testing.T) {
|
||||
cacher := &fakeEbookImageCacher{returnNil: true}
|
||||
e := &Enricher{imageCacher: cacher}
|
||||
result := &metadata.MetadataResult{
|
||||
PosterPath: "https://example.test/book.jpg",
|
||||
}
|
||||
|
||||
e.cacheRemotePoster(context.Background(), "content-1", result)
|
||||
|
||||
if cacher.calls != 1 {
|
||||
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
|
||||
}
|
||||
if result.PosterPath != "https://example.test/book.jpg" {
|
||||
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEnrichmentProviderIDsKeepsExistingIDs(t *testing.T) {
|
||||
dst := &metadata.MetadataResult{ProviderIDs: map[string]string{"isbn": "9780306406157"}}
|
||||
src := &metadata.MetadataResult{ProviderIDs: map[string]string{"isbn": "new", "openlibrary": "OL1M", "empty": ""}}
|
||||
|
||||
mergeEnrichmentProviderIDs(dst, src)
|
||||
|
||||
if got := dst.ProviderIDs["isbn"]; got != "9780306406157" {
|
||||
t.Fatalf("isbn = %q, want original", got)
|
||||
}
|
||||
if got := dst.ProviderIDs["openlibrary"]; got != "OL1M" {
|
||||
t.Fatalf("openlibrary = %q, want OL1M", got)
|
||||
}
|
||||
if _, exists := dst.ProviderIDs["empty"]; exists {
|
||||
t.Fatal("empty provider ID should not be merged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEnrichmentProviderIDsDropsAsinIDs(t *testing.T) {
|
||||
dst := &metadata.MetadataResult{ProviderIDs: map[string]string{"isbn": "9780306406157"}}
|
||||
src := &metadata.MetadataResult{
|
||||
ProviderIDs: map[string]string{
|
||||
"ASIN": "B00TEST",
|
||||
"audible_asin": "B00AUDIO",
|
||||
"openlibrary": "OL1M",
|
||||
},
|
||||
}
|
||||
|
||||
mergeEnrichmentProviderIDs(dst, src)
|
||||
|
||||
if _, exists := dst.ProviderIDs["ASIN"]; exists {
|
||||
t.Fatal("ASIN provider ID should not be merged for ebooks")
|
||||
}
|
||||
if _, exists := dst.ProviderIDs["audible_asin"]; exists {
|
||||
t.Fatal("audible_asin provider ID should not be merged for ebooks")
|
||||
}
|
||||
if got := dst.ProviderIDs["openlibrary"]; got != "OL1M" {
|
||||
t.Fatalf("openlibrary = %q, want OL1M", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEbookSearchQueryUsesFilteredScannerISBN(t *testing.T) {
|
||||
item := enrichmentItemRow{
|
||||
Title: "Tagged Ebook",
|
||||
Year: 2024,
|
||||
Language: "en",
|
||||
ProviderIDs: map[string]string{
|
||||
" ISBN ": " 9780306406157 ",
|
||||
"ASIN": "B00TEST",
|
||||
"audible_asin": "B00AUDIO",
|
||||
},
|
||||
}
|
||||
|
||||
query, ids := buildEbookSearchQuery(item)
|
||||
|
||||
if query.Title != "Tagged Ebook" || query.Year != 2024 || query.Language != "en" || query.ContentType != "ebook" {
|
||||
t.Fatalf("query basics = %+v", query)
|
||||
}
|
||||
if got := query.ProviderIDs["isbn"]; got != "9780306406157" {
|
||||
t.Fatalf("query isbn = %q, want scanner ISBN", got)
|
||||
}
|
||||
if _, exists := query.ProviderIDs["ASIN"]; exists {
|
||||
t.Fatal("query should not include ASIN for ebooks")
|
||||
}
|
||||
if got := ids["isbn"]; got != "9780306406157" {
|
||||
t.Fatalf("accumulated isbn = %q, want scanner ISBN", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEbookProviderIDsDropsAsinAliases(t *testing.T) {
|
||||
got := filterEbookProviderIDs(map[string]string{
|
||||
"ASIN": "B00TEST",
|
||||
"audibleASIN": "B00AUDIO",
|
||||
"audible-asin": "B00AUDIO2",
|
||||
"audible_asin": "B00AUDIO3",
|
||||
" ISBN ": " 9780306406157 ",
|
||||
" OpenLibraryID": " OL1M ",
|
||||
})
|
||||
|
||||
if got["asin"] != "" || got["audibleasin"] != "" || got["audible-asin"] != "" || got["audible_asin"] != "" {
|
||||
t.Fatalf("ASIN aliases should be filtered, got %+v", got)
|
||||
}
|
||||
if got["isbn"] != "9780306406157" || got["openlibraryid"] != "OL1M" {
|
||||
t.Fatalf("filtered IDs = %+v, want ISBN and OpenLibraryID only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEbookMetadataRequestCarriesAccumulatedISBN(t *testing.T) {
|
||||
req := buildEbookMetadataRequest(map[string]string{
|
||||
" ISBN ": " 9780306406157 ",
|
||||
"openlibrary": "OL1M",
|
||||
}, "fr")
|
||||
|
||||
if req.ContentType != "ebook" || req.Language != "fr" {
|
||||
t.Fatalf("request basics = %+v", req)
|
||||
}
|
||||
if got := req.ProviderIDs["isbn"]; got != "9780306406157" {
|
||||
t.Fatalf("request isbn = %q, want normalized key/value", got)
|
||||
}
|
||||
if got := req.ProviderIDs["openlibrary"]; got != "OL1M" {
|
||||
t.Fatalf("request openlibrary = %q, want OL1M", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEbookImageCacher struct {
|
||||
calls int
|
||||
req metadata.CacheImageRequest
|
||||
err error
|
||||
returnNil bool
|
||||
}
|
||||
|
||||
func (f *fakeEbookImageCacher) CacheImage(_ context.Context, req metadata.CacheImageRequest) (*metadata.CacheImageResult, error) {
|
||||
f.calls++
|
||||
f.req = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.returnNil {
|
||||
return nil, nil
|
||||
}
|
||||
return &metadata.CacheImageResult{
|
||||
BasePath: req.ProviderID + "/" + req.ContentType + "/" + req.ContentID + "/poster",
|
||||
Thumbhash: "thumb",
|
||||
Ext: ".webp",
|
||||
}, nil
|
||||
}
|
||||
@@ -114,6 +114,22 @@ func (c *Cacher) CacheAudiobookCover(ctx context.Context, data []byte, contentID
|
||||
return res.BasePath, res.Ext, res.Thumbhash, nil
|
||||
}
|
||||
|
||||
// CacheEbookCover stores an embedded ebook cover under
|
||||
// "local/ebooks/{contentID}/poster/..." using the same poster variants as
|
||||
// provider-hosted book artwork.
|
||||
func (c *Cacher) CacheEbookCover(ctx context.Context, data []byte, contentID string) (basePath string, ext string, thumbhash string, err error) {
|
||||
res, err := c.CacheBytes(ctx, data, CacheRequest{
|
||||
ProviderID: "local",
|
||||
ContentType: "ebooks",
|
||||
ContentID: contentID,
|
||||
ImageType: metadata.ImagePoster,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return res.BasePath, res.Ext, res.Thumbhash, nil
|
||||
}
|
||||
|
||||
// CacheBytes performs the same variant generation, thumbhash, and S3 upload as
|
||||
// Cache but starts from raw image bytes already in hand. Used by the
|
||||
// audiobook scanner to push embedded M4B cover art into S3 without round-
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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,6 +142,7 @@ func TestBuildPlaybackSource_SeedsRequestedAudioStreamIndex(t *testing.T) {
|
||||
version,
|
||||
DeviceProfile{},
|
||||
playbackInfoRequest{AudioStreamIndex: compatIntValuePtr(requestedAudioStreamIndex)},
|
||||
true,
|
||||
)
|
||||
|
||||
if source.SelectedAudioStreamIndex == nil {
|
||||
|
||||
@@ -46,6 +46,14 @@ 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 {
|
||||
@@ -61,6 +69,7 @@ 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.
|
||||
@@ -70,7 +79,7 @@ type directContentService struct {
|
||||
seasonRepo seasonListSource
|
||||
episodeRepo episodeListSource
|
||||
detailSvc *catalog.DetailService
|
||||
folderRepo *catalog.FolderRepository
|
||||
folderRepo folderListSource
|
||||
storeProvider userstore.UserStoreProvider
|
||||
accessFilter AccessFilterResolver
|
||||
posterPresigner LibraryPosterPresigner
|
||||
@@ -83,7 +92,7 @@ func newDirectContentService(
|
||||
seasonRepo *catalog.SeasonRepository,
|
||||
episodeRepo *catalog.EpisodeRepository,
|
||||
detailSvc *catalog.DetailService,
|
||||
folderRepo *catalog.FolderRepository,
|
||||
folderRepo folderListSource,
|
||||
storeProvider userstore.UserStoreProvider,
|
||||
accessFilter AccessFilterResolver,
|
||||
) *directContentService {
|
||||
@@ -135,7 +144,12 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
|
||||
|
||||
var folders []*models.MediaFolder
|
||||
var err error
|
||||
if len(filter.AllowedLibraryIDs) > 0 {
|
||||
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
|
||||
}
|
||||
folders, err = s.folderRepo.ListByIDs(ctx, filter.AllowedLibraryIDs)
|
||||
} else {
|
||||
folders, err = s.folderRepo.GetEnabled(ctx)
|
||||
@@ -144,8 +158,16 @@ 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,
|
||||
@@ -265,6 +287,18 @@ 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
|
||||
}
|
||||
@@ -347,6 +381,17 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +590,128 @@ 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,27 +1183,45 @@ 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,
|
||||
@@ -1212,6 +1230,22 @@ 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
|
||||
@@ -1722,10 +1756,20 @@ func (h *ItemsHandler) handleResumeResponse(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
type progressHydratedItem struct {
|
||||
itemType string
|
||||
dto baseItemDTO
|
||||
itemType string
|
||||
contentID string
|
||||
isFavorite bool
|
||||
entry upstreamProgress
|
||||
target *compatEpisodeTarget
|
||||
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)
|
||||
@@ -1733,7 +1777,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
batchSize = 48
|
||||
}
|
||||
|
||||
result := make([]baseItemDTO, 0, query.limit)
|
||||
result := make([]progressHydratedItem, 0, query.limit)
|
||||
offset := query.startIndex
|
||||
for {
|
||||
progressEntries, err := h.userData.ListProgress(ctx, session, status, batchSize, offset)
|
||||
@@ -1752,14 +1796,14 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
if len(result) >= query.limit {
|
||||
break
|
||||
}
|
||||
result = append(result, item.dto)
|
||||
result = append(result, item)
|
||||
}
|
||||
if len(result) >= query.limit || len(progressEntries) < batchSize {
|
||||
break
|
||||
}
|
||||
offset += len(progressEntries)
|
||||
}
|
||||
return result, 0, nil
|
||||
return h.finishProgressPage(ctx, session, result, query, libraryID), 0, nil
|
||||
}
|
||||
|
||||
batchSize := min(max(query.limit*3, 48), 200)
|
||||
@@ -1767,7 +1811,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
batchSize = 48
|
||||
}
|
||||
|
||||
items := make([]baseItemDTO, 0, query.limit)
|
||||
items := make([]progressHydratedItem, 0, query.limit)
|
||||
matchedCount := 0
|
||||
offset := 0
|
||||
|
||||
@@ -1789,7 +1833,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
continue
|
||||
}
|
||||
if matchedCount >= query.startIndex && len(items) < query.limit {
|
||||
items = append(items, item.dto)
|
||||
items = append(items, item)
|
||||
}
|
||||
matchedCount++
|
||||
}
|
||||
@@ -1807,7 +1851,49 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
if query.enableTotalRecordCount {
|
||||
total = matchedCount
|
||||
}
|
||||
return items, total, nil
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Session, entries []upstreamProgress, fields map[string]bool, libraryID *int) ([]progressHydratedItem, error) {
|
||||
@@ -1843,8 +1929,11 @@ 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),
|
||||
dto: dto,
|
||||
itemType: strings.ToLower(item.Type),
|
||||
contentID: entry.MediaItemID,
|
||||
isFavorite: favorites[entry.MediaItemID],
|
||||
entry: entry,
|
||||
dto: dto,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -1853,8 +1942,12 @@ func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Sessio
|
||||
h.applyCompatEpisodeTarget(&dto, target)
|
||||
stubDetailListFields(&dto, fields)
|
||||
result = append(result, progressHydratedItem{
|
||||
itemType: "episode",
|
||||
dto: dto,
|
||||
itemType: "episode",
|
||||
contentID: entry.MediaItemID,
|
||||
isFavorite: favorites[entry.MediaItemID],
|
||||
entry: entry,
|
||||
target: &target,
|
||||
dto: dto,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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"
|
||||
@@ -154,6 +155,22 @@ 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,
|
||||
@@ -300,6 +317,9 @@ 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
|
||||
}
|
||||
@@ -416,8 +436,9 @@ 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)
|
||||
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
||||
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
|
||||
continue
|
||||
}
|
||||
@@ -513,6 +534,7 @@ 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)
|
||||
@@ -531,6 +553,12 @@ 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.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
func TestHydrateProgressItems_DoesNotCallGetItemDetail(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ 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,8 +204,11 @@ 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; standard Jellyfin clients refetch real MediaSources via
|
||||
// /Items/{id}/PlaybackInfo on play, so this stub is never load-bearing.
|
||||
// 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.
|
||||
var (
|
||||
stubDetailPerson = personDTO{ID: "0", Name: ""}
|
||||
stubDetailMediaSource = mediaSourceDTO{ID: "0"}
|
||||
@@ -214,10 +217,11 @@ 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
|
||||
// hydrator, which deliberately skips per-item GetItemDetail fanout; clients
|
||||
// that need real MediaSources refetch them via /Items/{id}/PlaybackInfo on
|
||||
// play.
|
||||
// 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.
|
||||
func stubDetailListFields(dto *baseItemDTO, fields map[string]bool) {
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
@@ -234,7 +238,31 @@ func stubDetailListFields(dto *baseItemDTO, fields map[string]bool) {
|
||||
dto.MediaStreams = []mediaStreamDTO{stubDetailMediaStream}
|
||||
}
|
||||
if fields["mediasources"] && dto.MediaSources == nil {
|
||||
dto.MediaSources = []mediaSourceDTO{stubDetailMediaSource}
|
||||
// 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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,3 +157,59 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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,6 +165,10 @@ 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
|
||||
}
|
||||
@@ -182,6 +186,10 @@ 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
|
||||
@@ -228,6 +236,10 @@ 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
|
||||
@@ -810,6 +822,9 @@ 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")
|
||||
}
|
||||
@@ -1019,8 +1034,9 @@ 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{})
|
||||
source := h.buildPlaybackSource(routeID, playSessionID, version, DeviceProfile{}, playbackInfoRequest{}, allow4KTranscode)
|
||||
sources = append(sources, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -722,6 +722,19 @@ var mediaItemMergeSteps = []mediaItemMergeStep{
|
||||
last_codec_video = CASE WHEN EXCLUDED.updated_at >= user_watch_progress.updated_at THEN EXCLUDED.last_codec_video ELSE user_watch_progress.last_codec_video END,
|
||||
last_edition_key = CASE WHEN EXCLUDED.updated_at >= user_watch_progress.updated_at THEN EXCLUDED.last_edition_key ELSE user_watch_progress.last_edition_key END`},
|
||||
{"delete source progress", `DELETE FROM user_watch_progress WHERE media_item_id = $1`},
|
||||
{"merge ebook reader progress", `
|
||||
INSERT INTO ebook_reader_progress (
|
||||
user_id, profile_id, content_id, file_id, location, progress, updated_at
|
||||
)
|
||||
SELECT user_id, profile_id, $2, file_id, location, progress, updated_at
|
||||
FROM ebook_reader_progress
|
||||
WHERE content_id = $1
|
||||
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE
|
||||
SET file_id = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.file_id ELSE ebook_reader_progress.file_id END,
|
||||
location = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.location ELSE ebook_reader_progress.location END,
|
||||
progress = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.progress ELSE ebook_reader_progress.progress END,
|
||||
updated_at = GREATEST(ebook_reader_progress.updated_at, EXCLUDED.updated_at)`},
|
||||
{"delete source ebook reader progress", `DELETE FROM ebook_reader_progress WHERE content_id = $1`},
|
||||
{"move history", `UPDATE user_watch_history SET media_item_id = $2 WHERE media_item_id = $1`},
|
||||
{"merge hidden history", `
|
||||
INSERT INTO user_history_hidden_items (user_id, profile_id, media_item_id, hidden_before, updated_at)
|
||||
|
||||
@@ -110,6 +110,25 @@ func TestMergeStepPlaceholdersAreBounded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeStepsPreserveEbookReaderProgress(t *testing.T) {
|
||||
var hasMerge bool
|
||||
var hasDelete bool
|
||||
for _, step := range mediaItemMergeSteps {
|
||||
if strings.Contains(step.sql, "ebook_reader_progress") && strings.Contains(step.sql, "INSERT INTO") {
|
||||
hasMerge = true
|
||||
}
|
||||
if strings.Contains(step.sql, "ebook_reader_progress") && strings.Contains(step.sql, "DELETE FROM") {
|
||||
hasDelete = true
|
||||
}
|
||||
}
|
||||
if !hasMerge {
|
||||
t.Fatal("media item merge steps should merge ebook_reader_progress rows")
|
||||
}
|
||||
if !hasDelete {
|
||||
t.Fatal("media item merge steps should delete source ebook_reader_progress rows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBothCandidatesHaveContentDetectsRealUserData(t *testing.T) {
|
||||
withFiles := canonicalCandidate{ContentID: "a", ActiveFiles: 2}
|
||||
withLibraries := canonicalCandidate{ContentID: "b", LibraryCount: 1}
|
||||
|
||||
@@ -70,6 +70,10 @@ 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
|
||||
@@ -3741,18 +3745,9 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
|
||||
return s.hooks.createOrFindSkeleton(ctx, file, folderID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
libraryType, err := s.folderTypeForSkeleton(ctx, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentRootPath := filepath.Dir(file.FilePath)
|
||||
@@ -4027,7 +4022,12 @@ 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.
|
||||
// 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
|
||||
}
|
||||
contentID, err := generateContentID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate content id: %w", err)
|
||||
@@ -4054,10 +4054,53 @@ 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 {
|
||||
s.logLibraryMembershipError("upserting skeleton membership", contentID, folderID, err)
|
||||
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)
|
||||
}
|
||||
|
||||
res.ContentID = contentID
|
||||
@@ -4065,6 +4108,47 @@ 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,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
@@ -81,6 +82,16 @@ 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()
|
||||
@@ -259,6 +270,8 @@ 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
|
||||
@@ -273,13 +286,22 @@ 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()
|
||||
defer r.mu.Unlock()
|
||||
if err := r.updateErrors[fileID]; err != nil {
|
||||
r.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
r.contentIDs[fileID] = contentID
|
||||
afterUpdate := r.afterUpdate
|
||||
r.mu.Unlock()
|
||||
if afterUpdate != nil {
|
||||
afterUpdate(fileID, contentID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1168,6 +1190,109 @@ 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.
|
||||
|
||||
+206
-19
@@ -1,15 +1,38 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultDRIDir = "/dev/dri"
|
||||
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),
|
||||
}
|
||||
|
||||
// HWAccelInfo describes the detected hardware acceleration capability.
|
||||
type HWAccelInfo struct {
|
||||
@@ -22,6 +45,11 @@ 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 {
|
||||
@@ -31,7 +59,7 @@ func DetectHWAccel() HWAccelInfo {
|
||||
}
|
||||
}
|
||||
return HWAccelInfo{
|
||||
Resolved: ResolveHWAccel("auto"),
|
||||
Resolved: ResolveHWAccelWithFFmpeg("auto", ffmpegPath),
|
||||
RenderDevices: devices,
|
||||
IntelDetected: intel,
|
||||
Source: "local",
|
||||
@@ -53,34 +81,163 @@ func PickRenderDevice(explicit string) string {
|
||||
return dev
|
||||
}
|
||||
|
||||
// ResolveHWAccel resolves "auto" into a concrete acceleration method by
|
||||
// probing the system. Preference order: qsv > vaapi > none.
|
||||
// Non-"auto" values are returned unchanged.
|
||||
// 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.
|
||||
// Non-"auto" values are returned unchanged.
|
||||
func ResolveHWAccelWithFFmpeg(hwAccel string, ffmpegPath string) string {
|
||||
if hwAccel != "auto" {
|
||||
return hwAccel
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
if currentGOOS != "linux" {
|
||||
return "none"
|
||||
}
|
||||
|
||||
devices := listRenderDevices(defaultDRIDir)
|
||||
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).
|
||||
var intelDevice string
|
||||
var nvidiaDevice string
|
||||
var vaapiDevice string
|
||||
for _, dev := range devices {
|
||||
if isIntelDevice(dev) {
|
||||
slog.Info("hw_accel=auto: Intel GPU detected, using QSV", "device", dev)
|
||||
return "qsv"
|
||||
switch {
|
||||
case isNVIDIADevice(dev):
|
||||
if nvidiaDevice == "" {
|
||||
nvidiaDevice = dev
|
||||
}
|
||||
case isIntelDevice(dev):
|
||||
if intelDevice == "" {
|
||||
intelDevice = dev
|
||||
}
|
||||
default:
|
||||
if vaapiDevice == "" {
|
||||
vaapiDevice = dev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any accessible render device supports VAAPI.
|
||||
slog.Info("hw_accel=auto: non-Intel GPU detected, using VAAPI", "device", devices[0])
|
||||
return "vaapi"
|
||||
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
|
||||
}
|
||||
|
||||
// listRenderDevices returns all accessible /dev/dri/renderD* paths, sorted.
|
||||
@@ -107,7 +264,7 @@ func listRenderDevices(driDir string) []string {
|
||||
func isIntelDevice(renderDevPath string) bool {
|
||||
// /dev/dri/renderD128 → card name "renderD128"
|
||||
name := filepath.Base(renderDevPath)
|
||||
vendorPath := filepath.Join("/sys/class/drm", name, "device", "vendor")
|
||||
vendorPath := filepath.Join(sysClassDRMDir, name, "device", "vendor")
|
||||
data, err := os.ReadFile(vendorPath)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -115,6 +272,36 @@ 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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user