Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fb9bef7ed | ||
|
|
843bfdf3eb | ||
|
|
dd04f5b354 | ||
|
|
86a77beae2 | ||
|
|
0327336ed9 | ||
|
|
5f317da348 | ||
|
|
3460678b6b | ||
|
|
bde7fc80b3 | ||
|
|
ce6e85ddb9 | ||
|
|
165b0205ae | ||
|
|
bbc421a809 | ||
|
|
399807a6fe | ||
|
|
b16e014dc6 | ||
|
|
5e43be2278 | ||
|
|
edc01ef733 | ||
|
|
69c5e6d1a1 | ||
|
|
0cd37aa618 | ||
|
|
4d894379df | ||
|
|
44fc0090c7 | ||
|
|
38c7fb44e1 | ||
|
|
c0222a55e1 | ||
|
|
ea6636f9d9 | ||
|
|
8c172ec9dd | ||
|
|
514b72cddb | ||
|
|
6983925b2e | ||
|
|
d897124e94 | ||
|
|
543534a058 | ||
|
|
234491f1cc | ||
|
|
cb0cb04534 | ||
|
|
d4dc695e1d | ||
|
|
f4b9d4cf38 | ||
|
|
fd41f56381 | ||
|
|
aef670c3bc | ||
|
|
3a43537e67 | ||
|
|
bc2b815391 | ||
|
|
7af0cf241e | ||
|
|
209fb50f16 | ||
|
|
a5a0fad86e | ||
|
|
9c6964e1a1 | ||
|
|
901285b225 | ||
|
|
620cd2746b | ||
|
|
355da1a4fd | ||
|
|
8deb18273d | ||
|
|
a8227723bf | ||
|
|
28ef3ac3c8 | ||
|
|
6d77e5a135 |
@@ -1,12 +1,6 @@
|
||||
# Silo Docker deployment
|
||||
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
|
||||
|
||||
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
|
||||
# Linux/macOS example for enabling NVIDIA override:
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
# Windows example:
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
|
||||
|
||||
# Required for docker compose: host path to your media library.
|
||||
# Example: MEDIA_ROOT=/srv/media
|
||||
MEDIA_ROOT=/srv/media
|
||||
@@ -46,9 +40,6 @@ POSTGRES_SHM_SIZE=8gb
|
||||
# PROXY_PORT=8083
|
||||
# TRANSCODE_PORT=8082
|
||||
|
||||
# Optional NVIDIA override controls (used by docker-compose.nvidia.yml).
|
||||
# NVIDIA_GPU_COUNT=1
|
||||
|
||||
# Run from source / advanced overrides
|
||||
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
|
||||
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
|
||||
|
||||
@@ -34,29 +34,6 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
|
||||
|
||||
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
|
||||
|
||||
### Optional NVIDIA/NVENC
|
||||
|
||||
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
|
||||
|
||||
Install the NVIDIA Container Toolkit and use a Docker Compose version with GPU reservation support before enabling this override.
|
||||
|
||||
Use the optional override file when you want NVENC:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
|
||||
```
|
||||
|
||||
If you want this controlled from `.env`, set `COMPOSE_FILE`:
|
||||
|
||||
```dotenv
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
NVIDIA_GPU_COUNT=1
|
||||
```
|
||||
|
||||
Windows uses `;` instead of `:` between compose files.
|
||||
|
||||
Then `docker compose up -d` will include the NVIDIA override automatically.
|
||||
|
||||
4. **Configure through the admin UI**
|
||||
|
||||
Add libraries, users, metadata providers, and playback settings from the web interface.
|
||||
|
||||
+48
-32
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -17,7 +18,6 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -425,8 +425,6 @@ func main() {
|
||||
|
||||
appCtx, appCancel := context.WithCancel(ctx)
|
||||
defer appCancel()
|
||||
restartReqCh := make(chan struct{}, 1)
|
||||
var restartRequested atomic.Bool
|
||||
|
||||
eventBus := cache.NewEventBus(cfg.Redis.URL)
|
||||
logStreamHub := logstream.NewHub(nodeID, eventBus)
|
||||
@@ -527,20 +525,14 @@ func main() {
|
||||
OpsLogRepo: opsRepo,
|
||||
FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID),
|
||||
PublicURL: os.Getenv("SILO_PUBLIC_URL"),
|
||||
RequestServerRestart: func(context.Context) error {
|
||||
if !restartRequested.CompareAndSwap(false, true) {
|
||||
return handlers.ErrServerRestartAlreadyRequested
|
||||
}
|
||||
restartReqCh <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
|
||||
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
|
||||
audiobooksEnabled, err := audiobooksService.Enabled(appCtx)
|
||||
if err != nil {
|
||||
slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err)
|
||||
absCompatEnabled = false
|
||||
slog.Warn("audiobooks feature disabled; failed to read setting", "err", err)
|
||||
audiobooksEnabled = false
|
||||
}
|
||||
deps.AudiobooksEnabled = audiobooksEnabled
|
||||
adminJobCancelRegistry := adminjob.NewCancelRegistry()
|
||||
deps.AdminJobCancelRegistry = adminJobCancelRegistry
|
||||
if needsWorkers && deps.DB != nil {
|
||||
@@ -1498,9 +1490,6 @@ func main() {
|
||||
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil {
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB)
|
||||
if err := autoscanRepo.MarkInterruptedEvents(appCtx); err != nil {
|
||||
slog.Warn("autoscan: failed to mark interrupted polls", "err", err)
|
||||
}
|
||||
autoscanSvc := api.BuildAutoscanService(
|
||||
autoscanRepo,
|
||||
pluginService,
|
||||
@@ -1530,7 +1519,7 @@ func main() {
|
||||
taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler))
|
||||
taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler))
|
||||
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB)))
|
||||
if audiobookEnricher != nil {
|
||||
if audiobooksEnabled && audiobookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
@@ -1553,7 +1542,7 @@ func main() {
|
||||
// available. Routes are mounted at the root level by NewRouter (not under
|
||||
// /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and
|
||||
// /abs/socket.io/* without path prefix hacks.
|
||||
if absCompatEnabled && deps.DB != nil {
|
||||
if audiobooksEnabled && deps.DB != nil {
|
||||
absUserRepo := auth.NewUserRepository(deps.DB)
|
||||
absSessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
absJWTService := auth.NewJWTService(
|
||||
@@ -1895,17 +1884,47 @@ func main() {
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
|
||||
|
||||
// Access filter resolver for viewer-scoped library access.
|
||||
// Backed by the shared access.Resolver so account-level library
|
||||
// restrictions (users.library_ids), profile restrictions,
|
||||
// user-disabled libraries, and rating/quality ceilings apply to
|
||||
// the compat API exactly as they do to the native API.
|
||||
if userStoreProvider != nil {
|
||||
compatDeps.AccessFilterFn = jellycompat.NewScopeAccessFilter(access.NewResolver(
|
||||
userRepo,
|
||||
userStoreProvider,
|
||||
nil, // profile tokens unused: compat login already verifies PINs
|
||||
))
|
||||
// Access filter resolver for profile-scoped library access.
|
||||
compatDeps.AccessFilterFn = func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
|
||||
if userStoreProvider == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
store, err := userStoreProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
profile, err := store.GetProfile(ctx, profileID)
|
||||
if err != nil || profile == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
filter := catalog.AccessFilter{
|
||||
MaxContentRating: profile.MaxContentRating,
|
||||
}
|
||||
if profile.LibraryRestrictionsEnabled && len(profile.AllowedLibraryIDs) > 0 {
|
||||
filter.AllowedLibraryIDs = profile.AllowedLibraryIDs
|
||||
}
|
||||
// Apply user-disabled library IDs.
|
||||
if raw, err := store.GetSetting(ctx, "disabled_library_ids"); err == nil && raw != "" {
|
||||
var disabled []int
|
||||
if json.Unmarshal([]byte(raw), &disabled) == nil && len(disabled) > 0 {
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
filtered := make([]int, 0, len(filter.AllowedLibraryIDs))
|
||||
disSet := make(map[int]struct{}, len(disabled))
|
||||
for _, id := range disabled {
|
||||
disSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range filter.AllowedLibraryIDs {
|
||||
if _, ok := disSet[id]; !ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
filter.AllowedLibraryIDs = filtered
|
||||
} else {
|
||||
filter.DisabledLibraryIDs = disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1997,9 +2016,6 @@ func main() {
|
||||
case sig := <-sigCh:
|
||||
appCancel()
|
||||
slog.Info("received signal, shutting down", "signal", sig)
|
||||
case <-restartReqCh:
|
||||
appCancel()
|
||||
slog.Info("server restart requested, shutting down")
|
||||
case serverErr := <-errCh:
|
||||
appCancel()
|
||||
slog.Error("server error, shutting down", "error", serverErr)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
services:
|
||||
silo:
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: ${NVIDIA_GPU_COUNT:-1}
|
||||
capabilities: [gpu]
|
||||
|
||||
# Optional distributed-mode example. Most single-host installs should
|
||||
# just use the integrated service above.
|
||||
# silo-transcode:
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: ${NVIDIA_GPU_COUNT:-1}
|
||||
# capabilities: [gpu]
|
||||
+1
-1
@@ -46,7 +46,7 @@ services:
|
||||
- "${ABS_PORT:-13378}:13378"
|
||||
volumes:
|
||||
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}}:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
|
||||
|
||||
@@ -64,8 +64,8 @@ type PluginScanSourceAdapter struct {
|
||||
Svc *plugins.Service
|
||||
}
|
||||
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClient(ctx, installationID, capabilityID)
|
||||
}
|
||||
|
||||
// scanSourceCapabilityType is the plugin capability type autoscan discovery
|
||||
@@ -75,6 +75,15 @@ const scanSourceCapabilityType = "scan_source.v1"
|
||||
// PluginScanSourceLister adapts the plugin installation store to
|
||||
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
|
||||
// capability across ALL installed plugins, regardless of enabled state.
|
||||
//
|
||||
// Using List (not ListEnabled) is deliberate for orphan detection: a temporarily
|
||||
// DISABLED-but-installed plugin must NOT be treated as orphaned. If we dropped
|
||||
// disabled plugins here, PollOnce would prune their sources and skip them with no
|
||||
// last_error — they'd vanish silently. Keeping them present means PollOnce still
|
||||
// attempts them; the plugin client fails to load (not running) and the source
|
||||
// gets a visible RecordError instead. Only a fully UNINSTALLED plugin (gone from
|
||||
// List entirely) is a true orphan. The Add-source picker shares this all-installed
|
||||
// set, which is fine — operators can bind sources against an installed capability.
|
||||
type PluginScanSourceLister struct {
|
||||
Store *plugins.InstallationStore
|
||||
}
|
||||
@@ -95,9 +104,10 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
|
||||
continue
|
||||
}
|
||||
out = append(out, autoscan.DiscoveredSource{
|
||||
PluginID: inst.PluginID,
|
||||
CapabilityID: c.ID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
InstallationID: c.InstallationID,
|
||||
CapabilityID: c.ID,
|
||||
PluginID: inst.PluginID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// HandleGetAudiobook serves GET /api/v1/audiobooks/{id}. Returns the
|
||||
// audiobook media_item, its media_files (with embedded chapters),
|
||||
// author/narrator from item_people (kinds 7/8), and the caller's
|
||||
// per-profile listening progress.
|
||||
func (h *AudiobookHandler) HandleGetAudiobook(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load audiobook failed")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
item := items[0]
|
||||
if item == nil || item.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.Files.GetByContentID(r.Context(), contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
|
||||
return
|
||||
}
|
||||
|
||||
author, narrator, err := h.fetchAuthorNarrator(r, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load people failed")
|
||||
return
|
||||
}
|
||||
|
||||
progress := h.fetchProgress(r, contentID)
|
||||
|
||||
alsoByAuthor := h.fetchAlsoByAuthor(r.Context(), contentID)
|
||||
inSeries := h.fetchInSeries(r.Context(), contentID)
|
||||
similar := h.fetchSimilar(r.Context(), contentID)
|
||||
otherNarrations := h.fetchOtherNarrations(r.Context(), contentID)
|
||||
|
||||
resp := audiobookDetailResponse{
|
||||
Audiobook: audiobookDetailItem{
|
||||
ContentID: item.ContentID,
|
||||
Title: item.Title,
|
||||
Year: item.Year,
|
||||
Overview: item.Overview,
|
||||
PosterURL: h.presignAudiobookPoster(r.Context(), item.PosterPath),
|
||||
Publisher: pickFirstString(item.Studios),
|
||||
Genres: append([]string(nil), item.Genres...),
|
||||
},
|
||||
Author: author,
|
||||
Narrator: narrator,
|
||||
Files: audiobookDetailFiles(files),
|
||||
Progress: progress,
|
||||
AlsoByAuthor: alsoByAuthor,
|
||||
InSeries: inSeries,
|
||||
SimilarAudiobooks: similar,
|
||||
OtherNarrations: otherNarrations,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// fetchAuthorNarrator retrieves the first author (kind=7) and narrator (kind=8)
|
||||
// name from item_people for the given content ID.
|
||||
func (h *AudiobookHandler) fetchAuthorNarrator(r *http.Request, contentID string) (author, narrator string, err error) {
|
||||
people, err := h.Items.GetPeople(r.Context(), contentID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, p := range people {
|
||||
switch p.Kind {
|
||||
case models.PersonKindAuthor:
|
||||
if author == "" {
|
||||
author = p.Name
|
||||
}
|
||||
case models.PersonKindNarrator:
|
||||
if narrator == "" {
|
||||
narrator = p.Name
|
||||
}
|
||||
}
|
||||
if author != "" && narrator != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return author, narrator, nil
|
||||
}
|
||||
|
||||
// fetchAlsoByAuthor returns other audiobooks sharing any author (item_people
|
||||
// kind=PersonKindAuthor) with the given book. Capped at 12 to keep the rail
|
||||
// snappy on the client. Returns an empty slice when there is no author or
|
||||
// no other matching titles.
|
||||
func (h *AudiobookHandler) fetchAlsoByAuthor(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
const q = `
|
||||
SELECT m2.content_id, m2.title, COALESCE(m2.year, 0), COALESCE(m2.poster_path, '')
|
||||
FROM item_people ip1
|
||||
JOIN item_people ip2
|
||||
ON ip2.person_id = ip1.person_id
|
||||
AND ip2.kind = ip1.kind
|
||||
AND ip2.content_id <> ip1.content_id
|
||||
JOIN media_items m2
|
||||
ON m2.content_id = ip2.content_id
|
||||
AND m2.type = 'audiobook'
|
||||
WHERE ip1.content_id = $1
|
||||
AND ip1.kind = $2
|
||||
ORDER BY m2.year DESC NULLS LAST, LOWER(m2.sort_title)
|
||||
LIMIT 12
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]audiobookRelatedItem, 0, 12)
|
||||
seen := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var it audiobookRelatedItem
|
||||
var poster string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, dup := seen[it.ContentID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[it.ContentID] = struct{}{}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchSimilar returns the "You might also like" rail. Prefers embedding-based
|
||||
// nearest-neighbor search when the source book has an embedding; falls back to
|
||||
// shared-genre matching otherwise (book is brand new, embedding job hasn't run,
|
||||
// or the embedding lookup errored). The genre fallback excludes same-author
|
||||
// books so the rail complements rather than duplicates also_by_author.
|
||||
func (h *AudiobookHandler) fetchSimilar(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Recs != nil {
|
||||
if items := h.fetchSimilarByEmbedding(ctx, contentID); items != nil {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return h.fetchSimilarByGenres(ctx, contentID)
|
||||
}
|
||||
|
||||
// fetchSimilarByEmbedding returns nearest-neighbor audiobooks by cosine
|
||||
// similarity on the gemini embedding vector. Returns nil (not an empty slice)
|
||||
// when the source book has no embedding yet, so the caller can fall back to
|
||||
// the genre-based recommender.
|
||||
func (h *AudiobookHandler) fetchSimilarByEmbedding(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Recs == nil || h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
emb, err := h.Recs.GetEmbedding(ctx, contentID)
|
||||
if err != nil || emb == nil {
|
||||
return nil
|
||||
}
|
||||
// Pull extra candidates so we can drop the source book's own author from
|
||||
// the rail (they belong in also_by_author).
|
||||
scored, err := h.Recs.FindSimilar(ctx, emb, []string{contentID}, "audiobook", 36)
|
||||
if err != nil || len(scored) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(scored))
|
||||
for _, s := range scored {
|
||||
ids = append(ids, s.MediaItemID)
|
||||
}
|
||||
const q = `
|
||||
WITH this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
||||
)
|
||||
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
|
||||
FROM media_items m
|
||||
WHERE m.content_id = ANY($3)
|
||||
AND m.type = 'audiobook'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $2
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor, ids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
byID := make(map[string]audiobookRelatedItem, len(scored))
|
||||
for rows.Next() {
|
||||
var it audiobookRelatedItem
|
||||
var poster string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
|
||||
return nil
|
||||
}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
byID[it.ContentID] = it
|
||||
}
|
||||
out := make([]audiobookRelatedItem, 0, 12)
|
||||
for _, s := range scored {
|
||||
if it, ok := byID[s.MediaItemID]; ok {
|
||||
out = append(out, it)
|
||||
if len(out) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchOtherNarrations returns sibling audiobook items that share the same
|
||||
// "core" title (after stripping the common "read by X" / "(Read by X)" /
|
||||
// "(UK Version: Read by X)" suffixes) and the same author. Powers the
|
||||
// narrator picker dropdown on the detail page when a single book has
|
||||
// multiple narration editions stored as separate items in the catalog.
|
||||
func (h *AudiobookHandler) fetchOtherNarrations(ctx context.Context, contentID string) []audiobookNarration {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
// The regex strips a trailing block of the form "[ separator] [( ]read by ...[) ]".
|
||||
// Tested against patterns in the corpus: "Foo Read By X",
|
||||
// "Foo - read by X", "Foo (Read by X)", "Foo (UK Version: Read by X)".
|
||||
const stripRE = `\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*[Rr]ead [Bb]y [^()]+\)?\s*$`
|
||||
const q = `
|
||||
WITH this_book AS (
|
||||
SELECT trim(regexp_replace(title, $1, '', 'g')) AS norm_title
|
||||
FROM media_items WHERE content_id = $2
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $2 AND kind = $3
|
||||
)
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(MAX(p.name), '') AS narrator,
|
||||
COALESCE(m.year, 0)
|
||||
FROM media_items m
|
||||
LEFT JOIN item_people ipn ON ipn.content_id = m.content_id AND ipn.kind = $4
|
||||
LEFT JOIN people p ON p.id = ipn.person_id
|
||||
WHERE m.type = 'audiobook'
|
||||
AND m.content_id <> $2
|
||||
AND trim(regexp_replace(m.title, $1, '', 'g')) = (SELECT norm_title FROM this_book)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $3
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
GROUP BY m.content_id, m.title, m.year
|
||||
ORDER BY m.year DESC NULLS LAST, m.title
|
||||
LIMIT 8
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q,
|
||||
stripRE, contentID, models.PersonKindAuthor, models.PersonKindNarrator)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]audiobookNarration, 0, 4)
|
||||
for rows.Next() {
|
||||
var n audiobookNarration
|
||||
if err := rows.Scan(&n.ContentID, &n.Title, &n.Narrator, &n.Year); err != nil {
|
||||
return nil
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchSimilarByGenres returns up to 12 audiobooks sharing genres with the
|
||||
// given book, excluding books by the same author so the rail complements
|
||||
// rather than duplicates also_by_author. Used as a fallback when no
|
||||
// embedding exists for the source yet.
|
||||
func (h *AudiobookHandler) fetchSimilarByGenres(ctx context.Context, contentID string) []audiobookRelatedItem {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
const q = `
|
||||
WITH this_genres AS (
|
||||
SELECT unnest(genres) AS g FROM media_items WHERE content_id = $1
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
||||
)
|
||||
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
|
||||
FROM media_items m
|
||||
WHERE m.type = 'audiobook'
|
||||
AND m.content_id <> $1
|
||||
AND m.genres && (SELECT array_agg(g) FROM this_genres)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $2
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
ORDER BY
|
||||
cardinality(ARRAY(SELECT unnest(m.genres) INTERSECT SELECT g FROM this_genres)) DESC,
|
||||
COALESCE(m.year, 0) DESC,
|
||||
LOWER(m.sort_title)
|
||||
LIMIT 12
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]audiobookRelatedItem, 0, 12)
|
||||
for rows.Next() {
|
||||
var it audiobookRelatedItem
|
||||
var poster string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
|
||||
return nil
|
||||
}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchInSeries returns the series the audiobook belongs to (if any) along
|
||||
// with the ordered list of sibling entries. Driven by the audiobook_series
|
||||
// table populated by either the scanner (tag-derived) or migration 145's
|
||||
// best-effort title-pattern backfill.
|
||||
func (h *AudiobookHandler) fetchInSeries(ctx context.Context, contentID string) *audiobookSeriesGroup {
|
||||
if h.Files == nil {
|
||||
return nil
|
||||
}
|
||||
const q = `
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(m.year, 0),
|
||||
COALESCE(m.poster_path, ''),
|
||||
s.series_name,
|
||||
s.series_index
|
||||
FROM audiobook_series root
|
||||
JOIN audiobook_series s ON LOWER(s.series_name) = LOWER(root.series_name)
|
||||
JOIN media_items m ON m.content_id = s.content_id AND m.type = 'audiobook'
|
||||
WHERE root.content_id = $1
|
||||
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
|
||||
LIMIT 30
|
||||
`
|
||||
rows, err := h.Files.Pool().Query(ctx, q, contentID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var seriesName string
|
||||
entries := make([]audiobookRelatedItem, 0, 16)
|
||||
for rows.Next() {
|
||||
var (
|
||||
it audiobookRelatedItem
|
||||
poster string
|
||||
thisName string
|
||||
idxNum *float64
|
||||
)
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster, &thisName, &idxNum); err != nil {
|
||||
return nil
|
||||
}
|
||||
if seriesName == "" {
|
||||
seriesName = thisName
|
||||
}
|
||||
if idxNum != nil {
|
||||
n := int(*idxNum)
|
||||
if float64(n) == *idxNum {
|
||||
it.SeriesIndex = &n
|
||||
}
|
||||
}
|
||||
it.PosterURL = h.presignPoster(ctx, poster)
|
||||
entries = append(entries, it)
|
||||
}
|
||||
// A series with only this book is not useful as a rail.
|
||||
if len(entries) < 2 {
|
||||
return nil
|
||||
}
|
||||
return &audiobookSeriesGroup{Name: seriesName, Entries: entries}
|
||||
}
|
||||
|
||||
// fetchProgress returns the caller's listening progress for this audiobook, or
|
||||
// nil when no progress exists or the caller is not authenticated with a profile.
|
||||
func (h *AudiobookHandler) fetchProgress(r *http.Request, contentID string) *audiobookDetailProgress {
|
||||
if h.StoreProvider == nil {
|
||||
return nil
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if userID == 0 {
|
||||
return nil
|
||||
}
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if profileID == "" {
|
||||
return nil
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
||||
if err != nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
wp, err := store.GetProgress(r.Context(), profileID, contentID)
|
||||
if err != nil || wp == nil {
|
||||
return nil
|
||||
}
|
||||
return &audiobookDetailProgress{
|
||||
PositionSeconds: wp.PositionSeconds,
|
||||
Completed: wp.Completed,
|
||||
UpdatedAt: wp.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type audiobookDetailResponse struct {
|
||||
Audiobook audiobookDetailItem `json:"audiobook"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
Files []audiobookDetailFile `json:"files"`
|
||||
Progress *audiobookDetailProgress `json:"progress,omitempty"`
|
||||
AlsoByAuthor []audiobookRelatedItem `json:"also_by_author,omitempty"`
|
||||
InSeries *audiobookSeriesGroup `json:"in_series,omitempty"`
|
||||
SimilarAudiobooks []audiobookRelatedItem `json:"similar_audiobooks,omitempty"`
|
||||
OtherNarrations []audiobookNarration `json:"other_narrations,omitempty"`
|
||||
}
|
||||
|
||||
// audiobookNarration is a sibling narration (same book, different
|
||||
// narrator/edition). Returned alongside the primary item so the UI can
|
||||
// surface a narrator picker.
|
||||
type audiobookNarration struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookRelatedItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
SeriesIndex *int `json:"series_index,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookSeriesGroup struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Entries []audiobookRelatedItem `json:"entries"`
|
||||
}
|
||||
|
||||
type audiobookDetailItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) presignPoster(ctx context.Context, path string) string {
|
||||
if path == "" || h.Detail == nil {
|
||||
return ""
|
||||
}
|
||||
return h.Detail.PresignURL(ctx, path, "featured")
|
||||
}
|
||||
|
||||
func pickFirstString(values []string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type audiobookDetailFile struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Container string `json:"container,omitempty"`
|
||||
CodecAudio string `json:"codec_audio,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
AudioChannels int `json:"audio_channels,omitempty"`
|
||||
Chapters []models.MediaChapter `json:"chapters,omitempty"`
|
||||
}
|
||||
|
||||
type audiobookDetailProgress struct {
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
Completed bool `json:"completed"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func audiobookDetailFiles(files []*models.MediaFile) []audiobookDetailFile {
|
||||
out := make([]audiobookDetailFile, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, audiobookDetailFile{
|
||||
ID: f.ID,
|
||||
Path: f.FilePath,
|
||||
DurationSeconds: f.Duration,
|
||||
Container: f.Container,
|
||||
CodecAudio: f.CodecAudio,
|
||||
Bitrate: f.Bitrate,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Chapters: f.Chapters,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// AudiobookHandler serves silo-native audiobook endpoints under
|
||||
// /api/v1/audiobooks/*. Sub-plan 3 implements list/detail/progress;
|
||||
// sub-plan 4's ABS-compat layer is separate.
|
||||
type AudiobookHandler struct {
|
||||
Items *catalog.ItemRepository
|
||||
Files *scanner.FileRepository
|
||||
StoreProvider userstore.UserStoreProvider
|
||||
Detail *catalog.DetailService
|
||||
// Recs is optional. When set, the detail handler uses embedding-based
|
||||
// nearest-neighbor search for the "You might also like" rail and falls
|
||||
// back to shared-genre matching only when no embedding exists for the
|
||||
// source book yet.
|
||||
Recs *recommendations.Repo
|
||||
}
|
||||
|
||||
// HandleListAudiobooks serves GET /api/v1/audiobooks?limit=&offset=.
|
||||
// Paginated catalog of media_items with type='audiobook' scoped to the
|
||||
// caller's accessible libraries.
|
||||
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
access := requestAccessFilter(r)
|
||||
genre := r.URL.Query().Get("genre")
|
||||
conditions, args, argIdx, empty := audiobookListConditions(access, genre)
|
||||
if empty {
|
||||
writeAudiobookListResponse(w, nil, 0, limit, offset)
|
||||
return
|
||||
}
|
||||
|
||||
pool := h.Files.Pool()
|
||||
var total int
|
||||
countSQL := "SELECT COUNT(*) FROM media_items mi WHERE " + strings.Join(conditions, " AND ")
|
||||
if err := pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks count failed")
|
||||
return
|
||||
}
|
||||
|
||||
dataArgs := append([]any(nil), args...)
|
||||
limitIdx := argIdx
|
||||
dataArgs = append(dataArgs, limit)
|
||||
offsetIdx := argIdx + 1
|
||||
dataArgs = append(dataArgs, offset)
|
||||
|
||||
rows, err := pool.Query(ctx, fmt.Sprintf(`
|
||||
SELECT
|
||||
mi.content_id,
|
||||
mi.title,
|
||||
COALESCE(mi.year, 0),
|
||||
COALESCE(mi.poster_path, ''),
|
||||
COALESCE(SUM(mf.duration), 0)::int AS duration_seconds,
|
||||
COALESCE(MIN(mf.container), '') AS container,
|
||||
COALESCE(MIN(mf.codec_audio), '') AS codec_audio,
|
||||
COUNT(mf.id) AS file_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN media_files mf ON mf.content_id = mi.content_id
|
||||
WHERE %s
|
||||
GROUP BY mi.content_id, mi.title, mi.year, mi.poster_path, mi.sort_title
|
||||
ORDER BY LOWER(mi.sort_title), LOWER(mi.title)
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, strings.Join(conditions, " AND "), limitIdx, offsetIdx), dataArgs...)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]audiobookListItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var it audiobookListItem
|
||||
var posterPath string
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &posterPath, &it.DurationSeconds, &it.Container, &it.CodecAudio, &it.FileCount); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "scan audiobook row failed")
|
||||
return
|
||||
}
|
||||
it.PosterURL = h.presignAudiobookPoster(ctx, posterPath)
|
||||
items = append(items, it)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "iterate audiobook rows failed")
|
||||
return
|
||||
}
|
||||
writeAudiobookListResponse(w, items, total, limit, offset)
|
||||
}
|
||||
|
||||
func audiobookListConditions(access catalog.AccessFilter, genre string) ([]string, []any, int, bool) {
|
||||
conditions := []string{"mi.type = 'audiobook'"}
|
||||
var args []any
|
||||
argIdx := 1
|
||||
|
||||
if genre != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("$%d = ANY(mi.genres)", argIdx))
|
||||
args = append(args, genre)
|
||||
argIdx++
|
||||
}
|
||||
if access.AllowedLibraryIDs != nil {
|
||||
if len(access.AllowedLibraryIDs) == 0 {
|
||||
return nil, nil, 1, true
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf(`
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil
|
||||
WHERE mil.content_id = mi.content_id
|
||||
AND mil.media_folder_id = ANY($%d)
|
||||
)`, argIdx))
|
||||
args = append(args, access.AllowedLibraryIDs)
|
||||
argIdx++
|
||||
}
|
||||
if len(access.DisabledLibraryIDs) > 0 {
|
||||
if access.AllowedLibraryIDs == nil {
|
||||
conditions = append(conditions, `
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil
|
||||
WHERE mil.content_id = mi.content_id
|
||||
)`)
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf(`
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil
|
||||
WHERE mil.content_id = mi.content_id
|
||||
AND mil.media_folder_id = ANY($%d)
|
||||
)`, argIdx))
|
||||
args = append(args, access.DisabledLibraryIDs)
|
||||
argIdx++
|
||||
}
|
||||
catalog.ApplySectionAccessFilter("mi", catalog.AccessFilter{MaxContentRating: access.MaxContentRating}, &conditions, &args, &argIdx)
|
||||
return conditions, args, argIdx, false
|
||||
}
|
||||
|
||||
func writeAudiobookListResponse(w http.ResponseWriter, items []audiobookListItem, total, limit, offset int) {
|
||||
if items == nil {
|
||||
items = []audiobookListItem{}
|
||||
}
|
||||
resp := struct {
|
||||
Items []audiobookListItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}{Items: items, Total: total, Limit: limit, Offset: offset}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
type audiobookListItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Container string `json:"container,omitempty"`
|
||||
CodecAudio string `json:"codec_audio,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) presignAudiobookPoster(ctx context.Context, path string) string {
|
||||
if h == nil || h.Detail == nil || path == "" {
|
||||
return ""
|
||||
}
|
||||
return h.Detail.PresignURL(ctx, cardThumbnailPath(path), "card")
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
type audiobookProgressRequest struct {
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
MediaFileID int `json:"media_file_id,omitempty"`
|
||||
}
|
||||
|
||||
// HandleReportAudiobookProgress serves POST /api/v1/audiobooks/{id}/progress.
|
||||
// UPSERTs into user_watch_progress for the caller's (user_id, profile_id, content_id).
|
||||
func (h *AudiobookHandler) HandleReportAudiobookProgress(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req audiobookProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.PositionSeconds < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "position_seconds must be >= 0")
|
||||
return
|
||||
}
|
||||
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if userID == 0 || profileID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if h.Items == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "catalog unavailable")
|
||||
return
|
||||
}
|
||||
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "check access failed")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 || items[0] == nil || items[0].Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
durationSeconds, err := h.audiobookProgressDuration(r.Context(), contentID, req.MediaFileID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
|
||||
return
|
||||
}
|
||||
|
||||
if h.StoreProvider == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
|
||||
return
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(r.Context(), userID)
|
||||
if err != nil || store == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SetProgress(r.Context(), profileID, contentID, req.PositionSeconds, durationSeconds, userstore.ProgressThresholds{}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "save progress failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) audiobookProgressDuration(ctx context.Context, contentID string, mediaFileID int) (float64, error) {
|
||||
if h == nil || h.Files == nil {
|
||||
return 0, nil
|
||||
}
|
||||
files, err := h.Files.GetByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return audiobookDurationForProgress(files, mediaFileID), nil
|
||||
}
|
||||
|
||||
func audiobookDurationForProgress(files []*models.MediaFile, mediaFileID int) float64 {
|
||||
var total int
|
||||
for _, f := range files {
|
||||
if f == nil || f.Duration <= 0 {
|
||||
continue
|
||||
}
|
||||
if mediaFileID > 0 && f.ID == mediaFileID {
|
||||
return float64(f.Duration)
|
||||
}
|
||||
total += f.Duration
|
||||
}
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(total)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestPresignAudiobookPosterDoesNotExposeRawKeysWithoutPresigner(t *testing.T) {
|
||||
h := &AudiobookHandler{}
|
||||
got := h.presignAudiobookPoster(context.Background(), "metadata/audiobooks/book-1/poster/original.webp")
|
||||
if got != "" {
|
||||
t.Fatalf("PosterURL = %q, want empty without presigner", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookListConditionsIncludeAccessPredicates(t *testing.T) {
|
||||
conditions, args, _, empty := audiobookListConditions(catalog.AccessFilter{
|
||||
AllowedLibraryIDs: []int{1, 2},
|
||||
DisabledLibraryIDs: []int{9},
|
||||
MaxContentRating: "PG-13",
|
||||
}, "Fantasy")
|
||||
if empty {
|
||||
t.Fatal("empty = true, want false")
|
||||
}
|
||||
sql := strings.Join(conditions, " AND ")
|
||||
for _, want := range []string{
|
||||
"mi.type = 'audiobook'",
|
||||
"ANY(mi.genres)",
|
||||
"EXISTS",
|
||||
"NOT EXISTS",
|
||||
"mi.content_rating = ANY",
|
||||
} {
|
||||
if !strings.Contains(sql, want) {
|
||||
t.Fatalf("conditions missing %q in:\n%s", want, sql)
|
||||
}
|
||||
}
|
||||
if len(args) != 4 {
|
||||
t.Fatalf("len(args) = %d, want 4", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookDurationForProgress(t *testing.T) {
|
||||
files := []*models.MediaFile{
|
||||
{ID: 10, Duration: 120},
|
||||
{ID: 11, Duration: 180},
|
||||
}
|
||||
|
||||
if got := audiobookDurationForProgress(files, 10); got != 120 {
|
||||
t.Fatalf("selected file duration = %v, want 120", got)
|
||||
}
|
||||
if got := audiobookDurationForProgress(files, 0); got != 300 {
|
||||
t.Fatalf("total duration = %v, want 300", got)
|
||||
}
|
||||
if got := audiobookDurationForProgress([]*models.MediaFile{{ID: 1}}, 1); got != 0 {
|
||||
t.Fatalf("unknown duration = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@ type autoscanStore interface {
|
||||
CountAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) (int, error)
|
||||
ListEvents(ctx context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
|
||||
CountEvents(ctx context.Context, filter autoscan.EventListFilter) (int, error)
|
||||
ListRunningEvents(ctx context.Context) ([]autoscan.Event, error)
|
||||
GetQueueSummary(ctx context.Context) (autoscan.QueueSummary, error)
|
||||
LatestEventAt(ctx context.Context) (*time.Time, error)
|
||||
}
|
||||
@@ -310,7 +309,7 @@ func (h *AutoscanHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.
|
||||
// credentials: the connection link is by id only.
|
||||
type autoscanSourceResponse struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -332,7 +331,7 @@ func sourceResponse(s autoscan.Source) autoscanSourceResponse {
|
||||
config := normalizeSourceConfig(s.SourceConfig)
|
||||
return autoscanSourceResponse{
|
||||
ID: s.ID,
|
||||
PluginID: s.PluginID,
|
||||
InstallationID: s.InstallationID,
|
||||
CapabilityID: s.CapabilityID,
|
||||
ConnectionID: s.ConnectionID,
|
||||
Enabled: s.Enabled,
|
||||
@@ -365,9 +364,10 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
|
||||
// --- Available scan-source plugins (Add-source picker) ---
|
||||
|
||||
type autoscanScanSourcePluginResponse struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// HandleListAvailableScanSources returns every installed scan_source capability
|
||||
@@ -381,9 +381,10 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
out := make([]autoscanScanSourcePluginResponse, 0, len(available))
|
||||
for _, a := range available {
|
||||
out = append(out, autoscanScanSourcePluginResponse{
|
||||
PluginID: a.PluginID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
DisplayName: a.DisplayName,
|
||||
InstallationID: a.InstallationID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
PluginID: a.PluginID,
|
||||
DisplayName: a.DisplayName,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
@@ -393,12 +394,12 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
|
||||
// --- Create source ---
|
||||
|
||||
// autoscanCreateSourceInput is the create payload: it names the scan_source
|
||||
// capability to bind (plugin_id + capability_id) plus the initial
|
||||
// binding/scheduling fields. Unlike the update payload, identity is supplied
|
||||
// here rather than read from an existing row.
|
||||
// autoscanCreateSourceInput is the create payload: it names the installed
|
||||
// scan_source capability to bind (installation_id + capability_id) plus the
|
||||
// initial binding/scheduling fields. Unlike the update payload, identity is
|
||||
// supplied here rather than read from an existing row.
|
||||
type autoscanCreateSourceInput struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -419,11 +420,6 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
capID := strings.TrimSpace(in.CapabilityID)
|
||||
pluginID := strings.TrimSpace(in.PluginID)
|
||||
if pluginID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id is required")
|
||||
return
|
||||
}
|
||||
if capID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
|
||||
return
|
||||
@@ -436,7 +432,7 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
// Validate the (plugin_id, capability_id) is a currently-installed
|
||||
// Validate the (installation_id, capability_id) is a currently-installed
|
||||
// scan_source capability — a source may only be created against an installed
|
||||
// plugin capability.
|
||||
available, err := h.svc.ListAvailableScanSources(r.Context())
|
||||
@@ -444,13 +440,13 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
if !scanSourceInstalled(available, pluginID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id + capability_id is not a currently-installed scan_source capability")
|
||||
if !scanSourceInstalled(available, in.InstallationID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "installation_id + capability_id is not a currently-installed scan_source capability")
|
||||
return
|
||||
}
|
||||
connArg := normalizeConnectionID(in.ConnectionID)
|
||||
created, err := h.repo.CreateSource(r.Context(), autoscan.Source{
|
||||
PluginID: pluginID,
|
||||
InstallationID: in.InstallationID,
|
||||
CapabilityID: capID,
|
||||
ConnectionID: connArg,
|
||||
Enabled: in.Enabled,
|
||||
@@ -466,18 +462,18 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusCreated, sourceResponse(created))
|
||||
}
|
||||
|
||||
// scanSourceInstalled reports whether (pluginID, capabilityID) is in the
|
||||
// scanSourceInstalled reports whether (installationID, capabilityID) is in the
|
||||
// set of currently-installed scan_source capabilities.
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, pluginID, capabilityID string) bool {
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, installationID int, capabilityID string) bool {
|
||||
for _, a := range available {
|
||||
if a.PluginID == pluginID && a.CapabilityID == capabilityID {
|
||||
if a.InstallationID == installationID && a.CapabilityID == capabilityID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoscanSourceInput is the source write payload. The (plugin_id,
|
||||
// autoscanSourceInput is the source write payload. The (installation_id,
|
||||
// capability_id) identity is read from the existing source row, so only the
|
||||
// schedulable/binding fields are accepted.
|
||||
//
|
||||
@@ -552,7 +548,7 @@ func (h *AutoscanHandler) HandleUpdateSource(w http.ResponseWriter, r *http.Requ
|
||||
// connection_id is a full-state field: nil means unbind, a UUID string
|
||||
// means bind. A whitespace-only string is normalised to nil (unbound).
|
||||
connArg := normalizeConnectionID(in.ConnectionID)
|
||||
// Update by id; identity (plugin_id, capability_id) is immutable and
|
||||
// Update by id; identity (installation_id, capability_id) is immutable and
|
||||
// preserved by the repo. A missing source maps to 404.
|
||||
updated, err := h.repo.UpdateSource(r.Context(), autoscan.Source{
|
||||
ID: id,
|
||||
@@ -585,8 +581,9 @@ func normalizeConnectionID(in *string) *string {
|
||||
}
|
||||
|
||||
// HandleDeleteSource removes a source row. This is the operator's escape hatch
|
||||
// for an orphaned source (one whose scan_source plugin was uninstalled or no
|
||||
// longer resolves cleanly). An unknown id maps to 404 via autoscan.ErrNotFound.
|
||||
// for an orphaned source (one whose scan_source plugin was uninstalled): the
|
||||
// poll loop skips such rows quietly, and this endpoint clears them. An unknown
|
||||
// id maps to 404 via autoscan.ErrNotFound.
|
||||
func (h *AutoscanHandler) HandleDeleteSource(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if err := h.repo.DeleteSource(r.Context(), id); err != nil {
|
||||
@@ -713,7 +710,7 @@ type autoscanEventScanRunResponse struct {
|
||||
type autoscanEventResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID *string `json:"source_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
@@ -756,7 +753,7 @@ func eventResponse(event autoscan.EventWithRuns) autoscanEventResponse {
|
||||
return autoscanEventResponse{
|
||||
ID: e.ID,
|
||||
SourceID: e.SourceID,
|
||||
PluginID: e.PluginID,
|
||||
InstallationID: e.InstallationID,
|
||||
CapabilityID: e.CapabilityID,
|
||||
StartedAt: e.StartedAt,
|
||||
CompletedAt: e.CompletedAt,
|
||||
@@ -797,9 +794,9 @@ func (h *AutoscanHandler) HandleListEvents(w http.ResponseWriter, r *http.Reques
|
||||
filter.Offset = offset
|
||||
}
|
||||
switch filter.Status {
|
||||
case "", autoscan.EventStatusRunning, autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
case "", autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be running, success, error, or unresolved")
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be success, error, or unresolved")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -838,7 +835,7 @@ type autoscanScanResponse struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
AutoscanEventID *int64 `json:"autoscan_event_id,omitempty"`
|
||||
SourceID *string `json:"source_id,omitempty"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
CapabilityID string `json:"capability_id,omitempty"`
|
||||
EventStatus string `json:"event_status,omitempty"`
|
||||
EventCompletedAt *time.Time `json:"event_completed_at,omitempty"`
|
||||
@@ -865,7 +862,7 @@ func autoscanScanRunResponse(scan autoscan.ScanWithEvent) autoscanScanResponse {
|
||||
CompletedAt: scan.CompletedAt,
|
||||
AutoscanEventID: scan.AutoscanEventID,
|
||||
SourceID: scan.SourceID,
|
||||
PluginID: scan.PluginID,
|
||||
InstallationID: scan.InstallationID,
|
||||
CapabilityID: scan.CapabilityID,
|
||||
EventStatus: string(scan.EventStatus),
|
||||
EventCompletedAt: scan.EventCompletedAt,
|
||||
@@ -926,35 +923,24 @@ func (h *AutoscanHandler) HandleListScans(w http.ResponseWriter, r *http.Request
|
||||
// --- Status ---
|
||||
|
||||
type autoscanStatusSource struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Label string `json:"label"`
|
||||
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type autoscanRunningPollResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID *string `json:"source_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
MarkerBefore *string `json:"marker_before,omitempty"`
|
||||
ID string `json:"id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Label string `json:"label"`
|
||||
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type autoscanStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Sources []autoscanStatusSource `json:"sources"`
|
||||
RunningPolls []autoscanRunningPollResponse `json:"running_polls"`
|
||||
ActiveScans int `json:"active_scans"`
|
||||
AcceptedScans int `json:"accepted_scans"`
|
||||
RunningScans int `json:"running_scans"`
|
||||
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Sources []autoscanStatusSource `json:"sources"`
|
||||
ActiveScans int `json:"active_scans"`
|
||||
AcceptedScans int `json:"accepted_scans"`
|
||||
RunningScans int `json:"running_scans"`
|
||||
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -974,11 +960,6 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
runningEvents, err := h.repo.ListRunningEvents(ctx)
|
||||
if err != nil {
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
latestEventAt, err := h.repo.LatestEventAt(ctx)
|
||||
if err != nil {
|
||||
writeAutoscanError(w, err)
|
||||
@@ -991,38 +972,20 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
rewrites = []autoscan.PathRewrite{}
|
||||
}
|
||||
trimmed = append(trimmed, autoscanStatusSource{
|
||||
ID: src.ID,
|
||||
PluginID: src.PluginID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
ConnectionID: src.ConnectionID,
|
||||
Enabled: src.Enabled,
|
||||
Label: src.Label,
|
||||
PathRewrites: rewrites,
|
||||
LastRunAt: src.LastRunAt,
|
||||
LastError: src.LastError,
|
||||
})
|
||||
}
|
||||
now := time.Now()
|
||||
runningPolls := make([]autoscanRunningPollResponse, 0, len(runningEvents))
|
||||
for _, event := range runningEvents {
|
||||
elapsed := now.Sub(event.StartedAt)
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
runningPolls = append(runningPolls, autoscanRunningPollResponse{
|
||||
ID: event.ID,
|
||||
SourceID: event.SourceID,
|
||||
PluginID: event.PluginID,
|
||||
CapabilityID: event.CapabilityID,
|
||||
StartedAt: event.StartedAt,
|
||||
ElapsedMS: elapsed.Milliseconds(),
|
||||
MarkerBefore: event.MarkerBefore,
|
||||
ID: src.ID,
|
||||
InstallationID: src.InstallationID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
ConnectionID: src.ConnectionID,
|
||||
Enabled: src.Enabled,
|
||||
Label: src.Label,
|
||||
PathRewrites: rewrites,
|
||||
LastRunAt: src.LastRunAt,
|
||||
LastError: src.LastError,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, autoscanStatusResponse{
|
||||
Enabled: s.Enabled,
|
||||
Sources: trimmed,
|
||||
RunningPolls: runningPolls,
|
||||
ActiveScans: queue.Active,
|
||||
AcceptedScans: queue.Accepted,
|
||||
RunningScans: queue.Running,
|
||||
|
||||
@@ -31,7 +31,6 @@ type fakeAutoscanStore struct {
|
||||
countScansFn func(autoscan.ScanListFilter) (int, error)
|
||||
listEventsFn func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
|
||||
countEventsFn func(autoscan.EventListFilter) (int, error)
|
||||
listRunningFn func() ([]autoscan.Event, error)
|
||||
queueSummaryFn func() (autoscan.QueueSummary, error)
|
||||
latestEventAtFn func() (*time.Time, error)
|
||||
}
|
||||
@@ -141,13 +140,6 @@ func (f *fakeAutoscanStore) CountEvents(_ context.Context, filter autoscan.Event
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanStore) ListRunningEvents(context.Context) ([]autoscan.Event, error) {
|
||||
if f.listRunningFn != nil {
|
||||
return f.listRunningFn()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanStore) GetQueueSummary(context.Context) (autoscan.QueueSummary, error) {
|
||||
if f.queueSummaryFn != nil {
|
||||
return f.queueSummaryFn()
|
||||
@@ -281,7 +273,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
listSourcesFn: func() ([]autoscan.Source, error) {
|
||||
listed = true
|
||||
return []autoscan.Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr"}}, nil
|
||||
return []autoscan.Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr"}}, nil
|
||||
},
|
||||
}
|
||||
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
|
||||
@@ -308,7 +300,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
|
||||
func TestAutoscanHandleListAvailableScanSources(t *testing.T) {
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "sonarr", CapabilityID: "arr", DisplayName: "Sonarr"},
|
||||
{InstallationID: 1, CapabilityID: "arr", PluginID: "sonarr", DisplayName: "Sonarr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
|
||||
|
||||
@@ -339,19 +331,19 @@ func TestAutoscanHandleCreateSourceSucceeds(t *testing.T) {
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
|
||||
`{"installation_id":1,"capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.PluginID != "silo.autoscan.arr" || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
if got.InstallationID != 1 || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
t.Fatalf("unexpected created source: %+v", got)
|
||||
}
|
||||
var body autoscanSourceResponse
|
||||
@@ -371,14 +363,14 @@ func TestAutoscanHandleCreateSourceRejectsUnknownCapability(t *testing.T) {
|
||||
return s, nil
|
||||
},
|
||||
}
|
||||
// Installed set does NOT include ("silo.autoscan.arr", "arr").
|
||||
// Installed set does NOT include (1, "arr").
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "silo.autoscan.other", CapabilityID: "other"},
|
||||
{InstallationID: 2, CapabilityID: "other"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":false}`, "")
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":false}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -402,12 +394,12 @@ func TestAutoscanHandleCreateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":true}`, "")
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":true}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -623,7 +615,7 @@ func TestAutoscanHandleUpdateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -649,7 +641,7 @@ func TestAutoscanHandleUpdateSourceEnableWithConnectionSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -677,7 +669,7 @@ func TestAutoscanHandleUpdateSourceBindConnectionSucceeds(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
// Source starts with no connection.
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -705,7 +697,7 @@ func TestAutoscanHandleUpdateSourceUnbindConnectionSucceeds(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
// Source starts with a connection already bound.
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -735,7 +727,7 @@ func TestAutoscanHandleUpdateSourceUnbindWhileEnabledSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -933,7 +925,7 @@ func TestAutoscanHandleUpdateSourceRoundTripsPathRewrites(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -998,7 +990,7 @@ func TestAutoscanHandleUpdateSourceRejectsBlankRewrite(t *testing.T) {
|
||||
upserted := false
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
upserted = true
|
||||
@@ -1028,7 +1020,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
eventCompleted := completed.Add(-1 * time.Second)
|
||||
eventID := int64(42)
|
||||
sourceID := "src-1"
|
||||
pluginID := "silo.autoscan.cephfs"
|
||||
installationID := 7
|
||||
var gotFilter autoscan.ScanListFilter
|
||||
var gotCountFilter autoscan.ScanListFilter
|
||||
store := &fakeAutoscanStore{
|
||||
@@ -1048,7 +1040,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
},
|
||||
AutoscanEventID: &eventID,
|
||||
SourceID: &sourceID,
|
||||
PluginID: pluginID,
|
||||
InstallationID: &installationID,
|
||||
CapabilityID: "cephfs",
|
||||
EventStatus: autoscan.EventStatusSuccess,
|
||||
EventCompletedAt: &eventCompleted,
|
||||
@@ -1092,7 +1084,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
if scan.AutoscanEventID == nil || *scan.AutoscanEventID != eventID || scan.EventStatus != "success" {
|
||||
t.Fatalf("event context = %+v", scan)
|
||||
}
|
||||
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.PluginID != pluginID || scan.CapabilityID != "cephfs" {
|
||||
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.InstallationID == nil || *scan.InstallationID != installationID || scan.CapabilityID != "cephfs" {
|
||||
t.Fatalf("source context = %+v", scan)
|
||||
}
|
||||
}
|
||||
@@ -1136,7 +1128,7 @@ func TestAutoscanHandleListEventsSerializesFiltersAndRuns(t *testing.T) {
|
||||
Event: autoscan.Event{
|
||||
ID: 42,
|
||||
SourceID: &sourceID,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
InstallationID: 7,
|
||||
CapabilityID: "cephfs",
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
@@ -1266,30 +1258,18 @@ func TestAutoscanHandleUpdateSourceNormalizesLabel(t *testing.T) {
|
||||
|
||||
func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
|
||||
latestEventAt := time.Date(2026, 6, 4, 14, 30, 0, 0, time.UTC)
|
||||
runningStartedAt := time.Now().Add(-90 * time.Second)
|
||||
sourceID := "src-1"
|
||||
store := &fakeAutoscanStore{
|
||||
getSettingsFn: func() (autoscan.Settings, error) {
|
||||
return autoscan.Settings{Enabled: true}, nil
|
||||
},
|
||||
listSourcesFn: func() ([]autoscan.Source, error) {
|
||||
return []autoscan.Source{
|
||||
{ID: "src-1", PluginID: "silo.autoscan.cephfs", CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
|
||||
{ID: "src-1", InstallationID: 7, CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
|
||||
}, nil
|
||||
},
|
||||
queueSummaryFn: func() (autoscan.QueueSummary, error) {
|
||||
return autoscan.QueueSummary{Active: 3, Accepted: 2, Running: 1}, nil
|
||||
},
|
||||
listRunningFn: func() ([]autoscan.Event, error) {
|
||||
return []autoscan.Event{{
|
||||
ID: 44,
|
||||
SourceID: &sourceID,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
CapabilityID: "cephfs",
|
||||
StartedAt: runningStartedAt,
|
||||
Status: autoscan.EventStatusRunning,
|
||||
}}, nil
|
||||
},
|
||||
latestEventAtFn: func() (*time.Time, error) {
|
||||
return &latestEventAt, nil
|
||||
},
|
||||
@@ -1316,16 +1296,6 @@ func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
|
||||
if body.ActiveScans != 3 || body.AcceptedScans != 2 || body.RunningScans != 1 {
|
||||
t.Errorf("queue summary = %+v", body)
|
||||
}
|
||||
if len(body.RunningPolls) != 1 {
|
||||
t.Fatalf("running_polls = %+v", body.RunningPolls)
|
||||
}
|
||||
poll := body.RunningPolls[0]
|
||||
if poll.ID != 44 || poll.SourceID == nil || *poll.SourceID != sourceID || poll.PluginID != "silo.autoscan.cephfs" || poll.CapabilityID != "cephfs" {
|
||||
t.Fatalf("running poll identity = %+v", poll)
|
||||
}
|
||||
if poll.ElapsedMS <= 0 {
|
||||
t.Fatalf("running poll elapsed_ms = %d, want > 0", poll.ElapsedMS)
|
||||
}
|
||||
if body.LatestEventAt == nil || !body.LatestEventAt.Equal(latestEventAt) {
|
||||
t.Errorf("latest_event_at = %v, want %v", body.LatestEventAt, latestEventAt)
|
||||
}
|
||||
|
||||
@@ -221,9 +221,6 @@ func (h *CatalogHandler) HandleGetCatalogFacetSearch(w http.ResponseWriter, r *h
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
||||
return
|
||||
}
|
||||
if n > 100 {
|
||||
n = 100
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
|
||||
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
|
||||
}
|
||||
}
|
||||
case "movie", "episode", "audiobook":
|
||||
case "movie", "episode":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
|
||||
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
@@ -2036,10 +2036,8 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
levels = []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
levels = []string{"movie"}
|
||||
case "audiobooks", "audiobook":
|
||||
levels = []string{"audiobook"}
|
||||
case "mixed":
|
||||
levels = []string{"movie", "series", "season", "episode", "audiobook"}
|
||||
levels = []string{"movie", "series", "season", "episode"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
var ErrServerRestartAlreadyRequested = errors.New("server restart already requested")
|
||||
|
||||
type ServerControlHandler struct {
|
||||
requestRestart func(ctx context.Context) error
|
||||
commands *playback.CommandDispatcher
|
||||
}
|
||||
|
||||
type serverRestartRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type serverRestartResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
NotifiedSessions int `json:"notified_sessions"`
|
||||
}
|
||||
|
||||
func NewServerControlHandler(
|
||||
requestRestart func(ctx context.Context) error,
|
||||
commands *playback.CommandDispatcher,
|
||||
) *ServerControlHandler {
|
||||
return &ServerControlHandler{
|
||||
requestRestart: requestRestart,
|
||||
commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRestart handles POST /admin/server/restart.
|
||||
func (h *ServerControlHandler) HandleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.requestRestart == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Server restart is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var req serverRestartRequest
|
||||
if err := decodeOptionalJSONBody(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
notifiedSessions := h.notifyPlaybackSessions(req)
|
||||
|
||||
if err := h.requestRestart(context.Background()); err != nil {
|
||||
if errors.Is(err, ErrServerRestartAlreadyRequested) {
|
||||
writeJSON(w, http.StatusAccepted, serverRestartResponse{
|
||||
Status: "already_requested",
|
||||
Message: "Server restart is already in progress.",
|
||||
NotifiedSessions: notifiedSessions,
|
||||
})
|
||||
return
|
||||
}
|
||||
slog.Error("server restart request failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request server restart")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, serverRestartResponse{
|
||||
Status: "restart_requested",
|
||||
Message: "Server restart requested. The process will shut down gracefully.",
|
||||
NotifiedSessions: notifiedSessions,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest) int {
|
||||
if h == nil || h.commands == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
title := req.Title
|
||||
if title == "" {
|
||||
title = "Server restarting"
|
||||
}
|
||||
message := req.Message
|
||||
if message == "" {
|
||||
message = "The server is restarting now. Playback may reconnect shortly."
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"title": title,
|
||||
"message": message,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("server restart: failed to build realtime payload", "error", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
results := h.commands.DispatchToAll(func(session *playback.Session) (playback.CommandEnvelope, time.Duration, func(), error) {
|
||||
if session == nil {
|
||||
return playback.CommandEnvelope{}, 0, nil, playback.ErrCommandDispatchUnavailable
|
||||
}
|
||||
command, err := playback.NewCommandEnvelope(
|
||||
session.ID,
|
||||
uuid.NewString(),
|
||||
playback.CommandServerRestarting,
|
||||
payload,
|
||||
)
|
||||
if err != nil {
|
||||
return playback.CommandEnvelope{}, 0, nil, err
|
||||
}
|
||||
command.Reason = req.Reason
|
||||
if command.Reason == "" {
|
||||
command.Reason = "server_restart_requested"
|
||||
}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
return command, 0, nil, nil
|
||||
})
|
||||
|
||||
delivered := 0
|
||||
for _, result := range results {
|
||||
if result.Delivered {
|
||||
delivered++
|
||||
continue
|
||||
}
|
||||
if result.DispatchErr != nil && !errors.Is(result.DispatchErr, playback.ErrRealtimeConnectionNotFound) {
|
||||
slog.Warn("server restart: failed to notify playback session",
|
||||
"session_id", result.SessionID,
|
||||
"error", result.DispatchErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
type serverControlTestConn struct {
|
||||
messages []any
|
||||
}
|
||||
|
||||
func (c *serverControlTestConn) WriteJSON(v any) error {
|
||||
c.messages = append(c.messages, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestServerControlRestartRequestsShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
called := 0
|
||||
handler := NewServerControlHandler(func(context.Context) error {
|
||||
called++
|
||||
return nil
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if called != 1 {
|
||||
t.Fatalf("restart calls = %d, want 1", called)
|
||||
}
|
||||
|
||||
var resp serverRestartResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Status != "restart_requested" {
|
||||
t.Fatalf("status = %q, want restart_requested", resp.Status)
|
||||
}
|
||||
if resp.NotifiedSessions != 0 {
|
||||
t.Fatalf("notified_sessions = %d, want 0", resp.NotifiedSessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerControlRestartUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewServerControlHandler(nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerControlRestartAlreadyRequested(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewServerControlHandler(func(context.Context) error {
|
||||
return ErrServerRestartAlreadyRequested
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp serverRestartResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Status != "already_requested" {
|
||||
t.Fatalf("status = %q, want already_requested", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerControlRestartNotifiesPlaybackSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", 100, playback.PlayDirect, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
realtimeHub := playback.NewRealtimeHub()
|
||||
conn := &serverControlTestConn{}
|
||||
registration := realtimeHub.Register(session.ID, conn)
|
||||
if registration == nil {
|
||||
t.Fatal("expected realtime registration")
|
||||
}
|
||||
defer realtimeHub.Unregister(registration)
|
||||
|
||||
dispatcher := playback.NewCommandDispatcher(sessionMgr, realtimeHub, nil)
|
||||
handler := NewServerControlHandler(func(context.Context) error {
|
||||
return nil
|
||||
}, dispatcher)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/admin/server/restart",
|
||||
strings.NewReader(`{"reason":"maintenance","message":"Restarting for maintenance"}`),
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp serverRestartResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.NotifiedSessions != 1 {
|
||||
t.Fatalf("notified_sessions = %d, want 1", resp.NotifiedSessions)
|
||||
}
|
||||
if len(conn.messages) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(conn.messages))
|
||||
}
|
||||
|
||||
command, ok := conn.messages[0].(playback.CommandEnvelope)
|
||||
if !ok {
|
||||
t.Fatalf("message type = %T, want playback.CommandEnvelope", conn.messages[0])
|
||||
}
|
||||
if command.Name != playback.CommandServerRestarting {
|
||||
t.Fatalf("command name = %q, want %q", command.Name, playback.CommandServerRestarting)
|
||||
}
|
||||
if command.Reason != "maintenance" {
|
||||
t.Fatalf("reason = %q, want maintenance", command.Reason)
|
||||
}
|
||||
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(command.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload["message"] != "Restarting for maintenance" {
|
||||
t.Fatalf("payload message = %q, want custom message", payload["message"])
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,14 @@ import (
|
||||
type SystemHandler struct {
|
||||
transcodePool *nodepool.TranscodePool
|
||||
jwtSecret string
|
||||
ffmpegPath string
|
||||
buildInfo buildinfo.Info
|
||||
}
|
||||
|
||||
// NewSystemHandler creates a SystemHandler.
|
||||
func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string, ffmpegPath string) *SystemHandler {
|
||||
func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string) *SystemHandler {
|
||||
return &SystemHandler{
|
||||
transcodePool: transcodePool,
|
||||
jwtSecret: jwtSecret,
|
||||
ffmpegPath: ffmpegPath,
|
||||
buildInfo: buildinfo.Current(),
|
||||
}
|
||||
}
|
||||
@@ -47,7 +45,7 @@ func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
info := playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)
|
||||
info := playback.DetectHWAccel()
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@ type tasteSeedSubmitResponse struct {
|
||||
|
||||
// HandleTasteSeedItems handles GET /recommendations/taste-seed/items.
|
||||
//
|
||||
// Returns a paginated, hydrated list of posters used for the new-user
|
||||
// taste-seeding picker. Blends server-watched popularity with rating reliability
|
||||
// and recency so fresh servers (no watch history yet) still surface recognizable
|
||||
// content. The user_state field carries the existing is_favorite flag, so the UI
|
||||
// can pre-select items the profile already favorited.
|
||||
// Returns a paginated, hydrated list of "popular on this server" posters used
|
||||
// for the new-user taste-seeding picker. Blends server-watched popularity with
|
||||
// TMDB rating so fresh servers (no watch history yet) still surface meaningful
|
||||
// content. The user_state field carries the existing is_favorite flag, so the
|
||||
// UI can pre-select items the profile already favorited.
|
||||
func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r *http.Request) {
|
||||
if h.recsRepo == nil || h.Fetcher == nil {
|
||||
writeJSON(w, http.StatusOK, tasteSeedItemsResponse{Items: []sectionItemResponse{}})
|
||||
|
||||
+18
-9
@@ -143,7 +143,6 @@ type Dependencies struct {
|
||||
PlaybackRealtimeHub *playback.RealtimeHub
|
||||
OnUserSessionsRevoked func(ctx context.Context, userID int)
|
||||
OnServerSettingUpdated func(ctx context.Context, key, value string)
|
||||
RequestServerRestart func(ctx context.Context) error
|
||||
|
||||
// UserCollectionSync handles per-profile imported collections (TMDB /
|
||||
// Trakt / MDBList) — the user-facing analogue of CollectionService.
|
||||
@@ -165,6 +164,9 @@ type Dependencies struct {
|
||||
// clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all
|
||||
// resolve correctly. May be nil; no ABS routes are registered in that case.
|
||||
ABSHandler absHandler
|
||||
// AudiobooksEnabled gates native audiobook admin/UI routes behind the same
|
||||
// server setting as the ABS compatibility listener.
|
||||
AudiobooksEnabled bool
|
||||
}
|
||||
|
||||
// absHandler is the narrow interface the router needs from the ABS handler.
|
||||
@@ -577,7 +579,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// Build playback handler if session manager is available.
|
||||
var playbackHandler *handlers.PlaybackHandler
|
||||
var adminPlaybackControlHandler *handlers.AdminPlaybackControlHandler
|
||||
var playbackCommandDispatcher *playback.CommandDispatcher
|
||||
var streamHandler *handlers.StreamHandler
|
||||
var watchTogetherHandler *handlers.WatchTogetherHandler
|
||||
if deps.SessionMgr != nil {
|
||||
@@ -661,7 +662,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
playbackHandler.RealtimeHub = realtimeHub
|
||||
playbackHandler.CommandTracker = commandTracker
|
||||
playbackHandler.CommandDispatcher = playback.NewCommandDispatcher(deps.SessionMgr, realtimeHub, commandTracker)
|
||||
playbackCommandDispatcher = playbackHandler.CommandDispatcher
|
||||
playbackHandler.IntroAnalyzer = deps.IntroAnalyzer
|
||||
playbackHandler.IntroRepository = deps.IntroRepository
|
||||
playbackHandler.MarkerRegistry = deps.MarkerRegistry
|
||||
@@ -700,8 +700,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
streamHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
|
||||
}
|
||||
|
||||
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher)
|
||||
|
||||
// Build admin handler if we have a user repo.
|
||||
var adminHandler *handlers.AdminHandler
|
||||
var catalogSeedHandler *handlers.CatalogSeedHandler
|
||||
@@ -1811,6 +1809,20 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/sections/recipes", recipeHandler.HandleList)
|
||||
r.Get("/sections/recipes/{type}/candidates", recipeHandler.HandleCandidates)
|
||||
|
||||
// Audiobook endpoints (no profile required — catalog-level list/detail).
|
||||
if deps.AudiobooksEnabled && itemRepo != nil {
|
||||
audiobookHandler := &handlers.AudiobookHandler{
|
||||
Items: itemRepo,
|
||||
Files: deps.FileRepo,
|
||||
Detail: detailSvc,
|
||||
StoreProvider: deps.UserStoreProvider,
|
||||
Recs: recsRepoForStale,
|
||||
}
|
||||
r.Get("/audiobooks", audiobookHandler.HandleListAudiobooks)
|
||||
r.Get("/audiobooks/{id}", audiobookHandler.HandleGetAudiobook)
|
||||
r.Post("/audiobooks/{id}/progress", audiobookHandler.HandleReportAudiobookProgress)
|
||||
}
|
||||
|
||||
// Section endpoints (profile-scoped).
|
||||
if sectionHandler != nil {
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -1908,7 +1920,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
|
||||
r.Get("/unmatched", adminHandler.HandleListUnmatched)
|
||||
r.Get("/stats", adminHandler.HandleGetStats)
|
||||
r.Post("/server/restart", serverControlHandler.HandleRestart)
|
||||
r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus)
|
||||
r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection)
|
||||
if sectionSettingsHandler != nil {
|
||||
@@ -2102,12 +2113,10 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// System inspection.
|
||||
{
|
||||
sysJWTSecret := ""
|
||||
sysFFmpegPath := ""
|
||||
if deps.Config != nil {
|
||||
sysJWTSecret = deps.Config.Auth.JWTSecret
|
||||
sysFFmpegPath = deps.Config.Playback.FFmpegPath
|
||||
}
|
||||
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret, sysFFmpegPath)
|
||||
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret)
|
||||
r.Route("/system", func(r chi.Router) {
|
||||
r.Get("/build", systemHandler.HandleBuildInfo)
|
||||
r.Get("/hw-accel", systemHandler.HandleHWAccel)
|
||||
|
||||
@@ -18,8 +18,7 @@ func (h *Handler) handleAuthorDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
access, err := h.accessFilterForAuth(r.Context(), a)
|
||||
if err != nil {
|
||||
slog.Warn("abs author access resolution failed", "err", err, "id", id)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
author, err := h.deps.MediaStore.GetAuthorByID(r.Context(), id, access)
|
||||
@@ -48,8 +47,7 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
access, err := h.accessFilterForAuth(r.Context(), a)
|
||||
if err != nil {
|
||||
slog.Warn("abs series access resolution failed", "err", err, "id", id)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
series, err := h.deps.MediaStore.GetSeriesByName(r.Context(), id, access)
|
||||
|
||||
@@ -44,9 +44,7 @@ func TestAuthor_Detail_ReturnsBooks(t *testing.T) {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got["name"] != "Brandon Sanderson" {
|
||||
t.Errorf("name = %v", got["name"])
|
||||
}
|
||||
@@ -80,9 +78,7 @@ func TestSeries_Detail_ReturnsBooks(t *testing.T) {
|
||||
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got["name"] != "Mistborn" {
|
||||
t.Errorf("name = %v", got["name"])
|
||||
}
|
||||
|
||||
@@ -61,12 +61,7 @@ func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
|
||||
if err != nil {
|
||||
slog.Error("abs bookmark item lookup failed", "err", err, "user", a.UserID, "item", itemID)
|
||||
http.Error(w, "item lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
if err != nil || item == nil {
|
||||
http.Error(w, "item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
@@ -158,14 +157,10 @@ func (p *recordingPublisher) snapshot() []publishedEvent {
|
||||
// handler tests can drive both the 200 and 404 branches.
|
||||
type stubMediaStore struct {
|
||||
noopMediaStore
|
||||
known map[string]*models.MediaItem // itemID → row (nil means "exists but no row needed")
|
||||
lookupErr error
|
||||
known map[string]*models.MediaItem // itemID → row (nil means "exists but no row needed")
|
||||
}
|
||||
|
||||
func (s *stubMediaStore) GetAudiobookByID(_ context.Context, id string, _ catalog.AccessFilter) (*models.MediaItem, error) {
|
||||
if s.lookupErr != nil {
|
||||
return nil, s.lookupErr
|
||||
}
|
||||
if it, ok := s.known[id]; ok {
|
||||
if it == nil {
|
||||
return &models.MediaItem{ContentID: id}, nil
|
||||
@@ -416,19 +411,6 @@ func TestMissingItem_404(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemLookup_ErrorFromMediaStore_500(t *testing.T) {
|
||||
hb := newBookmarksHarness(t, "book-1")
|
||||
hb.H.deps.MediaStore = &stubMediaStore{
|
||||
known: map[string]*models.MediaItem{"book-1": nil},
|
||||
lookupErr: errors.New("media lookup failed"),
|
||||
}
|
||||
|
||||
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"x","time":1}`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidBody_400(t *testing.T) {
|
||||
hb := newBookmarksHarness(t, "book-1")
|
||||
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{not json`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
@@ -40,12 +39,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == nil {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(*body.Name)
|
||||
if name == "" {
|
||||
if body.Name == nil || *body.Name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -54,7 +48,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
ID: ulid.Make().String(),
|
||||
UserID: a.UserID,
|
||||
ProfileID: a.ProfileID,
|
||||
Name: name,
|
||||
Name: *body.Name,
|
||||
}
|
||||
if body.Description != nil {
|
||||
c.Description = *body.Description
|
||||
@@ -273,12 +267,7 @@ func (h *Handler) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if body.Name != nil {
|
||||
name := strings.TrimSpace(*body.Name)
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.Name = name
|
||||
c.Name = *body.Name
|
||||
}
|
||||
if body.Description != nil {
|
||||
c.Description = *body.Description
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
// and profile_id; we mirror that so List can filter correctly).
|
||||
type memCollectionStore struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]Collection // id -> row
|
||||
rows map[string]Collection // id -> row
|
||||
items map[string][]CollectionItem // collection_id -> items
|
||||
}
|
||||
|
||||
@@ -281,16 +281,8 @@ func TestCollection_List_DoesNotLeakOtherUsers(t *testing.T) {
|
||||
// User 2 lists.
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/collections", nil, nil, "2", "", hb.H.handleListCollections)
|
||||
var env map[string]any
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
list, ok := env["collections"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("response missing 'collections' key; body=%s", rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
list, _ := env["collections"].([]any)
|
||||
if len(list) != 0 {
|
||||
t.Errorf("user 2 sees %d collections, want 0", len(list))
|
||||
}
|
||||
@@ -303,16 +295,8 @@ func TestCollection_List_ProfileIsolation(t *testing.T) {
|
||||
_ = dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(`{"name":"A"}`), "1", pA, hb.H.handleCreateCollection)
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/collections", nil, nil, "1", pB, hb.H.handleListCollections)
|
||||
var env map[string]any
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
list, ok := env["collections"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("response missing 'collections' key; body=%s", rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
list, _ := env["collections"].([]any)
|
||||
if len(list) != 0 {
|
||||
t.Errorf("profile B sees %d collections, want 0", len(list))
|
||||
}
|
||||
|
||||
@@ -32,12 +32,7 @@ func (h *Handler) setHideFromContinue(w http.ResponseWriter, r *http.Request, hi
|
||||
return
|
||||
}
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
|
||||
if err != nil {
|
||||
slog.Error("abs continue item lookup failed", "err", err, "user", a.UserID, "item", itemID)
|
||||
http.Error(w, "item lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
if err != nil || item == nil {
|
||||
http.Error(w, "item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package abs
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -74,18 +73,3 @@ func TestContinue_UnknownItem_404(t *testing.T) {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinue_MediaLookupError_500(t *testing.T) {
|
||||
prog := &recordingProgressFake{}
|
||||
media := &stubMediaStore{
|
||||
known: map[string]*models.MediaItem{"book-1": nil},
|
||||
lookupErr: errors.New("media lookup failed"),
|
||||
}
|
||||
h := New(Dependencies{MediaStore: media, ProgressStore: prog})
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/book-1/remove-from-continue-listening",
|
||||
map[string]string{"itemId": "book-1"}, nil, "1", "", h.handleRemoveFromContinueListening)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,18 +151,14 @@ func (h *Handler) handleDeleteItemProgress(w http.ResponseWriter, r *http.Reques
|
||||
// returns the empty progress shape so the client can store offline state
|
||||
// without raising an error.
|
||||
func (h *Handler) handleSetEpisodeProgress(w http.ResponseWriter, r *http.Request) {
|
||||
if a, ok := absAuthFrom(r); !ok || a.UserID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"libraryItemId": chi.URLParam(r, "libraryItemId"),
|
||||
"episodeId": chi.URLParam(r, "episodeId"),
|
||||
"currentTime": 0.0,
|
||||
"duration": 0.0,
|
||||
"isFinished": false,
|
||||
"progress": 0.0,
|
||||
"lastUpdate": 0,
|
||||
"libraryItemId": chi.URLParam(r, "libraryItemId"),
|
||||
"episodeId": chi.URLParam(r, "episodeId"),
|
||||
"currentTime": 0.0,
|
||||
"duration": 0.0,
|
||||
"isFinished": false,
|
||||
"progress": 0.0,
|
||||
"lastUpdate": 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,8 +182,8 @@ func (h *Handler) handleEbookFile(w http.ResponseWriter, _ *http.Request) {
|
||||
// optimistic update succeeds.
|
||||
func (h *Handler) handleEbookStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"libraryItemId": chi.URLParam(r, "id"),
|
||||
"fileId": chi.URLParam(r, "fileid"),
|
||||
"libraryItemId": chi.URLParam(r, "id"),
|
||||
"fileId": chi.URLParam(r, "fileid"),
|
||||
"isSupplementary": false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ type SettingsReader interface {
|
||||
GetString(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// Service owns the Audiobookshelf compatibility adapters. Native audiobook
|
||||
// libraries are normal catalog media_items and do not depend on this service.
|
||||
// Service is the audiobooks feature's top-level orchestrator. Sub-plan 1
|
||||
// exposes only Enabled(); subsequent sub-plans hang additional methods
|
||||
// off Service as new capabilities (scanner branches, ABS handlers, etc.)
|
||||
// come online.
|
||||
type Service struct {
|
||||
settings SettingsReader
|
||||
ABSHandler *abs.Handler
|
||||
@@ -179,16 +181,17 @@ func buildABSRecommender(deps ABSHandlerDeps) abs.Recommender {
|
||||
return &ABSRecommender{Pool: deps.Pool, Recs: deps.Recs}
|
||||
}
|
||||
|
||||
// ABSCompatEnabled reports whether the Audiobookshelf compatibility listener
|
||||
// should be started. Native audiobook libraries intentionally do not read this
|
||||
// setting.
|
||||
func (s *Service) ABSCompatEnabled(ctx context.Context) (bool, error) {
|
||||
// Enabled reports whether the audiobooks feature flag (set by
|
||||
// 160_audiobooks_feature_flag and toggled by operators) is currently true.
|
||||
// Any value other than the literal string "true" reads as false; this matches how silo
|
||||
// treats other boolean server_settings rows.
|
||||
func (s *Service) Enabled(ctx context.Context) (bool, error) {
|
||||
if s == nil || s.settings == nil {
|
||||
return false, nil
|
||||
}
|
||||
value, err := s.settings.GetString(ctx, "audiobookshelf_compat.enabled")
|
||||
value, err := s.settings.GetString(ctx, "audiobooks.enabled")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read audiobookshelf_compat.enabled: %w", err)
|
||||
return false, fmt.Errorf("read audiobooks.enabled: %w", err)
|
||||
}
|
||||
return value == "true", nil
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@ type fakeSettingsReader struct {
|
||||
}
|
||||
|
||||
func (f *fakeSettingsReader) GetString(_ context.Context, key string) (string, error) {
|
||||
if key != "audiobookshelf_compat.enabled" {
|
||||
if key != "audiobooks.enabled" {
|
||||
return "", errors.New("unexpected key: " + key)
|
||||
}
|
||||
return f.value, f.err
|
||||
}
|
||||
|
||||
func TestServiceABSCompatEnabledReadsFlag(t *testing.T) {
|
||||
func TestServiceEnabledReadsFlag(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stored string
|
||||
@@ -33,44 +33,44 @@ func TestServiceABSCompatEnabledReadsFlag(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := New(&fakeSettingsReader{value: tc.stored})
|
||||
got, err := svc.ABSCompatEnabled(context.Background())
|
||||
got, err := svc.Enabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ABSCompatEnabled returned error: %v", err)
|
||||
t.Fatalf("Enabled returned error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("ABSCompatEnabled = %v, want %v", got, tc.want)
|
||||
t.Fatalf("Enabled = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceABSCompatEnabledPropagatesError(t *testing.T) {
|
||||
func TestServiceEnabledPropagatesError(t *testing.T) {
|
||||
wantErr := errors.New("db down")
|
||||
svc := New(&fakeSettingsReader{err: wantErr})
|
||||
_, err := svc.ABSCompatEnabled(context.Background())
|
||||
_, err := svc.Enabled(context.Background())
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("ABSCompatEnabled error = %v, want %v wrapped", err, wantErr)
|
||||
t.Fatalf("Enabled error = %v, want %v wrapped", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceABSCompatEnabledNilReceiverReturnsFalse(t *testing.T) {
|
||||
func TestServiceEnabledNilReceiverReturnsFalse(t *testing.T) {
|
||||
var svc *Service
|
||||
got, err := svc.ABSCompatEnabled(context.Background())
|
||||
got, err := svc.Enabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ABSCompatEnabled returned error: %v", err)
|
||||
t.Fatalf("Enabled returned error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatal("ABSCompatEnabled = true, want false")
|
||||
t.Fatal("Enabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
|
||||
func TestServiceEnabledNilSettingsReturnsFalse(t *testing.T) {
|
||||
svc := New(nil)
|
||||
got, err := svc.ABSCompatEnabled(context.Background())
|
||||
got, err := svc.Enabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ABSCompatEnabled returned error: %v", err)
|
||||
t.Fatalf("Enabled returned error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Fatal("ABSCompatEnabled = true, want false")
|
||||
t.Fatal("Enabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,19 @@ import (
|
||||
// enriched with the metadata the Add-source picker needs (plugin id + a
|
||||
// human-friendly display name).
|
||||
type DiscoveredSource struct {
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
// PluginID is the installation's plugin id (e.g. "sonarr"); empty when the
|
||||
// lister cannot supply it.
|
||||
PluginID string
|
||||
CapabilityID string
|
||||
PluginID string
|
||||
// DisplayName is a human-friendly label for the capability (from the
|
||||
// capability's manifest display_name, falling back to plugin/capability ids).
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// ScanSourceLister enumerates every installed scan_source.v1 capability so the
|
||||
// engine can offer them in the Add-source picker.
|
||||
// engine can (a) offer them in the Add-source picker and (b) detect orphaned
|
||||
// source rows whose plugin has been uninstalled.
|
||||
type ScanSourceLister interface {
|
||||
// ListScanSources returns one entry per installed scan_source.v1 capability,
|
||||
// enriched with plugin id + display name.
|
||||
@@ -29,9 +31,17 @@ type ScanSourceLister interface {
|
||||
// AvailableScanSource is one installed scan_source capability an operator can
|
||||
// create a source against (the Add-source picker list).
|
||||
type AvailableScanSource struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// installedKey identifies an installed scan_source capability for set
|
||||
// membership tests (orphan detection in PollOnce).
|
||||
type installedKey struct {
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
}
|
||||
|
||||
// ListAvailableScanSources enumerates every installed scan_source capability so
|
||||
@@ -49,10 +59,30 @@ func (s *Service) ListAvailableScanSources(ctx context.Context) ([]AvailableScan
|
||||
out := make([]AvailableScanSource, 0, len(discovered))
|
||||
for _, d := range discovered {
|
||||
out = append(out, AvailableScanSource{
|
||||
PluginID: d.PluginID,
|
||||
CapabilityID: d.CapabilityID,
|
||||
DisplayName: d.DisplayName,
|
||||
InstallationID: d.InstallationID,
|
||||
CapabilityID: d.CapabilityID,
|
||||
PluginID: d.PluginID,
|
||||
DisplayName: d.DisplayName,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// installedScanSources returns the set of currently-installed scan_source
|
||||
// capabilities so PollOnce can skip orphaned source rows (their plugin is
|
||||
// gone). A nil set means the set is unavailable (no lister) — callers must treat
|
||||
// that as "discovery unavailable" and NOT prune.
|
||||
func (s *Service) installedScanSources(ctx context.Context) (map[installedKey]struct{}, error) {
|
||||
if s.lister == nil {
|
||||
return nil, nil
|
||||
}
|
||||
discovered, err := s.lister.ListScanSources(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scan sources: %w", err)
|
||||
}
|
||||
present := make(map[installedKey]struct{}, len(discovered))
|
||||
for _, d := range discovered {
|
||||
present[installedKey{InstallationID: d.InstallationID, CapabilityID: d.CapabilityID}] = struct{}{}
|
||||
}
|
||||
return present, nil
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ func (f fakeLister) ListScanSources(context.Context) ([]DiscoveredSource, error)
|
||||
|
||||
func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) {
|
||||
lister := fakeLister{sources: []DiscoveredSource{
|
||||
{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"},
|
||||
{PluginID: "radarr", CapabilityID: "arr-b", DisplayName: "Radarr"},
|
||||
{InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"},
|
||||
{InstallationID: 2, CapabilityID: "arr-b", PluginID: "radarr", DisplayName: "Radarr"},
|
||||
}}
|
||||
svc := &Service{lister: lister}
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) {
|
||||
if len(available) != 2 {
|
||||
t.Fatalf("expected 2 available, got %d: %+v", len(available), available)
|
||||
}
|
||||
if available[0] != (AvailableScanSource{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"}) {
|
||||
if available[0] != (AvailableScanSource{InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"}) {
|
||||
t.Fatalf("unexpected first available: %+v", available[0])
|
||||
}
|
||||
}
|
||||
@@ -41,3 +41,32 @@ func TestListAvailableScanSourcesNilListerEmpty(t *testing.T) {
|
||||
t.Fatalf("nil lister must return empty, got %+v", available)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledScanSourcesSetMembership(t *testing.T) {
|
||||
lister := fakeLister{sources: []DiscoveredSource{
|
||||
{InstallationID: 1, CapabilityID: "arr-a"},
|
||||
}}
|
||||
svc := &Service{lister: lister}
|
||||
|
||||
present, err := svc.installedScanSources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("installedScanSources: %v", err)
|
||||
}
|
||||
if _, ok := present[installedKey{1, "arr-a"}]; !ok {
|
||||
t.Fatalf("expected 1/arr-a present: %+v", present)
|
||||
}
|
||||
if _, ok := present[installedKey{2, "arr-b"}]; ok {
|
||||
t.Fatalf("did not expect 2/arr-b present: %+v", present)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledScanSourcesNilListerNilSet(t *testing.T) {
|
||||
svc := &Service{lister: nil}
|
||||
present, err := svc.installedScanSources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("installedScanSources: %v", err)
|
||||
}
|
||||
if present != nil {
|
||||
t.Fatalf("nil lister must return a nil set, got %+v", present)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,3 @@ import "errors"
|
||||
// ErrNotFound is returned when an autoscan operation references a connection or
|
||||
// source that does not exist. The admin API surfaces it as a 404.
|
||||
var ErrNotFound = errors.New("autoscan: not found")
|
||||
|
||||
// ErrPollAlreadyRunning is returned when a source already has an active poll
|
||||
// event. Callers should treat it as a no-op skip, not a source failure.
|
||||
var ErrPollAlreadyRunning = errors.New("autoscan: source poll already running")
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestTestConnectionNoProbeConfigured(t *testing.T) {
|
||||
func TestSuggestRewritesMatchesArrRootsToSiloFolders(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
sources: []Source{
|
||||
{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1")},
|
||||
{ID: "src-1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1")},
|
||||
},
|
||||
}
|
||||
arr := &fakeArrClient{roots: []string{"/data/tv"}}
|
||||
@@ -87,7 +87,7 @@ func TestSuggestRewritesMatchesArrRootsToSiloFolders(t *testing.T) {
|
||||
|
||||
func TestSuggestRewritesNoConnection(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
sources: []Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}},
|
||||
sources: []Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}},
|
||||
}
|
||||
svc := &Service{store: store, connres: passthroughConnRes{}, rootFolders: &fakeArrClient{}, folders: fakeFolderLister{}}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// ScanSourceProvider yields changed paths for one source. The engine calls
|
||||
// PollChanges; production wraps the plugins.Service scan_source resolver.
|
||||
type ScanSourceProvider interface {
|
||||
PollChanges(ctx context.Context, pluginID, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) (changes []Change, nextMarker string, err error)
|
||||
PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) (changes []Change, nextMarker string, err error)
|
||||
}
|
||||
|
||||
// PollChangesClient is the slice of *pluginhost.ScanSourceClient used here. It
|
||||
@@ -20,10 +20,10 @@ type PollChangesClient interface {
|
||||
PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error)
|
||||
}
|
||||
|
||||
// ScanSourceResolver yields a per-(plugin, capability) scan-source client.
|
||||
// ScanSourceResolver yields a per-(installation, capability) scan-source client.
|
||||
// Exported for the same cross-package adapter reason as PollChangesClient.
|
||||
type ScanSourceResolver interface {
|
||||
ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (PollChangesClient, error)
|
||||
ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (PollChangesClient, error)
|
||||
}
|
||||
|
||||
type pluginProvider struct{ resolver ScanSourceResolver }
|
||||
@@ -34,8 +34,8 @@ func NewPluginProvider(resolver ScanSourceResolver) ScanSourceProvider {
|
||||
return &pluginProvider{resolver: resolver}
|
||||
}
|
||||
|
||||
func (p *pluginProvider) PollChanges(ctx context.Context, pluginID, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
client, err := p.resolver.ScanSourceClient(ctx, pluginID, capabilityID)
|
||||
func (p *pluginProvider) PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
client, err := p.resolver.ScanSourceClient(ctx, installationID, capabilityID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -17,17 +17,10 @@ func (c *capturingClient) PollChanges(_ context.Context, req *pluginv1.PollChang
|
||||
return &pluginv1.PollChangesResponse{SourcePaths: []string{"/mnt/media/x"}, NextMarker: "m2"}, nil
|
||||
}
|
||||
|
||||
// capturingResolver yields a fixed PollChangesClient and records the requested
|
||||
// stable scan-source identity.
|
||||
type capturingResolver struct {
|
||||
client PollChangesClient
|
||||
lastPluginID string
|
||||
lastCapID string
|
||||
}
|
||||
// capturingResolver yields a fixed PollChangesClient.
|
||||
type capturingResolver struct{ client PollChangesClient }
|
||||
|
||||
func (r *capturingResolver) ScanSourceClient(_ context.Context, pluginID, capabilityID string) (PollChangesClient, error) {
|
||||
r.lastPluginID = pluginID
|
||||
r.lastCapID = capabilityID
|
||||
func (r capturingResolver) ScanSourceClient(context.Context, int, string) (PollChangesClient, error) {
|
||||
return r.client, nil
|
||||
}
|
||||
|
||||
@@ -35,12 +28,11 @@ func (r *capturingResolver) ScanSourceClient(_ context.Context, pluginID, capabi
|
||||
// delivered to the plugin on the PollChangesRequest.
|
||||
func TestPluginProviderPopulatesConnection(t *testing.T) {
|
||||
client := &capturingClient{}
|
||||
resolver := &capturingResolver{client: client}
|
||||
prov := NewPluginProvider(resolver)
|
||||
prov := NewPluginProvider(capturingResolver{client})
|
||||
|
||||
conn := ResolvedConnection{BaseURL: "https://arr.example", APIKey: "secret-key"}
|
||||
sourceConfig := map[string]string{"exclusions": ".downloads"}
|
||||
changes, next, err := prov.PollChanges(context.Background(), "silo.autoscan.arr", "cap", "m1", conn, sourceConfig)
|
||||
changes, next, err := prov.PollChanges(context.Background(), 1, "cap", "m1", conn, sourceConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("PollChanges: %v", err)
|
||||
}
|
||||
@@ -53,9 +45,6 @@ func TestPluginProviderPopulatesConnection(t *testing.T) {
|
||||
if client.last.GetCapabilityId() != "cap" || client.last.GetMarker() != "m1" {
|
||||
t.Fatalf("unexpected request fields: cap=%q marker=%q", client.last.GetCapabilityId(), client.last.GetMarker())
|
||||
}
|
||||
if resolver.lastPluginID != "silo.autoscan.arr" || resolver.lastCapID != "cap" {
|
||||
t.Fatalf("resolver identity = %q/%q", resolver.lastPluginID, resolver.lastCapID)
|
||||
}
|
||||
rc := client.last.GetConnection()
|
||||
if rc == nil {
|
||||
t.Fatal("expected connection to be populated on the request")
|
||||
@@ -70,9 +59,9 @@ func TestPluginProviderPopulatesConnection(t *testing.T) {
|
||||
|
||||
func TestPluginProviderPrefersStructuredChanges(t *testing.T) {
|
||||
client := &capturingStructuredClient{}
|
||||
prov := NewPluginProvider(&capturingResolver{client: client})
|
||||
prov := NewPluginProvider(capturingResolver{client: client})
|
||||
|
||||
changes, next, err := prov.PollChanges(context.Background(), "silo.autoscan.arr", "cap", "m1", ResolvedConnection{}, nil)
|
||||
changes, next, err := prov.PollChanges(context.Background(), 1, "cap", "m1", ResolvedConnection{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PollChanges: %v", err)
|
||||
}
|
||||
|
||||
+26
-124
@@ -175,14 +175,14 @@ func (r *Repository) GetConnection(ctx context.Context, id string) (Connection,
|
||||
|
||||
// --- Sources ---
|
||||
|
||||
const sourceColumns = `id, plugin_id, capability_id, connection_id, enabled,
|
||||
const sourceColumns = `id, installation_id, capability_id, connection_id, enabled,
|
||||
poll_interval_seconds, path_rewrites, source_config, label, marker, last_run_at, last_error`
|
||||
|
||||
func scanSource(row interface{ Scan(...any) error }) (Source, error) {
|
||||
var s Source
|
||||
var pathRewrites []byte
|
||||
var sourceConfig []byte
|
||||
if err := row.Scan(&s.ID, &s.PluginID, &s.CapabilityID, &s.ConnectionID,
|
||||
if err := row.Scan(&s.ID, &s.InstallationID, &s.CapabilityID, &s.ConnectionID,
|
||||
&s.Enabled, &s.PollIntervalSeconds, &pathRewrites, &sourceConfig, &s.Label, &s.Marker, &s.LastRunAt, &s.LastError); err != nil {
|
||||
return Source{}, err
|
||||
}
|
||||
@@ -283,11 +283,11 @@ func (r *Repository) CreateSource(ctx context.Context, s Source) (Source, error)
|
||||
}
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO autoscan_sources (
|
||||
plugin_id, capability_id, connection_id, enabled, poll_interval_seconds, path_rewrites, source_config, label
|
||||
installation_id, capability_id, connection_id, enabled, poll_interval_seconds, path_rewrites, source_config, label
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING `+sourceColumns,
|
||||
s.PluginID, s.CapabilityID, connectionIDArg(s.ConnectionID), s.Enabled, s.PollIntervalSeconds, rewrites, sourceConfig, s.Label)
|
||||
s.InstallationID, s.CapabilityID, connectionIDArg(s.ConnectionID), s.Enabled, s.PollIntervalSeconds, rewrites, sourceConfig, s.Label)
|
||||
out, err := scanSource(row)
|
||||
if err != nil {
|
||||
if connID, ok := connectionFKViolation(err, s.ConnectionID); ok {
|
||||
@@ -299,7 +299,7 @@ func (r *Repository) CreateSource(ctx context.Context, s Source) (Source, error)
|
||||
}
|
||||
|
||||
// UpdateSource updates a source's binding/scheduling fields by id. Identity
|
||||
// (plugin_id, capability_id) and bookkeeping fields (marker/last_run_at/
|
||||
// (installation_id, capability_id) and bookkeeping fields (marker/last_run_at/
|
||||
// last_error) are left untouched. An unknown id maps to ErrNotFound; a
|
||||
// non-existent connection trips the FK constraint and also maps to ErrNotFound.
|
||||
func (r *Repository) UpdateSource(ctx context.Context, s Source) (Source, error) {
|
||||
@@ -353,7 +353,7 @@ func connectionFKViolation(err error, connectionID *string) (string, bool) {
|
||||
|
||||
func (r *Repository) ListSources(ctx context.Context) ([]Source, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+sourceColumns+`
|
||||
FROM autoscan_sources ORDER BY plugin_id, capability_id`)
|
||||
FROM autoscan_sources ORDER BY installation_id, capability_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list autoscan sources: %w", err)
|
||||
}
|
||||
@@ -364,7 +364,7 @@ func (r *Repository) ListSources(ctx context.Context) ([]Source, error) {
|
||||
func (r *Repository) ListEnabledSources(ctx context.Context) ([]Source, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT `+sourceColumns+`
|
||||
FROM autoscan_sources WHERE enabled = true
|
||||
ORDER BY plugin_id, capability_id`)
|
||||
ORDER BY installation_id, capability_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled autoscan sources: %w", err)
|
||||
}
|
||||
@@ -462,7 +462,7 @@ func (r *Repository) RecordError(ctx context.Context, sourceID, msg string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
const eventColumns = `id, source_id, plugin_id, capability_id, started_at, completed_at,
|
||||
const eventColumns = `id, source_id, installation_id, capability_id, started_at, completed_at,
|
||||
duration_ms, status, changes_returned, changes_resolved, targets_claimed, scans_created,
|
||||
scans_reused, scans_suppressed, error_message, marker_before, marker_after`
|
||||
|
||||
@@ -472,7 +472,7 @@ func scanEvent(row interface{ Scan(...any) error }) (Event, error) {
|
||||
if err := row.Scan(
|
||||
&e.ID,
|
||||
&e.SourceID,
|
||||
&e.PluginID,
|
||||
&e.InstallationID,
|
||||
&e.CapabilityID,
|
||||
&e.StartedAt,
|
||||
&e.CompletedAt,
|
||||
@@ -499,80 +499,25 @@ func (r *Repository) CreateEvent(ctx context.Context, in EventCreate) (int64, er
|
||||
if started.IsZero() {
|
||||
started = time.Now()
|
||||
}
|
||||
sourceID := strings.TrimSpace(in.SourceID)
|
||||
if sourceID == "" {
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO autoscan_events (
|
||||
source_id, plugin_id, capability_id, started_at, completed_at,
|
||||
duration_ms, status, error_message, marker_before
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $4, 0, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
nil,
|
||||
in.PluginID,
|
||||
in.CapabilityID,
|
||||
started,
|
||||
string(EventStatusRunning),
|
||||
"",
|
||||
nullable(in.MarkerBefore),
|
||||
)
|
||||
var id int64
|
||||
if err := row.Scan(&id); err != nil {
|
||||
return 0, fmt.Errorf("create autoscan event: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin autoscan event: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(900173, hashtext($1))`, sourceID); err != nil {
|
||||
return 0, fmt.Errorf("lock autoscan event: %w", err)
|
||||
}
|
||||
|
||||
var runningID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id
|
||||
FROM autoscan_events
|
||||
WHERE source_id = $1
|
||||
AND status = $2
|
||||
ORDER BY started_at ASC, id ASC
|
||||
LIMIT 1`,
|
||||
sourceID,
|
||||
string(EventStatusRunning),
|
||||
).Scan(&runningID)
|
||||
if err == nil {
|
||||
return 0, fmt.Errorf("%w: source %s event %d", ErrPollAlreadyRunning, sourceID, runningID)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, fmt.Errorf("check running autoscan event: %w", err)
|
||||
}
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO autoscan_events (
|
||||
source_id, plugin_id, capability_id, started_at, completed_at,
|
||||
source_id, installation_id, capability_id, started_at, completed_at,
|
||||
duration_ms, status, error_message, marker_before
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $4, 0, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
sourceID,
|
||||
in.PluginID,
|
||||
nullable(in.SourceID),
|
||||
in.InstallationID,
|
||||
in.CapabilityID,
|
||||
started,
|
||||
string(EventStatusRunning),
|
||||
"",
|
||||
string(EventStatusError),
|
||||
"poll started but did not finish",
|
||||
nullable(in.MarkerBefore),
|
||||
)
|
||||
var id int64
|
||||
if err := row.Scan(&id); err != nil {
|
||||
return 0, fmt.Errorf("create autoscan event: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, fmt.Errorf("commit autoscan event: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -620,25 +565,6 @@ func (r *Repository) FinishEvent(ctx context.Context, in EventFinish) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkInterruptedEvents(ctx context.Context) error {
|
||||
msg := "poll started but did not finish"
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE autoscan_events
|
||||
SET completed_at = now(),
|
||||
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint,
|
||||
status = $1,
|
||||
error_message = $2
|
||||
WHERE status = $3`,
|
||||
string(EventStatusError),
|
||||
msg,
|
||||
string(EventStatusRunning),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark interrupted autoscan events: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clampAutoscanLimit bounds a requested page size to a sane window: the
|
||||
// default page when unset, capped so a single query can never fan out.
|
||||
func clampAutoscanLimit(limit int) int {
|
||||
@@ -832,30 +758,6 @@ func (r *Repository) ListEvents(ctx context.Context, filter EventListFilter) ([]
|
||||
return events, runRows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) ListRunningEvents(ctx context.Context) ([]Event, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+eventColumns+`
|
||||
FROM autoscan_events
|
||||
WHERE status = $1
|
||||
ORDER BY started_at ASC, id ASC`,
|
||||
string(EventStatusRunning),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list running autoscan events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]Event, 0)
|
||||
for rows.Next() {
|
||||
event, scanErr := scanEvent(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilter) ([]ScanWithEvent, error) {
|
||||
limit := clampAutoscanLimit(filter.Limit)
|
||||
offset := filter.Offset
|
||||
@@ -878,15 +780,15 @@ func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilte
|
||||
sr.trigger,
|
||||
sr.status,
|
||||
COALESCE(sr.error_message, ''),
|
||||
sr.requested_at,
|
||||
sr.started_at,
|
||||
sr.completed_at,
|
||||
sr.autoscan_event_id,
|
||||
e.source_id,
|
||||
COALESCE(e.plugin_id, ''),
|
||||
COALESCE(e.capability_id, ''),
|
||||
COALESCE(e.status, ''),
|
||||
e.completed_at
|
||||
sr.requested_at,
|
||||
sr.started_at,
|
||||
sr.completed_at,
|
||||
sr.autoscan_event_id,
|
||||
e.source_id,
|
||||
e.installation_id,
|
||||
COALESCE(e.capability_id, ''),
|
||||
COALESCE(e.status, ''),
|
||||
e.completed_at
|
||||
FROM scan_runs sr
|
||||
LEFT JOIN autoscan_events e ON e.id = sr.autoscan_event_id
|
||||
WHERE `+strings.Join(clauses, " AND ")+`
|
||||
@@ -916,7 +818,7 @@ func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilte
|
||||
&scan.CompletedAt,
|
||||
&scan.AutoscanEventID,
|
||||
&scan.SourceID,
|
||||
&scan.PluginID,
|
||||
&scan.InstallationID,
|
||||
&scan.CapabilityID,
|
||||
&eventStatus,
|
||||
&scan.EventCompletedAt,
|
||||
@@ -951,7 +853,7 @@ func (r *Repository) GetQueueSummary(ctx context.Context) (QueueSummary, error)
|
||||
|
||||
func (r *Repository) LatestEventAt(ctx context.Context) (*time.Time, error) {
|
||||
var latest *time.Time
|
||||
err := r.pool.QueryRow(ctx, `SELECT max(completed_at) FROM autoscan_events WHERE status <> $1`, string(EventStatusRunning)).Scan(&latest)
|
||||
err := r.pool.QueryRow(ctx, `SELECT max(completed_at) FROM autoscan_events`).Scan(&latest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get latest autoscan event time: %w", err)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,8 @@ func (s *Service) SetSuggesterDeps(rootFolders RootFolderClient, folders FolderL
|
||||
// from scan_source plugins; connres resolves a source's connection to concrete
|
||||
// credentials; resolver/queue/suppress drive the resolve→suppress→enqueue loop.
|
||||
// lister enumerates installed scan_source capabilities so the Add-source picker
|
||||
// can offer them; it may be nil (the picker then returns an empty list).
|
||||
// can offer them and PollOnce can skip orphaned source rows; it may be nil
|
||||
// (the picker then returns an empty list and orphan-pruning is disabled).
|
||||
func NewService(
|
||||
store Store,
|
||||
provider ScanSourceProvider,
|
||||
@@ -112,15 +113,24 @@ func NewService(
|
||||
}
|
||||
}
|
||||
|
||||
// PollOnce runs one autoscan cycle. Per-source failures are logged, recorded on
|
||||
// the source/event, and the loop continues; only settings/listing errors
|
||||
// propagate. The opaque next marker returned by the
|
||||
// PollOnce runs one autoscan cycle. Per-source failures are logged and skipped;
|
||||
// only settings/listing errors propagate. The opaque next marker returned by the
|
||||
// provider is stored verbatim, but only when the cycle's work is genuinely
|
||||
// consumed: the provider returned no paths, or it returned paths and at least one
|
||||
// resolved+enqueued. When paths come back but NONE resolve to a library folder
|
||||
// (e.g. a freshly-enabled source with unconfigured rewrites) the marker is held
|
||||
// and an error recorded, so those imports aren't skipped forever.
|
||||
func (s *Service) PollOnce(ctx context.Context) error {
|
||||
// Fetch the set of currently-installed scan_source capabilities so we can skip
|
||||
// orphaned source rows (their plugin was uninstalled). Listing failures are
|
||||
// non-fatal: a nil set means the installed set is unavailable, in which case
|
||||
// we do NOT prune orphans — every enabled source is assumed present.
|
||||
present, derr := s.installedScanSources(ctx)
|
||||
if derr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: list installed scan sources failed", "err", derr)
|
||||
present = nil
|
||||
}
|
||||
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -136,6 +146,16 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
now := time.Now()
|
||||
|
||||
for _, src := range sources {
|
||||
// Skip orphaned sources: an enabled source whose scan_source plugin has
|
||||
// been uninstalled/disabled is no longer in the discovered set, so polling
|
||||
// it would error every cycle. Skip it quietly (no RecordError) so the spam
|
||||
// stops; the operator can delete it via the source delete endpoint. A nil
|
||||
// `present` set means discovery is unavailable — don't prune in that case.
|
||||
if present != nil {
|
||||
if _, ok := present[installedKey{InstallationID: src.InstallationID, CapabilityID: src.CapabilityID}]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Honor the per-source poll interval as a "poll at most every N seconds"
|
||||
// floor: the global task fires at the default cadence, so a source with a
|
||||
// longer interval is skipped until enough time has elapsed.
|
||||
@@ -150,10 +170,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
if src.Marker != nil {
|
||||
marker = *src.Marker
|
||||
}
|
||||
eventID, started := s.createEvent(ctx, src, marker, time.Now())
|
||||
if !started {
|
||||
continue
|
||||
}
|
||||
eventID := s.createEvent(ctx, src, marker, time.Now())
|
||||
// A connection is OPTIONAL. Server-based providers (Sonarr/Radarr) bind a
|
||||
// connection and the resolved {base_url, api_key} is handed to the plugin.
|
||||
// Other providers (e.g. a filesystem/CephFS watcher) need none and get an
|
||||
@@ -178,7 +195,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
}
|
||||
conn = resolved
|
||||
}
|
||||
changes, next, perr := s.provider.PollChanges(ctx, src.PluginID, src.CapabilityID, marker, conn, src.SourceConfig)
|
||||
changes, next, perr := s.provider.PollChanges(ctx, src.InstallationID, src.CapabilityID, marker, conn, src.SourceConfig)
|
||||
if perr != nil {
|
||||
slog.WarnContext(ctx, "autoscan: poll changes failed", "source_id", src.ID, "err", perr)
|
||||
if rerr := s.store.RecordError(ctx, src.ID, perr.Error()); rerr != nil {
|
||||
@@ -278,26 +295,22 @@ func (s *Service) PollOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) createEvent(ctx context.Context, src Source, marker string, startedAt time.Time) (int64, bool) {
|
||||
func (s *Service) createEvent(ctx context.Context, src Source, marker string, startedAt time.Time) int64 {
|
||||
if s == nil || s.store == nil {
|
||||
return 0, true
|
||||
return 0
|
||||
}
|
||||
id, err := s.store.CreateEvent(ctx, EventCreate{
|
||||
SourceID: src.ID,
|
||||
PluginID: src.PluginID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
StartedAt: startedAt,
|
||||
MarkerBefore: marker,
|
||||
SourceID: src.ID,
|
||||
InstallationID: src.InstallationID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
StartedAt: startedAt,
|
||||
MarkerBefore: marker,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPollAlreadyRunning) {
|
||||
slog.DebugContext(ctx, "autoscan: source poll already running", "source_id", src.ID)
|
||||
return 0, false
|
||||
}
|
||||
slog.WarnContext(ctx, "autoscan: create event failed", "source_id", src.ID, "err", err)
|
||||
return 0, true
|
||||
return 0
|
||||
}
|
||||
return id, true
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *Service) finishEvent(ctx context.Context, eventID int64, finish EventFinish) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package autoscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -13,14 +12,13 @@ import (
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
settings Settings
|
||||
sources []Source
|
||||
connection Connection
|
||||
advanced map[string]string // source ID -> marker
|
||||
recorded map[string]string // source ID -> error message
|
||||
createdEvents []EventCreate
|
||||
events []EventFinish
|
||||
createEventErr error
|
||||
settings Settings
|
||||
sources []Source
|
||||
connection Connection
|
||||
advanced map[string]string // source ID -> marker
|
||||
recorded map[string]string // source ID -> error message
|
||||
createdEvents []EventCreate
|
||||
events []EventFinish
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetSettings(context.Context) (Settings, error) { return f.settings, nil }
|
||||
@@ -53,9 +51,6 @@ func (f *fakeStore) RecordError(_ context.Context, sourceID, msg string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) CreateEvent(_ context.Context, event EventCreate) (int64, error) {
|
||||
if f.createEventErr != nil {
|
||||
return 0, f.createEventErr
|
||||
}
|
||||
f.createdEvents = append(f.createdEvents, event)
|
||||
return int64(len(f.createdEvents)), nil
|
||||
}
|
||||
@@ -70,20 +65,14 @@ type fakeProvider struct {
|
||||
changes map[string][]Change // key: capabilityID, structured changes
|
||||
nextMarker string
|
||||
err error
|
||||
errByCap map[string]error
|
||||
lastConfig map[string]string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeProvider) PollChanges(_ context.Context, _ string, capabilityID, _ string, _ ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
f.calls++
|
||||
func (f *fakeProvider) PollChanges(_ context.Context, _ int, capabilityID, _ string, _ ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
|
||||
f.lastConfig = sourceConfig
|
||||
if f.err != nil {
|
||||
return nil, "", f.err
|
||||
}
|
||||
if err := f.errByCap[capabilityID]; err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if changes, ok := f.changes[capabilityID]; ok {
|
||||
return changes, f.nextMarker, nil
|
||||
}
|
||||
@@ -98,7 +87,7 @@ func TestPollOncePassesSourceConfigToProvider(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
|
||||
SourceConfig: map[string]string{"exclusions": ".downloads\n.recyclebin"},
|
||||
}},
|
||||
}
|
||||
@@ -112,38 +101,6 @@ func TestPollOncePassesSourceConfigToProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollOnceSkipsSourceWhenPollAlreadyRunning(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
createEventErr: ErrPollAlreadyRunning,
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"cephfs": {"/mnt/media/Movie/movie.mkv"}}, nextMarker: "m1"}
|
||||
q := &recordingQueuer{}
|
||||
svc := newService(store, prov, q, allowSuppressor{})
|
||||
|
||||
if err := svc.PollOnce(context.Background()); err != nil {
|
||||
t.Fatalf("PollOnce: %v", err)
|
||||
}
|
||||
if prov.calls != 0 {
|
||||
t.Fatalf("provider calls = %d, want 0", prov.calls)
|
||||
}
|
||||
if len(q.enqueued) != 0 {
|
||||
t.Fatalf("enqueued scans = %+v, want none", q.enqueued)
|
||||
}
|
||||
if len(store.events) != 0 {
|
||||
t.Fatalf("finished events = %+v, want none", store.events)
|
||||
}
|
||||
if len(store.recorded) != 0 {
|
||||
t.Fatalf("recorded source errors = %+v, want none", store.recorded)
|
||||
}
|
||||
if len(store.advanced) != 0 {
|
||||
t.Fatalf("advanced markers = %+v, want none", store.advanced)
|
||||
}
|
||||
}
|
||||
|
||||
// passthroughConnRes resolves to empty credentials; the engine doesn't inspect
|
||||
// them in tests (the fake provider ignores conn).
|
||||
type passthroughConnRes struct{}
|
||||
@@ -253,7 +210,7 @@ func TestPollOnceEnqueuesDedupedFolders(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -284,7 +241,7 @@ func TestPollOnceRecordsSuccessfulEvent(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, Marker: &marker,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, Marker: &marker,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -326,7 +283,7 @@ func TestPollOnceRecordsReusedScanCounts(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -361,7 +318,7 @@ func TestPollOnceAppliesSourceRewritesBeforeEnqueue(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}},
|
||||
}},
|
||||
}
|
||||
@@ -390,7 +347,7 @@ func TestPollOnceStructuredFileChangeEnqueuesExactFile(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/ceph/tv", To: "/mnt/media/tv"}},
|
||||
}},
|
||||
}
|
||||
@@ -423,7 +380,7 @@ func TestPollOnceStructuredSubtreeChangeEnqueuesExactSubtree(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
|
||||
PathRewrites: []PathRewrite{{From: "/ceph/movies", To: "/mnt/media/movies"}},
|
||||
}},
|
||||
}
|
||||
@@ -459,7 +416,7 @@ func TestPollOnceScansDistinctPathsUnderSameFolder(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -489,7 +446,7 @@ func TestPollOnceStoresOpaqueMarkerVerbatim(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
const opaque = "eyJjdXJzb3IiOiJhYmMxMjMifQ==|2026-06-02T14:10:00Z"
|
||||
@@ -513,7 +470,7 @@ func TestPollOnceHoldsMarkerWhenPathsReturnedButNoneResolve(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
@@ -566,7 +523,7 @@ func TestPollOnceAdvancesMarkerWhenResolvedButAllSuppressed(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
// rewrite /data/tv -> /mnt/media/tv so fakeResolver resolves the paths.
|
||||
PathRewrites: []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}},
|
||||
}},
|
||||
@@ -599,7 +556,7 @@ func TestPollOnceAdvancesMarkerWhenZeroPathsReturned(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"arr": {}}, nextMarker: "m1"}
|
||||
@@ -631,7 +588,7 @@ func TestPollOnceDisabledNoop(t *testing.T) {
|
||||
func TestPollOnceProviderErrorKeepsMarker(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
sources: []Source{{ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
}
|
||||
prov := &fakeProvider{err: context.DeadlineExceeded}
|
||||
q := &recordingQueuer{}
|
||||
@@ -650,7 +607,7 @@ func TestPollOnceProviderErrorKeepsMarker(t *testing.T) {
|
||||
func TestPollOnceReleasesClaimOnEnqueueFailure(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
sources: []Source{{ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"}
|
||||
sup := &recordingSuppressor{}
|
||||
@@ -683,7 +640,7 @@ func TestPollOncePollsConnectionlessSource(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil, Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil, Enabled: true,
|
||||
}},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"}
|
||||
@@ -709,7 +666,7 @@ func TestPollOnceSkipsSourcePolledTooRecently(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
PollIntervalSeconds: &interval, LastRunAt: &recent,
|
||||
}},
|
||||
}
|
||||
@@ -733,7 +690,7 @@ func TestPollOnceRunsSourcePastItsInterval(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{{
|
||||
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
|
||||
PollIntervalSeconds: &interval, LastRunAt: &old,
|
||||
}},
|
||||
}
|
||||
@@ -751,23 +708,25 @@ func TestPollOnceRunsSourcePastItsInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollOnceRecordsScanSourceResolutionFailure(t *testing.T) {
|
||||
// Two enabled sources: one polls normally, while the other simulates the
|
||||
// production resolver failing because the plugin cannot be resolved.
|
||||
func TestPollOnceSkipsOrphanedSourceQuietly(t *testing.T) {
|
||||
// Two enabled sources: only "arr-live" is still discovered; "arr-gone"'s
|
||||
// plugin has been uninstalled. The orphan must be skipped quietly — no poll,
|
||||
// no RecordError spam — while the live source still polls normally.
|
||||
store := &fakeStore{
|
||||
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
|
||||
sources: []Source{
|
||||
{ID: "live", PluginID: "silo.autoscan.live", CapabilityID: "arr-live", ConnectionID: strptr("c1"), Enabled: true},
|
||||
{ID: "gone", PluginID: "silo.autoscan.gone", CapabilityID: "arr-gone", ConnectionID: strptr("c1"), Enabled: true},
|
||||
{ID: "live", InstallationID: 1, CapabilityID: "arr-live", ConnectionID: strptr("c1"), Enabled: true},
|
||||
{ID: "gone", InstallationID: 2, CapabilityID: "arr-gone", ConnectionID: strptr("c1"), Enabled: true},
|
||||
},
|
||||
}
|
||||
prov := &fakeProvider{paths: map[string][]string{
|
||||
"arr-live": {"/mnt/media/Show/S01/E01.mkv"},
|
||||
}, errByCap: map[string]error{
|
||||
"arr-gone": errors.New("scan source plugin \"silo.autoscan.gone\" is not installed"),
|
||||
"arr-gone": {"/mnt/media/Other/S01/E01.mkv"},
|
||||
}, nextMarker: "m1"}
|
||||
q := &recordingQueuer{}
|
||||
svc := newService(store, prov, q, allowSuppressor{})
|
||||
// Lister reports only the live capability; the orphan is absent.
|
||||
lister := fakeLister{sources: []DiscoveredSource{{InstallationID: 1, CapabilityID: "arr-live"}}}
|
||||
svc := NewService(store, prov, passthroughConnRes{}, fakeResolver{}, q, allowSuppressor{}, lister)
|
||||
|
||||
if err := svc.PollOnce(context.Background()); err != nil {
|
||||
t.Fatalf("PollOnce: %v", err)
|
||||
@@ -779,15 +738,9 @@ func TestPollOnceRecordsScanSourceResolutionFailure(t *testing.T) {
|
||||
t.Fatalf("expected live source marker advanced")
|
||||
}
|
||||
if _, ok := store.advanced["gone"]; ok {
|
||||
t.Fatalf("failed source must NOT advance")
|
||||
t.Fatalf("orphaned source must NOT poll/advance")
|
||||
}
|
||||
if got := store.recorded["gone"]; !strings.Contains(got, "not installed") {
|
||||
t.Fatalf("failed source error = %q", got)
|
||||
}
|
||||
if len(store.events) != 2 {
|
||||
t.Fatalf("expected one event per source, got %+v", store.events)
|
||||
}
|
||||
if store.events[1].Status != EventStatusError || !strings.Contains(store.events[1].ErrorMessage, "not installed") {
|
||||
t.Fatalf("failed source event = %+v", store.events[1])
|
||||
if _, ok := store.recorded["gone"]; ok {
|
||||
t.Fatalf("orphaned source must be skipped quietly, but an error was recorded: %q", store.recorded["gone"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ type Change struct {
|
||||
Scope ChangeScope
|
||||
}
|
||||
|
||||
// Source ties a scan_source plugin capability to a connection plus the
|
||||
// Source ties a scan_source plugin capability instance to a connection plus the
|
||||
// host-owned scheduling/bookkeeping state.
|
||||
type Source struct {
|
||||
ID string
|
||||
PluginID string
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
ConnectionID *string // nil until an operator binds a connection
|
||||
Enabled bool
|
||||
@@ -65,7 +65,6 @@ type Source struct {
|
||||
type EventStatus string
|
||||
|
||||
const (
|
||||
EventStatusRunning EventStatus = "running"
|
||||
EventStatusSuccess EventStatus = "success"
|
||||
EventStatusError EventStatus = "error"
|
||||
EventStatusUnresolved EventStatus = "unresolved"
|
||||
@@ -74,7 +73,7 @@ const (
|
||||
type Event struct {
|
||||
ID int64
|
||||
SourceID *string
|
||||
PluginID string
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
StartedAt time.Time
|
||||
CompletedAt time.Time
|
||||
@@ -92,11 +91,11 @@ type Event struct {
|
||||
}
|
||||
|
||||
type EventCreate struct {
|
||||
SourceID string
|
||||
PluginID string
|
||||
CapabilityID string
|
||||
StartedAt time.Time
|
||||
MarkerBefore string
|
||||
SourceID string
|
||||
InstallationID int
|
||||
CapabilityID string
|
||||
StartedAt time.Time
|
||||
MarkerBefore string
|
||||
}
|
||||
|
||||
type EventFinish struct {
|
||||
@@ -155,7 +154,7 @@ type ScanWithEvent struct {
|
||||
ScanRunSummary
|
||||
AutoscanEventID *int64
|
||||
SourceID *string
|
||||
PluginID string
|
||||
InstallationID *int
|
||||
CapabilityID string
|
||||
EventStatus EventStatus
|
||||
EventCompletedAt *time.Time
|
||||
|
||||
@@ -1089,9 +1089,8 @@ func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFi
|
||||
if def.MediaScope != "" &&
|
||||
def.MediaScope != "movie" &&
|
||||
def.MediaScope != "series" &&
|
||||
def.MediaScope != "episode" &&
|
||||
def.MediaScope != "audiobook" {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', or 'audiobook'", ErrInvalidCatalogRequest)
|
||||
def.MediaScope != "episode" {
|
||||
return fmt.Errorf("%w: media_scope must be 'movie', 'series', or 'episode'", ErrInvalidCatalogRequest)
|
||||
}
|
||||
if def.Match != "" && def.Match != "all" && def.Match != "any" {
|
||||
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
|
||||
|
||||
@@ -154,51 +154,6 @@ type ItemDetail struct {
|
||||
EffectiveVersionHDR *bool `json:"effective_version_hdr,omitempty"`
|
||||
EffectiveVersionCodecVideo *string `json:"effective_version_codec_video,omitempty"`
|
||||
EffectiveVersionEditionKey *string `json:"effective_version_edition_key,omitempty"`
|
||||
|
||||
// Audiobook-specific detail. Present only when Type == "audiobook".
|
||||
Audiobook *AudiobookDetailExtension `json:"audiobook,omitempty"`
|
||||
}
|
||||
|
||||
type AudiobookDetailExtension struct {
|
||||
Authors []AudiobookPerson `json:"authors"`
|
||||
Narrators []AudiobookPerson `json:"narrators"`
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
TotalDurationSeconds int `json:"total_duration_seconds"`
|
||||
Series *AudiobookSeriesGroup `json:"series,omitempty"`
|
||||
OtherNarrations []AudiobookNarration `json:"other_narrations"`
|
||||
Related AudiobookRelatedContent `json:"related"`
|
||||
}
|
||||
|
||||
type AudiobookPerson struct {
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
PhotoURL string `json:"photo_url,omitempty"`
|
||||
PhotoThumbhash string `json:"photo_thumbhash,omitempty"`
|
||||
}
|
||||
|
||||
type AudiobookRelatedContent struct {
|
||||
AlsoByAuthor []AudiobookRelatedItem `json:"also_by_author"`
|
||||
Similar []AudiobookRelatedItem `json:"similar"`
|
||||
}
|
||||
|
||||
type AudiobookRelatedItem struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
SeriesIndex *int `json:"series_index,omitempty"`
|
||||
}
|
||||
|
||||
type AudiobookSeriesGroup struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Entries []AudiobookRelatedItem `json:"entries"`
|
||||
}
|
||||
|
||||
type AudiobookNarration struct {
|
||||
ContentID string `json:"content_id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Narrators []string `json:"narrators"`
|
||||
}
|
||||
|
||||
// ItemUserState is per-profile viewer state included in item detail responses.
|
||||
@@ -269,7 +224,6 @@ type FileVersion struct {
|
||||
PresentationKind string `json:"presentation_kind,omitempty"`
|
||||
PresentationGroupKey string `json:"presentation_group_key,omitempty"`
|
||||
PresentationPartIndex int `json:"presentation_part_index,omitempty"`
|
||||
PresentationPartTotal int `json:"presentation_part_total,omitempty"`
|
||||
MultiEpisodeStart int `json:"multi_episode_start,omitempty"`
|
||||
MultiEpisodeEnd int `json:"multi_episode_end,omitempty"`
|
||||
EffectiveAudioTrackIndex *int `json:"effective_audio_track_index,omitempty"`
|
||||
@@ -879,9 +833,6 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
|
||||
}
|
||||
|
||||
files = FilterMediaFilesByAccess(files, filter)
|
||||
if item.Type == "audiobook" {
|
||||
sortAudiobookMediaFiles(files)
|
||||
}
|
||||
files = s.preparePlaybackFiles(ctx, files)
|
||||
detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits, detail.Recap, detail.Preview = s.buildPlaybackInfo(
|
||||
ctx,
|
||||
@@ -892,10 +843,6 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
|
||||
detail.OverlaySummary = overlays.BuildSummary(files)
|
||||
}
|
||||
|
||||
if item.Type == "audiobook" {
|
||||
detail.Audiobook = s.buildAudiobookExtension(ctx, item, detail.Versions, crewCredits, filter)
|
||||
}
|
||||
|
||||
// Series folder paths from confirmed claims when available, otherwise from
|
||||
// the file links that currently belong to the item. This keeps provisional
|
||||
// root-scoped series visible for manual match/admin flows without implying
|
||||
@@ -1005,351 +952,6 @@ func splitCastCrew(credits []PersonCredit) ([]CastCredit, []CrewCredit) {
|
||||
return cast, crew
|
||||
}
|
||||
|
||||
func (s *DetailService) buildAudiobookExtension(
|
||||
ctx context.Context,
|
||||
item *models.MediaItem,
|
||||
versions []FileVersion,
|
||||
crew []CrewCredit,
|
||||
filter AccessFilter,
|
||||
) *AudiobookDetailExtension {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
totalDuration := 0
|
||||
for _, version := range versions {
|
||||
if version.Duration > 0 {
|
||||
totalDuration += version.Duration
|
||||
}
|
||||
}
|
||||
|
||||
return &AudiobookDetailExtension{
|
||||
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
|
||||
Narrators: audiobookPeopleFromCrew(crew, models.PersonKindNarrator.String()),
|
||||
Publisher: firstNonEmptyString(item.Studios),
|
||||
TotalDurationSeconds: totalDuration,
|
||||
Series: s.fetchAudiobookSeries(ctx, item.ContentID, filter),
|
||||
OtherNarrations: s.fetchAudiobookOtherNarrations(ctx, item.ContentID, filter),
|
||||
Related: AudiobookRelatedContent{
|
||||
AlsoByAuthor: s.fetchAudiobookAlsoByAuthor(ctx, item.ContentID, filter),
|
||||
Similar: s.fetchAudiobookSimilarByGenres(ctx, item.ContentID, filter),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func audiobookPeopleFromCrew(crew []CrewCredit, job string) []AudiobookPerson {
|
||||
out := make([]AudiobookPerson, 0)
|
||||
for _, credit := range crew {
|
||||
if credit.Job != job || strings.TrimSpace(credit.Name) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, AudiobookPerson{
|
||||
PersonID: credit.PersonID,
|
||||
Name: credit.Name,
|
||||
PhotoURL: credit.PhotoURL,
|
||||
PhotoThumbhash: credit.PhotoThumbhash,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values []string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func appendAudiobookItemAccessConditions(
|
||||
alias string,
|
||||
filter AccessFilter,
|
||||
conditions *[]string,
|
||||
args *[]any,
|
||||
argIdx *int,
|
||||
) bool {
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
if len(filter.AllowedLibraryIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
*conditions = append(*conditions, fmt.Sprintf(`
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil_allowed
|
||||
WHERE mil_allowed.content_id = %s.content_id
|
||||
AND mil_allowed.media_folder_id = ANY($%d)
|
||||
)`, alias, *argIdx))
|
||||
*args = append(*args, filter.AllowedLibraryIDs)
|
||||
*argIdx = *argIdx + 1
|
||||
}
|
||||
if len(filter.DisabledLibraryIDs) > 0 {
|
||||
if filter.AllowedLibraryIDs == nil {
|
||||
*conditions = append(*conditions, fmt.Sprintf(`
|
||||
EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil_visible
|
||||
WHERE mil_visible.content_id = %s.content_id
|
||||
)`, alias))
|
||||
}
|
||||
*conditions = append(*conditions, fmt.Sprintf(`
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM media_item_libraries mil_disabled
|
||||
WHERE mil_disabled.content_id = %s.content_id
|
||||
AND mil_disabled.media_folder_id = ANY($%d)
|
||||
)`, alias, *argIdx))
|
||||
*args = append(*args, filter.DisabledLibraryIDs)
|
||||
*argIdx = *argIdx + 1
|
||||
}
|
||||
ApplySectionAccessFilter(alias, AccessFilter{MaxContentRating: filter.MaxContentRating}, conditions, args, argIdx)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
args := []any{contentID, models.PersonKindAuthor}
|
||||
argIdx := 3
|
||||
conditions := []string{
|
||||
"ip1.content_id = $1",
|
||||
"ip1.kind = $2",
|
||||
"m2.type = 'audiobook'",
|
||||
}
|
||||
if !appendAudiobookItemAccessConditions("m2", filter, &conditions, &args, &argIdx) {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT m2.content_id, m2.title, COALESCE(m2.year, 0), COALESCE(m2.poster_path, '')
|
||||
FROM item_people ip1
|
||||
JOIN item_people ip2
|
||||
ON ip2.person_id = ip1.person_id
|
||||
AND ip2.kind = ip1.kind
|
||||
AND ip2.content_id <> ip1.content_id
|
||||
JOIN media_items m2 ON m2.content_id = ip2.content_id
|
||||
WHERE %s
|
||||
ORDER BY m2.year DESC NULLS LAST, LOWER(m2.sort_title)
|
||||
LIMIT 12
|
||||
`, strings.Join(conditions, " AND "))
|
||||
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]AudiobookRelatedItem, 0, 12)
|
||||
seen := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var item AudiobookRelatedItem
|
||||
var posterPath string
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
if _, ok := seen[item.ContentID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.ContentID] = struct{}{}
|
||||
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
args := []any{contentID, models.PersonKindAuthor}
|
||||
argIdx := 3
|
||||
conditions := []string{
|
||||
"m.type = 'audiobook'",
|
||||
"m.content_id <> $1",
|
||||
"m.genres && (SELECT array_agg(g) FROM this_genres)",
|
||||
`NOT EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $2
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)`,
|
||||
}
|
||||
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
WITH this_genres AS (
|
||||
SELECT unnest(genres) AS g FROM media_items WHERE content_id = $1
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
||||
)
|
||||
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
|
||||
FROM media_items m
|
||||
WHERE %s
|
||||
ORDER BY
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM unnest(m.genres) AS genre(value)
|
||||
WHERE genre.value IN (SELECT g FROM this_genres)
|
||||
) DESC,
|
||||
COALESCE(m.year, 0) DESC,
|
||||
LOWER(m.sort_title)
|
||||
LIMIT 12
|
||||
`, strings.Join(conditions, " AND "))
|
||||
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]AudiobookRelatedItem, 0, 12)
|
||||
for rows.Next() {
|
||||
var item AudiobookRelatedItem
|
||||
var posterPath string
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
|
||||
return []AudiobookRelatedItem{}
|
||||
}
|
||||
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return nil
|
||||
}
|
||||
args := []any{contentID}
|
||||
argIdx := 2
|
||||
conditions := []string{
|
||||
"root.content_id = $1",
|
||||
"m.type = 'audiobook'",
|
||||
}
|
||||
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
|
||||
return nil
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(m.year, 0),
|
||||
COALESCE(m.poster_path, ''),
|
||||
s.series_name,
|
||||
s.series_index
|
||||
FROM audiobook_series root
|
||||
JOIN audiobook_series s ON LOWER(s.series_name) = LOWER(root.series_name)
|
||||
JOIN media_items m ON m.content_id = s.content_id
|
||||
WHERE %s
|
||||
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
|
||||
LIMIT 30
|
||||
`, strings.Join(conditions, " AND "))
|
||||
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var seriesName string
|
||||
entries := make([]AudiobookRelatedItem, 0, 16)
|
||||
for rows.Next() {
|
||||
var (
|
||||
item AudiobookRelatedItem
|
||||
poster string
|
||||
name string
|
||||
index *float64
|
||||
)
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &poster, &name, &index); err != nil {
|
||||
return nil
|
||||
}
|
||||
if seriesName == "" {
|
||||
seriesName = name
|
||||
}
|
||||
if index != nil {
|
||||
n := int(*index)
|
||||
if float64(n) == *index {
|
||||
item.SeriesIndex = &n
|
||||
}
|
||||
}
|
||||
item.PosterURL = s.PresignURL(ctx, poster, "featured")
|
||||
entries = append(entries, item)
|
||||
}
|
||||
if len(entries) < 2 {
|
||||
return nil
|
||||
}
|
||||
return &AudiobookSeriesGroup{Name: seriesName, Entries: entries}
|
||||
}
|
||||
|
||||
func (s *DetailService) fetchAudiobookOtherNarrations(ctx context.Context, contentID string, filter AccessFilter) []AudiobookNarration {
|
||||
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
||||
return []AudiobookNarration{}
|
||||
}
|
||||
const stripRE = `\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*[Rr]ead [Bb]y [^()]+\)?\s*$`
|
||||
args := []any{stripRE, contentID, models.PersonKindAuthor, models.PersonKindNarrator}
|
||||
argIdx := 5
|
||||
conditions := []string{
|
||||
"m.type = 'audiobook'",
|
||||
"m.content_id <> $2",
|
||||
"trim(regexp_replace(m.title, $1, '', 'g')) = (SELECT norm_title FROM this_book)",
|
||||
`EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $3
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)`,
|
||||
}
|
||||
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
|
||||
return []AudiobookNarration{}
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
WITH this_book AS (
|
||||
SELECT trim(regexp_replace(title, $1, '', 'g')) AS norm_title
|
||||
FROM media_items WHERE content_id = $2
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $2 AND kind = $3
|
||||
)
|
||||
SELECT
|
||||
m.content_id,
|
||||
m.title,
|
||||
COALESCE(m.year, 0),
|
||||
COALESCE(string_agg(DISTINCT NULLIF(BTRIM(p.name), ''), '|||'), '') AS narrators
|
||||
FROM media_items m
|
||||
LEFT JOIN item_people ipn ON ipn.content_id = m.content_id AND ipn.kind = $4
|
||||
LEFT JOIN people p ON p.id = ipn.person_id
|
||||
WHERE %s
|
||||
GROUP BY m.content_id, m.title, m.year
|
||||
ORDER BY m.year DESC NULLS LAST, m.title
|
||||
LIMIT 8
|
||||
`, strings.Join(conditions, " AND "))
|
||||
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return []AudiobookNarration{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]AudiobookNarration, 0, 4)
|
||||
for rows.Next() {
|
||||
var item AudiobookNarration
|
||||
var narrators string
|
||||
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &narrators); err != nil {
|
||||
return []AudiobookNarration{}
|
||||
}
|
||||
item.Narrators = splitDelimitedNames(narrators)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitDelimitedNames(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(raw, "|||")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value := strings.TrimSpace(part); value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seriesFolderPathsFromFiles(files []*models.MediaFile) []string {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
@@ -1965,30 +1567,6 @@ func markerFromRange(start, end *float64) *Marker {
|
||||
return &Marker{Start: *start, End: *end}
|
||||
}
|
||||
|
||||
func sortAudiobookMediaFiles(files []*models.MediaFile) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
a, b := files[i], files[j]
|
||||
if a == nil || b == nil {
|
||||
return a != nil
|
||||
}
|
||||
if a.PresentationPartIndex > 0 || b.PresentationPartIndex > 0 {
|
||||
if a.PresentationPartIndex != b.PresentationPartIndex {
|
||||
if a.PresentationPartIndex == 0 {
|
||||
return false
|
||||
}
|
||||
if b.PresentationPartIndex == 0 {
|
||||
return true
|
||||
}
|
||||
return a.PresentationPartIndex < b.PresentationPartIndex
|
||||
}
|
||||
}
|
||||
if a.FilePath != b.FilePath {
|
||||
return strings.Compare(a.FilePath, b.FilePath) < 0
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DetailService) buildPlaybackInfo(
|
||||
ctx context.Context,
|
||||
files []*models.MediaFile,
|
||||
@@ -2044,7 +1622,6 @@ func (s *DetailService) buildPlaybackInfo(
|
||||
PresentationKind: f.PresentationKind,
|
||||
PresentationGroupKey: f.PresentationGroupKey,
|
||||
PresentationPartIndex: f.PresentationPartIndex,
|
||||
PresentationPartTotal: f.PresentationPartTotal,
|
||||
MultiEpisodeStart: f.MultiEpisodeStart,
|
||||
MultiEpisodeEnd: f.MultiEpisodeEnd,
|
||||
EffectiveAudioTrackIndex: intPtr(effectiveAudioSelection.Index),
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestSortAudiobookMediaFilesPreservesPresentationPartOrder(t *testing.T) {
|
||||
files := []*models.MediaFile{
|
||||
{ID: 30, FilePath: "/books/book/03.mp3", PresentationPartIndex: 3},
|
||||
{ID: 10, FilePath: "/books/book/01.mp3", PresentationPartIndex: 1},
|
||||
{ID: 20, FilePath: "/books/book/02.mp3", PresentationPartIndex: 2},
|
||||
}
|
||||
|
||||
sortAudiobookMediaFiles(files)
|
||||
|
||||
got := []int{files[0].ID, files[1].ID, files[2].ID}
|
||||
want := []int{10, 20, 30}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("sorted IDs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortAudiobookMediaFilesFallsBackToPath(t *testing.T) {
|
||||
files := []*models.MediaFile{
|
||||
{ID: 20, FilePath: "/books/book/02.mp3"},
|
||||
{ID: 10, FilePath: "/books/book/01.mp3"},
|
||||
}
|
||||
|
||||
sortAudiobookMediaFiles(files)
|
||||
|
||||
got := []int{files[0].ID, files[1].ID}
|
||||
want := []int{10, 20}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("sorted IDs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,9 +185,8 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
|
||||
if normalized.MediaScope != "" &&
|
||||
normalized.MediaScope != "movie" &&
|
||||
normalized.MediaScope != "series" &&
|
||||
normalized.MediaScope != "episode" &&
|
||||
normalized.MediaScope != "audiobook" {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', or 'audiobook'")
|
||||
normalized.MediaScope != "episode" {
|
||||
return fmt.Errorf("media_scope must be 'movie', 'series', or 'episode'")
|
||||
}
|
||||
|
||||
if normalized.Match != "all" && normalized.Match != "any" {
|
||||
|
||||
@@ -60,18 +60,6 @@ func TestValidate_EpisodeMediaScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_AudiobookMediaScope(t *testing.T) {
|
||||
qd := QueryDefinition{
|
||||
MediaScope: "audiobook",
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{},
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
}
|
||||
if err := qd.Validate(); err != nil {
|
||||
t.Fatalf("expected audiobook media scope to be valid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWithSortScope_RejectsPersonalizedSortWithoutProfileScope(t *testing.T) {
|
||||
qd := QueryDefinition{
|
||||
Match: "all",
|
||||
|
||||
@@ -32,7 +32,7 @@ func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string
|
||||
runExtract = runFFmpegFrameExtract
|
||||
}
|
||||
|
||||
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
|
||||
resolvedAccel := playback.ResolveHWAccel(opts.HWAccel)
|
||||
resolvedDevice := opts.HWDevice
|
||||
if resolvedDevice == "" && (resolvedAccel == "qsv" || resolvedAccel == "vaapi") {
|
||||
resolvedDevice = playback.PickRenderDevice("")
|
||||
|
||||
@@ -652,7 +652,7 @@ func (s *Service) extractFrameLocal(ctx context.Context, inputPath string, seekS
|
||||
|
||||
func (s *Service) resolveHWConfig() (string, string) {
|
||||
s.hwResolveOnce.Do(func() {
|
||||
s.resolvedHWAccel = playback.ResolveHWAccelWithFFmpeg(s.hwAccel, s.ffmpegPath)
|
||||
s.resolvedHWAccel = playback.ResolveHWAccel(s.hwAccel)
|
||||
s.resolvedHWDevice = s.hwDevice
|
||||
if s.resolvedHWDevice == "" && (s.resolvedHWAccel == "qsv" || s.resolvedHWAccel == "vaapi") {
|
||||
s.resolvedHWDevice = playback.PickRenderDevice("")
|
||||
|
||||
@@ -198,12 +198,13 @@ type authConfigRaw struct {
|
||||
RefreshTokenExpiry string `yaml:"refresh_token_expiry"`
|
||||
}
|
||||
|
||||
// AudiobookshelfCompatConfig holds the dedicated ABS-compat listener setting.
|
||||
// The listener binds its own port (:13378, ABS's
|
||||
// AudiobookshelfCompatConfig holds the dedicated ABS-compat listener
|
||||
// settings. The listener binds its own port (default :13378, ABS's
|
||||
// conventional port) and serves the full ABS protocol — login, libraries,
|
||||
// items, sessions — without colliding with silo's SPA on the main listener.
|
||||
type AudiobookshelfCompatConfig struct {
|
||||
Listen string `yaml:"-"`
|
||||
Listen string `yaml:"listen"`
|
||||
PublicURL string `yaml:"public_url"`
|
||||
}
|
||||
|
||||
// JellyfinCompatConfig holds compatibility proxy settings.
|
||||
|
||||
@@ -356,13 +356,15 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
|
||||
|
||||
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
|
||||
// listener off unless ABS compatibility is explicitly enabled.
|
||||
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
|
||||
// listener off unless the audiobooks feature flag is explicitly enabled.
|
||||
audiobooksEnabled, err := boolOr(m, "audiobooks.enabled", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if absCompatEnabled {
|
||||
cfg.AudiobookshelfCompat.Listen = ":13378"
|
||||
if audiobooksEnabled {
|
||||
// Default :13378 mirrors the real Audiobookshelf server convention.
|
||||
cfg.AudiobookshelfCompat.Listen = stringOr(m, "audiobookshelf_compat.listen", ":13378")
|
||||
cfg.AudiobookshelfCompat.PublicURL = stringOr(m, "audiobookshelf_compat.public_url", "http://127.0.0.1:13378")
|
||||
}
|
||||
|
||||
// JellyfinCompat
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
|
||||
func TestLoadFromDBAudiobooksFeatureFlagGatesCompatListener(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{"audiobooks.enabled": "false"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromDB() returned error: %v", err)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
|
||||
t.Fatalf("disabled audiobooks listener = %q, want empty", cfg.AudiobookshelfCompat.Listen)
|
||||
}
|
||||
|
||||
cfg, err = LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "true"})
|
||||
cfg, err = LoadFromDB(map[string]string{"audiobooks.enabled": "true"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromDB() returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
// ScopeResolver resolves a viewer request into an effective access scope.
|
||||
// Implemented by *access.Resolver.
|
||||
type ScopeResolver interface {
|
||||
Resolve(ctx context.Context, input access.ResolveInput) (access.Scope, error)
|
||||
}
|
||||
|
||||
// NewScopeAccessFilter returns an AccessFilterResolver backed by the shared
|
||||
// access scope resolver, so account-level library restrictions
|
||||
// (users.library_ids), profile library restrictions, user-disabled
|
||||
// libraries, content-rating ceilings, and playback-quality ceilings all
|
||||
// apply to the Jellyfin-compat API exactly as they do to the native API.
|
||||
//
|
||||
// PIN verification is skipped because compat login already verified the
|
||||
// profile PIN (password#pin convention). Resolution failures fail closed:
|
||||
// the viewer gets an empty library allowlist rather than full access.
|
||||
func NewScopeAccessFilter(resolver ScopeResolver) AccessFilterResolver {
|
||||
return func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
|
||||
scope, err := resolver.Resolve(ctx, access.ResolveInput{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
SkipPINVerification: true,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("jellycompat: access scope resolution failed; denying library access",
|
||||
"user_id", userID,
|
||||
"profile_id", profileID,
|
||||
"error", err,
|
||||
)
|
||||
return catalog.AccessFilter{
|
||||
AllowedLibraryIDs: []int{},
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
}
|
||||
}
|
||||
return catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type stubScopeResolver struct {
|
||||
scope access.Scope
|
||||
err error
|
||||
input access.ResolveInput
|
||||
}
|
||||
|
||||
func (s *stubScopeResolver) Resolve(_ context.Context, input access.ResolveInput) (access.Scope, error) {
|
||||
s.input = input
|
||||
return s.scope, s.err
|
||||
}
|
||||
|
||||
func TestScopeAccessFilterMapsScope(t *testing.T) {
|
||||
resolver := &stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
AllowedLibraryIDs: []int{2, 19},
|
||||
LibrariesRestricted: true,
|
||||
MaxContentRating: "PG-13",
|
||||
MaxPlaybackQuality: "1080p",
|
||||
},
|
||||
}
|
||||
|
||||
filter := NewScopeAccessFilter(resolver)(context.Background(), 7, "profile-1")
|
||||
|
||||
if !resolver.input.SkipPINVerification {
|
||||
t.Fatal("expected SkipPINVerification=true (PIN is verified at compat login)")
|
||||
}
|
||||
if resolver.input.UserID != 7 || resolver.input.ProfileID != "profile-1" {
|
||||
t.Fatalf("unexpected resolve input: %+v", resolver.input)
|
||||
}
|
||||
if !reflect.DeepEqual(filter.AllowedLibraryIDs, []int{2, 19}) {
|
||||
t.Fatalf("AllowedLibraryIDs = %v, want [2 19]", filter.AllowedLibraryIDs)
|
||||
}
|
||||
if filter.MaxContentRating != "PG-13" {
|
||||
t.Fatalf("MaxContentRating = %q, want PG-13", filter.MaxContentRating)
|
||||
}
|
||||
if filter.MaxPlaybackQuality != "1080p" {
|
||||
t.Fatalf("MaxPlaybackQuality = %q, want 1080p", filter.MaxPlaybackQuality)
|
||||
}
|
||||
if filter.UserID != 7 || filter.ProfileID != "profile-1" {
|
||||
t.Fatalf("identity not propagated: %+v", filter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAccessFilterUnrestrictedScope(t *testing.T) {
|
||||
resolver := &stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
UserID: 1,
|
||||
DisabledLibraryIDs: []int{7},
|
||||
},
|
||||
}
|
||||
|
||||
filter := NewScopeAccessFilter(resolver)(context.Background(), 1, "profile-1")
|
||||
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
t.Fatalf("AllowedLibraryIDs = %v, want nil (unrestricted)", filter.AllowedLibraryIDs)
|
||||
}
|
||||
if !reflect.DeepEqual(filter.DisabledLibraryIDs, []int{7}) {
|
||||
t.Fatalf("DisabledLibraryIDs = %v, want [7]", filter.DisabledLibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAccessFilterFailsClosed(t *testing.T) {
|
||||
resolver := &stubScopeResolver{err: errors.New("boom")}
|
||||
|
||||
filter := NewScopeAccessFilter(resolver)(context.Background(), 7, "profile-1")
|
||||
|
||||
if filter.AllowedLibraryIDs == nil || len(filter.AllowedLibraryIDs) != 0 {
|
||||
t.Fatalf("AllowedLibraryIDs = %v, want empty non-nil allowlist (deny all)", filter.AllowedLibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
type stubFolderSource struct {
|
||||
enabled []*models.MediaFolder
|
||||
byIDs []*models.MediaFolder
|
||||
listByIDsArgs []int
|
||||
}
|
||||
|
||||
func (s *stubFolderSource) GetEnabled(context.Context) ([]*models.MediaFolder, error) {
|
||||
return s.enabled, nil
|
||||
}
|
||||
|
||||
func (s *stubFolderSource) ListByIDs(_ context.Context, ids []int) ([]*models.MediaFolder, error) {
|
||||
s.listByIDsArgs = ids
|
||||
return s.byIDs, nil
|
||||
}
|
||||
|
||||
func TestListUserLibrariesRestrictedAllowlist(t *testing.T) {
|
||||
folders := &stubFolderSource{
|
||||
byIDs: []*models.MediaFolder{
|
||||
{ID: 2, Name: "TV Shows", Type: "series"},
|
||||
{ID: 19, Name: "Movies", Type: "movies"},
|
||||
},
|
||||
}
|
||||
svc := &directContentService{
|
||||
folderRepo: folders,
|
||||
accessFilter: NewScopeAccessFilter(&stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
AllowedLibraryIDs: []int{2, 19},
|
||||
LibrariesRestricted: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 7, ProfileID: "profile-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserLibraries: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(folders.listByIDsArgs, []int{2, 19}) {
|
||||
t.Fatalf("ListByIDs called with %v, want [2 19]", folders.listByIDsArgs)
|
||||
}
|
||||
if len(libraries) != 2 {
|
||||
t.Fatalf("got %d libraries, want 2", len(libraries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUserLibrariesFiltersDisabled(t *testing.T) {
|
||||
svc := &directContentService{
|
||||
folderRepo: &stubFolderSource{
|
||||
enabled: []*models.MediaFolder{
|
||||
{ID: 2, Name: "TV Shows", Type: "series"},
|
||||
{ID: 7, Name: "XXX", Type: "movies"},
|
||||
{ID: 19, Name: "Movies", Type: "movies"},
|
||||
},
|
||||
},
|
||||
accessFilter: NewScopeAccessFilter(&stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
DisabledLibraryIDs: []int{7},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserLibraries: %v", err)
|
||||
}
|
||||
if len(libraries) != 2 {
|
||||
t.Fatalf("got %d libraries, want 2 (disabled library not filtered)", len(libraries))
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == 7 {
|
||||
t.Fatal("user-disabled library 7 still present in views list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUserLibrariesEmptyAllowlistDeniesAll(t *testing.T) {
|
||||
svc := &directContentService{
|
||||
accessFilter: NewScopeAccessFilter(&stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
AllowedLibraryIDs: []int{},
|
||||
LibrariesRestricted: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 7, ProfileID: "profile-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserLibraries: %v", err)
|
||||
}
|
||||
if len(libraries) != 0 {
|
||||
t.Fatalf("got %d libraries, want 0 for empty allowlist", len(libraries))
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,6 @@ func TestBuildPlaybackSource_SeedsRequestedAudioStreamIndex(t *testing.T) {
|
||||
version,
|
||||
DeviceProfile{},
|
||||
playbackInfoRequest{AudioStreamIndex: compatIntValuePtr(requestedAudioStreamIndex)},
|
||||
true,
|
||||
)
|
||||
|
||||
if source.SelectedAudioStreamIndex == nil {
|
||||
|
||||
@@ -46,14 +46,6 @@ type itemAccessSource interface {
|
||||
GetByIDs(ctx context.Context, contentIDs []string) ([]*models.MediaItem, error)
|
||||
}
|
||||
|
||||
// folderListSource is the subset of *catalog.FolderRepository that
|
||||
// directContentService relies on. Defined as an interface so tests can
|
||||
// substitute a stub without standing up a Postgres pool.
|
||||
type folderListSource interface {
|
||||
GetEnabled(ctx context.Context) ([]*models.MediaFolder, error)
|
||||
ListByIDs(ctx context.Context, ids []int) ([]*models.MediaFolder, error)
|
||||
}
|
||||
|
||||
// seasonListSource is the subset of *catalog.SeasonRepository that
|
||||
// directContentService relies on.
|
||||
type seasonListSource interface {
|
||||
@@ -69,7 +61,6 @@ type seasonListSource interface {
|
||||
type episodeListSource interface {
|
||||
ListBySeason(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error)
|
||||
ListBySeriesGroupedBySeason(ctx context.Context, seriesID string) (map[int][]*models.Episode, error)
|
||||
ListBySeriesIDs(ctx context.Context, seriesIDs []string) (map[string][]*models.Episode, error)
|
||||
}
|
||||
|
||||
// directContentService implements ContentService by calling catalog repos directly.
|
||||
@@ -79,7 +70,7 @@ type directContentService struct {
|
||||
seasonRepo seasonListSource
|
||||
episodeRepo episodeListSource
|
||||
detailSvc *catalog.DetailService
|
||||
folderRepo folderListSource
|
||||
folderRepo *catalog.FolderRepository
|
||||
storeProvider userstore.UserStoreProvider
|
||||
accessFilter AccessFilterResolver
|
||||
posterPresigner LibraryPosterPresigner
|
||||
@@ -92,7 +83,7 @@ func newDirectContentService(
|
||||
seasonRepo *catalog.SeasonRepository,
|
||||
episodeRepo *catalog.EpisodeRepository,
|
||||
detailSvc *catalog.DetailService,
|
||||
folderRepo folderListSource,
|
||||
folderRepo *catalog.FolderRepository,
|
||||
storeProvider userstore.UserStoreProvider,
|
||||
accessFilter AccessFilterResolver,
|
||||
) *directContentService {
|
||||
@@ -144,12 +135,7 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
|
||||
|
||||
var folders []*models.MediaFolder
|
||||
var err error
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
// A non-nil allowlist means the viewer is restricted; an empty
|
||||
// allowlist grants access to no libraries at all.
|
||||
if len(filter.AllowedLibraryIDs) == 0 {
|
||||
return []upstreamUserLibrary{}, nil
|
||||
}
|
||||
if len(filter.AllowedLibraryIDs) > 0 {
|
||||
folders, err = s.folderRepo.ListByIDs(ctx, filter.AllowedLibraryIDs)
|
||||
} else {
|
||||
folders, err = s.folderRepo.GetEnabled(ctx)
|
||||
@@ -158,16 +144,8 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
|
||||
return nil, fmt.Errorf("list libraries: %w", err)
|
||||
}
|
||||
|
||||
disabled := make(map[int]struct{}, len(filter.DisabledLibraryIDs))
|
||||
for _, id := range filter.DisabledLibraryIDs {
|
||||
disabled[id] = struct{}{}
|
||||
}
|
||||
|
||||
libraries := make([]upstreamUserLibrary, 0, len(folders))
|
||||
for _, f := range folders {
|
||||
if _, ok := disabled[f.ID]; ok {
|
||||
continue
|
||||
}
|
||||
lib := upstreamUserLibrary{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
@@ -287,18 +265,6 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
|
||||
if len(collected) > requestedLimit {
|
||||
collected = collected[:requestedLimit]
|
||||
}
|
||||
|
||||
// The handler's resolveUserStateForContentIDs overlay only covers items
|
||||
// with their own progress rows, which a series never has — aggregate
|
||||
// series watch state here so library views carry Played/UnplayedItemCount.
|
||||
// The is_played path already did this via enrichListItemsUserData.
|
||||
// That same absence is what keeps this safe from the handler overlay:
|
||||
// userDataDTO only overrides item-level UserData when a direct progress
|
||||
// row exists, and one never does for a series id.
|
||||
if isPlayedFilter == "" {
|
||||
s.enrichSeriesUserData(ctx, session, collected)
|
||||
}
|
||||
|
||||
if totalKnown {
|
||||
hasMore = totalFromCatalog > requestedOffset+requestedLimit
|
||||
}
|
||||
@@ -381,17 +347,6 @@ func (s *directContentService) GetItemDetail(ctx context.Context, session *Sessi
|
||||
IsInProgress: progress.PositionSeconds > 0 && !progress.Completed,
|
||||
}
|
||||
}
|
||||
|
||||
// A series never has a progress row of its own, so roll watch
|
||||
// state up from its episodes (mirrors applySeasonUserData) to
|
||||
// give clients Played/UnplayedItemCount at the series level.
|
||||
if result.UserData == nil && strings.EqualFold(result.Type, "series") && s.episodeRepo != nil {
|
||||
if episodesBySeries, epErr := s.episodeRepo.ListBySeriesIDs(ctx, []string{contentID}); epErr == nil {
|
||||
episodes := episodesBySeries[contentID]
|
||||
progressMap := chunkedProgressByMediaItems(ctx, store, session.ProfileID, modelEpisodeContentIDs(episodes))
|
||||
result.UserData = seriesUserDataFromEpisodes(episodes, progressMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,128 +545,6 @@ func (s *directContentService) enrichListItemsUserData(ctx context.Context, sess
|
||||
items[i].UserData = seasonUserDataFromProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
s.enrichSeriesListUserData(ctx, session, store, items)
|
||||
}
|
||||
|
||||
// enrichSeriesUserData is enrichSeriesListUserData with store acquisition,
|
||||
// for callers that haven't already resolved the user store.
|
||||
func (s *directContentService) enrichSeriesUserData(ctx context.Context, session *Session, items []upstreamListItem) {
|
||||
if s.storeProvider == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
store, err := s.userStore(ctx, session)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.enrichSeriesListUserData(ctx, session, store, items)
|
||||
}
|
||||
|
||||
// enrichSeriesListUserData fills aggregated user data for series rows in a
|
||||
// list response. A series never has a progress row of its own, so Played and
|
||||
// UnplayedItemCount are rolled up from episode progress, mirroring
|
||||
// applySeasonUserData semantics.
|
||||
func (s *directContentService) enrichSeriesListUserData(ctx context.Context, session *Session, store userstore.UserStore, items []upstreamListItem) {
|
||||
if s.episodeRepo == nil {
|
||||
return
|
||||
}
|
||||
seriesIDs := make([]string, 0, len(items))
|
||||
for i := range items {
|
||||
if items[i].UserData == nil && items[i].ContentID != "" && strings.EqualFold(items[i].Type, "series") {
|
||||
seriesIDs = append(seriesIDs, items[i].ContentID)
|
||||
}
|
||||
}
|
||||
if len(seriesIDs) == 0 {
|
||||
return
|
||||
}
|
||||
// Cap the rollup so a max-limit browse (compatBrowseMaxLimit) of a
|
||||
// series-only library can't materialize hundreds of thousands of episode
|
||||
// rows in one request. Series past the cap keep a nil UserData — the same
|
||||
// shape they had before series aggregation existed. Real clients page at
|
||||
// 20-100 items, well under the cap.
|
||||
const maxSeriesUserDataRollups = 250
|
||||
if len(seriesIDs) > maxSeriesUserDataRollups {
|
||||
seriesIDs = seriesIDs[:maxSeriesUserDataRollups]
|
||||
}
|
||||
episodesBySeries, err := s.episodeRepo.ListBySeriesIDs(ctx, seriesIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
episodeIDs := make([]string, 0, 256)
|
||||
for _, episodes := range episodesBySeries {
|
||||
episodeIDs = append(episodeIDs, modelEpisodeContentIDs(episodes)...)
|
||||
}
|
||||
progressMap := chunkedProgressByMediaItems(ctx, store, session.ProfileID, episodeIDs)
|
||||
for i := range items {
|
||||
if items[i].UserData != nil || !strings.EqualFold(items[i].Type, "series") {
|
||||
continue
|
||||
}
|
||||
if episodes, ok := episodesBySeries[items[i].ContentID]; ok {
|
||||
items[i].UserData = seriesUserDataFromEpisodes(episodes, progressMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seriesUserDataFromEpisodes computes WatchedCount/UnplayedCount/
|
||||
// InProgressCount/Played for a whole series from a pre-fetched progressMap.
|
||||
// Pure function — no I/O. Counting semantics match the native API's series
|
||||
// rollup (all episodes including specials; in-progress = started but not
|
||||
// completed).
|
||||
func seriesUserDataFromEpisodes(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) *catalog.SeasonUserData {
|
||||
watched := 0
|
||||
unplayed := 0
|
||||
inProgress := 0
|
||||
for _, ep := range episodes {
|
||||
if ep == nil {
|
||||
continue
|
||||
}
|
||||
progress, ok := progressMap[ep.ContentID]
|
||||
if ok && progress.Completed {
|
||||
watched++
|
||||
continue
|
||||
}
|
||||
if ok && progress.PositionSeconds > 0 {
|
||||
inProgress++
|
||||
}
|
||||
unplayed++
|
||||
}
|
||||
return &catalog.SeasonUserData{
|
||||
WatchedCount: watched,
|
||||
UnplayedCount: unplayed,
|
||||
InProgressCount: inProgress,
|
||||
Played: unplayed == 0 && len(episodes) > 0,
|
||||
}
|
||||
}
|
||||
|
||||
// modelEpisodeContentIDs returns the non-empty content ids of the given episodes.
|
||||
func modelEpisodeContentIDs(episodes []*models.Episode) []string {
|
||||
ids := make([]string, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep != nil && ep.ContentID != "" {
|
||||
ids = append(ids, ep.ContentID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// chunkedProgressByMediaItems batches ListProgressByMediaItems calls when a
|
||||
// series list expands to thousands of episode ids: per-user stores may be
|
||||
// SQLite-backed (999 bind-variable default), and chunking also keeps
|
||||
// Postgres IN-lists at a sane size. Returns an empty map (never nil); chunks
|
||||
// that error are skipped.
|
||||
func chunkedProgressByMediaItems(ctx context.Context, store userstore.UserStore, profileID string, mediaItemIDs []string) map[string]userstore.WatchProgress {
|
||||
const chunkSize = 500
|
||||
result := make(map[string]userstore.WatchProgress, len(mediaItemIDs))
|
||||
for start := 0; start < len(mediaItemIDs); start += chunkSize {
|
||||
chunk, err := store.ListProgressByMediaItems(ctx, profileID, mediaItemIDs[start:min(start+chunkSize, len(mediaItemIDs))])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for id, progress := range chunk {
|
||||
result[id] = progress
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// enrichSeasonUserData adds aggregated user data for a season. Used by
|
||||
|
||||
@@ -1183,45 +1183,27 @@ func (h *ItemsHandler) writeNextUpResponse(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// The scan stays on list-level data (per-item GetItemDetail fanout over
|
||||
// the FULL next-up list was 5N DB roundtrips for users with long lists);
|
||||
// only the sliced page below is upgraded to detail when the client asked
|
||||
// for detail-level Fields — see upgradeProgressPageToDetail for why the
|
||||
// listing's MediaSources are load-bearing for Infuse/SenPlayer.
|
||||
pageIDs := make([]string, 0, len(results))
|
||||
items := make([]baseItemDTO, 0, len(results))
|
||||
for _, res := range results {
|
||||
target, ok := episodeTargets[res.ContentID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// NextUp deliberately serves list-level data even when the client
|
||||
// requests Fields=People|Chapters|MediaStreams|MediaSources, mirroring
|
||||
// the Resume hydrator (handlers_items.go ~L1721). Per-item
|
||||
// GetItemDetail fanout was 5N DB roundtrips for users with long
|
||||
// next-up lists, and the detail data was never load-bearing for the
|
||||
// row UX — clients refetch MediaSources via /Items/{id}/PlaybackInfo
|
||||
// on play.
|
||||
dto := h.mapper.itemFromList(target.Item, favorites[res.ContentID], progress[res.ContentID], query.requestedFields)
|
||||
h.applyCompatEpisodeTarget(&dto, target)
|
||||
stubDetailListFields(&dto, query.requestedFields)
|
||||
pageIDs = append(pageIDs, res.ContentID)
|
||||
items = append(items, dto)
|
||||
}
|
||||
|
||||
total := len(items)
|
||||
items = sliceBaseItems(items, query.startIndex, query.limit)
|
||||
pageIDs = slicePageIDs(pageIDs, query.startIndex, query.limit)
|
||||
if query.needsDetailFields {
|
||||
for i := range items {
|
||||
if i >= maxDetailUpgrades || i >= len(pageIDs) {
|
||||
break
|
||||
}
|
||||
detail, detailErr := h.content.GetItemDetail(r.Context(), session, pageIDs[i], nil)
|
||||
if detailErr != nil {
|
||||
continue
|
||||
}
|
||||
h.rememberDetailImages(*detail)
|
||||
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[pageIDs[i]], progress[pageIDs[i]], query.requestedFields)
|
||||
if target, ok := episodeTargets[pageIDs[i]]; ok {
|
||||
h.applyCompatEpisodeTarget(&dto, target)
|
||||
}
|
||||
items[i] = dto
|
||||
}
|
||||
}
|
||||
applyImageTypeLimit(items, query.imageTypeLimit)
|
||||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||||
Items: items,
|
||||
@@ -1230,22 +1212,6 @@ func (h *ItemsHandler) writeNextUpResponse(w http.ResponseWriter, r *http.Reques
|
||||
})
|
||||
}
|
||||
|
||||
// slicePageIDs applies sliceBaseItems' bounds to the parallel content-id
|
||||
// slice so page positions stay aligned after StartIndex/Limit slicing.
|
||||
func slicePageIDs(ids []string, startIndex, limit int) []string {
|
||||
if startIndex < 0 {
|
||||
startIndex = 0
|
||||
}
|
||||
if startIndex >= len(ids) {
|
||||
return []string{}
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = len(ids)
|
||||
}
|
||||
end := min(startIndex+limit, len(ids))
|
||||
return ids[startIndex:end]
|
||||
}
|
||||
|
||||
// HandleUpcoming serves GET /Shows/Upcoming.
|
||||
//
|
||||
// Android TV's show-detail page calls this endpoint to populate a single
|
||||
@@ -1756,20 +1722,10 @@ func (h *ItemsHandler) handleResumeResponse(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
type progressHydratedItem struct {
|
||||
itemType string
|
||||
contentID string
|
||||
isFavorite bool
|
||||
entry upstreamProgress
|
||||
target *compatEpisodeTarget
|
||||
dto baseItemDTO
|
||||
itemType string
|
||||
dto baseItemDTO
|
||||
}
|
||||
|
||||
// maxDetailUpgrades caps how many returned-page items get re-mapped through
|
||||
// the per-item detail path when the client requested detail-level Fields.
|
||||
// Entries past the cap keep their stubbed list-level DTO. Guardrail against
|
||||
// absurd client limits — normal Resume/NextUp requests are 20-40 items.
|
||||
const maxDetailUpgrades = 100
|
||||
|
||||
func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, status string, query itemsQuery, typeSet map[string]bool, libraryID *int) ([]baseItemDTO, int, error) {
|
||||
if len(typeSet) == 0 && libraryID == nil && !query.enableTotalRecordCount {
|
||||
batchSize := min(max(query.limit*2, 48), 200)
|
||||
@@ -1777,7 +1733,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
batchSize = 48
|
||||
}
|
||||
|
||||
result := make([]progressHydratedItem, 0, query.limit)
|
||||
result := make([]baseItemDTO, 0, query.limit)
|
||||
offset := query.startIndex
|
||||
for {
|
||||
progressEntries, err := h.userData.ListProgress(ctx, session, status, batchSize, offset)
|
||||
@@ -1796,14 +1752,14 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
if len(result) >= query.limit {
|
||||
break
|
||||
}
|
||||
result = append(result, item)
|
||||
result = append(result, item.dto)
|
||||
}
|
||||
if len(result) >= query.limit || len(progressEntries) < batchSize {
|
||||
break
|
||||
}
|
||||
offset += len(progressEntries)
|
||||
}
|
||||
return h.finishProgressPage(ctx, session, result, query, libraryID), 0, nil
|
||||
return result, 0, nil
|
||||
}
|
||||
|
||||
batchSize := min(max(query.limit*3, 48), 200)
|
||||
@@ -1811,7 +1767,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
batchSize = 48
|
||||
}
|
||||
|
||||
items := make([]progressHydratedItem, 0, query.limit)
|
||||
items := make([]baseItemDTO, 0, query.limit)
|
||||
matchedCount := 0
|
||||
offset := 0
|
||||
|
||||
@@ -1833,7 +1789,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
continue
|
||||
}
|
||||
if matchedCount >= query.startIndex && len(items) < query.limit {
|
||||
items = append(items, item)
|
||||
items = append(items, item.dto)
|
||||
}
|
||||
matchedCount++
|
||||
}
|
||||
@@ -1851,49 +1807,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
|
||||
if query.enableTotalRecordCount {
|
||||
total = matchedCount
|
||||
}
|
||||
return h.finishProgressPage(ctx, session, items, query, libraryID), total, nil
|
||||
}
|
||||
|
||||
// finishProgressPage upgrades the returned page to detail-mapped DTOs when
|
||||
// the client asked for detail-level Fields, then unwraps to the DTO slice.
|
||||
func (h *ItemsHandler) finishProgressPage(ctx context.Context, session *Session, page []progressHydratedItem, query itemsQuery, libraryID *int) []baseItemDTO {
|
||||
h.upgradeProgressPageToDetail(ctx, session, page, query, libraryID)
|
||||
dtos := make([]baseItemDTO, 0, len(page))
|
||||
for _, item := range page {
|
||||
dtos = append(dtos, item.dto)
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
// upgradeProgressPageToDetail re-maps the returned page of progress rows
|
||||
// through the detail path when the client requested detail-level Fields
|
||||
// (e.g. MediaSources). The scan loop in loadProgressPage stays on list-level
|
||||
// data so type filtering over long in-progress lists never fans out — the
|
||||
// per-ENTRY fanout was the 38s timeout in error-report-2026-05-08.md §6 —
|
||||
// but the returned page is bounded by the request limit, so this costs the
|
||||
// same as HandleLatest's existing per-item detail path. The listing's
|
||||
// MediaSources ARE load-bearing for some clients: Infuse and SenPlayer build
|
||||
// their Continue Watching rows from them and discard entries served only the
|
||||
// list-level stub.
|
||||
func (h *ItemsHandler) upgradeProgressPageToDetail(ctx context.Context, session *Session, page []progressHydratedItem, query itemsQuery, libraryID *int) {
|
||||
if !query.needsDetailFields {
|
||||
return
|
||||
}
|
||||
for i := range page {
|
||||
if i >= maxDetailUpgrades {
|
||||
break
|
||||
}
|
||||
detail, err := h.content.GetItemDetail(ctx, session, page[i].contentID, libraryID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h.rememberDetailImages(*detail)
|
||||
dto := h.mapper.itemFromDetailWithFields(*detail, page[i].isFavorite, &page[i].entry, query.requestedFields)
|
||||
if page[i].target != nil {
|
||||
h.applyCompatEpisodeTarget(&dto, *page[i].target)
|
||||
}
|
||||
page[i].dto = dto
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Session, entries []upstreamProgress, fields map[string]bool, libraryID *int) ([]progressHydratedItem, error) {
|
||||
@@ -1929,11 +1843,8 @@ func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Sessio
|
||||
dto := h.mapper.itemFromList(item, favorites[entry.MediaItemID], &entry, fields)
|
||||
stubDetailListFields(&dto, fields)
|
||||
result = append(result, progressHydratedItem{
|
||||
itemType: strings.ToLower(item.Type),
|
||||
contentID: entry.MediaItemID,
|
||||
isFavorite: favorites[entry.MediaItemID],
|
||||
entry: entry,
|
||||
dto: dto,
|
||||
itemType: strings.ToLower(item.Type),
|
||||
dto: dto,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -1942,12 +1853,8 @@ func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Sessio
|
||||
h.applyCompatEpisodeTarget(&dto, target)
|
||||
stubDetailListFields(&dto, fields)
|
||||
result = append(result, progressHydratedItem{
|
||||
itemType: "episode",
|
||||
contentID: entry.MediaItemID,
|
||||
isFavorite: favorites[entry.MediaItemID],
|
||||
entry: entry,
|
||||
target: &target,
|
||||
dto: dto,
|
||||
itemType: "episode",
|
||||
dto: dto,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -155,22 +154,6 @@ func (h *PlaybackHandler) playbackThresholds(ctx context.Context) userstore.Prog
|
||||
return t
|
||||
}
|
||||
|
||||
var errTranscode4KDisallowed = errors.New("4k video transcode disallowed by server settings")
|
||||
|
||||
// allow4KVideoTranscode reads the allow_4k_transcode server setting,
|
||||
// defaulting to deny like the native playback handler.
|
||||
func (h *PlaybackHandler) allow4KVideoTranscode(ctx context.Context) bool {
|
||||
if h.SettingsRepo == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := h.SettingsRepo.Get(ctx, "allow_4k_transcode")
|
||||
return v == "true"
|
||||
}
|
||||
|
||||
func is4KResolution(res string) bool {
|
||||
return access.CompareQuality(res, "2160p") >= 0
|
||||
}
|
||||
|
||||
// NewPlaybackHandler creates a playback handler.
|
||||
func NewPlaybackHandler(
|
||||
cfg *config.Config,
|
||||
@@ -317,9 +300,6 @@ func (h *PlaybackHandler) startRemoteTranscode(
|
||||
initialSeekSeconds float64,
|
||||
transcodeNodeURL string,
|
||||
) error {
|
||||
if !source.TranscodeAudio && is4KResolution(source.Version.Resolution) && !h.allow4KVideoTranscode(ctx) {
|
||||
return errTranscode4KDisallowed
|
||||
}
|
||||
if d := float64(source.Version.Duration); d > 0 && initialSeekSeconds > d {
|
||||
initialSeekSeconds = d
|
||||
}
|
||||
@@ -436,9 +416,8 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
|
||||
sources := make([]PlaybackMediaSource, 0, len(detail.Versions))
|
||||
sourceDTOs := make([]mediaSourceDTO, 0, len(detail.Versions))
|
||||
|
||||
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
|
||||
for _, version := range detail.Versions {
|
||||
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
||||
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req)
|
||||
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
|
||||
continue
|
||||
}
|
||||
@@ -534,7 +513,6 @@ func (h *PlaybackHandler) buildPlaybackSource(
|
||||
version catalog.FileVersion,
|
||||
profile DeviceProfile,
|
||||
req playbackInfoRequest,
|
||||
allow4KTranscode bool,
|
||||
) PlaybackMediaSource {
|
||||
sourceID := h.codec.EncodeIntID(EncodedIDMediaSource, int64(version.FileID))
|
||||
enableDirectPlay := boolDefault(req.EnableDirectPlay, true)
|
||||
@@ -553,12 +531,6 @@ func (h *PlaybackHandler) buildPlaybackSource(
|
||||
allowAudioCopy &&
|
||||
audioSupported
|
||||
supportsTranscoding := boolDefault(req.EnableTranscoding, true) && profile.SupportsTranscoding(version)
|
||||
// Don't offer full video encodes of 4K sources when allow_4k_transcode is
|
||||
// off. Audio-only transcodes (transcodeAudio) stream-copy the video and
|
||||
// stay available.
|
||||
if supportsTranscoding && !transcodeAudio && !allow4KTranscode && is4KResolution(version.Resolution) {
|
||||
supportsTranscoding = false
|
||||
}
|
||||
|
||||
audioIndex := defaultAudioStreamIndex(version)
|
||||
subtitleIndex := defaultSubtitleStreamIndex(version)
|
||||
|
||||
@@ -25,11 +25,11 @@ func (s *panicOnGetItemDetailContent) GetItemDetail(_ context.Context, _ *Sessio
|
||||
// Fields=People|Chapters|MediaStreams|MediaSources, producing the 38s timeout
|
||||
// observed in production for users with large in-progress lists.
|
||||
//
|
||||
// hydrateProgressItems is the SCAN phase and must stay detail-free: it runs
|
||||
// over up to 200 entries per loop iteration purely for type filtering. The
|
||||
// detail data clients need (real MediaSources — load-bearing for Infuse and
|
||||
// SenPlayer Continue Watching rows) is served by upgradeProgressPageToDetail,
|
||||
// which runs only on the returned page, bounded by the request limit.
|
||||
// The function now serves list-level data unconditionally and stubs the four
|
||||
// detail-only fields with empty (but non-nil) collections via
|
||||
// stubDetailListFields. Standard Jellyfin clients refetch MediaSources via
|
||||
// /Items/{id}/PlaybackInfo on play, so the detail data was never load-bearing
|
||||
// for the Resume row UX.
|
||||
func TestHydrateProgressItems_DoesNotCallGetItemDetail(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
|
||||
|
||||
@@ -65,10 +65,6 @@ func (c *countingEpisodeListSource) ListBySeriesGroupedBySeason(_ context.Contex
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *countingEpisodeListSource) ListBySeriesIDs(_ context.Context, _ []string) (map[string][]*models.Episode, error) {
|
||||
return map[string][]*models.Episode{}, nil
|
||||
}
|
||||
|
||||
// TestListSeasons_UsesBatchEpisodeFetch verifies ListSeasons makes exactly
|
||||
// one ListBySeriesGroupedBySeason call (replacing N per-season ListBySeason
|
||||
// calls) and exactly one ListProgressByMediaItems call (replacing N
|
||||
|
||||
@@ -204,11 +204,8 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
|
||||
// The values are minimal: each carries the required (non-omitempty) fields
|
||||
// only. Clients that consume this data downstream will see a single source
|
||||
// with a stub ID, a single Video stream at index 0, and a single placeholder
|
||||
// person. The stub is a FALLBACK, not a no-op: Infuse and SenPlayer build
|
||||
// their Continue Watching rows from the listing's MediaSources, so pages
|
||||
// within the detail-upgrade cap get real sources (upgradeProgressPageToDetail)
|
||||
// and only overflow/error entries see the stub — which must therefore still
|
||||
// look playable and serialize collections as empty, never null.
|
||||
// person; standard Jellyfin clients refetch real MediaSources via
|
||||
// /Items/{id}/PlaybackInfo on play, so this stub is never load-bearing.
|
||||
var (
|
||||
stubDetailPerson = personDTO{ID: "0", Name: ""}
|
||||
stubDetailMediaSource = mediaSourceDTO{ID: "0"}
|
||||
@@ -217,11 +214,10 @@ var (
|
||||
|
||||
// stubDetailListFields sets the four detail-only fields on a list-mapped DTO
|
||||
// to single-element placeholder slices when the client requested them via
|
||||
// Fields=People|Chapters|MediaStreams|MediaSources. Used by the Resume/NextUp
|
||||
// scan phase, which deliberately skips per-item GetItemDetail fanout; the
|
||||
// returned page is then re-mapped through the real detail path
|
||||
// (upgradeProgressPageToDetail), so these stubs reach clients only for
|
||||
// entries past the detail-upgrade cap or whose detail fetch failed.
|
||||
// Fields=People|Chapters|MediaStreams|MediaSources. Used by the Resume
|
||||
// hydrator, which deliberately skips per-item GetItemDetail fanout; clients
|
||||
// that need real MediaSources refetch them via /Items/{id}/PlaybackInfo on
|
||||
// play.
|
||||
func stubDetailListFields(dto *baseItemDTO, fields map[string]bool) {
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
@@ -238,31 +234,7 @@ func stubDetailListFields(dto *baseItemDTO, fields map[string]bool) {
|
||||
dto.MediaStreams = []mediaStreamDTO{stubDetailMediaStream}
|
||||
}
|
||||
if fields["mediasources"] && dto.MediaSources == nil {
|
||||
// The stub must still look PLAYABLE: some clients (SenPlayer) decide
|
||||
// whether to render a Resume row entry from the listing's
|
||||
// MediaSources, and an all-false playability stub makes them drop
|
||||
// every item. Keep the stub ID ("0" — never a registered media source
|
||||
// owner) but mirror the shape detailMediaSourceDTO produces; the real
|
||||
// source set is still refetched via /Items/{id}/PlaybackInfo on play.
|
||||
src := stubDetailMediaSource
|
||||
src.Protocol = "File"
|
||||
src.Type = "Default"
|
||||
src.VideoType = "VideoFile"
|
||||
src.Name = dto.Name
|
||||
src.RunTimeTicks = dto.RunTimeTicks
|
||||
src.SupportsTranscoding = true
|
||||
src.SupportsDirectStream = true
|
||||
src.SupportsDirectPlay = true
|
||||
src.SupportsProbing = true
|
||||
src.TranscodingSubProtocol = "hls"
|
||||
src.MediaStreams = []mediaStreamDTO{stubDetailMediaStream}
|
||||
// Real servers never serialize these as JSON null (always []/{});
|
||||
// strict client deserializers fail the whole response on a null
|
||||
// array, which empties the Resume/NextUp rows entirely.
|
||||
src.Formats = []string{}
|
||||
src.RequiredHTTPHeaders = map[string]string{}
|
||||
src.MediaAttachments = []map[string]any{}
|
||||
dto.MediaSources = []mediaSourceDTO{src}
|
||||
dto.MediaSources = []mediaSourceDTO{stubDetailMediaSource}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,59 +157,3 @@ func TestStubDetailListFields_FreshChapterMapPerCall(t *testing.T) {
|
||||
t.Errorf("mutating dto a's chapter map leaked into dto b: b.Chapters[0][Name] = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStubDetailListFields_MediaSourceStubLooksPlayable locks in that the
|
||||
// MediaSources stub advertises playability. Some clients (SenPlayer) decide
|
||||
// whether to render a Resume row entry from the listing's MediaSources; an
|
||||
// all-false playability stub made them drop every entry (the Continue
|
||||
// Watching row showed empty while item-page resume worked fine).
|
||||
func TestStubDetailListFields_MediaSourceStubLooksPlayable(t *testing.T) {
|
||||
dto := baseItemDTO{Name: "Pilot", RunTimeTicks: 34800000000}
|
||||
stubDetailListFields(&dto, map[string]bool{"mediasources": true})
|
||||
|
||||
if len(dto.MediaSources) != 1 {
|
||||
t.Fatalf("MediaSources = %v, want single-element slice", dto.MediaSources)
|
||||
}
|
||||
src := dto.MediaSources[0]
|
||||
if !src.SupportsDirectPlay || !src.SupportsDirectStream || !src.SupportsTranscoding || !src.SupportsProbing {
|
||||
t.Errorf("stub source must advertise playability; got %+v", src)
|
||||
}
|
||||
if src.ID != "0" {
|
||||
t.Errorf("stub source ID = %q, want \"0\" (never a registered media source owner)", src.ID)
|
||||
}
|
||||
if src.Name != "Pilot" || src.RunTimeTicks != 34800000000 {
|
||||
t.Errorf("stub source should mirror item Name/RunTimeTicks; got Name=%q ticks=%d", src.Name, src.RunTimeTicks)
|
||||
}
|
||||
if len(src.MediaStreams) != 1 {
|
||||
t.Errorf("stub source should nest a stream stub; got %v", src.MediaStreams)
|
||||
}
|
||||
// Null arrays (vs empty) break strict client deserializers — real servers
|
||||
// always serialize these as []/{}.
|
||||
if src.Formats == nil || src.RequiredHTTPHeaders == nil || src.MediaAttachments == nil {
|
||||
t.Errorf("stub source must not serialize null collections; got Formats=%v RequiredHTTPHeaders=%v MediaAttachments=%v",
|
||||
src.Formats, src.RequiredHTTPHeaders, src.MediaAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStubDetailListFields_FreshMediaSourcePerCall guards against reference
|
||||
// sharing across responses: the stub is built by copying the package-level
|
||||
// stubDetailMediaSource value, which only stays safe while that var's
|
||||
// reference-typed fields (slices/maps) remain nil. Mirrors the existing
|
||||
// chapter-map guard.
|
||||
func TestStubDetailListFields_FreshMediaSourcePerCall(t *testing.T) {
|
||||
a := baseItemDTO{}
|
||||
b := baseItemDTO{}
|
||||
fields := map[string]bool{"mediasources": true}
|
||||
|
||||
stubDetailListFields(&a, fields)
|
||||
stubDetailListFields(&b, fields)
|
||||
|
||||
a.MediaSources[0].RequiredHTTPHeaders["X-Mutated"] = "by-a"
|
||||
a.MediaSources[0].MediaStreams[0].Index = 99
|
||||
if len(b.MediaSources[0].RequiredHTTPHeaders) != 0 {
|
||||
t.Error("mutating dto a's RequiredHTTPHeaders leaked into dto b")
|
||||
}
|
||||
if b.MediaSources[0].MediaStreams[0].Index == 99 {
|
||||
t.Error("mutating dto a's nested stream leaked into dto b")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
type stubSettingsReader struct {
|
||||
values map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubSettingsReader) Get(_ context.Context, key string) (string, error) {
|
||||
if s.err != nil {
|
||||
return "", s.err
|
||||
}
|
||||
return s.values[key], nil
|
||||
}
|
||||
|
||||
func TestAllow4KVideoTranscode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
repo SettingsReader
|
||||
want bool
|
||||
}{
|
||||
{name: "nil repo defaults to deny", repo: nil, want: false},
|
||||
{name: "unset defaults to deny", repo: stubSettingsReader{}, want: false},
|
||||
{name: "read error defaults to deny", repo: stubSettingsReader{err: errors.New("read failed")}, want: false},
|
||||
{name: "explicit false denies", repo: stubSettingsReader{values: map[string]string{"allow_4k_transcode": "false"}}, want: false},
|
||||
{name: "explicit true allows", repo: stubSettingsReader{values: map[string]string{"allow_4k_transcode": "true"}}, want: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := &PlaybackHandler{SettingsRepo: tt.repo}
|
||||
if got := h.allow4KVideoTranscode(context.Background()); got != tt.want {
|
||||
t.Errorf("allow4KVideoTranscode() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIs4KResolution(t *testing.T) {
|
||||
for res, want := range map[string]bool{
|
||||
"2160p": true,
|
||||
"4320p": true,
|
||||
"1080p": false,
|
||||
"720p": false,
|
||||
"": false,
|
||||
} {
|
||||
if got := is4KResolution(res); got != want {
|
||||
t.Errorf("is4KResolution(%q) = %v, want %v", res, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlaybackSource4KVideoTranscodeGate(t *testing.T) {
|
||||
version4K := catalog.FileVersion{
|
||||
FileID: 1,
|
||||
Resolution: "2160p",
|
||||
CodecVideo: "hevc",
|
||||
CodecAudio: "eac3",
|
||||
Container: "mkv",
|
||||
}
|
||||
version1080 := version4K
|
||||
version1080.Resolution = "1080p"
|
||||
|
||||
// Can only decode h264: HEVC versions need a full video transcode.
|
||||
h264Only := DeviceProfile{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Type: "Video", Container: "mp4", VideoCodec: "h264", AudioCodec: "aac"},
|
||||
},
|
||||
}
|
||||
// Decodes HEVC but not EAC3: transcoding path stream-copies the video.
|
||||
hevcNoEac3 := DeviceProfile{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Type: "Video", Container: "mp4", VideoCodec: "h264,hevc", AudioCodec: "aac"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
version catalog.FileVersion
|
||||
profile DeviceProfile
|
||||
allow4K bool
|
||||
wantTranscoding bool
|
||||
wantTranscodeAudio bool
|
||||
}{
|
||||
{
|
||||
name: "4K video transcode blocked when disallowed",
|
||||
version: version4K,
|
||||
profile: h264Only,
|
||||
allow4K: false,
|
||||
wantTranscoding: false,
|
||||
},
|
||||
{
|
||||
name: "4K video transcode offered when allowed",
|
||||
version: version4K,
|
||||
profile: h264Only,
|
||||
allow4K: true,
|
||||
wantTranscoding: true,
|
||||
},
|
||||
{
|
||||
name: "4K audio-only transcode (video copy) stays allowed",
|
||||
version: version4K,
|
||||
profile: hevcNoEac3,
|
||||
allow4K: false,
|
||||
wantTranscoding: true,
|
||||
wantTranscodeAudio: true,
|
||||
},
|
||||
{
|
||||
name: "non-4K video transcode unaffected",
|
||||
version: version1080,
|
||||
profile: h264Only,
|
||||
allow4K: false,
|
||||
wantTranscoding: true,
|
||||
},
|
||||
}
|
||||
|
||||
h := &PlaybackHandler{codec: NewResourceIDCodec()}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
source := h.buildPlaybackSource("item", "ps", tt.version, tt.profile, playbackInfoRequest{}, tt.allow4K)
|
||||
if source.SupportsTranscoding != tt.wantTranscoding {
|
||||
t.Errorf("SupportsTranscoding = %v, want %v", source.SupportsTranscoding, tt.wantTranscoding)
|
||||
}
|
||||
if source.TranscodeAudio != tt.wantTranscodeAudio {
|
||||
t.Errorf("TranscodeAudio = %v, want %v", source.TranscodeAudio, tt.wantTranscodeAudio)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureTranscodeSession4KGuard(t *testing.T) {
|
||||
h := &PlaybackHandler{} // nil SettingsRepo: 4K video transcodes denied
|
||||
|
||||
source := PlaybackMediaSource{
|
||||
FileID: 1,
|
||||
Version: catalog.FileVersion{FileID: 1, Resolution: "2160p", CodecVideo: "hevc"},
|
||||
}
|
||||
if _, err := h.ensureTranscodeSession(context.Background(), "ps", "session", source); !errors.Is(err, errTranscode4KDisallowed) {
|
||||
t.Errorf("ensureTranscodeSession() error = %v, want errTranscode4KDisallowed", err)
|
||||
}
|
||||
|
||||
// Video-copy sessions pass the guard (and fail later on the missing file
|
||||
// resolver, which is fine for this test).
|
||||
source.TranscodeAudio = true
|
||||
if _, err := h.ensureTranscodeSession(context.Background(), "ps", "session", source); errors.Is(err, errTranscode4KDisallowed) {
|
||||
t.Error("ensureTranscodeSession() blocked a video-copy session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRemoteTranscode4KGuard(t *testing.T) {
|
||||
h := &PlaybackHandler{} // nil SettingsRepo: 4K video transcodes denied
|
||||
|
||||
source := PlaybackMediaSource{
|
||||
FileID: 1,
|
||||
Version: catalog.FileVersion{FileID: 1, Resolution: "2160p", CodecVideo: "hevc"},
|
||||
}
|
||||
if err := h.startRemoteTranscode(context.Background(), "session", source, nil, 0, "http://node"); !errors.Is(err, errTranscode4KDisallowed) {
|
||||
t.Errorf("startRemoteTranscode() error = %v, want errTranscode4KDisallowed", err)
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
func ep(id string) *models.Episode {
|
||||
return &models.Episode{ContentID: id}
|
||||
}
|
||||
|
||||
// TestSeriesUserDataFromEpisodes covers the series watch-state rollup: a
|
||||
// series has no progress row of its own, so Played/UnplayedCount/
|
||||
// InProgressCount are aggregated from per-episode progress.
|
||||
func TestSeriesUserDataFromEpisodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
episodes []*models.Episode
|
||||
progress map[string]userstore.WatchProgress
|
||||
wantWatched int
|
||||
wantUnplayed int
|
||||
wantInProgress int
|
||||
wantPlayed bool
|
||||
}{
|
||||
{
|
||||
name: "no episodes",
|
||||
episodes: nil,
|
||||
progress: map[string]userstore.WatchProgress{},
|
||||
// Played must be false for an empty series — unplayed==0 alone
|
||||
// must not flip it.
|
||||
wantPlayed: false,
|
||||
},
|
||||
{
|
||||
name: "all watched",
|
||||
episodes: []*models.Episode{ep("a"), ep("b")},
|
||||
progress: map[string]userstore.WatchProgress{
|
||||
"a": {Completed: true},
|
||||
"b": {Completed: true},
|
||||
},
|
||||
wantWatched: 2,
|
||||
wantPlayed: true,
|
||||
},
|
||||
{
|
||||
name: "mixed watched, in-progress, untouched",
|
||||
episodes: []*models.Episode{ep("a"), ep("b"), ep("c")},
|
||||
progress: map[string]userstore.WatchProgress{
|
||||
"a": {Completed: true},
|
||||
"b": {PositionSeconds: 120},
|
||||
},
|
||||
wantWatched: 1,
|
||||
wantUnplayed: 2,
|
||||
wantInProgress: 1,
|
||||
wantPlayed: false,
|
||||
},
|
||||
{
|
||||
name: "no progress at all",
|
||||
episodes: []*models.Episode{ep("a")},
|
||||
progress: map[string]userstore.WatchProgress{},
|
||||
wantUnplayed: 1,
|
||||
wantPlayed: false,
|
||||
},
|
||||
{
|
||||
name: "nil episodes skipped",
|
||||
episodes: []*models.Episode{nil, ep("a"), nil},
|
||||
progress: map[string]userstore.WatchProgress{
|
||||
"a": {Completed: true},
|
||||
},
|
||||
wantWatched: 1,
|
||||
wantPlayed: true,
|
||||
},
|
||||
{
|
||||
name: "zero-position progress row is not in-progress",
|
||||
episodes: []*models.Episode{ep("a")},
|
||||
progress: map[string]userstore.WatchProgress{
|
||||
"a": {PositionSeconds: 0},
|
||||
},
|
||||
wantUnplayed: 1,
|
||||
wantInProgress: 0,
|
||||
wantPlayed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := seriesUserDataFromEpisodes(tc.episodes, tc.progress)
|
||||
if got.WatchedCount != tc.wantWatched {
|
||||
t.Errorf("WatchedCount = %d, want %d", got.WatchedCount, tc.wantWatched)
|
||||
}
|
||||
if got.UnplayedCount != tc.wantUnplayed {
|
||||
t.Errorf("UnplayedCount = %d, want %d", got.UnplayedCount, tc.wantUnplayed)
|
||||
}
|
||||
if got.InProgressCount != tc.wantInProgress {
|
||||
t.Errorf("InProgressCount = %d, want %d", got.InProgressCount, tc.wantInProgress)
|
||||
}
|
||||
if got.Played != tc.wantPlayed {
|
||||
t.Errorf("Played = %v, want %v", got.Played, tc.wantPlayed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelEpisodeContentIDs verifies nil episodes and empty content ids are
|
||||
// dropped — these ids feed SQL IN-lists.
|
||||
func TestModelEpisodeContentIDs(t *testing.T) {
|
||||
got := modelEpisodeContentIDs([]*models.Episode{nil, ep(""), ep("a"), ep("b")})
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Errorf("modelEpisodeContentIDs = %v, want [a b]", got)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSlicePageIDs locks slicePageIDs to sliceBaseItems' exact bounds
|
||||
// semantics — the two slice the same page in parallel in
|
||||
// writeNextUpResponse, and any divergence would attach detail upgrades to
|
||||
// the wrong DTOs.
|
||||
func TestSlicePageIDs(t *testing.T) {
|
||||
ids := []string{"a", "b", "c", "d"}
|
||||
cases := []struct {
|
||||
name string
|
||||
startIndex int
|
||||
limit int
|
||||
want []string
|
||||
}{
|
||||
{"plain page", 0, 2, []string{"a", "b"}},
|
||||
{"offset page", 1, 2, []string{"b", "c"}},
|
||||
{"limit past end clamps", 2, 10, []string{"c", "d"}},
|
||||
{"start past end is empty", 9, 2, []string{}},
|
||||
{"zero limit returns rest", 1, 0, []string{"b", "c", "d"}},
|
||||
{"negative start clamps to zero", -3, 2, []string{"a", "b"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := slicePageIDs(ids, tc.startIndex, tc.limit)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d (%v)", len(got), len(tc.want), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("got[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
// Mirror against sliceBaseItems on a same-length DTO slice.
|
||||
dtos := make([]baseItemDTO, len(ids))
|
||||
for i := range ids {
|
||||
dtos[i] = baseItemDTO{ID: ids[i]}
|
||||
}
|
||||
sliced := sliceBaseItems(dtos, tc.startIndex, tc.limit)
|
||||
if len(sliced) != len(got) {
|
||||
t.Fatalf("sliceBaseItems len %d diverges from slicePageIDs len %d", len(sliced), len(got))
|
||||
}
|
||||
for i := range sliced {
|
||||
if sliced[i].ID != got[i] {
|
||||
t.Errorf("alignment broken at %d: dto %q vs id %q", i, sliced[i].ID, got[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -165,10 +165,6 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
if err := h.startRemoteTranscode(r.Context(), playSession.UpstreamSessionID, *source, file, playSession.InitialSeekSeconds, tcNode.URL); err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, "TranscodeStartFailed", "Transcode node rejected the request")
|
||||
return
|
||||
}
|
||||
@@ -186,10 +182,6 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
// Ensure the transcode process is running.
|
||||
_, err = h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrManifestNotReady) {
|
||||
writeError(w, http.StatusServiceUnavailable, "NotReady", "Transcode playlist not ready")
|
||||
return
|
||||
@@ -236,10 +228,6 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque
|
||||
// Ensure the transcode process is running.
|
||||
_, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, playback.ErrManifestNotReady) {
|
||||
writeError(w, http.StatusServiceUnavailable, "NotReady", "Transcode playlist not ready")
|
||||
return
|
||||
@@ -822,9 +810,6 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
|
||||
if existing := h.getTranscodeSession(upstreamSessionID); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !source.TranscodeAudio && is4KResolution(source.Version.Resolution) && !h.allow4KVideoTranscode(ctx) {
|
||||
return nil, errTranscode4KDisallowed
|
||||
}
|
||||
if h.fileResolver == nil {
|
||||
return nil, fmt.Errorf("file resolver not available")
|
||||
}
|
||||
@@ -1034,9 +1019,8 @@ func (h *PlaybackHandler) createStaticPlaySession(ctx context.Context, session *
|
||||
|
||||
playSessionID := h.codec.EncodeStringID(EncodedIDPlaySession, uuidNewString())
|
||||
sources := make([]PlaybackMediaSource, 0, len(detail.Versions))
|
||||
allow4KTranscode := h.allow4KVideoTranscode(ctx)
|
||||
for _, version := range detail.Versions {
|
||||
source := h.buildPlaybackSource(routeID, playSessionID, version, DeviceProfile{}, playbackInfoRequest{}, allow4KTranscode)
|
||||
source := h.buildPlaybackSource(routeID, playSessionID, version, DeviceProfile{}, playbackInfoRequest{})
|
||||
sources = append(sources, source)
|
||||
}
|
||||
|
||||
|
||||
+19
-206
@@ -1,38 +1,15 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultDRIDir = "/dev/dri"
|
||||
defaultNVIDIAControlDevice = "/dev/nvidiactl"
|
||||
defaultNVIDIADeviceGlob = "/dev/nvidia[0-9]*"
|
||||
sysClassDRMDir = "/sys/class/drm"
|
||||
currentGOOS = runtime.GOOS
|
||||
nvencProbeCommandTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
type nvencProbeResult struct {
|
||||
available bool
|
||||
reason string
|
||||
}
|
||||
|
||||
var nvencProbeCache = struct {
|
||||
sync.Mutex
|
||||
byPath map[string]nvencProbeResult
|
||||
}{
|
||||
byPath: make(map[string]nvencProbeResult),
|
||||
}
|
||||
const defaultDRIDir = "/dev/dri"
|
||||
|
||||
// HWAccelInfo describes the detected hardware acceleration capability.
|
||||
type HWAccelInfo struct {
|
||||
@@ -45,11 +22,6 @@ type HWAccelInfo struct {
|
||||
|
||||
// DetectHWAccel probes this host's GPU hardware and returns structured info.
|
||||
func DetectHWAccel() HWAccelInfo {
|
||||
return DetectHWAccelWithFFmpeg("")
|
||||
}
|
||||
|
||||
// DetectHWAccelWithFFmpeg probes this host's GPU hardware and configured FFmpeg.
|
||||
func DetectHWAccelWithFFmpeg(ffmpegPath string) HWAccelInfo {
|
||||
devices := listRenderDevices(defaultDRIDir)
|
||||
intel := false
|
||||
for _, d := range devices {
|
||||
@@ -59,7 +31,7 @@ func DetectHWAccelWithFFmpeg(ffmpegPath string) HWAccelInfo {
|
||||
}
|
||||
}
|
||||
return HWAccelInfo{
|
||||
Resolved: ResolveHWAccelWithFFmpeg("auto", ffmpegPath),
|
||||
Resolved: ResolveHWAccel("auto"),
|
||||
RenderDevices: devices,
|
||||
IntelDetected: intel,
|
||||
Source: "local",
|
||||
@@ -81,163 +53,34 @@ func PickRenderDevice(explicit string) string {
|
||||
return dev
|
||||
}
|
||||
|
||||
// ResolveHWAccel resolves "auto" using the default FFmpeg binary.
|
||||
func ResolveHWAccel(hwAccel string) string {
|
||||
return ResolveHWAccelWithFFmpeg(hwAccel, "")
|
||||
}
|
||||
|
||||
// ResolveHWAccelWithFFmpeg resolves "auto" into a concrete acceleration method
|
||||
// by probing the system and the configured FFmpeg binary.
|
||||
// Preference order: nvenc > qsv > vaapi > none.
|
||||
// ResolveHWAccel resolves "auto" into a concrete acceleration method by
|
||||
// probing the system. Preference order: qsv > vaapi > none.
|
||||
// Non-"auto" values are returned unchanged.
|
||||
func ResolveHWAccelWithFFmpeg(hwAccel string, ffmpegPath string) string {
|
||||
func ResolveHWAccel(hwAccel string) string {
|
||||
if hwAccel != "auto" {
|
||||
return hwAccel
|
||||
}
|
||||
if currentGOOS != "linux" {
|
||||
if runtime.GOOS != "linux" {
|
||||
return "none"
|
||||
}
|
||||
|
||||
devices := listRenderDevices(defaultDRIDir)
|
||||
var intelDevice string
|
||||
var nvidiaDevice string
|
||||
var vaapiDevice string
|
||||
if len(devices) == 0 {
|
||||
slog.Info("hw_accel=auto: no render devices found, using software encoding")
|
||||
return "none"
|
||||
}
|
||||
|
||||
// Check for Intel GPU — enables QSV (preferred).
|
||||
for _, dev := range devices {
|
||||
switch {
|
||||
case isNVIDIADevice(dev):
|
||||
if nvidiaDevice == "" {
|
||||
nvidiaDevice = dev
|
||||
}
|
||||
case isIntelDevice(dev):
|
||||
if intelDevice == "" {
|
||||
intelDevice = dev
|
||||
}
|
||||
default:
|
||||
if vaapiDevice == "" {
|
||||
vaapiDevice = dev
|
||||
}
|
||||
if isIntelDevice(dev) {
|
||||
slog.Info("hw_accel=auto: Intel GPU detected, using QSV", "device", dev)
|
||||
return "qsv"
|
||||
}
|
||||
}
|
||||
|
||||
if nvidiaDevice != "" || hasNVIDIADevice() {
|
||||
if ok, reason := ffmpegSupportsNVENC(ffmpegPath); ok {
|
||||
if nvidiaDevice != "" {
|
||||
slog.Info("hw_accel=auto: NVIDIA GPU detected, using NVENC", "device", nvidiaDevice)
|
||||
} else {
|
||||
slog.Info("hw_accel=auto: NVIDIA device detected, using NVENC")
|
||||
}
|
||||
return "nvenc"
|
||||
} else {
|
||||
slog.Warn("hw_accel=auto: NVIDIA device detected but FFmpeg NVENC probe failed",
|
||||
"ffmpeg", normalizeFFmpegPath(ffmpegPath), "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
if intelDevice != "" {
|
||||
slog.Info("hw_accel=auto: Intel GPU detected, using QSV", "device", intelDevice)
|
||||
return "qsv"
|
||||
}
|
||||
|
||||
if vaapiDevice != "" {
|
||||
slog.Info("hw_accel=auto: non-Intel GPU detected, using VAAPI", "device", vaapiDevice)
|
||||
return "vaapi"
|
||||
}
|
||||
|
||||
slog.Info("hw_accel=auto: no compatible GPU devices found, using software encoding")
|
||||
return "none"
|
||||
}
|
||||
|
||||
func ffmpegSupportsNVENC(ffmpegPath string) (bool, string) {
|
||||
ffmpegPath = normalizeFFmpegPath(ffmpegPath)
|
||||
nvencProbeCache.Lock()
|
||||
if result, ok := nvencProbeCache.byPath[ffmpegPath]; ok {
|
||||
nvencProbeCache.Unlock()
|
||||
return result.available, result.reason
|
||||
}
|
||||
nvencProbeCache.Unlock()
|
||||
|
||||
result := probeFFmpegNVENC(ffmpegPath)
|
||||
nvencProbeCache.Lock()
|
||||
nvencProbeCache.byPath[ffmpegPath] = result
|
||||
nvencProbeCache.Unlock()
|
||||
return result.available, result.reason
|
||||
}
|
||||
|
||||
func normalizeFFmpegPath(ffmpegPath string) string {
|
||||
ffmpegPath = strings.TrimSpace(ffmpegPath)
|
||||
if ffmpegPath == "" {
|
||||
ffmpegPath = ffmpegBinary()
|
||||
}
|
||||
if strings.ContainsRune(ffmpegPath, os.PathSeparator) {
|
||||
return filepath.Clean(ffmpegPath)
|
||||
}
|
||||
return ffmpegPath
|
||||
}
|
||||
|
||||
func probeFFmpegNVENC(ffmpegPath string) nvencProbeResult {
|
||||
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-hwaccels"); err != nil {
|
||||
return nvencProbeResult{reason: "hwaccels probe failed: " + probeFailure(err, output)}
|
||||
} else if !ffmpegOutputHasToken(output, "cuda") {
|
||||
return nvencProbeResult{reason: "cuda hwaccel unavailable"}
|
||||
}
|
||||
|
||||
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-encoders"); err != nil {
|
||||
return nvencProbeResult{reason: "encoders probe failed: " + probeFailure(err, output)}
|
||||
} else if !ffmpegOutputHasToken(output, "h264_nvenc") {
|
||||
return nvencProbeResult{reason: "h264_nvenc encoder unavailable"}
|
||||
} else if !ffmpegOutputHasToken(output, "hevc_nvenc") {
|
||||
return nvencProbeResult{reason: "hevc_nvenc encoder unavailable"}
|
||||
}
|
||||
|
||||
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-filters"); err != nil {
|
||||
return nvencProbeResult{reason: "filters probe failed: " + probeFailure(err, output)}
|
||||
} else if !ffmpegOutputHasToken(output, "scale_cuda") {
|
||||
return nvencProbeResult{reason: "scale_cuda filter unavailable"}
|
||||
} else if !ffmpegOutputHasToken(output, "hwupload_cuda") {
|
||||
return nvencProbeResult{reason: "hwupload_cuda filter unavailable"}
|
||||
}
|
||||
|
||||
if output, err := runFFmpegProbe(ffmpegPath,
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc2=size=64x64:rate=1",
|
||||
"-frames:v", "1",
|
||||
"-an",
|
||||
"-c:v", "h264_nvenc",
|
||||
"-f", "null",
|
||||
"-",
|
||||
); err != nil {
|
||||
return nvencProbeResult{reason: "h264_nvenc smoke encode failed: " + probeFailure(err, output)}
|
||||
}
|
||||
|
||||
return nvencProbeResult{available: true}
|
||||
}
|
||||
|
||||
func runFFmpegProbe(ffmpegPath string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nvencProbeCommandTimeout)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, ffmpegPath, args...).CombinedOutput()
|
||||
}
|
||||
|
||||
func ffmpegOutputHasToken(output []byte, token string) bool {
|
||||
for _, field := range strings.Fields(string(output)) {
|
||||
if strings.EqualFold(field, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func probeFailure(err error, output []byte) string {
|
||||
message := strings.TrimSpace(err.Error())
|
||||
if trimmed := strings.TrimSpace(string(output)); trimmed != "" {
|
||||
if len(trimmed) > 240 {
|
||||
trimmed = trimmed[:240] + "..."
|
||||
}
|
||||
message += ": " + trimmed
|
||||
}
|
||||
return message
|
||||
// Any accessible render device supports VAAPI.
|
||||
slog.Info("hw_accel=auto: non-Intel GPU detected, using VAAPI", "device", devices[0])
|
||||
return "vaapi"
|
||||
}
|
||||
|
||||
// listRenderDevices returns all accessible /dev/dri/renderD* paths, sorted.
|
||||
@@ -264,7 +107,7 @@ func listRenderDevices(driDir string) []string {
|
||||
func isIntelDevice(renderDevPath string) bool {
|
||||
// /dev/dri/renderD128 → card name "renderD128"
|
||||
name := filepath.Base(renderDevPath)
|
||||
vendorPath := filepath.Join(sysClassDRMDir, name, "device", "vendor")
|
||||
vendorPath := filepath.Join("/sys/class/drm", name, "device", "vendor")
|
||||
data, err := os.ReadFile(vendorPath)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -272,36 +115,6 @@ func isIntelDevice(renderDevPath string) bool {
|
||||
return strings.TrimSpace(string(data)) == "0x8086"
|
||||
}
|
||||
|
||||
// isNVIDIADevice checks whether a render device belongs to an NVIDIA GPU by
|
||||
// reading the PCI vendor ID from sysfs. NVIDIA vendor ID is 0x10de.
|
||||
func isNVIDIADevice(renderDevPath string) bool {
|
||||
name := filepath.Base(renderDevPath)
|
||||
vendorPath := filepath.Join(sysClassDRMDir, name, "device", "vendor")
|
||||
data, err := os.ReadFile(vendorPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == "0x10de"
|
||||
}
|
||||
|
||||
func hasNVIDIADevice() bool {
|
||||
if file, err := os.Open(defaultNVIDIAControlDevice); err == nil {
|
||||
file.Close()
|
||||
return true
|
||||
}
|
||||
matches, err := filepath.Glob(defaultNVIDIADeviceGlob)
|
||||
if err != nil || len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, dev := range matches {
|
||||
if file, err := os.Open(dev); err == nil {
|
||||
file.Close()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectRenderDevice enumerates /dev/dri/renderD* and returns the first
|
||||
// available device, or empty string if none found.
|
||||
func detectRenderDevice(driDir string) string {
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hwAccelTestEnv struct {
|
||||
devDir string
|
||||
driDir string
|
||||
sysDir string
|
||||
}
|
||||
|
||||
type fakeFFmpegProbe struct {
|
||||
cuda bool
|
||||
h264NVENC bool
|
||||
hevcNVENC bool
|
||||
scaleCUDA bool
|
||||
uploadCUDA bool
|
||||
smokeOK bool
|
||||
hang bool
|
||||
}
|
||||
|
||||
type fakeFFmpegBinary struct {
|
||||
path string
|
||||
logPath string
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegAutoPrefersNVENCOverIntel(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x8086")
|
||||
env.addRenderDevice(t, "renderD129", "0x10de")
|
||||
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegFallsBackToIntelWhenNVENCProbeFails(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x8086")
|
||||
env.addRenderDevice(t, "renderD129", "0x10de")
|
||||
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "qsv" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want qsv", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegFallsBackToVAAPIWhenNVENCProbeFails(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x10de")
|
||||
env.addRenderDevice(t, "renderD129", "0x1002")
|
||||
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "vaapi" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want vaapi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegReturnsNoneWhenNVENCProbeFailsWithoutFallback(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x10de")
|
||||
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "none" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHWAccelWithFFmpegUsesNVIDIADeviceNodesWithoutDRM(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addNVIDIADevice(t, "nvidia0")
|
||||
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitNVENCBypassesFFmpegProbe(t *testing.T) {
|
||||
setupHWAccelTest(t)
|
||||
|
||||
if got := ResolveHWAccelWithFFmpeg("nvenc", "/does/not/exist/ffmpeg"); got != "nvenc" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegSupportsNVENCRequiresCUDAEncodersFiltersAndSmoke(t *testing.T) {
|
||||
setupHWAccelTest(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
probe fakeFFmpegProbe
|
||||
}{
|
||||
{
|
||||
name: "missing cuda hwaccel",
|
||||
probe: fakeFFmpegProbe{
|
||||
h264NVENC: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing h264 nvenc encoder",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing hevc nvenc encoder",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, h264NVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing scale cuda filter",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, h264NVENC: true, hevcNVENC: true, uploadCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing hwupload cuda filter",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, h264NVENC: true, hevcNVENC: true, scaleCUDA: true, smokeOK: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "smoke encode failure",
|
||||
probe: fakeFFmpegProbe{
|
||||
cuda: true, h264NVENC: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "probe timeout",
|
||||
probe: fakeFFmpegProbe{hang: true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resetNVENCProbeCacheForTest()
|
||||
ffmpeg := writeFakeFFmpeg(t, tt.probe)
|
||||
if ok, reason := ffmpegSupportsNVENC(ffmpeg.path); ok {
|
||||
t.Fatalf("ffmpegSupportsNVENC() = true, want false")
|
||||
} else if reason == "" {
|
||||
t.Fatalf("ffmpegSupportsNVENC() reason is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegSupportsNVENCCachesByFFmpegPath(t *testing.T) {
|
||||
env := setupHWAccelTest(t)
|
||||
env.addRenderDevice(t, "renderD128", "0x10de")
|
||||
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
|
||||
t.Fatalf("ResolveHWAccelWithFFmpeg() call %d = %q, want nvenc", i+1, got)
|
||||
}
|
||||
}
|
||||
|
||||
logData, err := os.ReadFile(ffmpeg.logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read ffmpeg probe log: %v", err)
|
||||
}
|
||||
if got := strings.Count(string(logData), "\n"); got != 4 {
|
||||
t.Fatalf("probe command count = %d, want 4; log:\n%s", got, logData)
|
||||
}
|
||||
}
|
||||
|
||||
func successfulNVENCProbe() fakeFFmpegProbe {
|
||||
return fakeFFmpegProbe{
|
||||
cuda: true,
|
||||
h264NVENC: true,
|
||||
hevcNVENC: true,
|
||||
scaleCUDA: true,
|
||||
uploadCUDA: true,
|
||||
smokeOK: true,
|
||||
}
|
||||
}
|
||||
|
||||
func setupHWAccelTest(t *testing.T) *hwAccelTestEnv {
|
||||
t.Helper()
|
||||
|
||||
oldDRIDir := defaultDRIDir
|
||||
oldNVIDIAControlDevice := defaultNVIDIAControlDevice
|
||||
oldNVIDIADeviceGlob := defaultNVIDIADeviceGlob
|
||||
oldSysClassDRMDir := sysClassDRMDir
|
||||
oldGOOS := currentGOOS
|
||||
oldProbeTimeout := nvencProbeCommandTimeout
|
||||
resetNVENCProbeCacheForTest()
|
||||
|
||||
tmp := t.TempDir()
|
||||
env := &hwAccelTestEnv{
|
||||
devDir: filepath.Join(tmp, "dev"),
|
||||
driDir: filepath.Join(tmp, "dev", "dri"),
|
||||
sysDir: filepath.Join(tmp, "sys", "class", "drm"),
|
||||
}
|
||||
defaultDRIDir = env.driDir
|
||||
defaultNVIDIAControlDevice = filepath.Join(env.devDir, "nvidiactl")
|
||||
defaultNVIDIADeviceGlob = filepath.Join(env.devDir, "nvidia[0-9]*")
|
||||
sysClassDRMDir = env.sysDir
|
||||
currentGOOS = "linux"
|
||||
nvencProbeCommandTimeout = 200 * time.Millisecond
|
||||
|
||||
if err := os.MkdirAll(env.driDir, 0o755); err != nil {
|
||||
t.Fatalf("create test dri dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(env.devDir, 0o755); err != nil {
|
||||
t.Fatalf("create test dev dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
defaultDRIDir = oldDRIDir
|
||||
defaultNVIDIAControlDevice = oldNVIDIAControlDevice
|
||||
defaultNVIDIADeviceGlob = oldNVIDIADeviceGlob
|
||||
sysClassDRMDir = oldSysClassDRMDir
|
||||
currentGOOS = oldGOOS
|
||||
nvencProbeCommandTimeout = oldProbeTimeout
|
||||
resetNVENCProbeCacheForTest()
|
||||
})
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *hwAccelTestEnv) addRenderDevice(t *testing.T, name string, vendor string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(e.driDir, name), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create render device: %v", err)
|
||||
}
|
||||
vendorPath := filepath.Join(e.sysDir, name, "device", "vendor")
|
||||
if err := os.MkdirAll(filepath.Dir(vendorPath), 0o755); err != nil {
|
||||
t.Fatalf("create vendor dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(vendorPath, []byte(vendor+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write vendor file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *hwAccelTestEnv) addNVIDIADevice(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(e.devDir, name), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create nvidia device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeFFmpeg(t *testing.T, probe fakeFFmpegProbe) fakeFFmpegBinary {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ffmpeg")
|
||||
logPath := filepath.Join(dir, "probe.log")
|
||||
|
||||
script := "#!/bin/sh\n"
|
||||
script += fmt.Sprintf("printf '%%s\\n' \"$*\" >> %q\n", logPath)
|
||||
if probe.hang {
|
||||
script += "sleep 5\n"
|
||||
}
|
||||
script += "case \"$*\" in\n"
|
||||
script += " *-hwaccels*)\n"
|
||||
script += " echo 'Hardware acceleration methods:'\n"
|
||||
if probe.cuda {
|
||||
script += " echo 'cuda'\n"
|
||||
}
|
||||
script += " exit 0 ;;\n"
|
||||
script += " *-encoders*)\n"
|
||||
if probe.h264NVENC {
|
||||
script += " echo ' V..... h264_nvenc NVIDIA NVENC H.264 encoder'\n"
|
||||
}
|
||||
if probe.hevcNVENC {
|
||||
script += " echo ' V..... hevc_nvenc NVIDIA NVENC hevc encoder'\n"
|
||||
}
|
||||
script += " exit 0 ;;\n"
|
||||
script += " *-filters*)\n"
|
||||
if probe.scaleCUDA {
|
||||
script += " echo ' ... scale_cuda V->V GPU video scaling'\n"
|
||||
}
|
||||
if probe.uploadCUDA {
|
||||
script += " echo ' ... hwupload_cuda V->V upload CUDA frames'\n"
|
||||
}
|
||||
script += " exit 0 ;;\n"
|
||||
script += " *)\n"
|
||||
if probe.smokeOK {
|
||||
script += " exit 0 ;;\n"
|
||||
} else {
|
||||
script += " echo 'no capable devices found' >&2\n"
|
||||
script += " exit 1 ;;\n"
|
||||
}
|
||||
script += "esac\n"
|
||||
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake ffmpeg: %v", err)
|
||||
}
|
||||
return fakeFFmpegBinary{path: path, logPath: logPath}
|
||||
}
|
||||
|
||||
func resetNVENCProbeCacheForTest() {
|
||||
nvencProbeCache.Lock()
|
||||
defer nvencProbeCache.Unlock()
|
||||
nvencProbeCache.byPath = make(map[string]nvencProbeResult)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ type TranscodeOpts struct {
|
||||
SegmentDuration int // seconds, default 6
|
||||
StartSegmentNumber int // -hls_segment_start_number, default 0
|
||||
FFmpegPath string // optional explicit ffmpeg binary path
|
||||
HWAccel string // auto, qsv, vaapi, nvenc, none
|
||||
HWAccel string // auto, qsv, vaapi, none
|
||||
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
|
||||
SubtitleTrackIndex int // -1 = no subtitles
|
||||
SubtitleBurnIn bool
|
||||
@@ -294,9 +294,6 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
|
||||
} else if opts.HWAccel == "vaapi" {
|
||||
scale := vaapiScaleFilter(opts.TargetResolution)
|
||||
args = append(args, "-vf", scale)
|
||||
} else if opts.HWAccel == "nvenc" {
|
||||
scale := nvencScaleFilter(opts.TargetResolution)
|
||||
args = append(args, "-vf", scale)
|
||||
} else if opts.TargetResolution != "" {
|
||||
scale := resolutionToScale(opts.TargetResolution)
|
||||
if scale != "" {
|
||||
@@ -354,7 +351,7 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
|
||||
}
|
||||
|
||||
func resolveEffectiveTranscodeHWAccel(opts TranscodeOpts) string {
|
||||
hwAccel := ResolveHWAccelWithFFmpeg(opts.HWAccel, opts.FFmpegPath)
|
||||
hwAccel := ResolveHWAccel(opts.HWAccel)
|
||||
if hwAccel == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -425,12 +422,12 @@ func appendSegmentBoundaryArgs(args []string, opts TranscodeOpts) []string {
|
||||
fmt.Sprintf("expr:gte(t,n_forced*%d)", opts.SegmentDuration))
|
||||
}
|
||||
|
||||
// Hardware encoders (QSV, VAAPI, NVENC) may not reliably honor
|
||||
// Hardware encoders (QSV, VAAPI) may not reliably honor
|
||||
// force_key_frames expressions. Set explicit GOP size so segment
|
||||
// boundaries always start with an IDR frame. We assume 30 fps as a
|
||||
// safe ceiling — the GOP will be at most segmentDuration * 30 frames.
|
||||
// Matches Jellyfin's approach for hardware encoders.
|
||||
if opts.HWAccel == "qsv" || opts.HWAccel == "vaapi" || opts.HWAccel == "nvenc" {
|
||||
if opts.HWAccel == "qsv" || opts.HWAccel == "vaapi" {
|
||||
gopSize := fmt.Sprintf("%d", opts.SegmentDuration*30)
|
||||
args = append(args, "-g", gopSize, "-keyint_min", gopSize)
|
||||
}
|
||||
@@ -468,15 +465,6 @@ func appendHWAccelArgs(args []string, opts TranscodeOpts) []string {
|
||||
"-hwaccel", "vaapi",
|
||||
"-hwaccel_output_format", "vaapi",
|
||||
)
|
||||
case "nvenc":
|
||||
args = append(args,
|
||||
"-hwaccel", "cuda",
|
||||
"-hwaccel_output_format", "cuda",
|
||||
"-noautorotate",
|
||||
)
|
||||
if hwDevice := strings.TrimSpace(opts.HWDevice); hwDevice != "" {
|
||||
args = append(args, "-hwaccel_device", hwDevice)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -542,26 +530,6 @@ func appendVideoArgs(args []string, opts TranscodeOpts) []string {
|
||||
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
|
||||
}
|
||||
case opts.HWAccel == "nvenc" && codec == "h264":
|
||||
args = append(args, "-c:v", "h264_nvenc", "-rc:v", "vbr")
|
||||
if hasBitrateCap {
|
||||
args = append(args,
|
||||
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
|
||||
} else {
|
||||
args = append(args, "-cq:v", "23", "-b:v", "0")
|
||||
}
|
||||
case opts.HWAccel == "nvenc" && codec == "hevc":
|
||||
args = append(args, "-c:v", "hevc_nvenc", "-rc:v", "vbr")
|
||||
if hasBitrateCap {
|
||||
args = append(args,
|
||||
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
|
||||
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
|
||||
} else {
|
||||
args = append(args, "-cq:v", "28", "-b:v", "0")
|
||||
}
|
||||
default:
|
||||
// CPU fallback — match Jellyfin's proven browser-compatible settings.
|
||||
// Force yuv420p to ensure 8-bit output (10-bit sources produce High 10
|
||||
@@ -641,10 +609,6 @@ func appendSubtitleBurnInArgs(args []string, opts TranscodeOpts) []string {
|
||||
// VAAPI-only: download, apply CPU filters, convert to nv12, upload back.
|
||||
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload"
|
||||
args = append(args, "-vf", vf)
|
||||
case "nvenc":
|
||||
// NVENC/CUDA: download to CPU for subtitle rendering, then upload back.
|
||||
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload_cuda"
|
||||
args = append(args, "-vf", vf)
|
||||
default:
|
||||
// CPU encoding: filters run directly on decoded frames.
|
||||
args = append(args, "-vf", cpuFilters)
|
||||
@@ -715,25 +679,6 @@ func vaapiScaleFilter(res string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func nvencScaleFilter(res string) string {
|
||||
switch res {
|
||||
case "2160p":
|
||||
return "scale_cuda=w=-2:h=2160:format=nv12"
|
||||
case "1080p":
|
||||
return "scale_cuda=w=-2:h=1080:format=nv12"
|
||||
case "720p":
|
||||
return "scale_cuda=w=-2:h=720:format=nv12"
|
||||
case "480p":
|
||||
return "scale_cuda=w=-2:h=480:format=nv12"
|
||||
case "420p":
|
||||
return "scale_cuda=w=-2:h=420:format=nv12"
|
||||
case "328p":
|
||||
return "scale_cuda=w=-2:h=328:format=nv12"
|
||||
default:
|
||||
return "scale_cuda=format=nv12"
|
||||
}
|
||||
}
|
||||
|
||||
// filterPathReplacer escapes special characters in file paths for ffmpeg filter syntax.
|
||||
var filterPathReplacer = strings.NewReplacer(
|
||||
"'", "'\\''",
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestBuildFFmpegArgs_CopyVideoSeekPreservesCodecCopy(t *testing.T) {
|
||||
t.Fatalf("copy-mode seek should preserve -c:v copy: %s", joined)
|
||||
}
|
||||
// Must not contain any video encoder.
|
||||
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc"} {
|
||||
for _, enc := range []string{"h264_qsv", "h264_vaapi", "libx264", "hevc_qsv"} {
|
||||
if strings.Contains(joined, enc) {
|
||||
t.Fatalf("copy-mode seek should not use encoder %s: %s", enc, joined)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestBuildFFmpegArgs_MPEG2CopyVideoUsesMPEGTS(t *testing.T) {
|
||||
if strings.Contains(joined, "movflags=+frag_discont") {
|
||||
t.Fatalf("mpeg2 MPEG-TS copy-mode should not use fMP4 movflags: %s", joined)
|
||||
}
|
||||
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc", "libx265"} {
|
||||
for _, enc := range []string{"h264_qsv", "h264_vaapi", "libx264", "hevc_qsv", "libx265"} {
|
||||
if strings.Contains(joined, enc) {
|
||||
t.Fatalf("mpeg2 copy-mode should not use encoder %s: %s", enc, joined)
|
||||
}
|
||||
@@ -234,11 +234,6 @@ func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
|
||||
opts: TranscodeOpts{HWAccel: "vaapi", SourceVideoCodec: "mpeg4", TargetCodecVideo: "h264"},
|
||||
want: "none",
|
||||
},
|
||||
{
|
||||
name: "nvenc passthrough",
|
||||
opts: TranscodeOpts{HWAccel: "nvenc", SourceVideoCodec: "h264", TargetCodecVideo: "h264"},
|
||||
want: "nvenc",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -252,38 +247,6 @@ func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgs_NVENCH264UsesCudaPipeline(t *testing.T) {
|
||||
args := buildFFmpegArgs(TranscodeOpts{
|
||||
InputPath: "/media/movie.mkv",
|
||||
OutputDir: "/tmp/out",
|
||||
SessionID: "session-nvenc",
|
||||
SourceVideoCodec: "h264",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
HWAccel: "nvenc",
|
||||
TargetResolution: "720p",
|
||||
TargetBitrateKbps: 2000,
|
||||
})
|
||||
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-hwaccel cuda") {
|
||||
t.Fatalf("nvenc args should enable cuda hwaccel: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-c:v h264_nvenc") {
|
||||
t.Fatalf("nvenc args should use h264_nvenc encoder: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-vf scale_cuda=w=-2:h=720:format=nv12") {
|
||||
t.Fatalf("nvenc args should use scale_cuda, not software scale: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "-vf scale=-2:720") {
|
||||
t.Fatalf("nvenc args must not use software scale on cuda frames: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-b:v 2000k -maxrate 2000k -bufsize 4000k") {
|
||||
t.Fatalf("nvenc args should include bitrate cap controls: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgs_VAAPIScalingUsesHardwareFilter(t *testing.T) {
|
||||
args := buildFFmpegArgs(TranscodeOpts{
|
||||
InputPath: "/media/movie.mkv",
|
||||
|
||||
@@ -482,53 +482,6 @@ func (s *Service) ScanSourceClient(
|
||||
return client.ScanSource(capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) ScanSourceClientByPluginID(
|
||||
ctx context.Context,
|
||||
pluginID string,
|
||||
capabilityID string,
|
||||
) (*pluginhost.ScanSourceClient, error) {
|
||||
if s == nil || s.installations == nil {
|
||||
return nil, fmt.Errorf("scan source plugin resolver is not configured")
|
||||
}
|
||||
installations, err := s.installations.ListByPluginID(ctx, pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(installations) == 0 {
|
||||
return nil, fmt.Errorf("scan source plugin %q is not installed", pluginID)
|
||||
}
|
||||
if len(installations) > 1 {
|
||||
return nil, fmt.Errorf("scan source plugin %q is ambiguous across %d installations", pluginID, len(installations))
|
||||
}
|
||||
|
||||
var matches []*Installation
|
||||
for _, installation := range installations {
|
||||
if installation == nil {
|
||||
continue
|
||||
}
|
||||
capabilities, err := s.installations.ListCapabilities(ctx, installation.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, capability := range capabilities {
|
||||
if capability == nil {
|
||||
continue
|
||||
}
|
||||
if capability.Type == "scan_source.v1" && capability.ID == capabilityID {
|
||||
matches = append(matches, installation)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("scan source capability %q is not installed for plugin %q", capabilityID, pluginID)
|
||||
}
|
||||
if !matches[0].Enabled {
|
||||
return nil, fmt.Errorf("scan source plugin %q is disabled", pluginID)
|
||||
}
|
||||
return s.ScanSourceClient(ctx, matches[0].ID, capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) EventConsumerClient(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"github.com/Silo-Server/silo-server/internal/pluginhost"
|
||||
)
|
||||
|
||||
func TestScanSourceClientByPluginIDResolvesCurrentInstallation(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.autoscan.cephfs", "0.2.2")
|
||||
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
|
||||
{Type: "scan_source.v1", Id: "cephfs", DisplayName: "CephFS"},
|
||||
}
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
host := &fakeServiceHost{
|
||||
clientErr: pluginhost.ErrClientNotFound,
|
||||
startResult: &fakePluginClient{manifest: manifest},
|
||||
}
|
||||
service := &Service{
|
||||
installations: &fakeServiceInstallationStore{
|
||||
byID: map[int]*Installation{
|
||||
8: {
|
||||
ID: 8,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
Version: "0.2.2",
|
||||
InstallPath: installPath,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
byPluginID: map[string][]*Installation{
|
||||
"silo.autoscan.cephfs": {
|
||||
{ID: 8, PluginID: "silo.autoscan.cephfs", Version: "0.2.2", InstallPath: installPath, Enabled: true},
|
||||
},
|
||||
},
|
||||
listCapabilities: []*Capability{{InstallationID: 8, Type: "scan_source.v1", ID: "cephfs"}},
|
||||
},
|
||||
host: host,
|
||||
}
|
||||
|
||||
if _, err := service.ScanSourceClientByPluginID(context.Background(), "silo.autoscan.cephfs", "cephfs"); err != nil {
|
||||
t.Fatalf("ScanSourceClientByPluginID: %v", err)
|
||||
}
|
||||
if len(host.started) != 1 || host.started[0].InstallationID != 8 {
|
||||
t.Fatalf("started installations = %+v, want installation 8", host.started)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSourceClientByPluginIDRejectsAmbiguousInstallations(t *testing.T) {
|
||||
manifest := testPluginManifest(t, "silo.autoscan.cephfs", "0.2.2")
|
||||
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
|
||||
{Type: "scan_source.v1", Id: "cephfs", DisplayName: "CephFS"},
|
||||
}
|
||||
installPath := writeInstalledPluginManifest(t, manifest)
|
||||
service := &Service{
|
||||
installations: &fakeServiceInstallationStore{
|
||||
byID: map[int]*Installation{
|
||||
8: {ID: 8, PluginID: "silo.autoscan.cephfs", Version: "0.2.2", InstallPath: installPath, Enabled: true},
|
||||
9: {ID: 9, PluginID: "silo.autoscan.cephfs", Version: "0.2.3", InstallPath: installPath, Enabled: true},
|
||||
},
|
||||
byPluginID: map[string][]*Installation{
|
||||
"silo.autoscan.cephfs": {
|
||||
{ID: 8, PluginID: "silo.autoscan.cephfs", Version: "0.2.2", InstallPath: installPath, Enabled: true},
|
||||
{ID: 9, PluginID: "silo.autoscan.cephfs", Version: "0.2.3", InstallPath: installPath, Enabled: true},
|
||||
},
|
||||
},
|
||||
listCapabilities: []*Capability{{Type: "scan_source.v1", ID: "cephfs"}},
|
||||
},
|
||||
host: &fakeServiceHost{},
|
||||
}
|
||||
|
||||
_, err := service.ScanSourceClientByPluginID(context.Background(), "silo.autoscan.cephfs", "cephfs")
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
||||
t.Fatalf("ScanSourceClientByPluginID error = %v, want ambiguous", err)
|
||||
}
|
||||
}
|
||||
@@ -39,35 +39,6 @@ func ensureCanonicalDimensions(vec []float32) ([]float32, error) {
|
||||
const embeddingLockSettingKey = "recommendations.embedding_lock"
|
||||
const minHNSWEfSearch = 200
|
||||
|
||||
const tasteSeedCandidateQuery = `
|
||||
WITH watched_counts AS (
|
||||
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
|
||||
COUNT(DISTINCT wp.user_id::text || ':' || COALESCE(wp.profile_id, '')) AS watch_count
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND wp.updated_at > NOW() - INTERVAL '180 days'
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
|
||||
WHERE mi.status = 'matched'
|
||||
AND mi.type IN ('movie', 'series')
|
||||
AND mi.poster_path IS NOT NULL
|
||||
AND mi.poster_path <> ''
|
||||
ORDER BY COALESCE(wc.watch_count, 0) DESC,
|
||||
CASE
|
||||
WHEN mi.rating_imdb IS NOT NULL THEN 2
|
||||
WHEN mi.rating_tmdb IS NOT NULL AND mi.rating_tmdb < 9.5 THEN 1
|
||||
ELSE 0
|
||||
END DESC,
|
||||
mi.rating_imdb DESC NULLS LAST,
|
||||
CASE WHEN mi.rating_tmdb < 9.5 THEN mi.rating_tmdb END DESC NULLS LAST,
|
||||
mi.year DESC NULLS LAST,
|
||||
mi.content_id ASC
|
||||
LIMIT $1 OFFSET $2`
|
||||
|
||||
// Repo provides database operations for the recommendation system.
|
||||
type Repo struct {
|
||||
pool *pgxpool.Pool
|
||||
@@ -1327,12 +1298,34 @@ func (r *Repo) GetTopRatedItems(ctx context.Context, minRatings, limit int) ([]S
|
||||
|
||||
// GetTasteSeedCandidates returns movie/series content IDs ordered for the
|
||||
// taste-seeding picker: server engagement first (most-watched in the last
|
||||
// 180 days), then rating reliability and rating score, then recency. This keeps
|
||||
// fresh servers from front-loading single-vote TMDB 10.0 obscurities while
|
||||
// established servers prioritize what users actually watch. Episodes are resolved
|
||||
// to their parent series. Items without a poster are excluded.
|
||||
// 180 days), then TMDB rating, then recency. This blend ensures fresh servers
|
||||
// (no watch history) still surface meaningful posters via TMDB ratings while
|
||||
// established servers prioritize what users actually watch. Episodes are
|
||||
// resolved to their parent series. Items without a poster are excluded.
|
||||
func (r *Repo) GetTasteSeedCandidates(ctx context.Context, limit, offset int) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx, tasteSeedCandidateQuery, limit, offset)
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH watched_counts AS (
|
||||
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
|
||||
COUNT(DISTINCT wp.user_id::text || ':' || COALESCE(wp.profile_id, '')) AS watch_count
|
||||
FROM user_watch_progress wp
|
||||
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
|
||||
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
|
||||
AND wp.updated_at > NOW() - INTERVAL '180 days'
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT mi.content_id
|
||||
FROM media_items mi
|
||||
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
|
||||
WHERE mi.status = 'matched'
|
||||
AND mi.type IN ('movie', 'series')
|
||||
AND mi.poster_path IS NOT NULL
|
||||
AND mi.poster_path <> ''
|
||||
ORDER BY COALESCE(wc.watch_count, 0) DESC,
|
||||
mi.rating_tmdb DESC NULLS LAST,
|
||||
mi.year DESC NULLS LAST,
|
||||
mi.content_id ASC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get taste seed candidates: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTasteSeedCandidateQueryOrdersByReliableColdStartSignals(t *testing.T) {
|
||||
query := strings.Join(strings.Fields(tasteSeedCandidateQuery), " ")
|
||||
|
||||
assertQueryTermsInOrder(t, query,
|
||||
"ORDER BY COALESCE(wc.watch_count, 0) DESC",
|
||||
"WHEN mi.rating_imdb IS NOT NULL THEN 2",
|
||||
"WHEN mi.rating_tmdb IS NOT NULL AND mi.rating_tmdb < 9.5 THEN 1",
|
||||
"ELSE 0 END DESC",
|
||||
"mi.rating_imdb DESC NULLS LAST",
|
||||
"CASE WHEN mi.rating_tmdb < 9.5 THEN mi.rating_tmdb END DESC NULLS LAST",
|
||||
"mi.year DESC NULLS LAST",
|
||||
"mi.content_id ASC",
|
||||
)
|
||||
|
||||
if strings.Contains(query, "ORDER BY COALESCE(wc.watch_count, 0) DESC, mi.rating_tmdb DESC NULLS LAST") {
|
||||
t.Fatal("taste seed query must not rank cold-start candidates by raw TMDB rating directly")
|
||||
}
|
||||
}
|
||||
|
||||
func assertQueryTermsInOrder(t *testing.T, query string, terms ...string) {
|
||||
t.Helper()
|
||||
|
||||
searchFrom := 0
|
||||
for _, term := range terms {
|
||||
idx := strings.Index(query[searchFrom:], term)
|
||||
if idx < 0 {
|
||||
t.Fatalf("query term %q missing or out of order in query:\n%s", term, query)
|
||||
}
|
||||
searchFrom += idx + len(term)
|
||||
}
|
||||
}
|
||||
@@ -652,8 +652,7 @@ func (s *Scanner) upsertAudiobookMediaFiles(
|
||||
folderPath string,
|
||||
book *parsedAudiobook,
|
||||
) error {
|
||||
partTotal := len(book.Files)
|
||||
for idx, af := range book.Files {
|
||||
for _, af := range book.Files {
|
||||
chapters := make([]models.MediaChapter, len(af.Chapters))
|
||||
for i, ch := range af.Chapters {
|
||||
chapters[i] = models.MediaChapter{
|
||||
@@ -685,12 +684,6 @@ func (s *Scanner) upsertAudiobookMediaFiles(
|
||||
Container: af.Container,
|
||||
AudioChannels: af.AudioChannels,
|
||||
}
|
||||
if partTotal > 1 {
|
||||
mf.PresentationKind = "multipart"
|
||||
mf.PresentationGroupKey = contentID
|
||||
mf.PresentationPartIndex = idx + 1
|
||||
mf.PresentationPartTotal = partTotal
|
||||
}
|
||||
|
||||
if _, err := s.fileRepo.Upsert(ctx, mf); err != nil {
|
||||
return fmt.Errorf("upsert media file %s: %w", af.Path, err)
|
||||
|
||||
@@ -225,9 +225,6 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
|
||||
if err := s.ScanAudiobookFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
@@ -235,9 +232,6 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
|
||||
if err := s.ScanPodcastFolder(watchCtx, folder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScanResult{}, nil
|
||||
}
|
||||
|
||||
@@ -1363,31 +1357,6 @@ func (s *Scanner) syncPresentLibraryState(ctx context.Context, folderID int) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) syncFolderScopedAudioLibraryState(ctx context.Context, folderID int) error {
|
||||
if err := s.syncPresentLibraryState(ctx, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
||||
INSERT INTO media_item_roots (media_folder_id, canonical_root_path, content_id)
|
||||
SELECT DISTINCT mf.media_folder_id, mf.canonical_root_path, mf.content_id
|
||||
FROM media_files mf
|
||||
JOIN media_items mi ON mi.content_id = mf.content_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.content_id IS NOT NULL
|
||||
AND COALESCE(mf.canonical_root_path, '') <> ''
|
||||
AND mi.type IN ('audiobook', 'podcast')
|
||||
ON CONFLICT (media_folder_id, canonical_root_path)
|
||||
DO UPDATE SET content_id = EXCLUDED.content_id,
|
||||
last_seen_at = NOW()
|
||||
`, folderID); err != nil {
|
||||
return fmt.Errorf("restoring folder-scoped audio roots: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) reconcileLibraryMemberships(ctx context.Context, folderID int) (int, int, []string, error) {
|
||||
if s.episodeLibraryRepo != nil {
|
||||
if _, err := s.episodeLibraryRepo.ReconcileFolderMembership(ctx, folderID); err != nil {
|
||||
|
||||
@@ -167,14 +167,6 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe
|
||||
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-random-tv", Scope: "library", LibraryID: libraryID, Position: 5, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true},
|
||||
}
|
||||
case "audiobooks", "audiobook":
|
||||
return []*PageSection{
|
||||
{ID: "default-continue-listening", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Listening", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-recently-added-audiobooks", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added Audiobooks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
|
||||
{ID: "default-recently-released-audiobooks", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released Audiobooks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
|
||||
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
|
||||
{ID: "default-random-audiobooks", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
|
||||
}
|
||||
default:
|
||||
return DefaultLibrarySections(libraryID)
|
||||
}
|
||||
|
||||
@@ -213,68 +213,6 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultLibrarySectionsForTypeAudiobooks(t *testing.T) {
|
||||
libraryID := 10
|
||||
got := DefaultLibrarySectionsForType(&libraryID, "audiobooks")
|
||||
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("expected 5 audiobook default sections, got %d", len(got))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
index int
|
||||
id string
|
||||
sectionType SectionType
|
||||
title string
|
||||
position int
|
||||
}{
|
||||
{index: 0, id: "default-continue-listening", sectionType: SectionContinueWatching, title: "Continue Listening", position: 0},
|
||||
{index: 1, id: "default-recently-added-audiobooks", sectionType: SectionRecentlyAdded, title: "Recently Added Audiobooks", position: 1},
|
||||
{index: 2, id: "default-recently-released-audiobooks", sectionType: SectionRecentlyReleased, title: "Recently Released Audiobooks", position: 2},
|
||||
{index: 3, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 3},
|
||||
{index: 4, id: "default-random-audiobooks", sectionType: SectionRandom, title: "Random Picks", position: 4},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
section := got[tt.index]
|
||||
if section.ID != tt.id {
|
||||
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
|
||||
}
|
||||
if section.SectionType != tt.sectionType {
|
||||
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
|
||||
}
|
||||
if section.Title != tt.title {
|
||||
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
|
||||
}
|
||||
if section.Position != tt.position {
|
||||
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
|
||||
}
|
||||
if section.Featured {
|
||||
t.Fatalf("section %d featured = true, want false", tt.index)
|
||||
}
|
||||
}
|
||||
|
||||
assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{
|
||||
MediaScope: "audiobook",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
})
|
||||
assertQueryDefinition(t, got[2].Config, catalog.QueryDefinition{
|
||||
MediaScope: "audiobook",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
})
|
||||
assertEmptyJSON(t, got[3].Config)
|
||||
assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{
|
||||
MediaScope: "audiobook",
|
||||
Match: "all",
|
||||
Groups: []catalog.QueryGroup{},
|
||||
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultLibrarySectionsForTypeMixed(t *testing.T) {
|
||||
libraryID := 99
|
||||
got := DefaultLibrarySectionsForType(&libraryID, "mixed")
|
||||
|
||||
@@ -112,11 +112,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleHWCapabilities(w http.ResponseWriter, _ *http.Request) {
|
||||
ffmpegPath := ""
|
||||
if cfg := s.watcher.Config(); cfg != nil {
|
||||
ffmpegPath = cfg.Playback.FFmpegPath
|
||||
}
|
||||
info := playback.DetectHWAccelWithFFmpeg(ffmpegPath)
|
||||
info := playback.DetectHWAccel()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
INSERT INTO server_settings (key, value)
|
||||
VALUES (
|
||||
'audiobookshelf_compat.enabled',
|
||||
COALESCE(
|
||||
(SELECT value FROM server_settings WHERE key = 'audiobooks.enabled'),
|
||||
'false'
|
||||
)
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DELETE FROM server_settings WHERE key = 'audiobookshelf_compat.enabled';
|
||||
-- +goose StatementEnd
|
||||
@@ -1,6 +0,0 @@
|
||||
-- +goose Up
|
||||
DELETE FROM server_settings
|
||||
WHERE key IN ('audiobookshelf_compat.listen', 'audiobookshelf_compat.public_url');
|
||||
|
||||
-- +goose Down
|
||||
SELECT 1;
|
||||
@@ -1,145 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.autoscan_sources
|
||||
ADD COLUMN plugin_id text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ADD COLUMN plugin_id text NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE public.autoscan_sources s
|
||||
SET plugin_id = pi.plugin_id
|
||||
FROM public.plugin_installations pi
|
||||
WHERE s.installation_id = pi.id
|
||||
AND s.plugin_id = '';
|
||||
|
||||
WITH inferred AS (
|
||||
SELECT s.id, min(pi.plugin_id) AS plugin_id, count(*) AS matches
|
||||
FROM public.autoscan_sources s
|
||||
JOIN public.plugin_capabilities pc
|
||||
ON pc.capability_type = 'scan_source.v1'
|
||||
AND pc.capability_id = s.capability_id
|
||||
JOIN public.plugin_installations pi
|
||||
ON pi.id = pc.plugin_installation_id
|
||||
WHERE s.plugin_id = ''
|
||||
GROUP BY s.id
|
||||
)
|
||||
UPDATE public.autoscan_sources s
|
||||
SET plugin_id = inferred.plugin_id
|
||||
FROM inferred
|
||||
WHERE s.id = inferred.id
|
||||
AND inferred.matches = 1;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.autoscan_sources
|
||||
WHERE enabled = true
|
||||
AND btrim(plugin_id) = ''
|
||||
) THEN
|
||||
RAISE EXCEPTION 'enabled autoscan source could not be migrated to plugin_id';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
UPDATE public.autoscan_events e
|
||||
SET plugin_id = s.plugin_id
|
||||
FROM public.autoscan_sources s
|
||||
WHERE e.source_id = s.id
|
||||
AND e.plugin_id = ''
|
||||
AND btrim(s.plugin_id) <> '';
|
||||
|
||||
UPDATE public.autoscan_events e
|
||||
SET plugin_id = pi.plugin_id
|
||||
FROM public.plugin_installations pi
|
||||
WHERE e.installation_id = pi.id
|
||||
AND e.plugin_id = '';
|
||||
|
||||
WITH inferred AS (
|
||||
SELECT e.id, min(pi.plugin_id) AS plugin_id, count(*) AS matches
|
||||
FROM public.autoscan_events e
|
||||
JOIN public.plugin_capabilities pc
|
||||
ON pc.capability_type = 'scan_source.v1'
|
||||
AND pc.capability_id = e.capability_id
|
||||
JOIN public.plugin_installations pi
|
||||
ON pi.id = pc.plugin_installation_id
|
||||
WHERE e.plugin_id = ''
|
||||
GROUP BY e.id
|
||||
)
|
||||
UPDATE public.autoscan_events e
|
||||
SET plugin_id = inferred.plugin_id
|
||||
FROM inferred
|
||||
WHERE e.id = inferred.id
|
||||
AND inferred.matches = 1;
|
||||
|
||||
CREATE INDEX idx_autoscan_sources_plugin_capability
|
||||
ON public.autoscan_sources (plugin_id, capability_id);
|
||||
|
||||
CREATE INDEX idx_autoscan_events_plugin_capability_completed
|
||||
ON public.autoscan_events (plugin_id, capability_id, completed_at DESC);
|
||||
|
||||
ALTER TABLE public.autoscan_sources
|
||||
ALTER COLUMN plugin_id DROP DEFAULT;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ALTER COLUMN plugin_id DROP DEFAULT;
|
||||
|
||||
ALTER TABLE public.autoscan_sources
|
||||
DROP COLUMN installation_id;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
DROP COLUMN installation_id;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.autoscan_sources
|
||||
ADD COLUMN installation_id integer NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ADD COLUMN installation_id integer NOT NULL DEFAULT 0;
|
||||
|
||||
WITH resolved AS (
|
||||
SELECT s.id, min(pc.plugin_installation_id)::integer AS installation_id, count(*) AS matches
|
||||
FROM public.autoscan_sources s
|
||||
JOIN public.plugin_installations pi
|
||||
ON pi.plugin_id = s.plugin_id
|
||||
JOIN public.plugin_capabilities pc
|
||||
ON pc.plugin_installation_id = pi.id
|
||||
AND pc.capability_type = 'scan_source.v1'
|
||||
AND pc.capability_id = s.capability_id
|
||||
WHERE btrim(s.plugin_id) <> ''
|
||||
GROUP BY s.id
|
||||
)
|
||||
UPDATE public.autoscan_sources s
|
||||
SET installation_id = resolved.installation_id
|
||||
FROM resolved
|
||||
WHERE s.id = resolved.id
|
||||
AND resolved.matches = 1;
|
||||
|
||||
WITH resolved AS (
|
||||
SELECT e.id, min(pc.plugin_installation_id)::integer AS installation_id, count(*) AS matches
|
||||
FROM public.autoscan_events e
|
||||
JOIN public.plugin_installations pi
|
||||
ON pi.plugin_id = e.plugin_id
|
||||
JOIN public.plugin_capabilities pc
|
||||
ON pc.plugin_installation_id = pi.id
|
||||
AND pc.capability_type = 'scan_source.v1'
|
||||
AND pc.capability_id = e.capability_id
|
||||
WHERE btrim(e.plugin_id) <> ''
|
||||
GROUP BY e.id
|
||||
)
|
||||
UPDATE public.autoscan_events e
|
||||
SET installation_id = resolved.installation_id
|
||||
FROM resolved
|
||||
WHERE e.id = resolved.id
|
||||
AND resolved.matches = 1;
|
||||
|
||||
DROP INDEX IF EXISTS public.idx_autoscan_events_plugin_capability_completed;
|
||||
DROP INDEX IF EXISTS public.idx_autoscan_sources_plugin_capability;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
DROP COLUMN plugin_id;
|
||||
|
||||
ALTER TABLE public.autoscan_sources
|
||||
DROP COLUMN plugin_id;
|
||||
-- +goose StatementEnd
|
||||
@@ -1,36 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE public.autoscan_events
|
||||
DROP CONSTRAINT IF EXISTS autoscan_events_status_check;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ADD CONSTRAINT autoscan_events_status_check
|
||||
CHECK (status = ANY (ARRAY[
|
||||
'running'::text,
|
||||
'success'::text,
|
||||
'error'::text,
|
||||
'unresolved'::text
|
||||
]));
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
UPDATE public.autoscan_events
|
||||
SET status = 'error',
|
||||
error_message = CASE
|
||||
WHEN error_message = '' THEN 'poll started but did not finish'
|
||||
ELSE error_message
|
||||
END
|
||||
WHERE status = 'running';
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
DROP CONSTRAINT IF EXISTS autoscan_events_status_check;
|
||||
|
||||
ALTER TABLE public.autoscan_events
|
||||
ADD CONSTRAINT autoscan_events_status_check
|
||||
CHECK (status = ANY (ARRAY[
|
||||
'success'::text,
|
||||
'error'::text,
|
||||
'unresolved'::text
|
||||
]));
|
||||
-- +goose StatementEnd
|
||||
@@ -87,6 +87,8 @@ import WatchTogetherJoin from "@/pages/WatchTogetherJoin";
|
||||
import WatchTogetherRoomPage from "@/pages/WatchTogetherRoomPage";
|
||||
import WatchRoute from "@/pages/WatchRoute";
|
||||
import ProfileCustomizeHome from "@/pages/ProfileCustomizeHome";
|
||||
import AudiobookLibrary from "@/pages/audiobooks/AudiobookLibrary";
|
||||
import AudiobookDetail from "@/pages/audiobooks/AudiobookDetail";
|
||||
import {
|
||||
WatchPlaybackBar,
|
||||
WatchPlaybackHost,
|
||||
@@ -522,6 +524,8 @@ function AppRoutes() {
|
||||
element={<RecommendationsSection />}
|
||||
/>
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/audiobooks" element={<AudiobookLibrary />} />
|
||||
<Route path="/audiobooks/book/:contentId" element={<AudiobookDetail />} />
|
||||
<Route
|
||||
path="/profile/customize-home"
|
||||
element={<ProfileCustomizeHome />}
|
||||
|
||||
+8
-60
@@ -528,46 +528,6 @@ export interface CrewMember {
|
||||
photo_thumbhash?: string;
|
||||
}
|
||||
|
||||
export interface AudiobookPerson {
|
||||
person_id?: string;
|
||||
name: string;
|
||||
photo_url?: string;
|
||||
photo_thumbhash?: string;
|
||||
}
|
||||
|
||||
export interface AudiobookRelatedItem {
|
||||
content_id: string;
|
||||
title: string;
|
||||
year?: number;
|
||||
poster_url?: string;
|
||||
series_index?: number;
|
||||
}
|
||||
|
||||
export interface AudiobookSeriesGroup {
|
||||
name?: string;
|
||||
entries: AudiobookRelatedItem[];
|
||||
}
|
||||
|
||||
export interface AudiobookNarration {
|
||||
content_id: string;
|
||||
title: string;
|
||||
year?: number;
|
||||
narrators: string[];
|
||||
}
|
||||
|
||||
export interface AudiobookDetailExtension {
|
||||
authors: AudiobookPerson[];
|
||||
narrators: AudiobookPerson[];
|
||||
publisher?: string;
|
||||
total_duration_seconds: number;
|
||||
series?: AudiobookSeriesGroup;
|
||||
other_narrations: AudiobookNarration[];
|
||||
related: {
|
||||
also_by_author: AudiobookRelatedItem[];
|
||||
similar: AudiobookRelatedItem[];
|
||||
};
|
||||
}
|
||||
|
||||
// Seasons / Watched State
|
||||
export interface LeafItemUserData {
|
||||
played: boolean;
|
||||
@@ -741,7 +701,6 @@ export interface FileVersion {
|
||||
presentation_kind?: string;
|
||||
presentation_group_key?: string;
|
||||
presentation_part_index?: number;
|
||||
presentation_part_total?: number;
|
||||
multi_episode_start?: number;
|
||||
multi_episode_end?: number;
|
||||
effective_audio_track_index?: number;
|
||||
@@ -990,7 +949,6 @@ export interface ItemDetail {
|
||||
effective_version_hdr?: boolean;
|
||||
effective_version_codec_video?: string;
|
||||
effective_version_edition_key?: string;
|
||||
audiobook?: AudiobookDetailExtension;
|
||||
}
|
||||
|
||||
export interface WatchDetail {
|
||||
@@ -1808,7 +1766,7 @@ export interface AutoscanPathRewrite {
|
||||
|
||||
export interface AutoscanSource {
|
||||
id: string;
|
||||
plugin_id: string;
|
||||
installation_id: number;
|
||||
capability_id: string;
|
||||
connection_id: string | null;
|
||||
enabled: boolean;
|
||||
@@ -1834,8 +1792,9 @@ export interface AutoscanSourcesResponse {
|
||||
}
|
||||
|
||||
export interface AutoscanAvailableSource {
|
||||
plugin_id: string;
|
||||
installation_id: number;
|
||||
capability_id: string;
|
||||
plugin_id: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
@@ -1844,7 +1803,7 @@ export interface AutoscanAvailableSourcesResponse {
|
||||
}
|
||||
|
||||
export interface AutoscanSourceCreateInput {
|
||||
plugin_id: string;
|
||||
installation_id: number;
|
||||
capability_id: string;
|
||||
connection_id?: string | null;
|
||||
enabled: boolean;
|
||||
@@ -1886,7 +1845,7 @@ export interface AutoscanRewriteSuggestions {
|
||||
|
||||
export interface AutoscanStatusSource {
|
||||
id: string;
|
||||
plugin_id: string;
|
||||
installation_id: number;
|
||||
capability_id: string;
|
||||
connection_id: string | null;
|
||||
enabled: boolean;
|
||||
@@ -1895,27 +1854,16 @@ export interface AutoscanStatusSource {
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface AutoscanRunningPoll {
|
||||
id: number;
|
||||
source_id: string | null;
|
||||
plugin_id: string;
|
||||
capability_id: string;
|
||||
started_at: string;
|
||||
elapsed_ms: number;
|
||||
marker_before?: string;
|
||||
}
|
||||
|
||||
export interface AutoscanStatus {
|
||||
enabled: boolean;
|
||||
sources: AutoscanStatusSource[];
|
||||
running_polls: AutoscanRunningPoll[];
|
||||
active_scans: number;
|
||||
accepted_scans: number;
|
||||
running_scans: number;
|
||||
latest_event_at?: string;
|
||||
}
|
||||
|
||||
export type AutoscanEventStatus = "running" | "success" | "error" | "unresolved";
|
||||
export type AutoscanEventStatus = "success" | "error" | "unresolved";
|
||||
|
||||
export interface AutoscanEventScanRun {
|
||||
id: string;
|
||||
@@ -1933,7 +1881,7 @@ export interface AutoscanEventScanRun {
|
||||
export interface AutoscanEvent {
|
||||
id: number;
|
||||
source_id: string | null;
|
||||
plugin_id: string;
|
||||
installation_id: number;
|
||||
capability_id: string;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
@@ -1971,7 +1919,7 @@ export interface AutoscanScan {
|
||||
completed_at?: string;
|
||||
autoscan_event_id?: number;
|
||||
source_id?: string;
|
||||
plugin_id?: string;
|
||||
installation_id?: number;
|
||||
capability_id?: string;
|
||||
event_status?: AutoscanEventStatus;
|
||||
event_completed_at?: string;
|
||||
|
||||
@@ -55,6 +55,12 @@ vi.mock("@/hooks/queries/useRequests", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/settings", () => ({
|
||||
useSettings: () => ({
|
||||
data: { "audiobooks.enabled": "false" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useViewTransition", () => ({
|
||||
useViewTransitionNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useUserLibraries } from "@/hooks/queries/libraries";
|
||||
import { usePluginSettingsList } from "@/hooks/queries/pluginSettings";
|
||||
import { useRequestFeatureStatus } from "@/hooks/queries/useRequests";
|
||||
import { useSidebarPins, useToggleSidebarPin } from "@/hooks/queries/sidebarPins";
|
||||
import { useSettings } from "@/hooks/queries/settings";
|
||||
import { useViewTransitionNavigate } from "@/hooks/useViewTransition";
|
||||
import { pluginRouteHref } from "@/lib/pluginRouteHref";
|
||||
import {
|
||||
@@ -64,9 +65,6 @@ function getLibraryIcon(type: string) {
|
||||
return <Film className="h-[18px] w-[18px]" />;
|
||||
case "series":
|
||||
return <Tv className="h-[18px] w-[18px]" />;
|
||||
case "audiobook":
|
||||
case "audiobooks":
|
||||
return <BookHeadphones className="h-[18px] w-[18px]" />;
|
||||
default:
|
||||
return <Library className="h-[18px] w-[18px]" />;
|
||||
}
|
||||
@@ -169,11 +167,13 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
|
||||
const { theme, setTheme, previewTheme, resetPreviewTheme } = useTheme();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const { data: libraries } = useUserLibraries();
|
||||
const { data: settings } = useSettings();
|
||||
const { pins } = useSidebarPins();
|
||||
const { togglePin } = useToggleSidebarPin();
|
||||
const { data: pluginSettings } = usePluginSettingsList();
|
||||
const requestStatus = useRequestFeatureStatus();
|
||||
const showRequestsNav = requestStatus.data?.requests_enabled === true;
|
||||
const showAudiobooksNav = settings?.["audiobooks.enabled"] === "true";
|
||||
const pluginNavLinks = useMemo(() => {
|
||||
const installations = pluginSettings?.installations ?? [];
|
||||
const links: { id: string; basePath: string; label: string; pluginId: string }[] = [];
|
||||
@@ -551,6 +551,25 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
|
||||
<SidebarLabel show={showLabels}>Calendar</SidebarLabel>
|
||||
</ViewTransitionLink>
|
||||
</li>
|
||||
{showAudiobooksNav && (
|
||||
<li>
|
||||
<ViewTransitionLink
|
||||
to="/audiobooks"
|
||||
onClick={onNavigate}
|
||||
className={navLinkClass("/audiobooks")}
|
||||
aria-current={isActive("/audiobooks") ? "page" : undefined}
|
||||
>
|
||||
{isActive("/audiobooks") && (
|
||||
<span
|
||||
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
|
||||
style={{ background: "var(--primary)" }}
|
||||
/>
|
||||
)}
|
||||
<BookHeadphones className="h-[18px] w-[18px] shrink-0" />
|
||||
<SidebarLabel show={showLabels}>Audiobooks</SidebarLabel>
|
||||
</ViewTransitionLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
|
||||
interface SharedItemGridProps {
|
||||
loading?: boolean;
|
||||
sortField?: string;
|
||||
libraryId?: number;
|
||||
selectionMode?: boolean;
|
||||
selectedIds?: ReadonlySet<string>;
|
||||
onToggleSelect?: (item: BrowseItem) => void;
|
||||
@@ -43,14 +42,7 @@ const GRID_CLASSES =
|
||||
"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
|
||||
|
||||
export default function ItemGrid(props: ItemGridProps) {
|
||||
const {
|
||||
loading,
|
||||
sortField,
|
||||
libraryId,
|
||||
selectionMode = false,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
} = props;
|
||||
const { loading, sortField, selectionMode = false, selectedIds, onToggleSelect } = props;
|
||||
const { prefs: overlayPrefs } = useOverlayPrefs();
|
||||
const totalItems = hasStaticItems(props) ? props.items.length : props.totalItems;
|
||||
const pages = hasStaticItems(props)
|
||||
@@ -155,7 +147,6 @@ export default function ItemGrid(props: ItemGridProps) {
|
||||
<div key={item.content_id} role="listitem">
|
||||
<ItemCard
|
||||
item={item}
|
||||
libraryId={libraryId}
|
||||
sortField={sortField}
|
||||
overlayPrefs={overlayPrefs}
|
||||
selectionMode={selectionMode}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { adminKeys, catalogKeys, libraryKeys, sectionKeys } from "@/hooks/queries/keys";
|
||||
import { invalidateCatalogState } from "./realtimeCatalogInvalidation";
|
||||
import { buildEventsUrl, RealtimeEventsProvider } from "./RealtimeEventsProvider";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
@@ -86,84 +84,6 @@ describe("buildEventsUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateCatalogState", () => {
|
||||
it("invalidates library lists for a scoped library change", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const otherCatalogKey = catalogKeys.list({
|
||||
source: "section",
|
||||
scope: "library",
|
||||
section_id: "all",
|
||||
library_id: 1,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
});
|
||||
const changedCatalogKey = catalogKeys.list({
|
||||
source: "section",
|
||||
scope: "library",
|
||||
section_id: "all",
|
||||
library_id: 3,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
});
|
||||
const otherSectionKey = sectionKeys.libraryLayout(1);
|
||||
const changedSectionKey = sectionKeys.libraryLayout(3);
|
||||
const userLibrariesKey = libraryKeys.user("profile-1");
|
||||
|
||||
queryClient.setQueryData(adminKeys.libraries(), []);
|
||||
queryClient.setQueryData(adminKeys.libraryMatchQueueStatuses(), []);
|
||||
queryClient.setQueryData(userLibrariesKey, []);
|
||||
queryClient.setQueryData(otherCatalogKey, { items: [] });
|
||||
queryClient.setQueryData(changedCatalogKey, { items: [] });
|
||||
queryClient.setQueryData(otherSectionKey, { sections: [] });
|
||||
queryClient.setQueryData(changedSectionKey, { sections: [] });
|
||||
|
||||
invalidateCatalogState(queryClient, { libraryId: 3, allowDashboardRefetch: false });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(queryClient.getQueryState(adminKeys.libraries())?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(adminKeys.libraryMatchQueueStatuses())?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(queryClient.getQueryState(userLibrariesKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(otherCatalogKey)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(changedCatalogKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(otherSectionKey)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(changedSectionKey)?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("can skip library lists for item-scoped catalog changes", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const changedCatalogKey = catalogKeys.list({
|
||||
source: "section",
|
||||
scope: "library",
|
||||
section_id: "all",
|
||||
library_id: 3,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
queryClient.setQueryData(adminKeys.libraries(), []);
|
||||
queryClient.setQueryData(adminKeys.libraryMatchQueueStatuses(), []);
|
||||
queryClient.setQueryData(libraryKeys.all, []);
|
||||
queryClient.setQueryData(changedCatalogKey, { items: [] });
|
||||
|
||||
invalidateCatalogState(queryClient, {
|
||||
itemId: "item-1",
|
||||
libraryId: 3,
|
||||
allowDashboardRefetch: false,
|
||||
includeLibraryLists: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(queryClient.getQueryState(adminKeys.libraries())?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(adminKeys.libraryMatchQueueStatuses())?.isInvalidated).toBe(
|
||||
false,
|
||||
);
|
||||
expect(queryClient.getQueryState(libraryKeys.all)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(changedCatalogKey)?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RealtimeEventsProvider", () => {
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.instances = [];
|
||||
|
||||
@@ -18,10 +18,9 @@ import {
|
||||
type RealtimeConnectionState,
|
||||
type RealtimeEventsContextValue,
|
||||
} from "@/components/realtimeEventsContext";
|
||||
import { invalidateCatalogState } from "@/components/realtimeCatalogInvalidation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { usePageActivity } from "@/hooks/usePageActivity";
|
||||
import { adminKeys, historyImportKeys, libraryKeys } from "@/hooks/queries/keys";
|
||||
import { adminKeys, catalogKeys, historyImportKeys, libraryKeys } from "@/hooks/queries/keys";
|
||||
import {
|
||||
invalidateMediaSurfaceQueries,
|
||||
updateCatalogItemDetail,
|
||||
@@ -47,12 +46,7 @@ interface UserStatePayload {
|
||||
in_watchlist?: boolean;
|
||||
}
|
||||
|
||||
const CATALOG_ITEM_CHANGED_EVENTS = new Set([
|
||||
"catalog.item.changed",
|
||||
"library.item_added",
|
||||
"metadata.updated",
|
||||
]);
|
||||
const SCOPED_CATALOG_LIBRARY_EVENTS = new Set(["catalog.library.changed", "library.changed"]);
|
||||
const CATALOG_ITEM_CHANGED_EVENTS = new Set(["metadata.updated", "catalog.item.changed"]);
|
||||
const DASHBOARD_QUERY_KEYS = [
|
||||
adminKeys.stats(),
|
||||
adminKeys.sessions(),
|
||||
@@ -184,12 +178,25 @@ function invalidateDashboardQueries(queryClient: QueryClient, allowRefetch: bool
|
||||
}
|
||||
}
|
||||
|
||||
function catalogEventLibraryID(data: unknown) {
|
||||
if (!data || typeof data !== "object" || !("library_id" in data)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = (data as { library_id?: unknown }).library_id;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
function invalidateCatalogState(
|
||||
queryClient: QueryClient,
|
||||
options: { itemId?: string; allowDashboardRefetch: boolean },
|
||||
) {
|
||||
const { itemId, allowDashboardRefetch } = options;
|
||||
void invalidateMediaSurfaceQueries(queryClient, itemId ? { itemId } : {}).then(() => {
|
||||
bumpHomeRefreshSignal(queryClient);
|
||||
});
|
||||
void queryClient.refetchQueries({ queryKey: catalogKeys.all, type: "active" });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: adminKeys.libraries(),
|
||||
refetchType: allowDashboardRefetch ? "active" : "none",
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: adminKeys.stats(),
|
||||
refetchType: allowDashboardRefetch ? "active" : "none",
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: libraryKeys.all });
|
||||
}
|
||||
|
||||
function handleJobSideEffects(
|
||||
@@ -466,29 +473,18 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
function handleEvent(message: EventsEventMessage) {
|
||||
switch (message.channel) {
|
||||
case "catalog":
|
||||
{
|
||||
const eventLibraryID = catalogEventLibraryID(message.data);
|
||||
if (CATALOG_ITEM_CHANGED_EVENTS.has(message.event)) {
|
||||
invalidateCatalogState(queryClient, {
|
||||
itemId:
|
||||
typeof message.data === "object" && message.data && "content_id" in message.data
|
||||
? (message.data as { content_id?: string }).content_id
|
||||
: undefined,
|
||||
libraryId: eventLibraryID,
|
||||
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
|
||||
includeLibraryLists: false,
|
||||
});
|
||||
} else if (SCOPED_CATALOG_LIBRARY_EVENTS.has(message.event) && eventLibraryID) {
|
||||
invalidateCatalogState(queryClient, {
|
||||
libraryId: eventLibraryID,
|
||||
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
|
||||
});
|
||||
} else {
|
||||
invalidateCatalogState(queryClient, {
|
||||
libraryId: eventLibraryID,
|
||||
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
|
||||
});
|
||||
}
|
||||
if (CATALOG_ITEM_CHANGED_EVENTS.has(message.event)) {
|
||||
invalidateCatalogState(queryClient, {
|
||||
itemId:
|
||||
typeof message.data === "object" && message.data && "content_id" in message.data
|
||||
? (message.data as { content_id?: string }).content_id
|
||||
: undefined,
|
||||
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
|
||||
});
|
||||
} else {
|
||||
invalidateCatalogState(queryClient, {
|
||||
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "jobs":
|
||||
|
||||
@@ -68,9 +68,7 @@ export default function CollectionRulesEditor({
|
||||
onChange({
|
||||
...value,
|
||||
media_scope:
|
||||
next === "all"
|
||||
? undefined
|
||||
: (next as "movie" | "series" | "episode" | "audiobook"),
|
||||
next === "all" ? undefined : (next as "movie" | "series" | "episode"),
|
||||
sort: nextSort,
|
||||
});
|
||||
}}
|
||||
@@ -84,7 +82,6 @@ export default function CollectionRulesEditor({
|
||||
<SelectItem value="movie">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="episode">Episodes</SelectItem>
|
||||
<SelectItem value="audiobook">Audiobooks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { adminKeys, libraryKeys } from "@/hooks/queries/keys";
|
||||
import { invalidateMediaSurfaceQueries } from "@/hooks/queries/mediaSurfaceRefresh";
|
||||
import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh";
|
||||
|
||||
export function invalidateCatalogState(
|
||||
queryClient: QueryClient,
|
||||
options: {
|
||||
itemId?: string;
|
||||
libraryId?: number;
|
||||
allowDashboardRefetch: boolean;
|
||||
includeLibraryLists?: boolean;
|
||||
},
|
||||
) {
|
||||
const { itemId, libraryId, allowDashboardRefetch, includeLibraryLists = true } = options;
|
||||
void invalidateMediaSurfaceQueries(queryClient, { itemId, libraryId }).then(() => {
|
||||
bumpHomeRefreshSignal(queryClient);
|
||||
});
|
||||
if (includeLibraryLists) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: adminKeys.libraries(),
|
||||
refetchType: allowDashboardRefetch ? "active" : "none",
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() });
|
||||
void queryClient.invalidateQueries({ queryKey: libraryKeys.all });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: adminKeys.stats(),
|
||||
refetchType: allowDashboardRefetch ? "active" : "none",
|
||||
});
|
||||
}
|
||||
@@ -61,9 +61,8 @@ export function SectionSummaryBadges({
|
||||
<Badge variant="secondary">{recipeLabel(catalog, section.sectionType)}</Badge>
|
||||
{queryDefinition.media_scope === "movie" ? <Badge variant="outline">Movies</Badge> : null}
|
||||
{queryDefinition.media_scope === "series" ? <Badge variant="outline">Series</Badge> : null}
|
||||
{queryDefinition.media_scope === "episode" ? <Badge variant="outline">Episodes</Badge> : null}
|
||||
{queryDefinition.media_scope === "audiobook" ? (
|
||||
<Badge variant="outline">Audiobooks</Badge>
|
||||
{queryDefinition.media_scope === "episode" ? (
|
||||
<Badge variant="outline">Episodes</Badge>
|
||||
) : null}
|
||||
{libraries
|
||||
? queryDefinition.library_ids.map((libraryId) => {
|
||||
|
||||
@@ -477,9 +477,7 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
|
||||
setQueryDefinition({
|
||||
...queryDefinition,
|
||||
media_scope:
|
||||
value === "all"
|
||||
? undefined
|
||||
: (value as "movie" | "series" | "episode" | "audiobook"),
|
||||
value === "all" ? undefined : (value as "movie" | "series" | "episode"),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -491,7 +489,6 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
|
||||
<SelectItem value="movie">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="episode">Episodes</SelectItem>
|
||||
<SelectItem value="audiobook">Audiobooks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import type { AudiobookDetailResponse } from "@/lib/audiobooks/types";
|
||||
|
||||
export function useAudiobook(contentId: string | undefined) {
|
||||
return useQuery<AudiobookDetailResponse>({
|
||||
queryKey: ["audiobooks", "detail", contentId],
|
||||
enabled: Boolean(contentId),
|
||||
queryFn: () => api<AudiobookDetailResponse>(`/audiobooks/${contentId}`),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import type { AudiobookListResponse } from "@/lib/audiobooks/types";
|
||||
|
||||
interface UseAudiobookLibraryOpts {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useAudiobookLibrary(opts: UseAudiobookLibraryOpts = {}) {
|
||||
const { limit = 50, offset = 0, enabled = true } = opts;
|
||||
return useQuery<AudiobookListResponse>({
|
||||
queryKey: ["audiobooks", "list", limit, offset],
|
||||
enabled,
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
return api<AudiobookListResponse>(`/audiobooks?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface UseInfiniteAudiobookLibraryOpts {
|
||||
limit?: number;
|
||||
enabled?: boolean;
|
||||
genre?: string;
|
||||
}
|
||||
|
||||
export function useInfiniteAudiobookLibrary(opts: UseInfiniteAudiobookLibraryOpts = {}) {
|
||||
const { limit = 60, enabled = true, genre = "" } = opts;
|
||||
return useInfiniteQuery<AudiobookListResponse>({
|
||||
queryKey: ["audiobooks", "list-infinite", limit, genre],
|
||||
enabled,
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam = 0 }) => {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(pageParam),
|
||||
});
|
||||
if (genre) params.set("genre", genre);
|
||||
return api<AudiobookListResponse>(`/audiobooks?${params.toString()}`);
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const next = lastPage.offset + lastPage.items.length;
|
||||
return next < lastPage.total ? next : undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
interface ReportProgressVars {
|
||||
contentId: string;
|
||||
positionSeconds: number;
|
||||
mediaFileId?: number;
|
||||
}
|
||||
|
||||
interface ReportProgressBody {
|
||||
position_seconds: number;
|
||||
media_file_id?: number;
|
||||
}
|
||||
|
||||
export function useReportAudiobookProgress() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ contentId, positionSeconds, mediaFileId }: ReportProgressVars) => {
|
||||
const body: ReportProgressBody = { position_seconds: positionSeconds };
|
||||
if (mediaFileId !== undefined) {
|
||||
body.media_file_id = mediaFileId;
|
||||
}
|
||||
return api(`/audiobooks/${contentId}/progress`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
onSuccess: (_, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["audiobooks", "detail", vars.contentId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -91,51 +91,6 @@ describe("invalidateMediaSurfaceQueries", () => {
|
||||
expect(queryClient.getQueryState(catalogKeys.itemDetail("item-1"))?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("does not invalidate catalog list queries for a different library scope", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const moviesKey = catalogKeys.list({
|
||||
source: "section",
|
||||
scope: "library",
|
||||
section_id: "all",
|
||||
library_id: 1,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
});
|
||||
const internationalKey = catalogKeys.list({
|
||||
source: "section",
|
||||
scope: "library",
|
||||
section_id: "all",
|
||||
library_id: 3,
|
||||
limit: 60,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
queryClient.setQueryData(moviesKey, { items: [] });
|
||||
queryClient.setQueryData(internationalKey, { items: [] });
|
||||
|
||||
await invalidateMediaSurfaceQueries(queryClient, { libraryId: 3 });
|
||||
|
||||
expect(queryClient.getQueryState(moviesKey)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(internationalKey)?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("does not invalidate library section queries for a different library scope", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const moviesLayoutKey = sectionKeys.libraryLayout(1);
|
||||
const moviesSectionKey = sectionKeys.libraryItems(1, "recently-added");
|
||||
const internationalLayoutKey = sectionKeys.libraryLayout(3);
|
||||
|
||||
queryClient.setQueryData(moviesLayoutKey, { sections: [] });
|
||||
queryClient.setQueryData(moviesSectionKey, { section: { id: "recently-added", items: [] } });
|
||||
queryClient.setQueryData(internationalLayoutKey, { sections: [] });
|
||||
|
||||
await invalidateMediaSurfaceQueries(queryClient, { libraryId: 3 });
|
||||
|
||||
expect(queryClient.getQueryState(moviesLayoutKey)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(moviesSectionKey)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(internationalLayoutKey)?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("sets all cached detail keys for the mutated item", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
||||
@@ -12,14 +12,9 @@ import {
|
||||
sectionKeys,
|
||||
watchlistKeys,
|
||||
} from "./keys";
|
||||
import {
|
||||
activeCatalogQueryMatchesLibrary,
|
||||
activeSectionQueryMatchesLibrary,
|
||||
} from "@/lib/queryInvalidation";
|
||||
|
||||
interface InvalidateMediaSurfaceOptions {
|
||||
itemId?: string;
|
||||
libraryId?: number;
|
||||
watchedKeys?: Array<readonly unknown[]>;
|
||||
}
|
||||
|
||||
@@ -101,14 +96,8 @@ export async function invalidateMediaSurfaceQueries(
|
||||
) {
|
||||
const invalidations: Array<Promise<void>> = [
|
||||
queryClient.invalidateQueries({ queryKey: itemKeys.all }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: catalogKeys.all,
|
||||
predicate: (query) => activeCatalogQueryMatchesLibrary(query.queryKey, options.libraryId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: sectionKeys.all,
|
||||
predicate: (query) => activeSectionQueryMatchesLibrary(query.queryKey, options.libraryId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: sectionKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: progressKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: historyKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: favoriteKeys.all }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueries } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import type { ProgressListResponse, ProgressEntry, ItemDetail } from "@/api/types";
|
||||
import { catalogKeys, progressKeys } from "./keys";
|
||||
@@ -65,39 +65,3 @@ export function useContinueWatching(
|
||||
isLoading: enabled && progressLoading,
|
||||
};
|
||||
}
|
||||
|
||||
interface ReportMediaProgressVars {
|
||||
contentId: string;
|
||||
positionSeconds: number;
|
||||
durationSeconds: number;
|
||||
forceOverwrite?: boolean;
|
||||
}
|
||||
|
||||
export function useReportMediaProgress() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
contentId,
|
||||
positionSeconds,
|
||||
durationSeconds,
|
||||
forceOverwrite = true,
|
||||
}: ReportMediaProgressVars) =>
|
||||
api("/sync/progress", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
media_item_id: contentId,
|
||||
position: positionSeconds,
|
||||
duration: durationSeconds,
|
||||
force_overwrite: forceOverwrite,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: progressKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ interface TasteSeedSubmitResponse {
|
||||
const TASTE_SEED_PAGE_SIZE = 30;
|
||||
|
||||
/**
|
||||
* Fetches catalog posters for the taste-seeding picker. Blends server engagement
|
||||
* with rating reliability and recency so even a fresh server with no watch
|
||||
* history surfaces meaningful posters. Items already favorited by the profile
|
||||
* carry user_state.is_favorite=true so the UI can pre-select them.
|
||||
* Fetches popular catalog posters for the taste-seeding picker. Blends
|
||||
* server-engagement popularity with TMDB rating so even a fresh server with no
|
||||
* watch history surfaces meaningful posters. Items already favorited by the
|
||||
* profile carry user_state.is_favorite=true so the UI can pre-select them.
|
||||
*/
|
||||
export function useTasteSeedItems(enabled = true) {
|
||||
return useInfiniteQuery({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user