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,19 +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
|
||||
|
||||
# REQUIRED: master key for at-rest credential encryption. Silo refuses to start
|
||||
# without it. Integration API keys, S3 keys, and other server-owned secrets are
|
||||
# encrypted under a key derived from this value, so it must be at least 32
|
||||
# characters and kept secret. Generate one with:
|
||||
# openssl rand -base64 48
|
||||
# Back it up SEPARATELY from your database dumps (treat it like a CA private
|
||||
# key): losing it makes every encrypted secret unrecoverable. See
|
||||
# docs/architecture/secret-encryption.md.
|
||||
# SECRET_KEY=replace-with-output-of-openssl-rand-base64-48
|
||||
|
||||
# Run from source / advanced overrides
|
||||
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
|
||||
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
|
||||
|
||||
@@ -3,7 +3,6 @@ FROM node:22-slim AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/pnpm-lock.yaml ./
|
||||
COPY web/vendor/foliate-js ./vendor/foliate-js
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY web/ .
|
||||
@@ -23,7 +22,6 @@ ENV GONOSUMDB=github.com/Silo-Server/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
@@ -5,7 +5,6 @@ FROM node:22-slim AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/pnpm-lock.yaml ./
|
||||
COPY web/vendor/foliate-js ./vendor/foliate-js
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY web/ .
|
||||
@@ -20,7 +19,6 @@ ENV GONOSUMDB=github.com/Silo-Server/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
@@ -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.
|
||||
|
||||
+69
-123
@@ -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"
|
||||
|
||||
@@ -47,7 +47,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/database"
|
||||
"github.com/Silo-Server/silo-server/internal/ebooks"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"github.com/Silo-Server/silo-server/internal/imagecache"
|
||||
@@ -73,10 +72,11 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/radarr"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
|
||||
"github.com/Silo-Server/silo-server/internal/s3client"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/secret"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
"github.com/Silo-Server/silo-server/internal/server"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
@@ -141,7 +141,7 @@ func mustGetSetting(store interface {
|
||||
func configureOperationalLogging(
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
settingsRepo catalog.SettingsStore,
|
||||
settingsRepo *catalog.ServerSettingsRepo,
|
||||
redisCfg config.RedisConfig,
|
||||
logStreamHub *logstream.Hub,
|
||||
baseHandler slog.Handler,
|
||||
@@ -252,38 +252,6 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
|
||||
}
|
||||
}
|
||||
|
||||
// runCredentialBackfills sweeps any plaintext server-owned credential to
|
||||
// ciphertext on the primary (migration-running) node. All passes are
|
||||
// best-effort: a failed row leaves the prior plaintext (no new exposure) and
|
||||
// still reads via the read-path pass-through, so a backfill error must never
|
||||
// block boot. The sensitive-settings pass runs first so the arr
|
||||
// resolve-then-encrypt pass sees consistent referenced settings.
|
||||
func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) {
|
||||
settingsN, err := settings.BackfillSensitiveSettings(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: sensitive settings", "error", err)
|
||||
}
|
||||
columnsN, err := secret.BackfillColumns(ctx, pool, cipher, secret.ColumnBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: credential columns", "error", err)
|
||||
}
|
||||
historyServersN, err := historyimport.NewRepository(pool, cipher).BackfillSessionServerSecrets(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: history import session server credentials", "error", err)
|
||||
}
|
||||
// The arr resolver is the encrypting settings decorator: it decrypts a
|
||||
// sensitive target (e.g. requests.radarr.api_key) or passes through a
|
||||
// plaintext custom key, exactly replicating the deleted resolveAPIKey.
|
||||
arrN, err := secret.BackfillReferencedColumns(ctx, pool, cipher, settings.Get, secret.ArrKeyBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: arr api keys", "error", err)
|
||||
}
|
||||
if total := settingsN + columnsN + historyServersN + arrN; total > 0 {
|
||||
slog.Info("secret backfill: encrypted plaintext credentials at rest",
|
||||
"settings", settingsN, "columns", columnsN, "history_session_servers", historyServersN, "arr_keys", arrN, "total", total)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
envFile := flag.String("env", ".env", "path to .env bootstrap file")
|
||||
migrateOnly := flag.Bool("migrate-only", false, "apply database migrations and exit")
|
||||
@@ -298,15 +266,6 @@ func main() {
|
||||
log.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
// Construct the at-rest credential cipher from SECRET_KEY immediately after
|
||||
// bootstrap, before any settings repo is built. It is threaded explicitly as
|
||||
// a dependency into every repo that stores a server-owned secret — never a
|
||||
// package-level global.
|
||||
dataCipher, err := secret.New(bc.SecretKey)
|
||||
if err != nil {
|
||||
log.Fatalf("secret cipher: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Connect to PostgreSQL (bootstrap pool with default max connections)
|
||||
bootstrapDBCfg := config.DatabaseConfig{URL: bc.DatabaseURL, MaxConnections: 20}
|
||||
pool, err := database.NewPool(ctx, bootstrapDBCfg)
|
||||
@@ -355,11 +314,7 @@ func main() {
|
||||
// Run migrations only for integrated/api modes. Proxy and transcode nodes
|
||||
// should never alter the schema — they may scale independently and would
|
||||
// race or apply migrations before the primary node is deliberately upgraded.
|
||||
// The same gate decides whether this node runs the credential-encryption
|
||||
// backfills: only the primary (migration-running) node sweeps plaintext to
|
||||
// ciphertext; secondary nodes read whatever the primary encrypted.
|
||||
isPrimaryNode := bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == ""
|
||||
if isPrimaryNode {
|
||||
if bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == "" {
|
||||
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
if migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql"); migErr != nil {
|
||||
migCancel()
|
||||
@@ -369,14 +324,8 @@ func main() {
|
||||
slog.Info("database migrations applied")
|
||||
}
|
||||
|
||||
// Step 3: Load settings from DB. settingsRepo is the encrypting decorator so
|
||||
// every consumer (config.LoadFromDB, admin, ABS, watchers) transparently sees
|
||||
// plaintext while sensitive keys rest as ciphertext. The settings backfill
|
||||
// (run after migrations, before this GetAll) is wired further below.
|
||||
settingsRepo := catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
|
||||
if isPrimaryNode {
|
||||
runCredentialBackfills(ctx, pool, dataCipher, settingsRepo)
|
||||
}
|
||||
// Step 3: Load settings from DB
|
||||
settingsRepo := catalog.NewServerSettingsRepo(pool)
|
||||
settings, err := settingsRepo.GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("loading settings: %v", err)
|
||||
@@ -447,9 +396,7 @@ func main() {
|
||||
log.Fatalf("recreating pool with configured max_connections: %v", err)
|
||||
}
|
||||
}
|
||||
// Re-wrap with the encrypting decorator so the recreated pool's settings repo
|
||||
// still encrypts/decrypts — no raw settings repo may escape into later wiring.
|
||||
settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
|
||||
settingsRepo = catalog.NewServerSettingsRepo(pool)
|
||||
nodeID := resolveNodeIdentity()
|
||||
|
||||
// Step 9: Validate
|
||||
@@ -478,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)
|
||||
@@ -513,7 +458,7 @@ func main() {
|
||||
DatabaseURL: cfg.Database.URL,
|
||||
JFListen: cfg.JellyfinCompat.Listen,
|
||||
}
|
||||
watcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, bootstrap)
|
||||
watcher := nodeconfig.NewWatcher(pool, eventBus, bootstrap)
|
||||
if err := watcher.Start(appCtx); err != nil {
|
||||
slog.Error("config watcher start failed", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -572,7 +517,6 @@ func main() {
|
||||
BootstrapSensitiveValues: bootstrapSensitiveValues,
|
||||
AppContext: appCtx,
|
||||
DB: pool,
|
||||
SecretCipher: dataCipher,
|
||||
EventBus: eventBus,
|
||||
LogStreamHub: logStreamHub,
|
||||
RealtimeHub: realtimeHub,
|
||||
@@ -581,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 {
|
||||
@@ -649,7 +587,7 @@ func main() {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
watchProviderService = watchsync.NewService(
|
||||
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
|
||||
watchsync.NewPostgresRepository(deps.DB),
|
||||
watchProviderRegistry,
|
||||
)
|
||||
deps.WatchProviderService = watchProviderService
|
||||
@@ -670,7 +608,6 @@ func main() {
|
||||
|
||||
deps.ProxyPool = proxyPool
|
||||
deps.TranscodePool = transcodePool
|
||||
deps.NodePlanner = nodepool.NewPlanner(proxyPool, transcodePool)
|
||||
|
||||
healthChecker := nodepool.NewHealthChecker(proxyPool, transcodePool, nodeRepo)
|
||||
healthChecker.Start(appCtx)
|
||||
@@ -943,7 +880,6 @@ func main() {
|
||||
var seasonRepo *catalog.SeasonRepository
|
||||
var episodeRepo *catalog.EpisodeRepository
|
||||
var audiobookEnricher *audiobooks.Enricher
|
||||
var ebookEnricher *ebooks.Enricher
|
||||
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
|
||||
chainRepo := metadata.NewChainRepository(deps.DB)
|
||||
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
|
||||
@@ -1025,14 +961,6 @@ func main() {
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
ebookEnricher = ebooks.NewEnricher(
|
||||
deps.DB,
|
||||
chainRepo,
|
||||
pluginResolver,
|
||||
itemRepo,
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
|
||||
// Always wire the image resolver so plugin-prefixed URLs (e.g.
|
||||
// metadb://) can be resolved to presigned HTTP URLs in API responses.
|
||||
@@ -1055,9 +983,6 @@ func main() {
|
||||
audiobookEnricher.SetImageCacher(imageCacher)
|
||||
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
ebookEnricher.SetImageCacher(imageCacher)
|
||||
}
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
@@ -1247,7 +1172,7 @@ func main() {
|
||||
deps.UserStoreProvider = userStoreProvider
|
||||
}
|
||||
if watchProviderService != nil {
|
||||
historyRepo := historyimport.NewRepository(deps.DB, deps.SecretCipher)
|
||||
historyRepo := historyimport.NewRepository(deps.DB)
|
||||
historyIdentity := watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, catalog.NewProviderIDRepository(deps.DB))
|
||||
watchProviderService.
|
||||
WithMatcher(historyimport.NewMatcher(historyRepo)).
|
||||
@@ -1510,7 +1435,6 @@ func main() {
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil {
|
||||
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
|
||||
}
|
||||
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
|
||||
if deps.IntroAnalyzer != nil {
|
||||
taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo))
|
||||
}
|
||||
@@ -1546,15 +1470,15 @@ func main() {
|
||||
taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService))
|
||||
}
|
||||
requestReconcileSvc := mediarequests.NewService(
|
||||
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
|
||||
mediarequests.NewRepository(deps.DB),
|
||||
nil,
|
||||
mediarequests.NewCatalogPresence(
|
||||
catalog.NewItemRepository(deps.DB),
|
||||
catalog.NewProviderIDRepository(deps.DB),
|
||||
),
|
||||
)
|
||||
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
api.AttachRequestRouter(requestReconcileSvc, pluginService)
|
||||
requestReconcileSvc.SetSecretResolver(settingsRepo)
|
||||
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
if userStoreProvider != nil {
|
||||
reconcileResolver := access.NewResolver(
|
||||
auth.NewUserRepository(deps.DB),
|
||||
@@ -1565,15 +1489,13 @@ func main() {
|
||||
}
|
||||
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil {
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
|
||||
if err := autoscanRepo.MarkInterruptedEvents(appCtx); err != nil {
|
||||
slog.Warn("autoscan: failed to mark interrupted polls", "err", err)
|
||||
}
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB)
|
||||
autoscanSvc := api.BuildAutoscanService(
|
||||
autoscanRepo,
|
||||
pluginService,
|
||||
pluginInstallationStore,
|
||||
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
|
||||
mediarequests.NewRepository(deps.DB),
|
||||
settingsRepo,
|
||||
deps.FolderRepo,
|
||||
deps.LibraryScanQueue,
|
||||
deps.RedisClient,
|
||||
@@ -1597,12 +1519,9 @@ 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 ebookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
|
||||
if err != nil {
|
||||
@@ -1623,7 +1542,7 @@ func main() {
|
||||
// available. Routes are mounted at the root level by NewRouter (not under
|
||||
// /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and
|
||||
// /abs/socket.io/* without path prefix hacks.
|
||||
if absCompatEnabled && deps.DB != nil {
|
||||
if audiobooksEnabled && deps.DB != nil {
|
||||
absUserRepo := auth.NewUserRepository(deps.DB)
|
||||
absSessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
absJWTService := auth.NewJWTService(
|
||||
@@ -1896,9 +1815,9 @@ func main() {
|
||||
compatDeps := jellycompat.Dependencies{
|
||||
Config: cfg,
|
||||
DB: deps.DB,
|
||||
SecretCipher: dataCipher,
|
||||
ClientIPResolver: ipResolver,
|
||||
NodePlanner: deps.NodePlanner,
|
||||
ProxyPool: deps.ProxyPool,
|
||||
TranscodePool: deps.TranscodePool,
|
||||
JWTSecret: cfg.Auth.JWTSecret,
|
||||
RecWorker: recWorker,
|
||||
}
|
||||
@@ -1949,12 +1868,12 @@ func main() {
|
||||
compatDeps.FileResolver = deps.FileRepo
|
||||
}
|
||||
|
||||
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB, deps.SecretCipher)
|
||||
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB)
|
||||
|
||||
// Construct auth service for jellycompat login.
|
||||
userRepo := auth.NewUserRepository(deps.DB)
|
||||
compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB)
|
||||
compatDeps.UserLoader = userRepo
|
||||
compatDeps.APIKeyUserLoader = userRepo
|
||||
compatDeps.ScanQueue = deps.LibraryScanQueue
|
||||
sessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
jwtService := auth.NewJWTService(
|
||||
@@ -1965,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2067,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)
|
||||
@@ -2449,7 +2395,7 @@ func mapFolderTypeToMediaType(t string) string {
|
||||
// exposes Get) to the audiobooks.SettingsReader interface (which
|
||||
// requires GetString). The two signatures are identical modulo name.
|
||||
type audiobooksSettingsAdapter struct {
|
||||
repo catalog.SettingsStore
|
||||
repo *catalog.ServerSettingsRepo
|
||||
}
|
||||
|
||||
func (a *audiobooksSettingsAdapter) GetString(ctx context.Context, key string) (string, error) {
|
||||
|
||||
@@ -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
-10
@@ -36,11 +36,6 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: integrated
|
||||
# Master key for at-rest credential encryption. Required — the server
|
||||
# refuses to start without it. Generate with: openssl rand -base64 48
|
||||
# Back it up SEPARATELY from your database dumps; losing it makes encrypted
|
||||
# secrets unrecoverable. See docs/architecture/secret-encryption.md.
|
||||
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
REDIS_URL: redis://redis:6379
|
||||
SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
|
||||
@@ -51,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
|
||||
@@ -76,8 +71,6 @@ services:
|
||||
# profiles: [proxy]
|
||||
# environment:
|
||||
# MODE: proxy
|
||||
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
|
||||
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
# REDIS_URL: redis://redis:6379
|
||||
# PORT: "8080"
|
||||
@@ -102,8 +95,6 @@ services:
|
||||
# profiles: [transcode]
|
||||
# environment:
|
||||
# MODE: transcode
|
||||
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
|
||||
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
# REDIS_URL: redis://redis:6379
|
||||
# SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# At-rest credential encryption
|
||||
|
||||
Silo encrypts third-party integration credentials and other server-owned secrets
|
||||
at rest. Sonarr/Radarr API keys, S3 keys, watch-sync OAuth tokens, the
|
||||
Audiobookshelf-compat signing key, and the sensitive `server_settings` entries
|
||||
are stored as ciphertext in PostgreSQL and decrypted only in memory. The key that
|
||||
protects them lives **outside** the database, so a full DB dump or compromise
|
||||
does not expose the secrets.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Cipher:** AES-256-GCM with a random 12-byte nonce per value. Implemented in
|
||||
`internal/secret`.
|
||||
- **Key derivation:** the data key is HKDF-SHA256–derived from the `SECRET_KEY`
|
||||
environment variable with a versioned domain label. `SECRET_KEY` is the
|
||||
encryption root; it is never stored in the database.
|
||||
- **Envelope:** every ciphertext is stored as `enc:v1:<base64url(nonce‖sealed)>`.
|
||||
The `enc:v1:` prefix is the version marker (see *Key rotation* below).
|
||||
- **Row binding (AAD):** each ciphertext is GCM-bound to its logical row via
|
||||
additional-authenticated data — `table:column:<pk>` for per-row columns,
|
||||
`server_settings:<key>` for settings. A DB-write attacker therefore cannot move
|
||||
a credential blob from one row/column to another: decrypting under a different
|
||||
binding fails authentication.
|
||||
- **Read path:** an empty value stays empty; a non-`enc:v1:` value is treated as
|
||||
legacy plaintext and passed through unchanged (so a not-yet-migrated row keeps
|
||||
working); an `enc:v1:` value is decrypted and **any** failure (wrong key,
|
||||
tampering, truncation) is surfaced as an error — it is never silently used as a
|
||||
credential.
|
||||
|
||||
## SECRET_KEY
|
||||
|
||||
`SECRET_KEY` is **required**. The server refuses to start without it (it fatals
|
||||
in `config.LoadBootstrap`), and it must be at least 32 characters.
|
||||
|
||||
Generate one with:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 48
|
||||
```
|
||||
|
||||
Set it in the deployment environment (or in `.env` for source/dev runs, which
|
||||
`godotenv` loads at startup). See `.env.example`.
|
||||
|
||||
### Back it up — separately from the database
|
||||
|
||||
Treat `SECRET_KEY` like a CA private key:
|
||||
|
||||
- **Store it outside your database backups.** A DB dump contains only ciphertext;
|
||||
the value of at-rest encryption is entirely lost if the key travels with the
|
||||
dump.
|
||||
- Keep it in a secrets manager / sealed secret / password manager, not in the
|
||||
repo or in the same bucket as your Postgres backups.
|
||||
- Rotating the *deployment* (new host, restored DB) requires the **same**
|
||||
`SECRET_KEY`. Restoring a database backup onto a node with a different
|
||||
`SECRET_KEY` makes every encrypted secret unreadable.
|
||||
|
||||
### Key loss
|
||||
|
||||
If `SECRET_KEY` is lost, the encrypted secrets are **unrecoverable** — that is the
|
||||
point of keeping the key out of the database. Recovery means re-entering the
|
||||
affected credentials:
|
||||
|
||||
- Re-enter Sonarr/Radarr and Autoscan API keys, S3 keys, watch-sync connections,
|
||||
history-import tokens, and subtitle provider credentials.
|
||||
- `auth.jwt_secret` becomes unreadable, so all existing sessions are invalid and
|
||||
users must log in again (a new secret is generated on next boot if the row is
|
||||
cleared — see below).
|
||||
|
||||
## Rollout (automatic backfill)
|
||||
|
||||
On the first boot after deploying this change, the primary (migration-running)
|
||||
node runs an idempotent, best-effort startup backfill that encrypts any
|
||||
remaining plaintext in place:
|
||||
|
||||
1. sensitive `server_settings` keys,
|
||||
2. the per-table credential columns (subtitles, watch-sync, webhook-sync,
|
||||
history-import, including temporary server-list credentials stored in
|
||||
session JSON),
|
||||
3. the two arr `api_key_ref` columns — these are **resolved-then-encrypted**: a
|
||||
legacy row that held a `server_settings` reference (e.g.
|
||||
`requests.radarr.api_key`) is collapsed to the real credential before being
|
||||
encrypted.
|
||||
|
||||
The backfill is safe to run repeatedly: already-encrypted values are skipped, and
|
||||
a per-row guard makes concurrent multi-node boots converge without
|
||||
double-encrypting. A failed row is logged and left as plaintext (no new exposure)
|
||||
rather than blocking boot. Proxy/transcode nodes skip the backfill; they read
|
||||
whatever the primary encrypted. No manual steps are required.
|
||||
|
||||
## Rollback / downgrade hazard
|
||||
|
||||
Downgrading to a binary that predates this change is **not** safe while secrets
|
||||
are encrypted, because the old binary has no read path: it would read
|
||||
`enc:v1:auth.jwt_secret` as a literal JWT secret (invalidating all sessions) and
|
||||
read `enc:v1:`-prefixed integration keys as garbage credentials.
|
||||
|
||||
To downgrade safely you must first return the affected values to plaintext, for
|
||||
example:
|
||||
|
||||
- Decrypt the sensitive `server_settings` back to plaintext (run a one-off that
|
||||
reads each via the encrypting repo and writes the plaintext via the raw repo),
|
||||
**or**
|
||||
- At minimum, clear `auth.jwt_secret` so the older binary regenerates a fresh
|
||||
plaintext one (this still logs everyone out), and re-enter any integration
|
||||
credentials as plaintext.
|
||||
|
||||
There is no automatic "decrypt everything" downgrade path in this release; plan
|
||||
downgrades accordingly.
|
||||
|
||||
## Key rotation (future)
|
||||
|
||||
The `enc:v1:` envelope and the HKDF domain label are versioned. A future rotation
|
||||
would introduce `enc:v2:` with a new derived key (and `Decrypt` already dispatches
|
||||
on the version, returning an explicit error for an unknown version). Rotation is
|
||||
out of scope for this release; the versioning exists so it can be added without a
|
||||
data migration of the envelope format.
|
||||
|
||||
## Scope and known gaps
|
||||
|
||||
Covered: arr (Requests + Autoscan) API keys, S3 keys, all sensitive
|
||||
`server_settings`, watch-sync tokens, webhook-sync `access_token`,
|
||||
history-import admin/session tokens and temporary server-list credentials,
|
||||
subtitle provider `api_key`/`password`,
|
||||
the Jellyfin-compat session's bridged Silo access/refresh tokens
|
||||
(`jellycompat_sessions.streamapp_access_token` / `streamapp_refresh_token`), and
|
||||
the ABS signing key.
|
||||
|
||||
Deliberately **not** encrypted (tracked as follow-ups):
|
||||
|
||||
- **Equality-looked-up secrets** — these are matched by exact value
|
||||
(`WHERE … = $1`), and AES-GCM is randomized, so encrypting them would break the
|
||||
lookup. They need a deterministic **blind-index hash** column instead:
|
||||
`api_keys.api_key`, `webhook_sync_connections.webhook_secret`,
|
||||
`jellycompat_sessions.token`, and `watch_together_rooms.join_token`.
|
||||
- **Plugin runtime config** — `plugin_runtime_configs.config_value` is opaque
|
||||
plugin-defined JSONB whose secret fields are manifest-declared and whose runtime
|
||||
lives in a separate repo; encrypting it correctly needs a coordinated,
|
||||
manifest-aware design.
|
||||
|
||||
Excluded (not a gap): `plex_sync_connections.*` is a dead table (zero Go
|
||||
references); `oauth_completion.token_ciphertext` is already AES-GCM;
|
||||
`users.password_hash` and the `*_hash` columns are already hashed.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Wholphin → jellycompat Endpoint Coverage Report
|
||||
|
||||
Date: 2026-06-09
|
||||
Scope: every Jellyfin server endpoint the Wholphin Android TV client (jellyfin-sdk-kotlin based) can call, cross-referenced against the routes registered in `internal/jellycompat/router.go`. Wholphin source analyzed from a local clone of `damontecres/Wholphin` (`app/src/main`). Commands assume the repository root is the cwd.
|
||||
|
||||
Wholphin's Seerr integration (`/api/v1/...` discover/request endpoints) targets a separate Seerr server, not Jellyfin, and is out of scope here.
|
||||
|
||||
## Methodology
|
||||
|
||||
1. Grepped Wholphin for every `*Api.method()` SDK call (`itemsApi`, `playStateApi`, etc.), websocket subscriptions, and raw URL construction; mapped each to its HTTP endpoint per the Jellyfin OpenAPI spec.
|
||||
2. Inventoried every route registered in `internal/jellycompat/router.go`, including path normalization (`path_normalization.go` handles casing and `/emby`//`jellyfin` prefixes).
|
||||
3. For each endpoint Wholphin uses but silo does not serve, checked whether a server-side gate (user policy flag, DTO field) prevents the client from ever calling it.
|
||||
|
||||
## Summary
|
||||
|
||||
- **Covered (full):** 22 endpoint families — everything on the core path: auth, browse, item detail, episodes/next-up, resume, playback info, direct/HLS streaming, subtitles, progress reporting, favorites/played, images, genres, persons, suggestions, media segments (skip intro), display preferences.
|
||||
- **Covered (partial/stub):** 4 — websocket (keep-alive only, no remote control), `/Studios`, `/Users/Public`, `/QuickConnect/Enabled` (deliberate `false`).
|
||||
- **Missing but safely gated:** 8 — the client never calls them because we don't advertise the capability (delete, subtitle download, trickplay, local trailers, additional parts, quick connect auth, live TV, playlists).
|
||||
- **Missing and reachable:** 4 — theme songs, universal audio stream, item download, client log upload. These return 404 when the client calls them. None block video playback.
|
||||
|
||||
**Bottom line: the entire core video path is covered.** The reachable gaps are quality-of-life features (theme songs, photo screensaver, debug log upload) plus one advertised-but-broken inconsistency (`CanDownload=true` with no `/Items/{id}/Download` route).
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
### Fully covered
|
||||
|
||||
| Wholphin call | Endpoint | silo handler |
|
||||
|---|---|---|
|
||||
| `userApi.authenticateUserByName` | `POST /Users/AuthenticateByName` | `handlers_auth.go` |
|
||||
| `userApi.getCurrentUser` | `GET /Users/Me` | `handlers_auth.go` |
|
||||
| `userApi.getUserById` | `GET /Users/{id}` | `handlers_auth.go` |
|
||||
| `systemApi.getPublicSystemInfo` | `GET /System/Info/Public` | `handlers_system.go` |
|
||||
| `sessionApi.postCapabilities` | `POST /Sessions/Capabilities` | `handlers_playback.go` |
|
||||
| `itemsApi.getItems` | `GET /Items` | `handlers_items.go` |
|
||||
| `itemsApi.getResumeItems` | `GET /UserItems/Resume` (+ legacy `/Users/{id}/Items/Resume`) | `handlers_items.go` |
|
||||
| `userLibraryApi.getItem` | `GET /Items/{id}` (+ legacy user-scoped form) | `handlers_items.go` |
|
||||
| `tvShowsApi.getEpisodes` | `GET /Shows/{id}/Episodes` | `handlers_items.go` |
|
||||
| `tvShowsApi.getNextUp` | `GET /Shows/NextUp` | `handlers_items.go` |
|
||||
| `suggestionsApi.getSuggestions` | `GET /Items/Suggestions` | `handlers_items.go` |
|
||||
| `genresApi.getGenres` | `GET /Genres` | `handlers_items.go` |
|
||||
| `personsApi.getPersons` | `GET /Persons` | `handlers_persons.go` (full when PersonRepo is wired; empty stub otherwise) |
|
||||
| `searchApi.getSearchHints` | `GET /Search/Hints` | `handlers_items.go` (Wholphin's call is currently commented out upstream) |
|
||||
| `mediaInfoApi.getPostedPlaybackInfo` | `POST /Items/{id}/PlaybackInfo` | `handlers_playback.go` |
|
||||
| `videosApi.getVideoStreamUrl` | `GET/HEAD /Videos/{id}/stream(.{container})` | `streams.go` |
|
||||
| (transcode path from PlaybackInfo) | `GET /Videos/{id}/master.m3u8` + HLS playlist/segments | `streams.go` |
|
||||
| (subtitle delivery URLs) | `GET /Videos/{id}/{source}/Subtitles/{idx}/stream.{fmt}` | `streams.go` |
|
||||
| `playStateApi.reportPlaybackStart/Progress/Stopped` | `POST /Sessions/Playing[/Progress|/Stopped]` | `streams.go` |
|
||||
| `playStateApi.markPlayedItem/markUnplayedItem` | `POST/DELETE /UserPlayedItems/{id}` (+ legacy) | `handlers_userdata.go` |
|
||||
| `userLibraryApi.markFavoriteItem/unmarkFavoriteItem` | `POST/DELETE /UserFavoriteItems/{id}` (+ legacy) | `handlers_userdata.go` |
|
||||
| `mediaSegmentsApi.getItemSegments` | `GET /MediaSegments/{id}` | `handlers_items.go` (skip intro/credits) |
|
||||
| `displayPreferencesApi.updateDisplayPreferences` | `GET/POST /DisplayPreferences/{id}` | `handlers_displayprefs.go` |
|
||||
| `imageApi.getItemImageUrl` | `GET /Items/{id}/Images/{type}[/{index}]` | `handlers_images.go` |
|
||||
| `imageApi.getUserImageUrl` | `GET /Users/{id}/Images/Primary` | `handlers_images.go` (placeholder avatar) |
|
||||
|
||||
### Partial / stub (graceful degradation)
|
||||
|
||||
| Endpoint | Status | Client impact |
|
||||
|---|---|---|
|
||||
| `GET /socket` (websocket) | Accepts upgrade, answers KeepAlive only (`handlers_websocket.go`) | Wholphin subscribes to `GeneralCommandMessage` and `PlaystateMessage` for server-initiated remote control. Connection stays healthy; remote-control commands are simply never delivered. No errors, feature inert. |
|
||||
| `GET /Studios` | Empty-array stub | Studio browse rows render empty. |
|
||||
| `GET /Users/Public` | Empty-array stub | No user tiles on the login screen; manual login works. |
|
||||
| `GET /QuickConnect/Enabled` | Hardcoded `false` | Deliberate: Wholphin hides Quick Connect UI, so the unimplemented `POST /QuickConnect/Authorize` and `POST /Users/AuthenticateWithQuickConnect` are never called. |
|
||||
|
||||
### Missing but safely gated (client never calls them)
|
||||
|
||||
| Endpoint | Why it's unreachable |
|
||||
|---|---|
|
||||
| `DELETE /Items/{id}` | We return `CanDelete=false` per item (`mapping.go`) and `EnableContentDeletion=false` in user policy (`handlers_auth.go`), so Wholphin hides delete UI. |
|
||||
| `POST /Items/{id}/RemoteSearch/Subtitles/{subtitleId}` | `EnableSubtitleManagement=false` in user policy hides subtitle search/download. |
|
||||
| `GET /Videos/{id}/Trickplay/...` | We never emit trickplay metadata on item DTOs, so the scrubber never requests tiles. |
|
||||
| `GET /Items/{id}/LocalTrailers` | Wholphin gates on `localTrailerCount > 0` (`TrailerService.kt`); we never set `LocalTrailerCount`. |
|
||||
| `GET /Videos/{id}/AdditionalParts` | Gated on `partCount > 1` (`PlaylistCreator.kt`); we never set `PartCount`. |
|
||||
| Quick Connect auth endpoints | Gated by `/QuickConnect/Enabled` = false. |
|
||||
| Live TV suite (`/LiveTv/Channels`, `/Programs`, `/Recordings`, timers) | We expose no live-TV views or channels, so the feature never activates. |
|
||||
| Playlists (`GET/POST/DELETE /Playlists/{id}/Items`) | We expose no playlist items in the catalog, so playlist screens are unreachable. Becomes a real gap the day silo grows playlists. |
|
||||
|
||||
### Missing and reachable (return 404 today)
|
||||
|
||||
| Endpoint | Wholphin caller | Impact | Severity |
|
||||
|---|---|---|---|
|
||||
| `GET /Items/{id}/ThemeSongs` | `ThemeSongPlayer.kt` | ~~404 → feature silently dead.~~ **Resolved 2026-06-09:** now stubbed with an empty `ThemeMediaResult` (including the `OwnerId` field jellyfin-sdk-kotlin requires). | Resolved |
|
||||
| `GET /Audio/{id}/universal` | `ThemeSongPlayer.kt`, `MusicService.kt` | Audio streaming for theme songs and music playback. No music libraries are exposed today, but this is the second half of the theme-song path and the blocker for any future audio support in jellycompat. | Low (today) |
|
||||
| `GET /Items/{id}/Download` | `SlideshowViewModel.kt`, `ScreensaverService.kt` | ~~Inconsistency: `CanDownload=true` while the route 404s.~~ **Resolved 2026-06-09:** a real `/Items/{id}/Download` route now serves the original file (`streams.go`), and `CanDownload=true` again. The earlier `CanDownload=false` workaround broke Infuse, which requires the flag for Direct Play. | Resolved |
|
||||
| `POST /ClientLog/Document` | `MediaReportService.kt`, `DebugPage.kt` | "Upload logs to server" debug action fails. | Low |
|
||||
| `GET /Audio/{id}/Lyrics` | `NowPlayingViewModel.kt` | Music-only; unreachable until audio libraries exist. | Low |
|
||||
|
||||
## Recommendations (priority order)
|
||||
|
||||
1. **Merge the repeated-`Fields` fix** (PR #110). `parseItemsQuery` still reads `q.Get("Fields")` (`internal/jellycompat/query.go`), which truncates jellyfin-sdk-kotlin's repeated query params and breaks Wholphin episode auto-advance. This is the only *covered* endpoint with a known correctness bug for this client. Follow up by sweeping the remaining single-value reads (`Ids`, `GenreIds`, `PersonIds`, `Filters`, `SortBy`, `SortOrder`) to `q.Values`, matching how `IncludeItemTypes`/`MediaTypes`/`ImageTypes` are already handled.
|
||||
2. ~~Resolve the `CanDownload` inconsistency~~ — **done 2026-06-09**: real `/Items/{id}/Download` route added; `CanDownload` stays `true` (Infuse requires it for Direct Play).
|
||||
3. ~~Stub `GET /Items/{id}/ThemeSongs`~~ — **done 2026-06-09**: empty `ThemeMediaResult` stub registered.
|
||||
4. **Websocket server-push** (remote control: server-initiated pause/stop, display messages) — tracked in issue #122.
|
||||
5. **Optional, later:** `POST /ClientLog/Document` accept-and-discard stub and real `/Studios` data. Playlists, lyrics, and universal audio only matter once silo exposes those content types through jellycompat.
|
||||
|
||||
## Notes
|
||||
|
||||
- Path normalization (`path_normalization.go`) already handles Wholphin's casing and any `/emby`//`jellyfin` prefixes, so none of the covered routes are at risk from URL-shape differences.
|
||||
- Unmatched jellycompat paths fall through to chi's default 404 with no body; jellyfin-sdk-kotlin surfaces these as `InvalidStatusException`, which Wholphin generally catches per-feature.
|
||||
@@ -1,82 +0,0 @@
|
||||
# PR #124 (ebooks) — Review & Hardening Pass
|
||||
|
||||
Date: 2026-06-10
|
||||
Scope: full review of the ebook feature branch (`work/ebooks-reader-base`) followed by
|
||||
fixes for every finding. Two review rounds were performed (a full-PR review, then an
|
||||
adversarial edge-case/security pass over both the original code and the first round of
|
||||
fixes), with all changes verified against the full Go test suite, the full web test
|
||||
suite, production frontend build, ESLint/Prettier, and `make verify-local-paths`.
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Security fixes
|
||||
|
||||
- **Stored XSS via book content (high):** book sections render in same-origin blob
|
||||
iframes with `allow-scripts` (WebKit requirement). A Content-Security-Policy is now
|
||||
served with all SPA HTML responses (`internal/server/frontend.go`) — blob/srcdoc
|
||||
documents inherit it, so `script-src 'self' 'wasm-unsafe-eval'` blocks script
|
||||
execution from book content on all browsers. Threat model documented on the policy
|
||||
constant. `X-Content-Type-Options: nosniff` added to frontend, jellycompat, and
|
||||
ebook file responses.
|
||||
- Ebook file serving can no longer fall through to `application/octet-stream` for an
|
||||
admitted file (extension/container whitelist and MIME resolution share one resolver),
|
||||
closing a browser-sniff escape combined with the `?token=` query-auth fallback.
|
||||
- External links in books are intercepted app-side: http(s) only, opened with
|
||||
`noopener,noreferrer` (blocks reverse tabnabbing and `javascript:` URLs).
|
||||
- Request size caps (`http.MaxBytesReader`, 413) on progress/config/annotation writes;
|
||||
`Content-Disposition` built with `mime.FormatMediaType` (RFC 5987, no header
|
||||
injection); annotation PATCH is atomic (`SELECT ... FOR UPDATE`) with
|
||||
presence-aware field semantics and invariant re-validation.
|
||||
- Verified safe under adversarial review: no IDOR (all reader-state SQL scoped to
|
||||
user+profile+content), no path traversal, range serving via stdlib, progress input
|
||||
validation. Accepted residual: CSS-only loads inside book iframes can leak the
|
||||
book's own content but cannot reach tokens, DOM, or navigation.
|
||||
|
||||
## Reliability fixes
|
||||
|
||||
- **Scanner missing-file reconciliation (critical class):** ebook scans now mark
|
||||
missing files like video/audio, with real walk-failure tracking (failed roots are
|
||||
excluded from deletion), symlinked-root support via the shared logical walker, and
|
||||
the empty-root cleanup allowance — an unmounted share can no longer wipe a library.
|
||||
- **Enrichment:** provider errors record failures (dedicated `ebook_enrichment_state`
|
||||
backoff table, capped retries) instead of permanently stamping items done;
|
||||
unconfigured chains and scan-window races skip without stamping. No longer shares
|
||||
`media_items.refresh_failures` with the metadata refresh-debt system.
|
||||
- Ebook items are created `pending` and promoted to `matched` on enrichment (backfill
|
||||
migration included), making curated-metadata protection real: matched items keep
|
||||
provider titles/people/series on re-scan (fill-empty only).
|
||||
- PDF metadata: head+tail window scan (non-linearized PDFs keep the Info dict at the
|
||||
end), delimiter-aware key matching, head-wins merge. `.md` dropped as an ebook
|
||||
format; plain `.fb2` reads capped like `.fbz`.
|
||||
- FK-cascade indexes added for the reader-state tables.
|
||||
|
||||
## Consistency & correctness fixes
|
||||
|
||||
- Hidden-history (`user_history_hidden_items`) gating applied to every ebook progress
|
||||
surface (watched/in-progress filters, all sort plans, Continue Reading, sort
|
||||
metrics, `Played` state, recommendation signals) with the exact video semantics.
|
||||
- `GetItemWatchers` counts distinct watchers (no binge inflation) and the
|
||||
`minWatchers` floor counts distinct accounts, not profiles; ebook reading now feeds
|
||||
implicit taste signals and taste-seed candidates; Continue Reading pages past
|
||||
dismissals and dedupes across pages.
|
||||
- The 0.9 finished threshold is centralized (`models.EbookFinishedProgressThreshold`).
|
||||
- Reader frontend: open-flow race teardown (no wrong-file progress saves), ordered
|
||||
progress writes with `pagehide`/`visibilitychange` flush, settings no longer
|
||||
clobbered by late server config, TTS stop actually stops, Media Session cleanup,
|
||||
512 MiB download guard, fraction bookmarks navigable.
|
||||
|
||||
## Feature completion
|
||||
|
||||
- Native read-state endpoints: `POST/DELETE /watched/{id}` and `/history/remove`
|
||||
accept ebook content IDs (mark read = progress 1.0 preserving location; mark unread
|
||||
mirrors video unwatch and clears position; history removal hides without losing
|
||||
position). Web UI exposes Mark Read/Unread on the item page and card menus, with
|
||||
Continue Reading dismiss copy and dismissal-path ID encoding fixed.
|
||||
|
||||
## Client follow-ups (Android / Apple)
|
||||
|
||||
- Ebook leaf user data encodes the reading ratio as `position_seconds` (0..1) with
|
||||
`duration_seconds = 1`; do not render absolute times for ebooks.
|
||||
- `/watched/{id}` returns `{type: "ebook", affected_count: 1, played: bool}` and the
|
||||
existing watched SSE event fires.
|
||||
- Reading progress is deliberately per-book (cross-format): one position per content
|
||||
ID.
|
||||
@@ -1,301 +0,0 @@
|
||||
# Ebook Reader Full Parity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Bring the core ebook reader up to the personal reader feature set with server-persisted config, annotations/bookmarks, selection tools, reading aids, and advanced settings.
|
||||
|
||||
**Architecture:** Keep ebook reading in core, matching audiobooks: core owns the ebook media type and reader behavior; metadata remains plugin-provided. Extend the existing `/ebooks/{content_id}` reader API and `EbookReaderHandler` rather than creating a separate plugin-style reader subsystem. Persist per-user/per-profile/per-book state in Postgres so settings, annotations, and bookmarks roam across devices.
|
||||
|
||||
**Tech Stack:** Go/Chi handlers, pgx/Postgres migrations, React 19, TanStack Query, Foliate/readest reader wrapper, Vitest, Go unit tests.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Creates `ebook_reader_config` and `ebook_reader_annotations`.
|
||||
- `internal/api/handlers/ebook_reader.go`
|
||||
- Adds reader config and annotation types, store interfaces, handlers, validation, and Postgres store methods.
|
||||
- `internal/api/handlers/ebook_reader_test.go`
|
||||
- Adds handler tests for config, annotation CRUD, access scoping, and validation.
|
||||
- `internal/api/router.go`
|
||||
- Wires config/annotation stores and routes under `/ebooks/{content_id}`.
|
||||
- `web/src/reader/FoliateBookReader.tsx`
|
||||
- Adds selection/annotation/readable-text/content-popup reader handles and applies saved config.
|
||||
- `web/src/pages/EbookReader.tsx`
|
||||
- Adds server-backed settings, annotation/bookmark UI, selection tools, TTS controls, wake-lock/e-ink controls, and advanced reader controls.
|
||||
- `web/src/hooks/useTTS.ts`, `web/src/hooks/useScreenWakeLock.ts`, `web/src/hooks/useEinkMode.ts`
|
||||
- Port focused personal-reader hooks into core web.
|
||||
- `web/src/reader/ebookReaderApi.ts`
|
||||
- Centralizes reader config and annotation API calls for tests and page usage.
|
||||
- `web/src/pages/EbookReader.test.tsx`, `web/src/reader/FoliateBookReader.test.ts`
|
||||
- Adds UI and wrapper tests.
|
||||
|
||||
## Task 1: Server-Persisted Reader Config
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Modify: `internal/api/handlers/ebook_reader.go`
|
||||
- Modify: `internal/api/handlers/ebook_reader_test.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Create: `web/src/reader/ebookReaderApi.ts`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing Go handler tests**
|
||||
|
||||
Add tests that:
|
||||
- `GET /ebooks/{content_id}/reader-config` returns `{ "config": {} }` when no row exists.
|
||||
- `PUT /ebooks/{content_id}/reader-config` saves JSON config for the authenticated user/profile/content.
|
||||
- inaccessible content returns 404 through the existing `ItemAccess` path.
|
||||
- invalid non-object config returns 400.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because config store/handlers do not exist.
|
||||
|
||||
- [ ] **Step 2: Add migration**
|
||||
|
||||
Create `ebook_reader_config`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE ebook_reader_config (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, profile_id, content_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_reader_config_profile_updated
|
||||
ON ebook_reader_config (user_id, profile_id, updated_at DESC);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement config store and handlers**
|
||||
|
||||
Add `EbookReaderConfig`, `EbookReaderConfigStore`, `HandleGetConfig`, `HandleSaveConfig`, and `PGEbookReaderConfigStore` methods in `ebook_reader.go`.
|
||||
|
||||
Validation:
|
||||
- authenticated user required
|
||||
- `content_id` required
|
||||
- `config` must decode to a JSON object
|
||||
- content access checked with `FileAuthorizer.ItemAccess.EnsureAccessible`
|
||||
|
||||
- [ ] **Step 4: Wire routes and store**
|
||||
|
||||
In `internal/api/router.go`, create the store when `deps.DB != nil`, assign it to `ebookReaderHandler`, and add:
|
||||
|
||||
```go
|
||||
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
|
||||
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add web API wrapper**
|
||||
|
||||
Create `web/src/reader/ebookReaderApi.ts` with:
|
||||
|
||||
```ts
|
||||
export function ebookReaderConfigPath(contentID: string): string
|
||||
export async function fetchEbookReaderConfig(contentID: string): Promise<Record<string, unknown>>
|
||||
export async function saveEbookReaderConfig(contentID: string, config: Record<string, unknown>): Promise<Record<string, unknown>>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Move settings persistence from localStorage to server**
|
||||
|
||||
Update `EbookReader.tsx` to fetch reader config on load and save settings through the config endpoint. Keep a local fallback only until the server value arrives.
|
||||
|
||||
- [ ] **Step 7: Verify and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
|
||||
cd web && pnpm test src/pages/EbookReader.test.tsx src/reader/FoliateBookReader.test.ts --run
|
||||
cd web && pnpm build
|
||||
```
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add migrations/sql/20260608000300_ebook_reader_state.sql internal/api/handlers/ebook_reader.go internal/api/handlers/ebook_reader_test.go internal/api/router.go web/src/reader/ebookReaderApi.ts web/src/pages/EbookReader.tsx web/src/pages/EbookReader.test.tsx
|
||||
git commit -m "feat: persist ebook reader config"
|
||||
```
|
||||
|
||||
## Task 2: Server-Persisted Annotations And Bookmarks
|
||||
|
||||
**Files:**
|
||||
- Modify: `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Modify: `internal/api/handlers/ebook_reader.go`
|
||||
- Modify: `internal/api/handlers/ebook_reader_test.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Modify: `web/src/reader/ebookReaderApi.ts`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing annotation handler tests**
|
||||
|
||||
Cover list, create, update, delete. Test annotation fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "highlight",
|
||||
"cfi_range": "epubcfi(/6/4,/1:0,/1:12)",
|
||||
"selected_text": "sample text",
|
||||
"note": "note text",
|
||||
"style": "highlight",
|
||||
"color": "#facc15"
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Annotation|TestEbookReader.*Bookmark' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because annotation APIs do not exist.
|
||||
|
||||
- [ ] **Step 2: Add annotation table**
|
||||
|
||||
Extend migration with:
|
||||
|
||||
```sql
|
||||
CREATE TABLE ebook_reader_annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('highlight', 'note', 'bookmark')),
|
||||
cfi_range TEXT,
|
||||
location TEXT,
|
||||
selected_text TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
style TEXT NOT NULL DEFAULT 'highlight',
|
||||
color TEXT NOT NULL DEFAULT '#facc15',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK ((kind = 'bookmark' AND location IS NOT NULL) OR (kind <> 'bookmark' AND cfi_range IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_reader_annotations_book
|
||||
ON ebook_reader_annotations (user_id, profile_id, content_id, updated_at DESC);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement annotation store and handlers**
|
||||
|
||||
Add handlers:
|
||||
- `GET /ebooks/{content_id}/annotations`
|
||||
- `POST /ebooks/{content_id}/annotations`
|
||||
- `PATCH /ebooks/{content_id}/annotations/{annotation_id}`
|
||||
- `DELETE /ebooks/{content_id}/annotations/{annotation_id}`
|
||||
|
||||
- [ ] **Step 4: Wire Foliate annotation drawing and selection**
|
||||
|
||||
Extend `FoliateBookReaderHandle` with:
|
||||
- `createSelectionAnnotation()`
|
||||
- `clearSelection()`
|
||||
|
||||
Draw stored annotations using Foliate `addAnnotation`.
|
||||
|
||||
- [ ] **Step 5: Add reader UI**
|
||||
|
||||
Add:
|
||||
- selection popover
|
||||
- highlight button
|
||||
- note button
|
||||
- bookmark button
|
||||
- annotation list in the side panel
|
||||
- delete/update note controls
|
||||
|
||||
- [ ] **Step 6: Verify and commit**
|
||||
|
||||
Run handler tests, web tests, and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook annotations and bookmarks"
|
||||
```
|
||||
|
||||
## Task 3: Reader Tools, TTS, And Reading Aids
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/useTTS.ts`
|
||||
- Create: `web/src/hooks/useScreenWakeLock.ts`
|
||||
- Create: `web/src/hooks/useEinkMode.ts`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Port focused hooks from personal reader**
|
||||
|
||||
Bring over:
|
||||
- Web Speech TTS controller
|
||||
- screen wake lock hook
|
||||
- e-ink body-class hook
|
||||
|
||||
- [ ] **Step 2: Add readable text handle**
|
||||
|
||||
Expose `getReadableText()` from `FoliateBookReader` for TTS.
|
||||
|
||||
- [ ] **Step 3: Add UI controls**
|
||||
|
||||
Add side-panel controls for:
|
||||
- Speak current text
|
||||
- Pause/resume/stop
|
||||
- voice/rate/pitch
|
||||
- wake lock
|
||||
- e-ink mode
|
||||
|
||||
- [ ] **Step 4: Add content popups and helpers**
|
||||
|
||||
Add selection popover actions:
|
||||
- define
|
||||
- translate
|
||||
|
||||
Add reader content popup handling for footnotes/images/tables when Foliate emits them.
|
||||
|
||||
- [ ] **Step 5: Verify and commit**
|
||||
|
||||
Run focused web tests and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook reader tools and aids"
|
||||
```
|
||||
|
||||
## Task 4: Advanced Reader Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add advanced reader settings**
|
||||
|
||||
Add server-persisted settings for:
|
||||
- RTL
|
||||
- writing mode
|
||||
- zoom/scale
|
||||
- brightness
|
||||
- hyphenation toggle
|
||||
- custom font selection/upload if API support is added in a later task
|
||||
|
||||
- [ ] **Step 2: Verify and commit**
|
||||
|
||||
Run focused tests and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook advanced reader settings"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
- The plan covers server persistence first, matching the user's explicit preference.
|
||||
- The plan keeps ebook features in core and metadata in plugins, matching the audiobook pattern.
|
||||
- The plan avoids extra ebook catalog tables; added tables are reader-user-state only.
|
||||
- The biggest implementation risk is annotation rendering against Foliate selection APIs; Task 2 isolates it after backend persistence exists.
|
||||
- The migration filename is intentionally after existing ebook migrations and can be adjusted if another migration is added before implementation.
|
||||
@@ -1,68 +0,0 @@
|
||||
# Ebook Reader Shell Parity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Bring the core ebook reader up to the first useful parity slice from `personal/silo-plugin-ebooks`: TOC, search, progress scrub, reader settings, and keyboard navigation.
|
||||
|
||||
**Architecture:** Keep the current core reader API and persistence model. Extend `FoliateBookReader` with imperative methods for TOC, search, href/fraction navigation, and runtime style updates; keep `EbookReader` responsible for the chrome/panels/settings state. Store reader preferences in localStorage for this first local slice so no backend schema is added.
|
||||
|
||||
**Tech Stack:** React 19, React Router, TanStack Query, foliate-js custom element, existing Silo UI components, Vitest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Reader Handle Capabilities
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/reader/FoliateBookReader.test.ts`
|
||||
|
||||
- [ ] Add exported types `ReaderSettings`, `ReaderSearchOptions`, `ReaderSearchResult`, and `ReaderReadyState`.
|
||||
- [ ] Extend `FoliateBookReaderHandle` with `goToFraction`, `goTo`, `search`, `clearSearch`, and `applySettings`.
|
||||
- [ ] Add a `settings?: ReaderSettings` prop and convert `readerStyles()` to accept settings.
|
||||
- [ ] Preserve existing progress save/restore behavior.
|
||||
- [ ] Verify with `pnpm vitest run src/reader/FoliateBookReader.test.ts`.
|
||||
|
||||
### Task 2: TOC and Search Panel
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add panel state for `toc`, `searchResults`, `searchTerm`, and current tab.
|
||||
- [ ] Render side panel tabs for Contents, Search, and Settings.
|
||||
- [ ] Wire TOC items to `readerRef.current.goTo(href)`.
|
||||
- [ ] Wire search form to `readerRef.current.search(term)` and result clicks to `goTo(cfi)`.
|
||||
- [ ] Verify with `pnpm vitest run src/pages/EbookReader.test.tsx`.
|
||||
|
||||
### Task 3: Reader Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add localStorage-backed settings for theme, font size, font family, line height, margin, max width, spread, and flow.
|
||||
- [ ] Render compact controls in the Settings tab.
|
||||
- [ ] Apply settings live through `FoliateBookReader`.
|
||||
- [ ] Verify settings persist across remount in tests.
|
||||
|
||||
### Task 4: Progress Scrub and Keyboard Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add a progress range input in the header.
|
||||
- [ ] Call `goToFraction` on commit/change and update display from relocate events.
|
||||
- [ ] Add ArrowLeft/ArrowRight keyboard navigation on the reader page.
|
||||
- [ ] Verify with page-level tests.
|
||||
|
||||
### Task 5: Local Verification
|
||||
|
||||
**Files:**
|
||||
- No source changes.
|
||||
|
||||
- [ ] Run `cd web && pnpm run build`.
|
||||
- [ ] Run `GOWORK=off go test ./internal/api/handlers ./internal/scanner`.
|
||||
- [ ] Keep changes local; do not push.
|
||||
@@ -1,518 +0,0 @@
|
||||
# Encrypt integration credentials at rest (issue #45) — implementation plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Do not edit this plan file while implementing.
|
||||
|
||||
**Issue:** [#45 — Encrypt arr integration credentials at rest (Requests + Autoscan)](https://github.com/Silo-Server/silo-server/issues/45)
|
||||
|
||||
> **Revision note (post-review):** This plan was revised after a code review. Key corrections baked in:
|
||||
> the arr backfill now **resolves the legacy `server_settings` reference before encrypting** (otherwise
|
||||
> rows holding a setting-name like `requests.radarr.api_key` would be encrypted as the *name*, not the
|
||||
> credential); the sensitive-settings allowlist is **audited from the config loader's real inputs**
|
||||
> (incl. `redis.sentinel_password` and legacy `s3.operational_*` aliases) rather than copied from the
|
||||
> admin redaction map; AAD binds to **row identity** (`table:column:pk`); read paths **pass through
|
||||
> legacy plaintext** and hard-fail only on corrupt ciphertext; `settingsRepo` is **re-wrapped after the
|
||||
> max-connections pool recreation**; history-import session tokens are **included**; and
|
||||
> `plugin_runtime_configs.config_value` is an **explicit, documented gap** (see Out of scope).
|
||||
|
||||
**Goal:** Stop storing third-party integration credentials in plaintext at rest. Today Sonarr/Radarr
|
||||
API keys, S3 keys, and a dozen other secrets sit in the database as naked `text` — `request_integrations.api_key_ref`
|
||||
and `autoscan_connections.api_key_ref` are resolved through a plaintext `server_settings` key/value
|
||||
store, and both resolvers contain a **ref-vs-literal fallback**: when the secret lookup returns empty,
|
||||
the stored string is used verbatim as the API key. This plan introduces a real symmetric-encryption
|
||||
layer (AES-256-GCM, `SECRET_KEY`-derived via HKDF), encrypts every recoverable server-owned credential
|
||||
**inline** in its owning row, deletes the ambiguous resolver fallback, and backfills existing rows
|
||||
automatically on startup.
|
||||
|
||||
**Decisions (locked with the requester):**
|
||||
- **Master key:** a **required** `SECRET_KEY` environment variable. The server fatals at startup if it
|
||||
is missing. The key lives outside Postgres, so encrypted secrets survive a full DB compromise/dump.
|
||||
- **Scope:** **all silo-server-owned credential storage** (was framed "full sweep"). Every recoverable
|
||||
server-owned secret — arr keys, S3 keys, all sensitive `server_settings`, and the per-table token
|
||||
columns — is covered. Two categories are deliberately *not* in this PR because they are a different
|
||||
*shape* of problem, not because they are unimportant: equality-looked-up secrets (need **hashing**,
|
||||
not encryption) and plugin runtime config JSONB (cross-repo + manifest-driven). Both are called out in
|
||||
Out of scope with follow-ups.
|
||||
- **Data model (best practice):** **inline-encrypted**. Store the ciphertext directly in the owning
|
||||
column and decrypt on read. Drop the `server_settings` "ref" indirection and the `SecretResolver`
|
||||
literal fallback entirely — this kills the ambiguity at its root and matches the repo's one existing
|
||||
good example (`oauth_completion.token_ciphertext` lives inline in its own row).
|
||||
- **Rollout:** idempotent Go startup **backfill** encrypts any plaintext (no-envelope-prefix) value in
|
||||
place on first boot after deploy. No manual steps.
|
||||
|
||||
**Architecture:** A new `internal/secret` package owns the one cryptographic primitive everything reuses:
|
||||
AES-256-GCM with a random 12-byte nonce, `base64.RawURLEncoding`, and a versioned `enc:v1:` envelope
|
||||
prefix; the data key is HKDF-SHA256–derived from `SECRET_KEY` with a domain label. Each ciphertext is
|
||||
GCM-bound to its logical **row** via additional-authenticated-data (`"table:column:<pk>"`, or
|
||||
`"server_settings:<key>"`) so a DB-write attacker cannot transplant a credential blob into another row
|
||||
or column. Three wiring seams consume the cipher: (1) an `EncryptedSettingsRepo` **decorator** around
|
||||
`catalog.ServerSettingsRepo` that transparently encrypts/decrypts the sensitive `server_settings` keys,
|
||||
so `config.LoadFromDB` and every downstream consumer keep seeing plaintext; (2) the two `api_key_ref`
|
||||
columns become inline ciphertext and the entire `SecretResolver` indirection is deleted from
|
||||
`internal/requests` and `internal/autoscan`; (3) a handful of per-table repos gain a `*secret.Cipher`
|
||||
and encrypt-on-write / decrypt-on-read. A startup backfill sweeps all groups idempotently — with a
|
||||
**special resolve-then-encrypt path for the two arr columns** so legacy `server_settings` references are
|
||||
collapsed to the real credential before encryption.
|
||||
|
||||
**Out of scope (each a deliberate, documented decision — not an oversight):**
|
||||
- **Equality-looked-up secrets → need hashing, not encryption.** `api_keys.api_key`
|
||||
(`WHERE api_key = $1`) and `webhook_sync_connections.webhook_secret` (`WHERE webhook_secret = $1`,
|
||||
[repo.go:101](internal/webhooksync/repo.go:101)) are matched by exact value. AES-GCM uses a fresh
|
||||
random nonce, so the same plaintext encrypts differently each time — encrypting these would silently
|
||||
break API-key auth and inbound-webhook routing. The correct fix is a deterministic **hash** column +
|
||||
lookup-by-hash, a distinct lookup-semantics + migration change. **Follow-up issue.**
|
||||
- **`plugin_runtime_configs.config_value` → cross-repo + manifest-driven.** This is opaque plugin-defined
|
||||
JSONB ([runtime_config.go:55](internal/plugins/runtime_config.go:55)); some plugins store secrets in it
|
||||
(e.g. the legacy one-time copy of `introdb.api_key` at [main.go:2341](cmd/silo/main.go:2341)). It is
|
||||
read/written only through `RuntimeConfigStore` *in this repo*, so whole-blob encrypt-on-write /
|
||||
decrypt-on-read at that store is technically feasible — **but** host-side plugin runtime lives in the
|
||||
separate **`Silo`** repo (per CLAUDE.md) and may read this table directly, and which fields are secret
|
||||
is declared in each plugin's manifest. Doing this correctly needs a coordinated, manifest-aware design
|
||||
across repos. **Explicitly deferred — known gap.** Note: while deferred, the legacy `introdb.api_key`
|
||||
copy means that secret still exists in plaintext inside plugin config even after the `server_settings`
|
||||
copy is encrypted; the follow-up must re-encrypt or purge it.
|
||||
- **`plex_sync_connections`** — dead table, zero Go references (superseded by `webhook_sync_connections`
|
||||
in migration `060`). Not touched.
|
||||
- **Already-protected / non-secret:** `users.password_hash` (bcrypt), `oauth_completion.token_ciphertext`
|
||||
(already AES-GCM), `abs_sessions.token_hash` / `oauth_completion.code_hash` (hashed), `oauth_session.state`
|
||||
(equality CSRF), `subtitle_provider_config.username` (a username, not a secret),
|
||||
`history_import_plex_sessions.pin_id`/`pin_code` (transient OAuth pairing handshake, not a stored
|
||||
access credential).
|
||||
- `internal/auth/oauth_store.go` is left as-is (its key is jwt-derived and its data is ephemeral).
|
||||
Optionally a shared low-level seal/open helper can be extracted later; not required here.
|
||||
|
||||
**Tech Stack:** Go 1.26.3 (module declares `go 1.26.3`; stdlib `crypto/hkdf` is available — no new
|
||||
dependency). PostgreSQL via `pgxpool`. **No schema migration** — every target column is already `text`
|
||||
(or `jsonb` left untouched) and now simply holds ciphertext; the backfill is pure Go. No frontend or
|
||||
client (silo-android / silo-apple) changes — API responses already mask these values to `has_api_key` /
|
||||
`has_credentials` booleans, so no contract changes.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
The `internal/secret` package is pure Go (no libvips/CGO). If a Go toolchain is on the host:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/secret/... -v
|
||||
```
|
||||
|
||||
`GOWORK=off` is required when running from a `.claude/worktrees/` checkout — the parent `go.work`
|
||||
otherwise pins the module to `main` and the build fails with "main module does not contain package".
|
||||
|
||||
If the host has no Go toolchain, run in a throwaway container (a named volume caches module downloads):
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/secret/... -v
|
||||
```
|
||||
|
||||
Full backend build/vet before opening the MR: `GOWORK=off go build ./...` then `make lint` and
|
||||
`make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**New files:**
|
||||
- `internal/secret/cipher.go` — **create**. `Cipher` type; `New`, `Encrypt`, `Decrypt`, `IsEncrypted`,
|
||||
`EncryptIfPlaintext`; envelope + HKDF + version dispatch. Row-bound AAD passed by callers.
|
||||
- `internal/secret/cipher_test.go` — **create**. Round-trip, tamper/AAD-mismatch rejection, wrong-key,
|
||||
`IsEncrypted` edge cases, version dispatch, idempotency.
|
||||
- `internal/secret/backfill.go` — **create**. Generic in-place backfill for literal-credential columns +
|
||||
the special resolve-then-encrypt backfill for the two arr columns.
|
||||
- `internal/secret/backfill_test.go` — **create**. Idempotency, mixed plaintext/ciphertext rows, empty
|
||||
values skipped, failure isolation, arr resolve-then-encrypt.
|
||||
|
||||
**Modified files:**
|
||||
- `internal/config/bootstrap.go` — add `SecretKey []byte` to `BootstrapConfig`; read `SECRET_KEY`; fatal
|
||||
if missing or `< 32` chars.
|
||||
- `cmd/silo/main.go` — construct `*secret.Cipher` after bootstrap; build the `EncryptedSettingsRepo`;
|
||||
run the settings + column backfills after migrations and before settings are read; **re-wrap
|
||||
`settingsRepo` after the max-connections pool recreation (line ~399)**; thread the cipher into the
|
||||
affected repo constructors; delete the `SetSecretResolver` wiring.
|
||||
- `internal/catalog/encrypted_settings_repo.go` — **create**. `EncryptedSettingsRepo` decorator +
|
||||
exported, **audited** `SensitiveSettingKeys` (single source of truth) + `BackfillSensitiveSettings`.
|
||||
- `internal/catalog/server_settings_repo.go` — unchanged behavior; remains the raw inner store.
|
||||
- `internal/api/handlers/admin.go` — replace the local `sensitiveSettingKeys` map with
|
||||
`catalog.SensitiveSettingKeys` (redaction now shares the audited allowlist; closes the prior gap where
|
||||
`redis.sentinel_password` and `s3.operational_*` were returned in plaintext).
|
||||
- `internal/requests/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
|
||||
insert/update (AAD bound to integration id); decrypt (pass-through legacy plaintext) in `scanIntegration`.
|
||||
- `internal/requests/service.go` — delete `SecretResolver` interface, `secrets` field,
|
||||
`SetSecretResolver`, and `resolveAPIKey`; call sites use the already-decrypted `Integration.APIKeyRef`.
|
||||
- `internal/autoscan/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
|
||||
create/update (AAD bound to connection id); decrypt (pass-through legacy) in `scanConnection`.
|
||||
- `internal/autoscan/connection.go` — delete `SecretResolver` interface, `secrets` field, and the
|
||||
secret-resolution branch in `Resolve`; linked-integration path uses the requests repo's decrypted key.
|
||||
- `internal/api/autoscan_wiring.go` — drop the `AutoscanSecretResolver` param from `BuildAutoscanService`.
|
||||
- `internal/api/router.go` — drop the `SetSecretResolver(settingsRepo)` calls; pass the
|
||||
`EncryptedSettingsRepo` where `settingsRepo` is used; thread the cipher into repo constructors.
|
||||
- `internal/subtitles/pgrepo.go` — `*secret.Cipher`; encrypt `api_key` + `password` on upsert; decrypt on
|
||||
read. PK for AAD = `provider_name`.
|
||||
- `internal/watchsync/repository.go` — `*secret.Cipher`; encrypt `access_token` + `refresh_token` on
|
||||
upsert; decrypt in a `decryptConnection` helper called after every scan. PK for AAD = connection `id`.
|
||||
- `internal/webhooksync/repo.go` — `*secret.Cipher`; encrypt `access_token` on create; decrypt on scan.
|
||||
**`webhook_secret` is NOT encrypted** (equality lookup — carved out). PK for AAD = connection `id`.
|
||||
- `internal/historyimport/repo_admin.go` — `*secret.Cipher`; encrypt in `SetSourceAdminToken`; decrypt in
|
||||
`GetSourceWithAdminToken`. PK for AAD = source `id`.
|
||||
- `internal/historyimport/repo.go` — `*secret.Cipher`; encrypt `connect_access_token`
|
||||
(`history_import_connect_sessions`) + `auth_token` (`history_import_plex_sessions`) on insert/update;
|
||||
decrypt on scan. PK for AAD = session `id`.
|
||||
- `docs/architecture/secret-encryption.md` — **create**. Operator runbook (SECRET_KEY generation, backup,
|
||||
key-loss, rollback/downgrade).
|
||||
|
||||
**No migration files.** All target columns are already `text`; the backfill is Go-side.
|
||||
|
||||
---
|
||||
|
||||
## Reference tables
|
||||
|
||||
### What is encrypted vs hashed vs excluded
|
||||
|
||||
AAD column shows the GCM additional-authenticated-data string; `<pk>` is the row's stable primary key.
|
||||
|
||||
| Group | Table.column | AAD binding | Action |
|
||||
| --- | --- | --- | --- |
|
||||
| Arr keys | `request_integrations.api_key_ref` | `request_integrations:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
|
||||
| Arr keys | `autoscan_connections.api_key_ref` | `autoscan_connections:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
|
||||
| Settings | every key in `SensitiveSettingKeys` (see audited list below) | `server_settings:<key>` | **Encrypt via decorator** |
|
||||
| Subtitles | `subtitle_provider_config.api_key`, `.password` | `subtitle_provider_config:<col>:<provider_name>` | **Encrypt inline** |
|
||||
| Watch sync | `watch_provider_connections.access_token`, `.refresh_token` | `watch_provider_connections:<col>:<id>` | **Encrypt inline** |
|
||||
| Webhook sync | `webhook_sync_connections.access_token` | `webhook_sync_connections:access_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_sources.admin_token` | `history_import_sources:admin_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_connect_sessions.connect_access_token` | `history_import_connect_sessions:connect_access_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_plex_sessions.auth_token` | `history_import_plex_sessions:auth_token:<id>` | **Encrypt inline** |
|
||||
| — | `api_keys.api_key` | — | **Carved out → hash** (follow-up) |
|
||||
| — | `webhook_sync_connections.webhook_secret` | — | **Carved out → hash** (follow-up) |
|
||||
| — | `plugin_runtime_configs.config_value` | — | **Deferred → cross-repo/manifest design** (follow-up) |
|
||||
| — | `plex_sync_connections.*` | — | **Excluded** (dead table) |
|
||||
|
||||
### Audited `SensitiveSettingKeys` (single source of truth)
|
||||
|
||||
Built from the config loader's actual secret inputs ([internal/config/db_loader.go](internal/config/db_loader.go))
|
||||
plus the admin redaction map plus other known consumers — **not** just the current redaction map. The
|
||||
union is intentional and also fixes a pre-existing redaction leak. Implementers must re-audit
|
||||
`db_loader.go` and `grep` for any `*secret*`/`*token*`/`*password*`/`*api_key*` settings keys before
|
||||
finalizing.
|
||||
|
||||
```
|
||||
auth.jwt_secret
|
||||
s3.public_access_key s3.public_secret_key s3.public_token_secret
|
||||
s3.private_access_key s3.private_secret_key
|
||||
s3.user_db_access_key s3.user_db_secret_key
|
||||
s3.operational_access_key s3.operational_secret_key s3.operational_token_secret # legacy aliases still read as fallbacks (db_loader L165-192); migration 086 copied but did not delete them
|
||||
redis.url # may embed credentials (redis://:pass@host)
|
||||
redis.sentinel_password # read at db_loader L334 — MISSING from the old redaction map
|
||||
tmdb.api_key
|
||||
mdblist.api_key
|
||||
introdb.api_key # read in main.go (legacy → plugin copy)
|
||||
subtitle_ai.api_key
|
||||
recommendations.embedding_auth_token recommendations.openai_api_key # legacy alias (db_loader L409)
|
||||
watchsync.trakt.client_id watchsync.trakt.client_secret
|
||||
watchsync.simkl.client_id watchsync.simkl.client_secret
|
||||
requests.radarr.api_key requests.sonarr.api_key # legacy single-instance arr keys
|
||||
```
|
||||
|
||||
`auth.jwt_secret` IS encrypted at rest under `SECRET_KEY` (it is no longer the encryption root — `SECRET_KEY`
|
||||
is). `redis.url`/client-id entries are not high-value secrets but are kept in the set as a harmless safe
|
||||
superset and to match existing redaction.
|
||||
|
||||
### Envelope format
|
||||
|
||||
```
|
||||
enc:v1:<base64.RawURLEncoding( nonce[12] ‖ gcm.Seal(...) )>
|
||||
```
|
||||
|
||||
- `enc:v1:` — exactly 7 ASCII chars; not valid base64url, not a UUID/JWT/URL/integer prefix. Collision-proof
|
||||
against any real plaintext credential in practice.
|
||||
- `IsEncrypted(s)` ≡ `strings.HasPrefix(s, "enc:v1:")`.
|
||||
- Decrypt parses the version (`strings.SplitN(s, ":", 3)`), dispatches to the v1 opener; an unknown
|
||||
version returns an explicit `ErrUnknownVersion`. This is the forward-compat hook for a future `enc:v2:`
|
||||
key rotation — implement the dispatch now even though only `v1` exists.
|
||||
|
||||
### Read-path contract (applies to every decrypt site — decorator and repos)
|
||||
|
||||
1. `value == ""` → return `""`.
|
||||
2. `!IsEncrypted(value)` → **return the value unchanged** (legacy plaintext pass-through during the
|
||||
backfill window; the value is no worse than today). This is unambiguous — there is no longer any
|
||||
"ref vs literal" question because the `_ref` indirection is gone.
|
||||
3. `IsEncrypted(value)` → `Decrypt`; **any error (wrong key, tamper, truncation) is propagated, never
|
||||
swallowed and never returned as the ciphertext string.** A corrupt ciphertext fails the operation.
|
||||
|
||||
This reconciles the best-effort backfill (a skipped/failed row stays readable as plaintext) with the
|
||||
hard guarantee that a real `enc:v1:` value never silently degrades to using ciphertext as a credential.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: The `internal/secret` cipher package
|
||||
|
||||
**Files:** Create `internal/secret/cipher.go`, `internal/secret/cipher_test.go`.
|
||||
|
||||
This is the foundation; it has no dependencies on `catalog`/`config` and is independently testable.
|
||||
|
||||
- [ ] **Step 1: `Cipher` type + `New`.** Derive a 32-byte AES key with stdlib
|
||||
`hkdf.Key(sha256.New, masterKey, nil /*salt*/, "silo/data-encryption/v1", 32)`. Return an error if
|
||||
`len(masterKey) < 32`. Store the derived key as `[32]byte`. The type is safe for concurrent use.
|
||||
- [ ] **Step 2: `Encrypt(plaintext, aad string) (string, error)`.** Empty plaintext returns `"", nil`
|
||||
(do not produce an `enc:v1:` blob for an empty secret). Otherwise: `aes.NewCipher` → `cipher.NewGCM` →
|
||||
12-byte `rand` nonce → `gcm.Seal(nonce, nonce, []byte(plaintext), []byte(aad))` →
|
||||
`envelopePrefix + base64.RawURLEncoding.EncodeToString(sealed)`. `aad` is the row-bound context string.
|
||||
- [ ] **Step 3: `Decrypt(ciphertext, aad string) (string, error)`.** Strip + validate the version prefix;
|
||||
unknown version → `ErrUnknownVersion`. base64-decode; reject if shorter than the nonce size; split
|
||||
nonce/body; `gcm.Open(nil, nonce, body, []byte(aad))`. **Any failure returns an error — never fall back
|
||||
to returning the ciphertext or an empty string.** (Pass-through of *non-prefixed* plaintext is the
|
||||
caller's job per the read-path contract, not `Decrypt`'s.)
|
||||
- [ ] **Step 4: `IsEncrypted(s string) bool`** and `EncryptIfPlaintext(s, aad string) (string, bool, error)`.
|
||||
`EncryptIfPlaintext` returns `(s, false, nil)` when already encrypted or empty; otherwise encrypts and
|
||||
returns `(ct, true, nil)`. This is the idempotent primitive the backfill uses.
|
||||
- [ ] **Step 5: Tests** (`cipher_test.go`):
|
||||
- round-trip with AAD recovers plaintext; ciphertext has the `enc:v1:` prefix.
|
||||
- two encryptions of the same plaintext+AAD differ (random nonce works).
|
||||
- tamper: flip one byte of the decoded body → `Decrypt` errors.
|
||||
- AAD mismatch: encrypt with `"t:c:1"`, decrypt with `"t:c:2"` → errors (proves row binding).
|
||||
- wrong key: encrypt with key A, decrypt with key B → errors.
|
||||
- `New` with a `< 32`-byte key → error.
|
||||
- `IsEncrypted`: `"enc:v1:x"`→true, `"sa_abc"`→false, `""`→false, `"ENC:V1:x"`→false.
|
||||
- unknown version `"enc:v99:..."` → `ErrUnknownVersion`.
|
||||
- `EncryptIfPlaintext` is idempotent (second call is a no-op, `changed=false`).
|
||||
- [ ] **Step 6:** `GOWORK=off go test ./internal/secret/... -v` passes.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SECRET_KEY` bootstrap + cipher construction
|
||||
|
||||
**Files:** Modify `internal/config/bootstrap.go`, `cmd/silo/main.go`.
|
||||
|
||||
- [ ] **Step 1:** Add `SecretKey []byte` to `BootstrapConfig`. In `LoadBootstrap`, after the
|
||||
`DATABASE_URL` check, read `os.Getenv("SECRET_KEY")`; return an error (which the existing
|
||||
`log.Fatalf` path surfaces) if it is empty or `< 32` characters. Message must be actionable, e.g.
|
||||
`SECRET_KEY is required (>=32 chars); generate one with: openssl rand -base64 48`. `godotenv` already
|
||||
loads `.env`, so dev sets it there.
|
||||
- [ ] **Step 2:** In `cmd/silo/main.go`, immediately after `LoadBootstrap` returns, construct the cipher:
|
||||
`dataCipher, err := secret.New(bc.SecretKey)` and `log.Fatalf` on error. The cipher is threaded
|
||||
explicitly as a dependency — **never** a package-level global.
|
||||
- [ ] **Step 3:** Add `SECRET_KEY=...` to `.env.example` (or the documented dev env) and note it in the
|
||||
operator runbook (Task 7).
|
||||
- [ ] **Step 4:** `GOWORK=off go build ./cmd/silo` compiles; starting without `SECRET_KEY` fatals with the
|
||||
actionable message.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `server_settings` encryption decorator
|
||||
|
||||
**Files:** Create `internal/catalog/encrypted_settings_repo.go`; modify `internal/api/handlers/admin.go`,
|
||||
`cmd/silo/main.go`, `internal/api/router.go`.
|
||||
|
||||
- [ ] **Step 1: Build the audited allowlist.** Create exported `var SensitiveSettingKeys map[string]bool`
|
||||
in `internal/catalog` populated from the "Audited `SensitiveSettingKeys`" list above. **Do not** simply
|
||||
move the admin redaction map — re-audit `internal/config/db_loader.go` (and other `server_settings`
|
||||
consumers) for every secret-bearing key, and include the legacy aliases (`s3.operational_*`,
|
||||
`recommendations.openai_api_key`) and `redis.sentinel_password`. Update `admin.go`
|
||||
(`HandleGetSettings` redaction, `HandleGetSensitiveStatus`, the `PUT` response branch) to reference
|
||||
`catalog.SensitiveSettingKeys`. One allowlist now drives **both** redaction and encryption.
|
||||
- [ ] **Step 2:** Implement `EncryptedSettingsRepo` wrapping `*ServerSettingsRepo` + `*secret.Cipher`:
|
||||
- `Set(key, value)`: if `SensitiveSettingKeys[key]` and `value != ""`, encrypt with AAD
|
||||
`"server_settings:"+key` before delegating; else delegate raw.
|
||||
- `Get(key)` / `GetAll()`: delegate, then apply the **read-path contract** to each sensitive key
|
||||
(pass through legacy plaintext; decrypt prefixed values; propagate decrypt errors).
|
||||
- [ ] **Step 3:** Add `BackfillSensitiveSettings(ctx)` on the decorator: for each key in
|
||||
`SensitiveSettingKeys`, read the raw inner value; if non-empty and not `IsEncrypted`, `inner.Set` the
|
||||
encrypted value (AAD `"server_settings:"+key`). Idempotent. Returns a count + aggregated errors.
|
||||
- [ ] **Step 4:** In `cmd/silo/main.go`, build `settingsRepo` as the `EncryptedSettingsRepo` wrapping the
|
||||
raw `catalog.NewServerSettingsRepo(pool)`. Confirm the auto-generate-`jwt_secret` step still works: the
|
||||
`GetAll` map carries plaintext, the `== ""` check is unchanged, and `Set("auth.jwt_secret", encoded)`
|
||||
now writes **encrypted** while the in-memory map keeps the plaintext for `LoadFromDB`. Any function
|
||||
currently typed to the concrete `*catalog.ServerSettingsRepo` changes to the `ServerSettingsStore`
|
||||
interface that both already satisfy.
|
||||
- [ ] **Step 5 (re-wrap after pool recreation):** `cmd/silo/main.go` rebuilds a **raw**
|
||||
`catalog.NewServerSettingsRepo(pool)` after recreating the pool for `max_connections` (line ~399).
|
||||
Re-wrap it there too (`settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)`)
|
||||
so every later consumer gets the encrypting repo, not a raw one. Audit for any other raw construction
|
||||
of the settings repo and wrap each.
|
||||
- [ ] **Step 6:** Tests: decorator round-trip (raw store holds `enc:v1:`, decorator `Get` returns
|
||||
plaintext); non-sensitive keys pass through unencrypted; legacy-plaintext pass-through on read;
|
||||
`BackfillSensitiveSettings` idempotency.
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...` compiles; admin settings GET still redacts (now including
|
||||
`redis.sentinel_password`); a sensitive key written via the admin API lands as `enc:v1:` in the DB.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Inline-encrypt arr keys + delete the `SecretResolver` indirection
|
||||
|
||||
**Files:** Modify `internal/requests/repository.go`, `internal/requests/service.go`,
|
||||
`internal/autoscan/repository.go`, `internal/autoscan/connection.go`,
|
||||
`internal/api/autoscan_wiring.go`, `internal/api/router.go`, `cmd/silo/main.go`.
|
||||
|
||||
> The legacy-data resolution for these two columns is handled by the **resolve-then-encrypt backfill** in
|
||||
> Task 6 (a row may currently hold a `server_settings` key name, not a literal key). The repo write path
|
||||
> below only ever encrypts the literal credential an admin submits going forward.
|
||||
|
||||
- [ ] **Step 1:** `requests.NewRepository` gains a `*secret.Cipher` parameter. In `insertIntegration` and
|
||||
`updateIntegration`, encrypt `i.APIKeyRef` (AAD `"request_integrations:api_key_ref:"+i.ID`) before
|
||||
binding — preserve the existing `CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END` keep-existing
|
||||
semantics (encrypt only the non-empty incoming value). In `scanIntegration`, apply the read-path
|
||||
contract to the scanned `api_key_ref`.
|
||||
- [ ] **Step 2:** Delete `requests.SecretResolver` (interface), the `Service.secrets` field,
|
||||
`Service.SetSecretResolver`, and `Service.resolveAPIKey`. Every former `resolveAPIKey(ctx, integration)`
|
||||
call site uses `strings.TrimSpace(integration.APIKeyRef)` directly (the repo already decrypted it).
|
||||
- [ ] **Step 3:** `autoscan.NewRepository` gains a `*secret.Cipher`. Encrypt `api_key_ref` in
|
||||
`CreateConnection` / `UpdateConnection` (AAD `"autoscan_connections:api_key_ref:"+id`, same
|
||||
keep-existing CASE semantics); apply the read-path contract in `scanConnection`.
|
||||
- [ ] **Step 4:** In `internal/autoscan/connection.go`, delete the `SecretResolver` interface, the
|
||||
`ConnectionResolver.secrets` field, and the secret-resolution branch in `Resolve`. For a linked Requests
|
||||
integration, `RequestIntegrationLookup.Get` returns the requests repo's **already-decrypted** key — use
|
||||
it directly. For a standalone connection, the autoscan repo already decrypted it. `Resolve` now only
|
||||
resolves base URL + linkage and returns plaintext.
|
||||
- [ ] **Step 5:** Remove the `AutoscanSecretResolver` parameter from `BuildAutoscanService`
|
||||
(`internal/api/autoscan_wiring.go`) and drop the `SetSecretResolver(settingsRepo)` calls in
|
||||
`internal/api/router.go` and `cmd/silo/main.go` (including the reconcile-worker service). Thread
|
||||
`dataCipher` into the requests/autoscan repo constructors at all wiring sites.
|
||||
- [ ] **Step 6:** Verify no API response serializes the raw key: the integration/connection response DTOs
|
||||
still emit only `has_api_key`. Add/extend a test asserting the create→store→read round-trip yields the
|
||||
original key, and that the stored column value is `enc:v1:`-prefixed.
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...`; `GOWORK=off go test ./internal/requests/... ./internal/autoscan/...`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Per-table credential columns
|
||||
|
||||
**Files:** Modify `internal/subtitles/pgrepo.go`, `internal/watchsync/repository.go`,
|
||||
`internal/webhooksync/repo.go`, `internal/historyimport/repo_admin.go`, `internal/historyimport/repo.go`,
|
||||
plus their constructor call sites.
|
||||
|
||||
Each repo gains a `*secret.Cipher` constructor arg and follows encrypt-on-write / decrypt-on-read with a
|
||||
row-bound AAD. Preserve existing keep-existing CASE/COALESCE semantics — encrypt only non-empty incoming
|
||||
values. All read paths use the read-path contract (pass through legacy plaintext, hard-fail corrupt
|
||||
ciphertext). These columns **never** had the resolver indirection — they always stored literal
|
||||
credentials — so no resolve-then-encrypt is needed (unlike the arr columns).
|
||||
|
||||
- [ ] **Step 1: subtitles** — encrypt `api_key` + `password` in `UpsertProviderConfig`; decrypt in
|
||||
`ListProviderConfigs` and `GetProviderConfig`. Keep the existing `HasAPIKey`/`HasCredentials` flags
|
||||
(set them from the decrypted values). AAD PK = `provider_name`.
|
||||
- [ ] **Step 2: watchsync** — encrypt `access_token` + `refresh_token` in `UpsertConnection`. Add a
|
||||
`decryptConnection(*Connection)` helper and call it after **every** scan path (list/get/by-provider/etc.
|
||||
— there are several). AAD PK = connection `id`.
|
||||
- [ ] **Step 3: webhooksync** — encrypt `access_token` in `CreateConnection`; decrypt in `scanConnection`.
|
||||
**Do NOT touch `webhook_secret`** — it is equality-looked-up in `GetConnectionBySecret`
|
||||
(`WHERE webhook_secret = $1`) and is carved out for the hashing follow-up. AAD PK = connection `id`.
|
||||
- [ ] **Step 4: historyimport admin token** — encrypt in `SetSourceAdminToken`; decrypt in
|
||||
`GetSourceWithAdminToken`; `ClearSourceAdminToken` (writes NULL) is unchanged. AAD PK = source `id`.
|
||||
- [ ] **Step 5: historyimport session tokens** — in `internal/historyimport/repo.go`, encrypt
|
||||
`connect_access_token` (`history_import_connect_sessions`) and `auth_token`
|
||||
(`history_import_plex_sessions`) on insert/update; decrypt on scan (note the existing
|
||||
`COALESCE(auth_token, '')` read). These are read by session `id`, not by token value. `pin_id`/`pin_code`
|
||||
are transient pairing artifacts — leave them. AAD PK = session `id`.
|
||||
- [ ] **Step 6:** Thread `dataCipher` into each constructor at its wiring site (`router.go` / `main.go`).
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...`; package tests for each modified repo pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Idempotent startup backfill
|
||||
|
||||
**Files:** Create `internal/secret/backfill.go`, `internal/secret/backfill_test.go`; modify `cmd/silo/main.go`.
|
||||
|
||||
- [ ] **Step 1: Generic in-place backfill** for the literal-credential columns (subtitle ×2, watchsync ×2,
|
||||
webhook `access_token`, historyimport `admin_token`, historyimport session `connect_access_token` +
|
||||
`auth_token`). Per `BackfillTarget {table, column, idColumn, aadPrefix}`: select rows where the column
|
||||
is non-null, non-empty, and `NOT LIKE 'enc:v1:%'`; for each, compute AAD = `aadPrefix + ":" + id`,
|
||||
`EncryptIfPlaintext`, and `UPDATE ... SET col = $1 WHERE id = $2 AND col = $3` (the `AND col = $3` guard
|
||||
makes concurrent multi-node boots converge without double-encrypting). Empty values are skipped (never
|
||||
encrypt `""`).
|
||||
- [ ] **Step 2: Resolve-then-encrypt backfill for the two arr columns** (`request_integrations.api_key_ref`,
|
||||
`autoscan_connections.api_key_ref`). This is **separate** from Step 1 because legacy rows may hold a
|
||||
`server_settings` key name rather than a literal key. For each row whose value is not already
|
||||
`enc:v1:`-prefixed, replicate the old `resolveAPIKey` semantics **once**:
|
||||
1. `resolved := encryptedSettingsRepo.Get(ctx, value)` (the decrypting decorator — handles both a
|
||||
sensitive-and-encrypted target like `requests.radarr.api_key` and a plaintext custom key).
|
||||
2. If `resolved` is non-empty, the row held a *reference* → encrypt `resolved` inline.
|
||||
Else the row held a *literal* key → encrypt `value` inline.
|
||||
3. `UPDATE ... SET api_key_ref = <enc> WHERE id = $2 AND api_key_ref = <original>`, AAD bound to the row id.
|
||||
Run this **after** `BackfillSensitiveSettings` so the referenced settings are consistent (the decorator
|
||||
decrypts regardless, but ordering keeps it deterministic). Optionally log/flag now-orphaned
|
||||
`requests.{radarr,sonarr}.api_key` settings rows for a later cleanup (do not delete in this PR).
|
||||
- [ ] **Step 3: Failure posture (decided):** best-effort. Collect per-row/per-target errors, log each at
|
||||
ERROR, and emit a single startup summary line (`secret backfill: encrypted N, failed M`). A failed
|
||||
encrypt leaves the pre-existing plaintext (no *new* exposure vs. today) and reads still succeed via the
|
||||
read-path pass-through, so it must **not** block boot — reliability over a hard stop. (Read-path decrypt
|
||||
failures on an `enc:v1:` value still hard-fail the individual operation.)
|
||||
- [ ] **Step 4: Ordering in `cmd/silo/main.go`** — gate on migration-running modes only (mirror the
|
||||
existing migration guard; proxy/transcode nodes skip the backfill). Sequence:
|
||||
`LoadBootstrap (read SECRET_KEY) → open pool → RunMigrations → construct dataCipher → wrap settingsRepo →
|
||||
BackfillSensitiveSettings → generic column backfill → arr resolve-then-encrypt backfill → GetAll(decrypts)
|
||||
→ auto-gen jwt_secret (writes encrypted) → [pool recreate? re-wrap settingsRepo] → LoadFromDB(plaintext) → validate`.
|
||||
The settings backfill + `GetAll` must precede `LoadFromDB` so config consumers read decrypted values.
|
||||
- [ ] **Step 5: Concurrency note** — the `AND col = $3` idempotency guard is sufficient for the common
|
||||
single-primary deploy. For multi-primary races, optionally wrap the backfill in a dedicated Postgres
|
||||
advisory lock with a **fresh** lock id (do **not** reuse the migration `legacyBootstrapLocker` id — that
|
||||
would extend the migration lock's held duration unpredictably).
|
||||
- [ ] **Step 6: Tests** (`backfill_test.go`, against a test DB): seed plaintext rows → backfill →
|
||||
all become `enc:v1:` and decrypt to the originals; second run is a no-op (no double-encrypt); mixed
|
||||
plaintext/ciphertext rows handled; empty values skipped; one failing row does not abort the rest;
|
||||
**arr resolve-then-encrypt:** a row holding `requests.radarr.api_key` (with that settings key present)
|
||||
ends up encrypting the *resolved* credential, and a row holding a literal key encrypts the literal.
|
||||
- [ ] **Step 7:** Boot against a DB pre-seeded with plaintext credentials **and** a legacy arr row that
|
||||
references a settings key; confirm the summary log, that every target column is `enc:v1:` afterward, that
|
||||
the arr row decrypts to the real Radarr key (not the setting name), and that a second boot reports
|
||||
`encrypted 0`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Verification, docs, follow-up
|
||||
|
||||
- [ ] **Step 1: Regression guard tests:**
|
||||
- API key auth still works — `api_keys.api_key` is stored as the literal `sa_`-prefixed token and
|
||||
`GetByKey` (`WHERE api_key = $1`) still resolves (proves it was *not* routed through encryption).
|
||||
- `auth.jwt_secret` is consistent across two simulated boots (encrypted at rest, plaintext to
|
||||
`LoadFromDB`), and the stored `server_settings` row is `enc:v1:`-prefixed.
|
||||
- Inbound webhook routing still works (`webhook_secret` untouched).
|
||||
- A legacy arr integration that referenced `requests.sonarr.api_key` still fulfills after backfill.
|
||||
- [ ] **Step 2:** `GOWORK=off go build ./...`, `make lint`, `make verify-local-paths`,
|
||||
`cd web && pnpm run lint && pnpm run format:check` (frontend unaffected — confirm green).
|
||||
- [ ] **Step 3:** Write `docs/architecture/secret-encryption.md`: how `SECRET_KEY` is generated, that it
|
||||
must be backed up **separately** from DB dumps (treat like a CA private key), what happens on key loss
|
||||
(encrypted secrets unrecoverable → re-enter integrations), the rollback/downgrade hazard (an old binary
|
||||
reads `enc:v1:auth.jwt_secret` as a literal JWT secret → all sessions invalid; document that downgrade
|
||||
requires decrypting settings back to plaintext or clearing `auth.jwt_secret`), and the envelope/version
|
||||
scheme for future rotation.
|
||||
- [ ] **Step 4: File the follow-up issues:**
|
||||
1. **Hash equality-lookup secrets** (`api_keys.api_key`, `webhook_sync_connections.webhook_secret`) —
|
||||
store a deterministic hash, look up by hash.
|
||||
2. **Encrypt plugin runtime config** (`plugin_runtime_configs.config_value`) — coordinated with the
|
||||
`Silo` host repo and plugin-manifest secret-field metadata; must also re-encrypt/purge the legacy
|
||||
plaintext `introdb.api_key` copy. Note the dead `plex_sync_connections` table as a drop candidate.
|
||||
- [ ] **Step 5:** Update the issue #45 acceptance check: no plaintext arr keys at rest (Requests +
|
||||
Autoscan); `api_key_ref` is unambiguous (inline ciphertext, no literal fallback); existing rows
|
||||
encrypted by the backfill (arr rows resolved-then-encrypted); S3 + the broader server-owned credential
|
||||
set covered; remaining gaps (equality-lookup hashing, plugin JSONB) tracked as follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## Safety checklist (the implementer must not skip)
|
||||
|
||||
1. `SECRET_KEY` is read in `LoadBootstrap` and **fatals** if absent/short — never defaults to a zero key
|
||||
or a hash of `DATABASE_URL`.
|
||||
2. The cipher is constructed once, right after bootstrap, before any `settingsRepo` call — threaded as a
|
||||
dependency, never a global.
|
||||
3. The settings backfill + decryption happen **before** `config.LoadFromDB` consumes the map.
|
||||
4. The auto-generated `auth.jwt_secret` is written **encrypted**, while the in-memory map keeps plaintext
|
||||
for `LoadFromDB`.
|
||||
5. **`settingsRepo` is re-wrapped as the `EncryptedSettingsRepo` after the max-connections pool recreation
|
||||
(main.go ~399)** and at every other construction site — no raw settings repo escapes into later wiring.
|
||||
6. The two **arr columns** use the **resolve-then-encrypt** backfill (not in-place) so legacy
|
||||
`server_settings` references become the real credential before encryption; the other columns use the
|
||||
plain in-place backfill.
|
||||
7. `SensitiveSettingKeys` is **audited from the config loader's real inputs** (incl.
|
||||
`redis.sentinel_password` and the legacy `s3.operational_*` / `recommendations.openai_api_key`
|
||||
aliases), not copied from the old redaction map.
|
||||
8. `api_keys.api_key` and `webhook_sync_connections.webhook_secret` are **never** routed through
|
||||
encryption — they are equality-looked-up and would break auth/routing (separate hashing task).
|
||||
9. Every decrypt site obeys the **read-path contract**: pass through non-prefixed plaintext; **error
|
||||
(never fall back)** on a corrupt `enc:v1:` value. Audit every old `if resolved == ""` site removed in Task 4.
|
||||
10. AAD binds to **row identity** (`table:column:<pk>`); PKs in scope (integration/connection ids, uuids,
|
||||
`provider_name`) are stable. server_settings binds to its key.
|
||||
11. The backfill is prefix-gated and idempotent (`AND col = $3` guard); proxy/transcode nodes skip it.
|
||||
12. `plex_sync_connections` is not a target (dead table); `plugin_runtime_configs.config_value` is a
|
||||
documented deferred gap, not silently ignored; `subtitle_provider_config.username` stays plaintext.
|
||||
@@ -1,377 +0,0 @@
|
||||
# AI Services Core, Metadata Translation & Whisper ASR — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Design:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md` (read it first; this plan does not repeat the rationale).
|
||||
|
||||
**Goal:** Extract the subtitle-AI LLM plumbing into shared packages (`internal/ai/llm`, `internal/ai/translate`, `internal/ai/jobrunner`), add AI translation of overviews/taglines into the existing localization tables with field provenance, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). Single PR.
|
||||
|
||||
**Architecture:** New `internal/ai/*` packages consumed by a refactored `internal/subtitles/ai` and a new `internal/metadata/translation` service. One new Goose migration (jobs table, provenance columns, library auto-translate flag). New `ai.*` settings keys with loader fallback to legacy `subtitle_ai.*` rows (encrypted values are GCM-bound to their key — never rename rows in SQL). Frontend: AI Services settings page, metadata-editor translate action, library toggle, player "generate from audio" mode.
|
||||
|
||||
**Tech stack:** Go (chi, pgx), PostgreSQL via Goose SQL migrations, React + TypeScript (TanStack Query), Vitest.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Ordering and verification
|
||||
|
||||
Tasks 1–4 are a pure refactor and must leave subtitle translation behaviorally unchanged — run `go build ./... && go test ./internal/...` after each. Tasks 5–11 are backend features; 12–15 frontend; 16 final verification.
|
||||
|
||||
Before opening the MR: `make lint`, `cd web && pnpm run lint && pnpm run format:check`, `make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Create**
|
||||
- `internal/ai/llm/client.go`, `client_test.go`, `config.go` — shared OpenAI-compatible client (chat + transcription)
|
||||
- `internal/ai/llm/transcribe.go` — multipart `/v1/audio/transcriptions`, `verbose_json` types
|
||||
- `internal/ai/translate/translate.go`, `translate_test.go` — generic segment batch translator (package `aitranslate`)
|
||||
- `internal/ai/jobrunner/runner.go`, `runner_test.go` — dispatch/semaphore/heartbeat/reaper/cancel registry
|
||||
- `internal/metadata/translation/{job.go,pgrepo.go,service.go,prompt.go,service_test.go}`
|
||||
- `internal/subtitles/ai/transcriber.go`, `transcriber_test.go` — ASR pipeline
|
||||
- `internal/playback/audio_extract.go` — ffmpeg chunked audio extraction helper
|
||||
- `internal/api/handlers/metadata_ai.go`
|
||||
- `migrations/sql/<timestamp>_ai_metadata_translation_and_asr.sql` (via `make migrate-create`)
|
||||
- `web/src/pages/admin-settings/AIServicesSettings.tsx`
|
||||
|
||||
**Modify**
|
||||
- `internal/subtitles/ai/{client.go→deleted,translator.go,service.go,engine.go,job.go,config.go,pgrepo.go}`
|
||||
- `internal/config/{config.go,db_loader.go}`; `internal/catalog/encrypted_settings_repo.go`
|
||||
- `internal/catalog/{localization_repo.go,detail.go}`; `internal/models/media.go`
|
||||
- `internal/metadata/service.go` (provider provenance + auto-enqueue hook)
|
||||
- `internal/api/handlers/subtitle_ai.go`; router registration; `cmd/silo/main.go` wiring
|
||||
- `web/src/pages/admin-settings/SubtitlesSettings.tsx`, `web/src/components/EditMetadataDialog.tsx`, `web/src/player/components/SubtitleTranslateModal.tsx`, library settings form, `web/src/api/` types/client, admin settings nav/route registration
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `internal/ai/llm` — shared client
|
||||
|
||||
- [ ] **Step 1: Move the chat client.** Create `internal/ai/llm` (package `llm`). Move `Client`, `chat` (export as `Chat`), `chatMessage` (export as `Message`), retry helpers (`sleepCtx`, `rateLimitBackoff`, `truncate`) from `internal/subtitles/ai/client.go`. New `llm.Config`:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
BaseURL string // chat endpoint, no trailing /v1
|
||||
APIKey string
|
||||
ChatModel string
|
||||
ASRBaseURL string // empty = BaseURL
|
||||
ASRAPIKey string // empty = APIKey
|
||||
ASRModel string
|
||||
MaxConcurrentJobs int
|
||||
}
|
||||
```
|
||||
|
||||
Log lines lose the "subtitle" wording (they are shared now).
|
||||
|
||||
- [ ] **Step 2: Add `Transcribe`.** In `transcribe.go`:
|
||||
|
||||
```go
|
||||
type TranscribeRequest struct {
|
||||
Filename string
|
||||
Audio io.Reader
|
||||
Language string // optional ISO-639-1 hint
|
||||
Timeout time.Duration // per-request; sized to chunk length by the caller
|
||||
}
|
||||
type TranscriptionSegment struct{ Start, End float64; Text string }
|
||||
type Transcription struct{ Language string; Segments []TranscriptionSegment }
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error)
|
||||
```
|
||||
|
||||
Multipart fields: `file`, `model` (=`ASRModel`), `response_format=verbose_json`, `temperature=0`, optional `language`. Reuse the same 429/5xx/transport retry-with-backoff loop as `Chat` (extract a shared `doWithRetry` rather than duplicating it). Empty `segments` in the response is an error (`transcription returned no segments`), not a silent fallback.
|
||||
|
||||
- [ ] **Step 3: Tests** (`client_test.go`, httptest): chat retries on 429 (honors `Retry-After`), 5xx, 200-with-error-object, empty choices; transcribe happy path (multipart fields present, segments parsed), missing-segments error, ASR base-url/key override falling back to chat values.
|
||||
|
||||
- [ ] **Step 4: Commit.** `refactor(ai): extract shared OpenAI-compatible LLM client with transcription support`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `internal/ai/translate` — generic segment translator
|
||||
|
||||
- [ ] **Step 1: Move the batch logic.** Package `aitranslate`. Move `buildIndexedJSON`, `extractJSONObject`, and the batch loop from `internal/subtitles/ai/translator.go`, generalized:
|
||||
|
||||
```go
|
||||
type Segment struct{ ID, Text string }
|
||||
type Request struct {
|
||||
Segments []Segment
|
||||
SystemPrompt string // caller-supplied; domain-specific
|
||||
BatchSize int
|
||||
ContextNeighbors int // preceding source segments sent untranslated
|
||||
}
|
||||
type ChatFn func(ctx context.Context, system, user string) (string, error)
|
||||
func Translate(ctx context.Context, chat ChatFn, req Request,
|
||||
onBatch func(batch []Segment, done, total int)) ([]Segment, error)
|
||||
```
|
||||
|
||||
Wire protocol unchanged: 1-based indexed JSON per batch, same-keys response, malformed-response retries (`maxRetries = 2`), completeness check. `ChatFn` keeps the package free of an `llm` dependency and trivially testable.
|
||||
|
||||
- [ ] **Step 2: Tests:** batch splitting/boundaries, context neighbors, code-fence tolerance, omitted-key retry then failure, ctx cancellation between batches.
|
||||
|
||||
- [ ] **Step 3: Commit.** `refactor(ai): extract generic batched segment translator`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `internal/ai/jobrunner` — shared job lifecycle
|
||||
|
||||
- [ ] **Step 1: Extract the runner.** Move dispatch/heartbeat/reaper/cancel mechanics from `internal/subtitles/ai/service.go` (lines ~136–319) behind:
|
||||
|
||||
```go
|
||||
type Store interface {
|
||||
Heartbeat(ctx context.Context, id int64) error
|
||||
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
|
||||
MarkCancelled(ctx context.Context, id int64, message string) error
|
||||
}
|
||||
type Runner struct{ /* baseCtx, sem (shared), store, logger, cancels, wg */ }
|
||||
func New(appCtx context.Context, sem chan struct{}, store Store, logger *slog.Logger) *Runner
|
||||
func (r *Runner) Recover() // reap + background reaper loop
|
||||
func (r *Runner) Dispatch(id int64, run func(ctx context.Context)) // semaphore + heartbeat + cancel registry
|
||||
func (r *Runner) Cancel(id int64) bool // true if an in-flight goroutine was cancelled
|
||||
```
|
||||
|
||||
Heartbeat-while-queued behavior (the comment block in `dispatch`) must be preserved verbatim — it is load-bearing for multi-instance safety. Constants (30 s heartbeat, 2 min stale, 1 min reaper) move here.
|
||||
|
||||
- [ ] **Step 2: Shared semaphore.** The semaphore is **constructed by the caller** (`cmd/silo`) at size `ai.max_concurrent_jobs` and passed to every `Runner`, so subtitle + metadata + ASR jobs share one bound.
|
||||
|
||||
- [ ] **Step 3: Tests:** dispatch bounded by a size-1 shared semaphore across two runners; cancel of queued job marks cancelled without running; reaper resets a stale heartbeat row (fake store).
|
||||
|
||||
- [ ] **Step 4: Commit.** `refactor(ai): extract shared job lifecycle runner`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Refactor `internal/subtitles/ai` onto the shared core
|
||||
|
||||
- [ ] **Step 1:** Delete `client.go`; `LLMTranslator` becomes an adapter: cues → `aitranslate.Segment` (ID = 1-based index, Text = joined lines), subtitle system prompt stays here, `splitCueLines` maps back. `Service` keeps job semantics but delegates lifecycle to `jobrunner.Runner`; `pgrepo` satisfies `jobrunner.Store`.
|
||||
- [ ] **Step 2:** Update `cmd/silo/main.go` wiring (build `llm.Client` + shared semaphore once; pass into the subtitle service).
|
||||
- [ ] **Step 3:** `go build ./... && go test ./internal/...` — subtitle translation behavior unchanged (same prompts, same batching, same job rows).
|
||||
- [ ] **Step 4: Commit.** `refactor(subtitles): consume shared AI core`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migration
|
||||
|
||||
- [ ] **Step 1:** `make migrate-create NAME=ai_metadata_translation_and_asr`, then fill the generated file:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE public.metadata_translation_jobs (
|
||||
id bigserial PRIMARY KEY,
|
||||
target_kind text NOT NULL, -- 'item' | 'season' | 'episode'
|
||||
content_id text NOT NULL,
|
||||
include_children boolean NOT NULL DEFAULT true,
|
||||
source_language text NOT NULL DEFAULT '',
|
||||
target_language text NOT NULL,
|
||||
engine text NOT NULL DEFAULT 'openai',
|
||||
model text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
|
||||
progress double precision NOT NULL DEFAULT 0,
|
||||
progress_message text NOT NULL DEFAULT '',
|
||||
fields_done integer NOT NULL DEFAULT 0,
|
||||
fields_total integer NOT NULL DEFAULT 0,
|
||||
force boolean NOT NULL DEFAULT false,
|
||||
error_message text NOT NULL DEFAULT '',
|
||||
idempotency_key text NOT NULL,
|
||||
requested_by integer,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
heartbeat_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX metadata_translation_jobs_active_idempotency_idx
|
||||
ON public.metadata_translation_jobs (idempotency_key)
|
||||
WHERE status IN ('pending', 'running');
|
||||
CREATE INDEX metadata_translation_jobs_content_idx
|
||||
ON public.metadata_translation_jobs (content_id, created_at DESC);
|
||||
CREATE INDEX metadata_translation_jobs_status_idx
|
||||
ON public.metadata_translation_jobs (status) WHERE status IN ('pending', 'running');
|
||||
|
||||
-- Field provenance: 'provider' | 'ai' | 'manual'. manual > provider > ai.
|
||||
ALTER TABLE media_item_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual')),
|
||||
ADD COLUMN tagline_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (tagline_source IN ('provider', 'ai', 'manual'));
|
||||
ALTER TABLE season_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual'));
|
||||
ALTER TABLE episode_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual'));
|
||||
|
||||
ALTER TABLE media_folders
|
||||
ADD COLUMN auto_translate_metadata boolean NOT NULL DEFAULT false;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE media_folders DROP COLUMN auto_translate_metadata;
|
||||
ALTER TABLE episode_localizations DROP COLUMN overview_source;
|
||||
ALTER TABLE season_localizations DROP COLUMN overview_source;
|
||||
ALTER TABLE media_item_localizations DROP COLUMN tagline_source, DROP COLUMN overview_source;
|
||||
DROP TABLE IF EXISTS public.metadata_translation_jobs;
|
||||
-- +goose StatementEnd
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `make migrate-up` against the local stack (`docker compose up -d postgres redis`), then `make migrate-status`.
|
||||
- [ ] **Step 3: Commit.** `feat(metadata): migration for translation jobs, localization provenance, library auto-translate`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Settings & config
|
||||
|
||||
- [ ] **Step 1: Config structs** (`internal/config/config.go`): add `AIConfig` (fields mirroring `llm.Config`); slim `SubtitleAIConfig` to `Enabled`, `TranscribeEnabled`, `BatchSize`, `ContextNeighbors`; add `MetadataAIConfig{Enabled bool}`.
|
||||
- [ ] **Step 2: Loader** (`internal/config/db_loader.go`): load `ai.*` with legacy fallback, following the `recommendations.embedding_auth_token` precedent at line ~409:
|
||||
|
||||
```go
|
||||
cfg.AI.BaseURL = stringOr(m, "ai.base_url", stringOr(m, "subtitle_ai.base_url", "https://api.openai.com"))
|
||||
cfg.AI.APIKey = stringOr(m, "ai.api_key", stringOr(m, "subtitle_ai.api_key", ""))
|
||||
cfg.AI.ChatModel = stringOr(m, "ai.chat_model", stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini"))
|
||||
// ai.max_concurrent_jobs ← subtitle_ai.max_concurrent_jobs ← 2
|
||||
// ai.asr_model (default "whisper-1"), ai.asr_base_url, ai.asr_api_key (default "")
|
||||
// subtitle_ai.transcribe_enabled, metadata_ai.enabled (default false)
|
||||
```
|
||||
|
||||
**Never** rename the legacy rows in SQL — encrypted values are GCM-bound to their setting key (`internal/catalog/encrypted_settings_repo.go`).
|
||||
|
||||
- [ ] **Step 3:** Add `ai.api_key`, `ai.asr_api_key` to `sensitiveSettingKeys`.
|
||||
- [ ] **Step 4:** Readiness helpers: subtitle translate = `SubtitleAI.Enabled && AI chat ready`; ASR = `SubtitleAI.TranscribeEnabled && AI.ASRModel != "" && (asr or chat base URL set)`; metadata = `MetadataAI.Enabled && AI chat ready`.
|
||||
- [ ] **Step 5: Commit.** `feat(config): shared ai.* settings with legacy subtitle_ai fallback`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Provenance-aware localization writes
|
||||
|
||||
- [ ] **Step 1: Models** (`internal/models/media.go`): add `OverviewSource`/`TaglineSource` to `MediaItemLocalization`, `OverviewSource` to `SeasonLocalization`/`EpisodeLocalization`.
|
||||
- [ ] **Step 2: Provider upsert rules** (`internal/catalog/localization_repo.go`): rewrite the three `Upsert` statements so, per AI-writable field:
|
||||
- existing source `manual` → keep existing value and source;
|
||||
- incoming value empty → keep existing value and source;
|
||||
- otherwise → take incoming value, set source `provider`.
|
||||
|
||||
Single-statement `ON CONFLICT … DO UPDATE` with `CASE` (no read-modify-write). Pattern for one field:
|
||||
|
||||
```sql
|
||||
overview = CASE
|
||||
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview
|
||||
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview
|
||||
ELSE EXCLUDED.overview END,
|
||||
overview_source = CASE
|
||||
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview_source
|
||||
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview_source
|
||||
ELSE 'provider' END
|
||||
```
|
||||
|
||||
- [ ] **Step 3: AI upsert methods**: `UpsertAITranslation(ctx, contentID, language string, overview, tagline *string, force bool)` per repo (nil pointer = field not part of this write). Insert path populates only the translated fields (other text columns empty). Update path per field: write when existing source is `ai`, existing value is empty, or `force` — never when `manual`; `force` may overwrite `provider` (the admin asked). Sets source `ai`.
|
||||
- [ ] **Step 4: Serving hardening** (`internal/catalog/detail.go`): verify `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel` only override base fields with **non-empty** localization values (AI rows carry empty titles). Fix any field that clobbers; add a regression test.
|
||||
- [ ] **Step 5: Tests** for the upsert matrix (provider-over-ai, ai-skips-provider-unless-force, manual-untouchable, empty-never-blanks), following the existing repo/service test patterns in `internal/catalog`.
|
||||
- [ ] **Step 6: Commit.** `feat(catalog): field provenance for localizations with provider/ai/manual precedence`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: `internal/metadata/translation` service
|
||||
|
||||
- [ ] **Step 1: Job model + repo** (`job.go`, `pgrepo.go`): mirror `internal/subtitles/ai/{job.go,pgrepo.go}` shapes against `metadata_translation_jobs`; satisfy `jobrunner.Store`. Idempotency key = SHA-256 of `content_id|target_kind|target_language|model`.
|
||||
- [ ] **Step 2: Service** (`service.go`): `Enqueue`, `GetJob`, `ListJobs(contentID)`, `Cancel`, `Recover`, using `jobrunner.Runner` + the shared semaphore. The run loop:
|
||||
1. Expand targets: `item` → item overview+tagline; series item with `include_children` → + every season overview + every episode overview; `season` (+children → its episodes); `episode` → its overview. Source text comes from the **base** rows (default metadata language); `source_language` recorded from the item's `default_metadata_language`.
|
||||
2. Skip-if-filled: drop any field whose target-language localization value is already non-empty, unless `force`. All skipped → complete immediately ("Nothing to translate").
|
||||
3. Build `aitranslate.Segment`s (IDs `item:overview`, `item:tagline`, `season:<content_id>:overview`, `episode:<content_id>:overview`), translate with `prompt.go`'s system prompt (names the title/year, "translate media catalog descriptions", preserve proper nouns/character names/tone, no added information), `BatchSize` = package constant `metadataBatchSize = 10`, `ContextNeighbors = 0`.
|
||||
4. Per batch: provenance-aware AI upserts (Task 7) + `fields_done`/progress update. Persisting per batch means a cancelled job keeps completed fields.
|
||||
- [ ] **Step 3: Tests** (fake repos + fake `ChatFn`): series expansion counts, skip-if-filled short-circuit (zero chat calls), force overwrites `ai`+`provider` but never `manual`, per-batch persistence on cancellation mid-job.
|
||||
- [ ] **Step 4: Commit.** `feat(metadata): AI translation service for overviews and taglines`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Ingestion hook + library flag
|
||||
|
||||
- [ ] **Step 1:** Switch the provider localization writes in `internal/metadata/service.go` (item ~line 1476, season ~2921, episode ~3080) to the provenance-aware upserts — no other behavior change.
|
||||
- [ ] **Step 2:** Thread `auto_translate_metadata` through the `MediaFolder` model, folder repo scan/update, and the folder settings API payload.
|
||||
- [ ] **Step 3: Auto-enqueue hook.** After an item's refresh persists (single point at the end of the item flow — not per season/episode), when: folder flag set, folder `metadata_language` non-empty and ≠ item `default_metadata_language`, metadata AI ready, and the item's target-language localization is missing overview (or tagline) → `Enqueue` a non-force `item` job with `include_children=true`. Fire-and-forget (log on error); the active-idempotency index plus skip-if-filled make repeat refreshes free.
|
||||
- [ ] **Step 4: Commit.** `feat(metadata): per-library auto-translate fallback on refresh`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Metadata AI API
|
||||
|
||||
- [ ] **Step 1:** `internal/api/handlers/metadata_ai.go`, mirroring `subtitle_ai.go` shapes:
|
||||
- `GET /api/v1/metadata/ai/status` → `{enabled}`
|
||||
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}` → `{job}` (joins an in-flight duplicate, same as subtitles)
|
||||
- `GET /api/v1/metadata/ai/jobs/{job_id}`, `POST /api/v1/metadata/ai/jobs/{job_id}/cancel`, `GET /api/v1/metadata/ai/jobs?content_id=…`
|
||||
- [ ] **Step 2:** Gate with the same permission as metadata editing (metadata curation permission; see `internal/catalog/update.go` callers). Register routes; wire the service in `cmd/silo/main.go`.
|
||||
- [ ] **Step 3:** Handler tests following `internal/api/handlers` conventions (validation: bad kind, missing language, disabled engine → 400/503 mapping).
|
||||
- [ ] **Step 4: Commit.** `feat(api): metadata AI translation endpoints`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Whisper ASR
|
||||
|
||||
- [ ] **Step 1: Audio extraction** (`internal/playback/audio_extract.go`), alongside the existing subtitle extraction helpers:
|
||||
|
||||
```go
|
||||
// ExtractAudioChunks extracts one audio track to 16 kHz mono WAV chunks in dir.
|
||||
// Returns ordered chunk paths; chunkSeconds = 600 in production.
|
||||
func ExtractAudioChunks(ctx context.Context, filePath string, audioTrackIndex int,
|
||||
dir, ffmpegPath string, chunkSeconds int) ([]string, error)
|
||||
```
|
||||
|
||||
Single ffmpeg pass: `-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time <n> <dir>/chunk%05d.wav`.
|
||||
|
||||
- [ ] **Step 2: Transcriber** (`internal/subtitles/ai/transcriber.go`):
|
||||
|
||||
```go
|
||||
type Transcriber interface {
|
||||
Transcribe(ctx context.Context, req TranscribeJobRequest,
|
||||
onChunk func(cues []SubtitleCue, done, total int)) ([]SubtitleCue, string /*detected lang*/, error)
|
||||
}
|
||||
```
|
||||
|
||||
`WhisperTranscriber` implementation: temp dir (`os.MkdirTemp`, removed on all exit paths) → `ExtractAudioChunks` → per chunk `llm.Client.Transcribe` (per-request timeout ∝ chunk duration; pass the job's language hint) → offset segment times by `chunkIndex*chunkSeconds` → cues (one per segment, wrapped to ≤2 lines ×~42 chars via a `wrapCueText` helper; whitespace-only segments dropped). Chunk processing order starts at the chunk containing `StartPosition`, then wraps (mirrors `reorderFromPosition` semantics).
|
||||
|
||||
- [ ] **Step 3: Wire kinds into the service.** `Service.run` branches on `job.Kind`:
|
||||
- `translate`: existing path.
|
||||
- `transcribe`: resolve audio track (`source_index` = audio index, `-1` → default/first; reject files without audio), transcribe (progress 10–70 %, live cue streaming via the existing notifier callbacks), store SRT as provider `transcribed`, language = hint or detected, release name `"<Language> (AI transcribed)"`, notify `SubtitleReady`.
|
||||
- `transcribe_translate`: `transcribe`, store the transcript track, then run the existing `Translator` on the cues (70–95 %), store provider `translated`, `result_subtitle_id` = translated track.
|
||||
|
||||
Enqueue validation per kind (ASR requires `TranscribeEnabled`; idempotency model component = `asr_model` or `asr_model+chat_model` for the chained kind).
|
||||
|
||||
- [ ] **Step 4:** API surface: `kind` field (optional, default `translate`) on the existing enqueue request in `subtitle_ai.go`; `transcribe_enabled` on the status response.
|
||||
- [ ] **Step 5: Tests** (`transcriber_test.go`, fake client): timestamp stitching across chunks, playhead-first chunk ordering, cue wrapping, empty-segments chunk → job error, temp-dir cleanup on failure (assert via `t.TempDir` layout).
|
||||
- [ ] **Step 6: Commit.** `feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Frontend — AI Services settings page
|
||||
|
||||
- [ ] **Step 1:** `web/src/pages/admin-settings/AIServicesSettings.tsx`: connection card (`ai.base_url`, `ai.api_key` via the sensitive-key pattern already used in `SubtitlesSettings.tsx:264`, `ai.chat_model`, `ai.asr_model`, optional `ai.asr_base_url`/`ai.asr_api_key`, `ai.max_concurrent_jobs`) + features card (`subtitle_ai.enabled`, `subtitle_ai.transcribe_enabled`, `metadata_ai.enabled`, `subtitle_ai.batch_size`, `subtitle_ai.context_neighbors`). Reads show effective values (new key, falling back to the legacy key when unset — same fallback order as the loader); writes always target the new `ai.*` keys.
|
||||
- [ ] **Step 2:** Remove the AI card from `SubtitlesSettings.tsx` (leave a link/hint to the new page); register the page in the admin settings nav + route following the existing page registrations.
|
||||
- [ ] **Step 3: Commit.** `feat(web): AI services settings page`
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Frontend — metadata translate action
|
||||
|
||||
- [ ] **Step 1:** API client functions + types for the Task 10 endpoints.
|
||||
- [ ] **Step 2:** `EditMetadataDialog.tsx`: "Translate with AI" action (visible when `metadata_ai` status is enabled): target language (default = library `metadata_language`), `include_children` (series only), `force` checkbox; enqueue, poll the job every ~1.5 s, show `progress_message`/`fields_done`, invalidate the item detail query on completion.
|
||||
- [ ] **Step 3: Commit.** `feat(web): translate descriptions from the metadata editor`
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Frontend — library auto-translate toggle
|
||||
|
||||
- [ ] **Step 1:** Add the `auto_translate_metadata` switch to the library settings form next to the metadata-language field (helper text: "When metadata providers have no translation for this library's language, translate descriptions with AI"). Disabled state with hint when `metadata_ai` is off.
|
||||
- [ ] **Step 2: Commit.** `feat(web): library auto-translate toggle`
|
||||
|
||||
---
|
||||
|
||||
## Task 15: Frontend — player "Generate from audio"
|
||||
|
||||
- [ ] **Step 1:** `SubtitleTranslateModal.tsx`: when `transcribe_enabled`, add a source option "Generate from audio (AI)" listing the file's audio tracks; submit with `kind: "transcribe"` (or `transcribe_translate` when the chosen output language differs from the audio language). Surface it prominently when no text subtitle source exists (today's dead end for bitmap-only files).
|
||||
- [ ] **Step 2: Commit.** `feat(web): generate subtitles from audio in the player`
|
||||
|
||||
---
|
||||
|
||||
## Task 16: Verification
|
||||
|
||||
- [ ] `go build ./... && go test ./...`
|
||||
- [ ] `make lint`
|
||||
- [ ] `cd web && pnpm run lint && pnpm run format:check`
|
||||
- [ ] `make verify-local-paths`
|
||||
- [ ] Manual smoke against the local stack: enqueue a metadata translation for a series (verify localization rows + provenance values), re-run a refresh (verify provider values overwrite `ai` rows and `manual` simulation survives), run a `transcribe` job on a short file against a local Whisper-compatible server.
|
||||
- [ ] MR description: problem, approach, link to the design doc, risks (Whisper endpoint variance, chunk-boundary artifacts), AI-use disclosure, screenshots of the new settings page / editor action / player mode.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,224 +0,0 @@
|
||||
# Audiobook Player QoL Improvements
|
||||
|
||||
Commands assume the repository root is the cwd. All paths are repository-relative.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the web audiobook player to feature parity with best-in-class audiobook apps
|
||||
(Audiobookshelf, Audible, BookPlayer, Prologue) on the quality-of-life axis. The current
|
||||
surface architecture (persistent mini bar + expandable Now Listening view, playback
|
||||
survives navigation) is already the de-facto standard and stays as-is.
|
||||
|
||||
Explicitly **out of scope**: Media Session / lock-screen integration, bookmarks (needs a
|
||||
server endpoint — follow-up), offline/download, queue.
|
||||
|
||||
## Features
|
||||
|
||||
1. **Keyboard shortcuts** — space/K play-pause, arrow skips, volume, chapter nav, speed step.
|
||||
2. **Configurable skip intervals** — asymmetric defaults (back 10s, forward 30s), user-tunable.
|
||||
3. **Expanded speed control** — 0.5×–3.0× with fine stepping, presets, and per-book memory.
|
||||
4. **Chapter prev/next buttons** — alongside the seconds-skip buttons.
|
||||
5. **Volume control** — reuse the shared `VolumeControl`, persisted like the video player.
|
||||
6. **Smart rewind** — auto-rewind on resume, scaled by how long playback was paused.
|
||||
7. **Time remaining at current speed** — real-clock remaining display in Now Listening.
|
||||
|
||||
No server/API changes are required; everything is web-frontend only, so no Android/Apple
|
||||
client coordination is needed. (Per-book speed memory is device-local in v1; syncing it
|
||||
server-side via profile settings is a noted follow-up.)
|
||||
|
||||
## Existing seams (read these first)
|
||||
|
||||
| File | Relevance |
|
||||
| --- | --- |
|
||||
| `web/src/pages/audiobooks/player/useAudiobookPlayback.ts` | Playback engine. `togglePlay` (~line 499), `skip` (~534), `setRate` (~541), returned API (~690+). All engine work lands here. |
|
||||
| `web/src/pages/audiobooks/player/MiniBar.tsx` | Bottom bar. Hardcoded `SKIP_*_SECONDS = 30` and `PLAYBACK_RATES` at lines 10–12. |
|
||||
| `web/src/pages/audiobooks/player/NowListening.tsx` | Full-screen view. Same hardcoded constants; dead "More" button (lines 50–56) becomes the settings menu trigger; remaining-time toggle at lines 33–37. |
|
||||
| `web/src/pages/audiobooks/player/AudiobookPlayer.tsx` | Wrapper that owns mini/now-listening mode — mount point for the keyboard shortcuts hook. |
|
||||
| `web/src/player/components/VolumeControl.tsx` | Existing slider + `getPersistedVolume`/`persistVolume` helpers (shared `player-volume`/`player-muted` storage keys). Styled for dark video overlay — needs a surface-toned variant. |
|
||||
| `web/src/player/components/SpeedMenu.tsx` | Current preset-only menu (shared with video player). Audiobook gets a richer `SpeedControl`; video keeps `SpeedMenu` untouched. |
|
||||
| `web/src/player/hooks/useKeyboardShortcuts.ts` | Video player's shortcut hook — pattern to mirror (input-field guard, single document listener). |
|
||||
| `web/src/utils/storage.ts` | Typed localStorage wrapper — add new keys here, never call `localStorage` directly. |
|
||||
| `web/src/player/components/SeekBar.tsx`, `CircleButton.tsx`, `SleepTimerMenu.tsx`, `ChaptersMenu.tsx` | Established player design language; reuse, don't fork. |
|
||||
|
||||
## Design language
|
||||
|
||||
These are additions to an existing, refined system — extend it, don't restyle it:
|
||||
|
||||
- **Surfaces**: mini bar is a themed surface (`bg-background`, `border-t`, token colors).
|
||||
Popovers anchored to it use the established dark-glass treatment
|
||||
(`bg-black/90 backdrop-blur-sm rounded-lg shadow-xl`, white/75 text, `data-active` rows)
|
||||
exactly as `SpeedMenu`/`SleepTimerMenu` do today.
|
||||
- **Type**: time and rate values are always `font-mono`/`tabular-nums` so nothing shifts as
|
||||
digits tick. The new speed readout (`1.45×`) must reserve width for two decimals.
|
||||
- **Buttons**: all transport controls are `CircleButton` (`sm` secondary flanks, `md`/`lg`
|
||||
primary center). Chapter prev/next use `SkipBack`/`SkipForward` lucide glyphs at the same
|
||||
stroke weight (1.6) as the rotate icons, so the cluster reads as one family.
|
||||
- **Micro-interactions**, small and purposeful:
|
||||
- Skip buttons: a quick ~18° icon flick in the skip direction on press
|
||||
(CSS transform transition, ~150ms ease-out) — confirms the action without a toast.
|
||||
- Speed stepper: value crossfades (opacity 120ms) on change; the active preset chip gets
|
||||
the same `bg-white/5 text-white` treatment as current menu rows.
|
||||
- Smart rewind: when it fires, the seek bar playhead animates the small jump back rather
|
||||
than teleporting (SeekBar already animates position; verify it holds for ≤30s deltas).
|
||||
- **Accessibility**: every new control keeps the existing patterns — `aria-label`,
|
||||
`aria-expanded`/`aria-haspopup` on triggers, roving focus + Escape in menus (copy from
|
||||
`SleepTimerMenu`), `role="slider"` semantics from `VolumeControl`/`SeekBar`.
|
||||
|
||||
## Phase 1 — Engine + preferences model
|
||||
|
||||
### 1a. Preferences hook
|
||||
|
||||
New `web/src/pages/audiobooks/player/useAudiobookPrefs.ts`:
|
||||
|
||||
- Add storage keys: `AUDIOBOOK_SKIP_BACK`, `AUDIOBOOK_SKIP_FORWARD`,
|
||||
`AUDIOBOOK_SMART_REWIND` (bool), `AUDIOBOOK_RATES` (JSON map) in `web/src/utils/storage.ts`.
|
||||
- Exposes `{ skipBack, skipForward, smartRewind, setSkipBack, setSkipForward, setSmartRewind }`.
|
||||
- Defaults: back **10s**, forward **30s** (the asymmetric convention — small "what did she
|
||||
say?" hops back, bigger hops forward), smart rewind **on**.
|
||||
- Allowed skip values: 5, 10, 15, 30, 45, 60, 90 (two digits max keeps `SkipIcon`'s inset
|
||||
number legible).
|
||||
- Per-book rate map: single JSON object `{ [contentId]: rate }` under `AUDIOBOOK_RATES`,
|
||||
LRU-capped at 50 entries (store `{ rate, at }` and evict oldest on insert).
|
||||
Helpers `getBookRate(contentId)` / `setBookRate(contentId, rate)` live here too.
|
||||
|
||||
### 1b. Engine extensions (`useAudiobookPlayback.ts`)
|
||||
|
||||
- **Volume/mute**: initialize from `getPersistedVolume()`; apply to the `<audio>` element in
|
||||
an effect; expose `volume`, `muted`, `setVolume`, `setMuted`; persist via `persistVolume`.
|
||||
Sharing the video player's keys is intentional — one volume preference per device.
|
||||
- **Per-book rate**: on mount, initialize `rate` from `getBookRate(contentId)` (fallback 1);
|
||||
in `setRate`, also `setBookRate`. The hook needs `contentId` passed in (it already
|
||||
receives the files/options object — extend that input).
|
||||
- **Chapter navigation**: expose `nextChapter()` and `prevChapter()`.
|
||||
- `next`: seek to start of the chapter after `currentChapter`; no-op at the last chapter.
|
||||
- `prev`: if more than 3s into the current chapter, seek to its start; otherwise seek to
|
||||
the previous chapter's start (the universal music/audiobook convention).
|
||||
- **Smart rewind**: record `pausedAtRef = performance.now()` whenever playback pauses (in
|
||||
the `pause` event handler so it also catches OS-initiated pauses). On resume, before
|
||||
`audio.play()`, compute rewind from pause duration and `seekTo(currentTime - rewind)`:
|
||||
- < 10s paused → 0s; < 1min → 3s; < 10min → 10s; < 1h → 20s; ≥ 1h → 30s.
|
||||
- Clamp at 0; skip entirely when the smart-rewind pref is off or the resume immediately
|
||||
follows a user seek (seeking sets a short suppress flag so explicit jumps aren't undone).
|
||||
- Also apply on **cold resume**: when starting with `initialPositionSeconds > 0` from the
|
||||
detail page, rewind by the same schedule using the age of the saved progress if the
|
||||
API exposes an updated-at timestamp; otherwise apply a flat 10s. Keep this logic in one
|
||||
pure function `smartRewindSeconds(pauseMs)` so it's unit-testable.
|
||||
- **Skip intervals**: no engine change — `skip(delta)` already takes a signed delta; the UI
|
||||
passes the configured values.
|
||||
|
||||
### 1c. Tests
|
||||
|
||||
Vitest, colocated like existing `*.test.tsx`:
|
||||
|
||||
- `smartRewindSeconds` schedule boundaries.
|
||||
- Per-book rate LRU (cap, eviction, malformed-JSON tolerance).
|
||||
- `prevChapter` 3s threshold behavior and first/last chapter edges
|
||||
(chapter math is pure — test against `buildChapterList` fixtures from
|
||||
`web/src/lib/audiobooks/chapters.ts`).
|
||||
|
||||
## Phase 2 — Controls UI
|
||||
|
||||
### 2a. SpeedControl (new, audiobook-specific)
|
||||
|
||||
New `web/src/pages/audiobooks/player/SpeedControl.tsx`, replacing `SpeedMenu` usage in both
|
||||
audiobook views (video player keeps `SpeedMenu`):
|
||||
|
||||
- Trigger: same `player-utility-btn` showing the current rate (`1.45×`, tabular-nums).
|
||||
- Popover (dark-glass, bottom-anchored like today):
|
||||
- A stepper row: `−` / big mono readout / `+`, stepping **0.05** per tap, clamped to
|
||||
**0.5–3.0**. Press-and-hold repeats (250ms initial, 80ms repeat).
|
||||
- A preset chip row: 1× · 1.25× · 1.5× · 1.75× · 2× · 2.5× · 3×.
|
||||
- Footnote line: "Remembered for this book" (quiet `text-white/40`, 11px) — makes the
|
||||
per-book memory discoverable instead of magical.
|
||||
- Keyboard: ArrowUp/Down step inside the popover; Escape closes (copy menu plumbing from
|
||||
`SleepTimerMenu`).
|
||||
|
||||
### 2b. Chapter prev/next buttons
|
||||
|
||||
- Add `SkipBack`/`SkipForward` `CircleButton size="sm" variant="secondary"` on the outside
|
||||
of the transport cluster in both `MiniBar` and `NowListening`:
|
||||
`⏮ ↺10 ▶ ↻30 ⏭`. Wire to `prevChapter`/`nextChapter`; render only when
|
||||
`playback.chapters.length > 0`; disable at the ends (prev stays enabled mid-chapter).
|
||||
- In `MiniBar`, hide the chapter buttons below `sm:` — the 3-column grid is already tight
|
||||
on phones and the chapters menu remains available.
|
||||
|
||||
### 2c. Volume
|
||||
|
||||
- Add a `tone` prop to `web/src/player/components/VolumeControl.tsx`
|
||||
(`"overlay"` = current white-on-dark, `"surface"` = theme tokens: track `bg-muted`,
|
||||
fill `bg-foreground`, focus ring `ring-ring`). Default `"overlay"` so the video player is
|
||||
untouched.
|
||||
- Mount in `MiniBar`'s right cluster (before the sleep timer) at `md:` and up, and in
|
||||
`NowListening`'s utility row. Wire to the new engine `volume`/`muted` state.
|
||||
|
||||
### 2d. Player settings menu
|
||||
|
||||
New `web/src/pages/audiobooks/player/PlayerSettingsMenu.tsx` (dark-glass popover):
|
||||
|
||||
- **Skip back** / **Skip forward**: rows of small value chips (5/10/15/30/45/60/90s).
|
||||
- **Smart rewind**: toggle row with a one-line description ("Backs up a little after a pause").
|
||||
- Triggers: the currently-dead "More" (`MoreHorizontal`) button in `NowListening`
|
||||
(lines 50–56) and a matching trigger in `MiniBar`'s right cluster.
|
||||
- `MiniBar`/`NowListening` read `useAudiobookPrefs()` and pass the configured seconds to
|
||||
the skip buttons and `SkipIcon` (which already renders a dynamic number) — delete the
|
||||
`SKIP_*_SECONDS` constants.
|
||||
|
||||
## Phase 3 — Shortcuts + time display + polish
|
||||
|
||||
### 3a. Keyboard shortcuts
|
||||
|
||||
New `web/src/pages/audiobooks/player/useAudiobookKeyboardShortcuts.ts`, mounted in
|
||||
`AudiobookPlayer.tsx` (so it's active in both mini and expanded modes, on every route):
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| Space / K | play-pause |
|
||||
| ← / → | skip by configured back/forward seconds |
|
||||
| ↑ / ↓ | volume ±5% |
|
||||
| M | mute toggle |
|
||||
| N / P | next / previous chapter |
|
||||
| Shift+. / Shift+, | speed +0.05 / −0.05 (YouTube convention) |
|
||||
| E | expand / collapse Now Listening |
|
||||
| Esc | collapse Now Listening (expanded mode only) |
|
||||
|
||||
- Mirror `useKeyboardShortcuts.ts` guards: ignore when target is input/textarea/
|
||||
contentEditable, single document listener, cleanup on unmount.
|
||||
- Additional guard: **do nothing while a video session is active** — the video player binds
|
||||
the same keys. Check `WatchPlaybackProvider` state (or simply whether a `<video>` watch
|
||||
route is mounted) and bail; the audiobook bar is backgrounded in that case anyway. Space
|
||||
must also not fire when a button has focus (let the focused control handle Enter/Space).
|
||||
- Add `title` tooltips with the shortcut hint to transport buttons
|
||||
(e.g. `Back 10 seconds (←)`) for discoverability.
|
||||
|
||||
### 3b. Time remaining at current speed
|
||||
|
||||
In `NowListening`, the right time label currently toggles total ↔ remaining. Make it cycle
|
||||
three states (persist last choice in component state only):
|
||||
|
||||
1. total (`12:04:00`)
|
||||
2. remaining (`−3:21:09`)
|
||||
3. remaining at speed (`−2:14:06 at 1.5×`) — `remaining / rate`, hidden (skipped in the
|
||||
cycle) when `rate === 1`.
|
||||
|
||||
Keep the `data-testid="now-listening-right-time"` hook and update its test.
|
||||
|
||||
### 3c. Polish pass
|
||||
|
||||
- Skip-flick and speed-crossfade micro-interactions from the design section.
|
||||
- Verify mini bar layout on 360px-wide viewports with all new controls (chapter buttons and
|
||||
volume hidden, settings menu accessible).
|
||||
- `cd web && pnpm run lint && pnpm run format:check`; run the player test suites.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- `useAudiobookPlayback.ts` is 707 lines and growing — extract smart rewind + prefs-coupled
|
||||
logic into small pure modules (`smartRewind.ts`) rather than inflating the hook; consider
|
||||
splitting volume/rate concerns into a `useAudioElementSettings` helper if the hook passes
|
||||
~800 lines.
|
||||
- Smart rewind must not fight the 10s progress reporter: rewinding on resume changes
|
||||
`currentTime`, which triggers a progress write — that's correct (the rewound position is
|
||||
the truth), just confirm no oscillation with the suppress-after-seek flag.
|
||||
- Per-book rate uses `contentId`; switching narrators navigates to a different `contentId`,
|
||||
so each narration remembers its own speed — acceptable, arguably desirable.
|
||||
- Cross-device sync of prefs/per-book speed is a candidate follow-up once a profile
|
||||
settings endpoint exists; the `useAudiobookPrefs` API is the seam to swap storage behind.
|
||||
@@ -1,311 +0,0 @@
|
||||
# AI services: shared core, metadata translation, Whisper ASR — design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Draft, pending review
|
||||
**Scope:** Extract the AI subtitle translation engine into a shared AI core, add AI translation of movie/series/season/episode descriptions into the existing localization tables, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). One PR.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
`internal/subtitles/ai` ships an on-demand LLM subtitle translator. The LLM plumbing inside
|
||||
it (OpenAI-compatible chat client with retry/backoff, batched indexed-JSON translation,
|
||||
job lifecycle with idempotency/heartbeat/reaper/bounded concurrency) is generic, but it is
|
||||
buried in a subtitle-specific package, so nothing else can use it.
|
||||
|
||||
Two features want exactly that plumbing:
|
||||
|
||||
1. **Metadata translation.** Libraries whose `metadata_language` the providers cannot fully
|
||||
serve end up with overviews/taglines in the item's default language (usually English).
|
||||
The localization tables (`media_item_localizations`, `season_localizations`,
|
||||
`episode_localizations`) and the serving path (`LocalizeItemModel` and friends in
|
||||
`internal/catalog/detail.go`) already exist — the gap is producing rows for languages the
|
||||
providers don't have.
|
||||
2. **Whisper ASR.** `subtitle_ai_jobs.kind` already reserves `transcribe` and
|
||||
`transcribe_translate`; media with no usable text subtitle track (foreign audio with only
|
||||
bitmap subs, or no subs at all) currently has no AI path to a subtitle track.
|
||||
|
||||
## Goals
|
||||
|
||||
- One shared AI core: a single OpenAI-compatible endpoint configuration (chat + audio
|
||||
transcription), one retry/backoff implementation, one generic batched text translator,
|
||||
one job-lifecycle runner — consumed by subtitle translation, metadata translation, and ASR.
|
||||
- AI-translated overviews and taglines stored in the **existing** localization tables, so
|
||||
web, Android, Apple, and jellycompat clients get them through the current serving path
|
||||
with **zero client changes**.
|
||||
- Provenance on AI-writable localization fields so refreshes never regress data quality:
|
||||
`manual` beats `provider` beats `ai`. A later provider localization overwrites an AI
|
||||
translation; the reverse never happens.
|
||||
- Two metadata-translation triggers: a manual admin action, and an opt-in per-library
|
||||
fallback ("provider had no localization for the library language → AI-translate") wired
|
||||
into metadata ingestion.
|
||||
- ASR jobs that produce ordinary `downloaded_subtitles` rows (provider `transcribed`),
|
||||
with `transcribe_translate` chaining the existing LLM translator for a target-language
|
||||
track; live cue streaming to the requesting playback session, same as translation today.
|
||||
- Total AI load on the configured endpoint bounded by **one** shared semaphore.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Episode/movie/series title translation.** Titles often have official localized forms
|
||||
that are not literal translations; provider data only.
|
||||
- **Collection names or descriptions.** Deferred: collections have no localization table
|
||||
yet. The metadata translation service is written against content-ID + field-list inputs,
|
||||
so a `collection_localizations` table can be added in a follow-up without reshaping it.
|
||||
- **Genre translation.** If ever wanted, that is a static mapping, not an LLM call.
|
||||
- **Per-profile metadata language.** Presentation language stays library-scoped
|
||||
(`media_folders.metadata_language`); changing that is an orthogonal feature.
|
||||
- **A localization editor UI.** The `manual` provenance value is honored by the write
|
||||
rules but no UI sets it yet (the existing metadata editor edits base fields, not
|
||||
localizations).
|
||||
- **Whisper word-level timing, diarization, VAD-aligned chunking.** v1 uses fixed-length
|
||||
chunks; boundary-word artifacts are an accepted limitation noted for follow-up.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Package layout
|
||||
|
||||
```
|
||||
internal/ai/llm Client: OpenAI-compatible /v1/chat/completions (moved from
|
||||
internal/subtitles/ai/client.go) + new /v1/audio/transcriptions.
|
||||
Shared retry/backoff/429 handling. Config for the endpoint(s).
|
||||
internal/ai/translate Generic batched text translation: []Segment{ID, Text} in,
|
||||
translated segments out. Indexed-JSON protocol, context
|
||||
neighbors, malformed-response retries — moved from
|
||||
internal/subtitles/ai/translator.go and de-subtitled.
|
||||
internal/ai/jobrunner Generic job lifecycle: bounded dispatch off a shared semaphore,
|
||||
heartbeat loop, stale-job reaper, cancel registry — extracted from
|
||||
internal/subtitles/ai/service.go behind a small repo interface.
|
||||
internal/subtitles/ai Stays: subtitle job semantics. Translate path becomes a thin
|
||||
adapter (cues ↔ segments); gains the transcribe paths.
|
||||
internal/metadata/translation
|
||||
New: metadata translation job service + repo + provenance-aware
|
||||
writes into the localization repos.
|
||||
```
|
||||
|
||||
Package names: `llm`, `aitranslate`, `jobrunner` (import paths above; `aitranslate` avoids
|
||||
a name collision when imported from package `ai`).
|
||||
|
||||
### Shared semaphore
|
||||
|
||||
`cmd/silo` builds one `chan struct{}` of size `ai.max_concurrent_jobs` and hands it to both
|
||||
job services via `jobrunner`. Subtitle, metadata, and ASR jobs all draw from it, so the
|
||||
operator's endpoint sees a bounded number of concurrent jobs regardless of job mix.
|
||||
(An ASR job holds one slot for its whole pipeline: extract → transcribe → translate.)
|
||||
|
||||
## Settings
|
||||
|
||||
Connection settings move to a shared `ai.*` namespace. Because encrypted values are
|
||||
GCM-bound to their setting key (see `internal/catalog/encrypted_settings_repo.go`), the
|
||||
legacy rows are **not renamed**; the loader falls back to them, following the precedent of
|
||||
`recommendations.embedding_auth_token` → `recommendations.openai_api_key` in
|
||||
`internal/config/db_loader.go`.
|
||||
|
||||
| Key | Default | Fallback | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `ai.base_url` | `https://api.openai.com` | `subtitle_ai.base_url` | |
|
||||
| `ai.api_key` | `""` | `subtitle_ai.api_key` | sensitive |
|
||||
| `ai.chat_model` | `gpt-4o-mini` | `subtitle_ai.chat_model` | |
|
||||
| `ai.max_concurrent_jobs` | `2` | `subtitle_ai.max_concurrent_jobs` | shared semaphore size |
|
||||
| `ai.asr_model` | `whisper-1` | — | |
|
||||
| `ai.asr_base_url` | `""` (= `ai.base_url`) | — | separate local Whisper server |
|
||||
| `ai.asr_api_key` | `""` (= `ai.api_key`) | — | sensitive |
|
||||
| `subtitle_ai.enabled` | `false` | — | existing; subtitle translation toggle |
|
||||
| `subtitle_ai.transcribe_enabled` | `false` | — | new; ASR toggle |
|
||||
| `subtitle_ai.batch_size` | `40` | — | existing; cues per chat request |
|
||||
| `subtitle_ai.context_neighbors` | `2` | — | existing |
|
||||
| `metadata_ai.enabled` | `false` | — | new; metadata translation toggle |
|
||||
|
||||
`ai.api_key` and `ai.asr_api_key` join `sensitiveSettingKeys`. The admin UI reads through
|
||||
the same fallback (effective values come from the loaded config) and writes the new keys.
|
||||
|
||||
Config structs: `AIConfig` (connection + concurrency), `SubtitleAIConfig` (slims down to
|
||||
toggles + batch tuning), new `MetadataAIConfig`. Readiness: subtitle translate requires
|
||||
`subtitle_ai.enabled` + chat config; ASR requires `subtitle_ai.transcribe_enabled` + ASR
|
||||
config; metadata requires `metadata_ai.enabled` + chat config.
|
||||
|
||||
## Metadata translation
|
||||
|
||||
### What gets translated
|
||||
|
||||
| Target | Fields |
|
||||
| --- | --- |
|
||||
| movie / series (`media_item_localizations`) | `overview`, `tagline` |
|
||||
| season (`season_localizations`) | `overview` |
|
||||
| episode (`episode_localizations`) | `overview` |
|
||||
|
||||
Titles, sort titles, and artwork columns in those rows are never written by AI.
|
||||
|
||||
### Provenance
|
||||
|
||||
New columns (default `'provider'`, check-constrained to `provider|ai|manual`):
|
||||
|
||||
- `media_item_localizations.overview_source`, `media_item_localizations.tagline_source`
|
||||
- `season_localizations.overview_source`
|
||||
- `episode_localizations.overview_source`
|
||||
|
||||
Write rules, enforced in the localization repos (single-statement upserts with CASE):
|
||||
|
||||
| Writer | May overwrite | Sets source | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| provider ingestion | `provider`, `ai` | `provider` | a non-empty provider value always replaces an AI value; an **empty** provider value never blanks an existing one |
|
||||
| AI translation | `ai`, empty | `ai` | with `force`, may also overwrite `provider` (admin explicitly re-translating) — never `manual` |
|
||||
| manual (future editor) | anything | `manual` | |
|
||||
|
||||
Serving hardening: `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel`
|
||||
must only override base fields with **non-empty** localization values, since AI-created
|
||||
rows legitimately carry empty titles. (Verify current behavior; fix if it clobbers.)
|
||||
|
||||
### Job model
|
||||
|
||||
New table `metadata_translation_jobs`, mirroring `subtitle_ai_jobs` conventions
|
||||
(status enum, progress, idempotency partial-unique index, heartbeat):
|
||||
|
||||
- `target_kind` (`item` | `season` | `episode`), `content_id`, `include_children`
|
||||
- `target_language`, `source_language` (default language of the base row)
|
||||
- `engine`, `model`, `status`, `progress`, `progress_message`, `fields_done`, `fields_total`
|
||||
- `error_message`, `idempotency_key`, `requested_by`, `force`, timestamps, `heartbeat_at`
|
||||
|
||||
A job for a series item with `include_children` expands to: item overview + tagline, every
|
||||
season overview, every episode overview. All fields become segments
|
||||
(`item:overview`, `season:<content_id>:overview`, `episode:<content_id>:overview`, …) fed
|
||||
to the generic translator in batches (metadata batch size constant, smaller than the
|
||||
subtitle one — paragraphs, not cue lines), so episode descriptions share terminology in one
|
||||
prompt context. The system prompt names the series/movie and year for grounding.
|
||||
|
||||
Skip logic in the run loop (not at enqueue): a field whose localization value is already
|
||||
non-empty is skipped unless `force` — so the auto trigger costs zero LLM calls on repeat
|
||||
refreshes. If every field is skipped the job completes immediately.
|
||||
|
||||
Idempotency key: `content_id | target_kind | target_language | model`, active-only partial
|
||||
unique index (completed/failed rows never block a retry).
|
||||
|
||||
### Triggers
|
||||
|
||||
1. **Manual.** Admin action on the item detail metadata editor: pick target language
|
||||
(defaults to the library's `metadata_language`), `include_children`, `force`. Gated by
|
||||
the same permission as metadata editing.
|
||||
2. **Auto fallback.** New `media_folders.auto_translate_metadata boolean NOT NULL DEFAULT
|
||||
false`. At the end of a metadata refresh for an item (after provider localizations are
|
||||
persisted in `internal/metadata/service.go`), if the folder has the flag, has a
|
||||
`metadata_language`, that language differs from the item's default metadata language,
|
||||
and the localization row for it is missing a translated field — enqueue a non-force job
|
||||
for the item (children included for series). Enqueue is fire-and-forget; refresh latency
|
||||
is unaffected.
|
||||
|
||||
### API
|
||||
|
||||
Admin-gated, mirroring the subtitle AI handler shapes:
|
||||
|
||||
- `GET /api/v1/metadata/ai/status`
|
||||
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}`
|
||||
- `GET /api/v1/metadata/ai/jobs/{job_id}` / `POST …/cancel`
|
||||
- `GET /api/v1/metadata/ai/jobs?content_id=…`
|
||||
|
||||
Progress UI polls the job endpoint (jobs are short; no websocket events in v1).
|
||||
|
||||
## Whisper ASR
|
||||
|
||||
### Kinds
|
||||
|
||||
`subtitle_ai_jobs.kind` gains its reserved values:
|
||||
|
||||
- `transcribe` — audio track → subtitle track in the spoken language.
|
||||
- `transcribe_translate` — `transcribe`, then the existing LLM translation chained to
|
||||
`target_language`. Both the transcript track and the translated track are stored (the
|
||||
transcript is a cache: a second target language skips ASR entirely because the transcript
|
||||
is now a selectable text source for a plain `translate` job). `result_subtitle_id`
|
||||
points at the translated track.
|
||||
|
||||
For ASR kinds, `source_index` holds the **audio** track index (`-1` = default/first audio
|
||||
track); `source_language` is an optional hint passed to Whisper, else taken from the
|
||||
detected `verbose_json.language`.
|
||||
|
||||
### Pipeline
|
||||
|
||||
1. Resolve the audio track from `media_files`; reject files with no audio.
|
||||
2. Extract once with ffmpeg into fixed chunks in a job temp dir
|
||||
(`-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time 600`):
|
||||
16 kHz mono WAV, 10-minute chunks (~19 MB, under typical 25 MB API limits). Temp dir
|
||||
removed on every exit path.
|
||||
3. Per chunk: `POST /v1/audio/transcriptions` (multipart: file, model,
|
||||
`response_format=verbose_json`, optional `language`, `temperature=0`) via the shared
|
||||
`llm.Client` with the shared retry/backoff. Segment timestamps are offset by the chunk
|
||||
start and merged.
|
||||
4. Cue building: one cue per Whisper segment; text wrapped to at most two lines
|
||||
(~42 chars/line). Empty/whitespace segments dropped.
|
||||
5. Store as `downloaded_subtitles` (provider `transcribed`, SRT, release name
|
||||
`"<Language> (AI transcribed)"`), notify `SubtitleReady`.
|
||||
6. `transcribe_translate`: feed the cues to the existing `Translator`; store the translated
|
||||
track (provider `translated`) and complete.
|
||||
|
||||
Chunks are processed starting from the chunk containing the viewer's playhead, then
|
||||
wrapping — same UX as translation — and cues stream live to the requesting session through
|
||||
the existing notifier. Progress bands: extract 0–10 %, transcribe 10–70 % (per chunk),
|
||||
translate 70–95 %.
|
||||
|
||||
Known v1 limitation: fixed chunk boundaries can clip a word at the seam; acceptable, noted
|
||||
for a silence-aligned follow-up.
|
||||
|
||||
### API / UI
|
||||
|
||||
The existing enqueue endpoint `POST /api/v1/subtitles/ai/translate` gains an optional
|
||||
`kind` (default `translate`; backward compatible). `GET /api/v1/subtitles/ai/status` gains
|
||||
`transcribe_enabled`. The player's translate modal gains a "Generate from audio" mode that
|
||||
lists audio tracks and a target language — the natural path when the only subtitle tracks
|
||||
are bitmap (today's hard error) or none exist.
|
||||
|
||||
## Frontend
|
||||
|
||||
- **AI Services settings page** (`web/src/pages/admin-settings/`): connection card
|
||||
(base URL, API key, chat model, ASR model, optional ASR base URL/key), features card
|
||||
(subtitle translation, ASR, metadata translation toggles; concurrency; batch tuning).
|
||||
The AI card currently inside `SubtitlesSettings.tsx` moves here; subtitle settings keep
|
||||
the download-provider config.
|
||||
- **Metadata editor**: "Translate with AI" action in `EditMetadataDialog.tsx` (language,
|
||||
include children, force), with job-poll progress and query invalidation on completion.
|
||||
- **Library settings**: `auto_translate_metadata` toggle next to the metadata-language
|
||||
field.
|
||||
- **Player**: "Generate from audio" mode in `SubtitleTranslateModal.tsx`.
|
||||
|
||||
## Client (Android / Apple) impact
|
||||
|
||||
None required. Translated overviews/taglines arrive through the existing localized detail
|
||||
responses; ASR/translated tracks arrive as ordinary downloaded subtitles. Optional
|
||||
follow-ups in the client repos: surface "Generate from audio" in their subtitle menus
|
||||
(the jobs API is shared) and the library auto-translate toggle in their admin surfaces.
|
||||
|
||||
## Reliability
|
||||
|
||||
- All jobs: idempotency dedup of in-flight work, heartbeat + stale reaper (crash-safe,
|
||||
multi-instance safe), terminal-state handling on cancel/shutdown — one implementation in
|
||||
`jobrunner`, no copy-paste between the two services.
|
||||
- Provider-wins/manual-wins provenance is enforced in SQL (single-statement upserts), not
|
||||
in racy read-modify-write Go code.
|
||||
- The shared semaphore bounds total endpoint load; ASR temp space is cleaned on success,
|
||||
failure, cancel, and shutdown.
|
||||
- Auto-translate re-runs are free: skip-if-filled happens before any LLM call.
|
||||
|
||||
## Migrations
|
||||
|
||||
One Goose migration (`make migrate-create NAME=ai_metadata_translation_and_asr`):
|
||||
|
||||
1. `metadata_translation_jobs` table + active-idempotency / content / status indexes.
|
||||
2. Provenance columns on the three localization tables (+ check constraints).
|
||||
3. `media_folders.auto_translate_metadata`.
|
||||
|
||||
Down: drop the table, columns, and constraint.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **Whisper-endpoint compatibility.** OpenAI, Groq, faster-whisper-server/speaches all
|
||||
speak `verbose_json`, but segment quality varies; the cue builder must tolerate missing
|
||||
segment arrays (fall back to one cue per chunk worth of `text` is *not* acceptable —
|
||||
fail the job with a clear message instead).
|
||||
- **`LocalizeItemModel` empty-field semantics** must be verified before AI rows (empty
|
||||
titles) ship; this is called out as an explicit plan task.
|
||||
- **Long-running ASR on slow local Whisper servers** can exceed the 10-minute HTTP client
|
||||
timeout per chunk; the transcription call uses a per-request timeout sized for chunk
|
||||
duration rather than the client default.
|
||||
- **Settings sprawl.** The `ai.*`/legacy fallback leaves old `subtitle_ai.*` connection
|
||||
rows in place indefinitely; harmless, but a future settings-GC could prune them once the
|
||||
new keys are written.
|
||||
@@ -1,78 +0,0 @@
|
||||
# On-view description translation + per-profile metadata language — design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Approved direction (per-profile preference; auto with button fallback; any user behind an opt-in setting)
|
||||
**Builds on:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md`
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
Metadata translation currently triggers from the admin editor or the per-library
|
||||
refresh fallback, always targeting the library's metadata language. A viewer whose
|
||||
language differs from the library's (or whose library was never auto-translated) has
|
||||
no way to get descriptions in their language, and there is no per-person language
|
||||
preference at all — presentation language is library-scoped.
|
||||
|
||||
## Design
|
||||
|
||||
### Per-profile preferred metadata language
|
||||
|
||||
- New column `user_profiles.preferred_metadata_language text NOT NULL DEFAULT ''`
|
||||
(empty = inherit the library's metadata language). Threaded through the profile
|
||||
model/repo/API exactly like the existing `language` (UI language) field, plus a
|
||||
picker in the profile settings UI with an "Inherit library default" option.
|
||||
- `access.Scope` gains `PreferredMetadataLanguage`, populated when the access
|
||||
resolver loads the profile, so every request already carries it.
|
||||
- `catalog.AccessFilter` gains `ProfilePreferredLanguage`, and presentation language
|
||||
resolution becomes: explicit `PresentationLanguage` (request param) →
|
||||
**profile preference** → library `metadata_language`. Applied wherever the scope
|
||||
is available (native API and jellycompat), so all clients get profile-localized
|
||||
metadata server-side.
|
||||
|
||||
### "Not in your language" signal
|
||||
|
||||
`ItemDetail` gains `pending_translation_language` (omitted when empty): set when the
|
||||
resolved presentation language differs from the item's default metadata language,
|
||||
the base overview is non-empty, and no localization overview exists for that
|
||||
language. It is pure data — clients combine it with the AI feature status to decide
|
||||
what to render.
|
||||
|
||||
### On-view trigger
|
||||
|
||||
- New setting `metadata_ai.on_view`: `off` (default) | `button` | `auto`, surfaced
|
||||
on `GET /api/v1/metadata/ai/status` as `on_view`.
|
||||
- New authenticated endpoint `POST /api/v1/items/{id}/translate-description`
|
||||
`{target_language}` — any profile, gated by `metadata_ai` readiness +
|
||||
`on_view != off`, item access enforced with the same `EnsureAccessible` check the
|
||||
watched-state endpoints use. The client echoes back the language the server
|
||||
reported in `pending_translation_language`.
|
||||
- The endpoint goes through a new `Service.RequestOnView`, which adds one guard on
|
||||
top of the existing pipeline: if the latest job for (content, language) failed
|
||||
within a cooldown window, return it instead of enqueuing — a broken endpoint must
|
||||
not be retried on every page view. Existing guarantees do the rest: in-flight
|
||||
idempotency collapses concurrent viewers onto one job, skip-if-filled makes
|
||||
repeats free, and the shared semaphore caps endpoint load.
|
||||
|
||||
### Web UX
|
||||
|
||||
On the detail page, when `pending_translation_language` is set:
|
||||
|
||||
- `on_view=auto`: fire the request on view (once per item), pulse-animate the
|
||||
description while waiting, and poll the detail query until the flag clears (the
|
||||
job's first batch translates the item's own overview, so this lands in seconds
|
||||
even when a series job continues episodes in the background). Time out after ~45 s
|
||||
and show the original text — no error states in the viewer's face.
|
||||
- `on_view=button`: a small "Translate" chip near the description triggers the same
|
||||
flow on click.
|
||||
|
||||
No job-status endpoint for viewers: completion is observed as the flag clearing on
|
||||
refetch; failures surface only as the shimmer timing out (the cooldown stops
|
||||
re-triggering).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Per-profile language in the Android/Apple UIs (server-side serving means they
|
||||
already *receive* localized metadata; surfacing the trigger there is a client
|
||||
follow-up using the same endpoint).
|
||||
- Translating titles, or any admin-side behavior changes.
|
||||
@@ -1,170 +0,0 @@
|
||||
# Tiered Security Model: Group-Based Permissions — Design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: Approved design, pending implementation plan
|
||||
|
||||
Commands and paths in this document assume the repository root is the cwd.
|
||||
|
||||
## Overview
|
||||
|
||||
Replace Silo's per-user authorization fields with a group-based model:
|
||||
|
||||
- Admins create **groups**; each group carries a full policy (feature permissions, library access, playback/transcode limits, download rights).
|
||||
- Users are assigned to one or more groups; effective policy is the most-permissive union of their groups.
|
||||
- Groups are the **only** source of policy. Per-user policy columns are removed.
|
||||
- Two protected built-in groups ship by default: `administrators` and `users`. New accounts join the group(s) named by a server setting (initially `users`).
|
||||
- The `users.role` column and JWT role claim are removed; "admin" becomes a permission granted by group membership.
|
||||
|
||||
Deployments may have **thousands of users**. All fan-out paths (revision bumps, member listings, migration artifacts) are designed set-based and paginated accordingly.
|
||||
|
||||
## Current state (what this replaces)
|
||||
|
||||
- `users.role` text (`"admin"`/`"user"`), checked by `RequireAdmin` middleware via JWT `Claims.Role` (`internal/api/middleware/auth.go`).
|
||||
- `users.permissions text[]` with two assignable permissions (`marker_edit`, `metadata_curation`) defined in `internal/auth/permissions.go`; admins implicitly hold all.
|
||||
- Per-user resource policy columns on `users`: `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
|
||||
- `users.access_policy_revision` invalidates profile tokens via `internal/access/resolver.go`.
|
||||
- Household profiles (`user_profiles`) carry a separate restriction layer (child flag, content rating, allowed libraries). **Profiles are out of scope** — that layer is unchanged and continues to apply on top of the user's effective policy.
|
||||
|
||||
## Data model
|
||||
|
||||
### `groups`
|
||||
|
||||
```sql
|
||||
CREATE TABLE groups (
|
||||
id serial PRIMARY KEY,
|
||||
slug text NOT NULL UNIQUE, -- stable identifier: 'administrators', 'users', ...
|
||||
name text NOT NULL, -- display name, renamable
|
||||
description text NOT NULL DEFAULT '',
|
||||
built_in boolean NOT NULL DEFAULT false,
|
||||
permissions text[] NOT NULL DEFAULT '{}',
|
||||
library_ids integer[], -- NULL = all libraries
|
||||
max_streams integer NOT NULL DEFAULT 6,
|
||||
max_transcodes integer NOT NULL DEFAULT 2,
|
||||
max_profiles integer NOT NULL DEFAULT 5,
|
||||
max_playback_quality text NOT NULL DEFAULT '', -- '' = unrestricted
|
||||
download_allowed boolean NOT NULL DEFAULT true,
|
||||
download_transcode_allowed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### `user_groups`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_groups (
|
||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id integer NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
CREATE INDEX user_groups_group_id_idx ON user_groups (group_id);
|
||||
```
|
||||
|
||||
### Admin as a permission
|
||||
|
||||
`'admin'` is a permission string in `groups.permissions`, not a column. The resolver treats it as implying every permission and unrestricted policy, mirroring the current `HasEffectivePermission` short-circuit for `role == "admin"`. One mechanism covers everything.
|
||||
|
||||
Permission catalog at launch: `admin`, `marker_edit`, `metadata_curation`. The catalog stays a Go-level allowlist in `internal/auth/permissions.go`; adding a permission is a code change, as today.
|
||||
|
||||
### Built-in groups
|
||||
|
||||
| Slug | Permissions | Policy | Protections |
|
||||
|---|---|---|---|
|
||||
| `administrators` | `['admin']` | irrelevant (admin implies unrestricted) | not deletable; `admin` permission not removable; last enabled member not removable |
|
||||
| `users` | `['marker_edit']` | all libraries, 6 streams, 2 transcodes, 5 profiles, downloads on, transcode downloads off | not deletable |
|
||||
|
||||
Protections are enforced at the service layer (consistent with existing last-admin guards), not by triggers.
|
||||
|
||||
### `users` table changes
|
||||
|
||||
Dropped: `role`, `permissions`, `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
|
||||
|
||||
Kept: `enabled` (account kill-switch stays user-level), `access_policy_revision` (still the invalidation counter).
|
||||
|
||||
### Default group setting
|
||||
|
||||
A `server_settings` entry `users.default_group_slugs` (initially `["users"]`), consumed by all account-provisioning paths (signup, invite, OAuth) in `internal/auth/account_provisioner.go`. Invite codes may later override it; out of scope now.
|
||||
|
||||
## Resolution semantics
|
||||
|
||||
A user's **effective policy** is the most-permissive union of their groups:
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `permissions` | set union; `admin` present → every permission granted |
|
||||
| `library_ids` | union; any group with `NULL` (all) or `admin` → unrestricted |
|
||||
| `max_streams`, `max_transcodes`, `max_profiles` | 0 means unlimited at enforcement, so any group with 0 wins; otherwise max across groups |
|
||||
| `max_playback_quality` | highest wins; `''` (unrestricted) beats everything (ordering as in `internal/access/quality.go`) |
|
||||
| `download_allowed`, `download_transcode_allowed` | OR |
|
||||
|
||||
**Zero groups → empty policy**: no permissions, no libraries (lockout is enforced by the empty library list, so stream/transcode limits are irrelevant; `max_profiles` is floored at 1 since 0 would mean unlimited profile creation). The user can authenticate but sees an empty server. This is deliberate ("groups only grant") and gives admins a soft-disable distinct from `enabled = false`.
|
||||
|
||||
**Where computed:** the user repository (`internal/auth/repository.go`) aggregates group policy in the same load path as the user row (a second indexed query over `user_groups`/`groups`, batched for list views; cost is O(memberships per user), independent of total user count). `models.User` keeps its existing policy fields (`LibraryIDs`, `MaxStreams`, `Permissions`, …) but they become **effective** values populated from the aggregate. The ~25 consumer packages (playback, catalog access filters, jellycompat, sections, audiobooks, requests, …) keep working unchanged. `Role` is replaced by `IsAdmin bool` derived from effective permissions. Assigned memberships are exposed separately (`GroupIDs`) for the admin API.
|
||||
|
||||
**Invalidation:** unchanged mechanism, new triggers:
|
||||
|
||||
- Group policy edit → bump `access_policy_revision` for all members in **one set-based `UPDATE ... FROM user_groups`** (never a per-user loop; the `users` group may have thousands of members).
|
||||
- Membership add/remove → bump the affected user's revision.
|
||||
|
||||
The profile-token revision check in `internal/access/resolver.go` then invalidates stale scopes automatically. The post-bump re-resolution stampede is bounded: each active session re-resolves once with a single indexed query on its next request. No new caching layer until profiling shows a need.
|
||||
|
||||
## Enforcement changes
|
||||
|
||||
- **JWT claims:** `Claims.Role` removed. Claims carry identity only (`user_id`, session, profile, revision) — never policy. Fixes the existing staleness gap where a role change waited for token re-issuance; after this, revoking admin takes effect on the next request.
|
||||
- **`RequireAdmin`** (`internal/api/middleware/auth.go`): loads the user (effective policy included) and checks `IsAdmin`; stashes the loaded user in request context so handlers don't re-fetch. One indexed query on low-traffic admin routes.
|
||||
- **Permission middleware** (`internal/api/middleware/permissions.go`): logic unchanged — it already operates on `models.User`. Only `user.Role == "admin"` comparisons become `user.IsAdmin`.
|
||||
- **Jellyfin compat** (`internal/jellycompat/handlers_auth.go`): `UserPolicy` maps from effective policy — `IsAdministrator` ← `IsAdmin`, `EnableContentDownloading` ← `DownloadAllowed`, `EnableAllFolders`/`EnabledFolders` ← `LibraryIDs`. Replaces the currently hardcoded policy values.
|
||||
- **Requests service** (`internal/requests`): role-string comparisons → `IsAdmin`; otherwise unchanged.
|
||||
|
||||
## API surface
|
||||
|
||||
All new endpoints behind `RequireAdmin`, under the existing admin API namespace:
|
||||
|
||||
- `GET /api/admin/groups` — list groups with member counts (single aggregated count query)
|
||||
- `POST /api/admin/groups` — create; slug auto-derived from name
|
||||
- `GET /api/admin/groups/{id}` — group detail: policy + member **count** (members are paginated separately)
|
||||
- `GET /api/admin/groups/{id}/members?offset=&limit=` — paginated member list
|
||||
- `PATCH /api/admin/groups/{id}` — update name/description/policy; rejects removing `admin` from `administrators`
|
||||
- `DELETE /api/admin/groups/{id}` — rejects built-ins; memberships cascade
|
||||
- `PUT /api/admin/groups/{id}/members/{userID}` — add member
|
||||
- `DELETE /api/admin/groups/{id}/members/{userID}` — remove member; rejects removing the last enabled member of `administrators`
|
||||
|
||||
Changed endpoints:
|
||||
|
||||
- `POST`/`PATCH` admin user endpoints: per-user policy fields removed from request bodies, replaced by `group_ids`. Responses gain `groups: [{id, slug, name}]`; the existing top-level policy fields (`library_ids`, `max_streams`, …) become read-only effective values derived from group membership.
|
||||
- User-facing account/session responses keep a **derived** `role` field (`"admin"`/`"user"` from `IsAdmin`) so `silo-android` and `silo-apple` need no immediate changes; group info is added alongside.
|
||||
|
||||
Default-group setting rides the existing server-settings endpoints.
|
||||
|
||||
## Admin UI (`web/src/pages/admin`)
|
||||
|
||||
- New **Groups** page: list (name, member count, key-policy summary) and an editor with three sections — permissions (checkboxes), library access (all / picker, reusing the user editor's component), limits (streams, transcodes, quality, downloads, profiles). Member management with a paginated member list and user search to add.
|
||||
- **Users** page: per-user policy controls replaced by a group multi-select plus a read-only "effective policy" summary showing the resolved union — the admin's answer to "why can this user do X".
|
||||
- Built-in groups render with a badge; delete and protected fields disabled with explanatory tooltips.
|
||||
|
||||
## Migration
|
||||
|
||||
One transactional Goose migration created with `make migrate-create NAME=group_based_permissions`:
|
||||
|
||||
1. Create `groups` and `user_groups`; seed `administrators` and `users` (policy from current column defaults).
|
||||
2. Map members: `role = 'admin'` → `administrators` only. Non-admin users whose policy columns match the defaults → `users`.
|
||||
3. Preserve outliers **bucketed by distinct policy tuple**: non-admin users whose policy columns deviate from the defaults are grouped by their unique `(permissions, library_ids, max_streams, max_transcodes, max_profiles, max_playback_quality, download_allowed, download_transcode_allowed)` combination; one group per distinct tuple (`migrated-policy-1`, `-2`, …) is created carrying those values, and matching users are added **to that group only — not to `users`**. (Adding outliers to `users` would erase their restrictions via the most-permissive union: `users` grants all libraries, so a user previously limited to specific libraries would become unrestricted.) Group count is bounded by distinct policies in use, not user count. Post-migration effective policy is exactly each user's prior policy; nothing is silently lost or gained. (Admins were already unrestricted, so admin outliers need no preservation.)
|
||||
4. Drop the old `users` columns; bump all `access_policy_revision` values set-based.
|
||||
|
||||
Down-migration recreates the columns and writes back each user's flattened effective policy (best-effort; acceptable at WIP stage).
|
||||
|
||||
## Testing
|
||||
|
||||
- Union resolver unit tests: multi-group merges, `admin` short-circuit, zero-group empty policy, NULL-libraries propagation, quality ordering (extending `internal/access/quality_test.go` coverage).
|
||||
- Service-layer guard tests: delete built-in, strip `admin` from `administrators`, remove last enabled administrator.
|
||||
- Migration test against a seeded DB asserting before/after effective-policy equivalence, including outlier users sharing a policy tuple.
|
||||
- Handler tests for group endpoints incl. member pagination; updated user-endpoint tests for changed payloads.
|
||||
- Jellycompat test asserting `UserPolicy` reflects group-derived policy.
|
||||
|
||||
## Risks & follow-ups
|
||||
|
||||
- **Client coordination:** `silo-android` / `silo-apple` keep working via the derived `role` field; follow-up tickets to surface group membership in account UI.
|
||||
- **Revision-bump stampede:** bounded and acceptable (one re-resolve per active session); revisit with a cache only if profiling shows pressure.
|
||||
- **Invite-code group overrides** and an expanded permission catalog (requests, watch-together, etc.) are natural follow-ups, out of scope here.
|
||||
- **Plugin/API-key auth paths** (`internal/auth/api_key_repository.go`, jellycompat API keys) must be audited during implementation for any `role` reads not covered above.
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Silo-Server/silo-server
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
@@ -14,8 +14,8 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sys v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -32,8 +32,7 @@ require (
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/zishang520/socket.io/v2 v2.5.0
|
||||
go.n16f.net/thumbhash v1.1.0
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/image v0.39.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
@@ -42,13 +41,13 @@ require (
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
@@ -56,9 +55,8 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.53.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
@@ -70,11 +68,15 @@ require (
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/webtransport-go v0.9.1 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
@@ -99,10 +101,8 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
replace github.com/zishang520/webtransport-go => ./internal/compat/zishang520-webtransport-go
|
||||
|
||||
@@ -2,8 +2,8 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
@@ -44,6 +44,7 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -51,12 +52,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
|
||||
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
@@ -165,14 +166,10 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
|
||||
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -225,6 +222,8 @@ github.com/zishang520/socket.io-go-parser/v2 v2.5.0 h1:uGKTwcH2qrrs9uwzfCo3ems+q
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0/go.mod h1:GK9GIIs/KQbBKfnxgZJMBYSlsFemB2rL7EFUn0kmTfM=
|
||||
github.com/zishang520/socket.io/v2 v2.5.0 h1:+KdZLbl4wWVzyI84RG+h21t8OY4ifz/Oo6qwgt9zHl8=
|
||||
github.com/zishang520/socket.io/v2 v2.5.0/go.mod h1:+GyoPyakXDS6KsW81RAQpDA9+mJBXbcYcQ+Itx2D+rU=
|
||||
github.com/zishang520/webtransport-go v0.9.1 h1:Y3gqPM8cIDvQILsTyXJ5G9fp2PYqGqLI2z+QXpgboQc=
|
||||
github.com/zishang520/webtransport-go v0.9.1/go.mod h1:IgNAD6qLe3oWu7MSSkjusRNftpvjYxWjI4LmoH4VEyY=
|
||||
go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
|
||||
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -243,19 +242,21 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -265,15 +266,17 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
|
||||
|
||||
@@ -5,9 +5,4 @@ import "errors"
|
||||
var (
|
||||
ErrProfileNotFound = errors.New("profile not found")
|
||||
ErrProfileUnverified = errors.New("profile unverified")
|
||||
// ErrUserDisabled is returned when the viewer's account is disabled.
|
||||
// Scope resolution re-loads the user on every request, so disabling an
|
||||
// account denies content access on the next request for every surface
|
||||
// that resolves viewer scope (native API and Jellyfin compat alike).
|
||||
ErrUserDisabled = errors.New("user account disabled")
|
||||
)
|
||||
|
||||
@@ -79,17 +79,3 @@ func MinQuality(a, b string) string {
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
// MaxQuality returns the more permissive quality ceiling. An empty string
|
||||
// means unrestricted and beats any concrete ceiling.
|
||||
func MaxQuality(a, b string) string {
|
||||
a = NormalizePlaybackQuality(a)
|
||||
b = NormalizePlaybackQuality(b)
|
||||
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
|
||||
return ""
|
||||
}
|
||||
if CompareQuality(a, b) >= 0 {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -54,22 +54,3 @@ func TestParsePlaybackQualityPreset(t *testing.T) {
|
||||
t.Fatal("expected invalid preset to return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxQuality(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b, want string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"", "1080p", ""}, // unrestricted beats any ceiling
|
||||
{"2160p", "", ""},
|
||||
{"1080p", "2160p", "2160p"},
|
||||
{"2160p", "1080p", "2160p"},
|
||||
{"1080p", "1080p", "1080p"},
|
||||
{"720p", "480p", "1080p"}, // presets normalize: 720p/480p both -> 1080p (standard)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MaxQuality(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("MaxQuality(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,6 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
if err != nil {
|
||||
return Scope{}, fmt.Errorf("loading user %d: %w", input.UserID, err)
|
||||
}
|
||||
if !user.Enabled {
|
||||
return Scope{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
scope := Scope{
|
||||
UserID: user.ID,
|
||||
@@ -76,7 +73,6 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
|
||||
scope.MaxContentRating = profile.MaxContentRating
|
||||
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
|
||||
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
|
||||
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(user.LibraryIDs, profile)
|
||||
scope.ProfileVerified = profile.PINHash == "" || input.SkipPINVerification
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int)
|
||||
|
||||
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
LibraryRestrictionsEnabled: true,
|
||||
@@ -285,7 +285,7 @@ func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
|
||||
|
||||
func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
|
||||
nil,
|
||||
)
|
||||
@@ -304,7 +304,7 @@ func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
|
||||
|
||||
func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
LibraryRestrictionsEnabled: true,
|
||||
@@ -324,7 +324,7 @@ func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
|
||||
|
||||
func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
LibraryRestrictionsEnabled: true,
|
||||
@@ -344,7 +344,7 @@ func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
|
||||
|
||||
func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{ID: "prof-1"},
|
||||
settings: map[string]string{"disabled_library_ids": "[3,5]"},
|
||||
@@ -367,7 +367,7 @@ func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
|
||||
|
||||
func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{ID: "prof-1"},
|
||||
settings: map[string]string{"disabled_library_ids": "[2,4]"},
|
||||
@@ -388,22 +388,9 @@ func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledUserIsDenied(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: false, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
|
||||
if !errors.Is(err, ErrUserDisabled) {
|
||||
t.Fatalf("Resolve() error = %v, want ErrUserDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
settings: map[string]string{"disabled_library_ids": "[7]"},
|
||||
}},
|
||||
|
||||
@@ -9,11 +9,8 @@ type Scope struct {
|
||||
LibrariesRestricted bool
|
||||
MaxContentRating string
|
||||
MaxPlaybackQuality string
|
||||
// PreferredMetadataLanguage is the profile's metadata (presentation)
|
||||
// language; "" inherits the library's metadata language.
|
||||
PreferredMetadataLanguage string
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
}
|
||||
|
||||
// ResolveInput is the request input for resolving a viewer access scope.
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
// Package jobrunner owns the lifecycle mechanics shared by Silo's AI job
|
||||
// services (subtitle translation/ASR, metadata translation): bounded dispatch
|
||||
// off a semaphore shared across services, a heartbeat loop that keeps a row
|
||||
// alive even while the job is queued, a stale-job reaper for rows orphaned by
|
||||
// a crashed worker, and a per-job cancel registry. The services keep their own
|
||||
// job tables, validation, and run logic; this package keeps them honest about
|
||||
// concurrency and crash recovery without copy-pasting the trickiest code.
|
||||
package jobrunner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// A running job refreshes its heartbeat every HeartbeatInterval; one whose
|
||||
// heartbeat has not advanced for StaleJobThreshold is treated as orphaned
|
||||
// by a crashed worker and reaped. The margin over HeartbeatInterval avoids
|
||||
// reaping a job that is merely mid–LLM-call.
|
||||
HeartbeatInterval = 30 * time.Second
|
||||
StaleJobThreshold = 2 * time.Minute
|
||||
// How often the background reaper scans for orphaned jobs.
|
||||
ReaperInterval = time.Minute
|
||||
)
|
||||
|
||||
// Store is the minimal persistence surface the runner needs. Both AI job
|
||||
// repositories satisfy it.
|
||||
type Store interface {
|
||||
Heartbeat(ctx context.Context, id int64) error
|
||||
// ResetStaleJobs marks pending/running jobs whose heartbeat predates
|
||||
// `before` as failed with the given message. Returns rows reset.
|
||||
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
|
||||
}
|
||||
|
||||
// NewSemaphore builds the dispatch semaphore shared across runners, so the
|
||||
// configured endpoint sees one global bound regardless of job mix. size <= 0
|
||||
// falls back to 2.
|
||||
func NewSemaphore(size int) chan struct{} {
|
||||
if size <= 0 {
|
||||
size = 2
|
||||
}
|
||||
return make(chan struct{}, size)
|
||||
}
|
||||
|
||||
// Runner executes jobs with bounded concurrency, heartbeats, cancellation,
|
||||
// and crash recovery. One Runner per job table; the semaphore may be shared
|
||||
// across Runners.
|
||||
type Runner struct {
|
||||
// baseCtx is the application context; dispatched jobs and the reaper
|
||||
// derive from it so they stop when the server shuts down.
|
||||
baseCtx context.Context
|
||||
sem chan struct{}
|
||||
store Store
|
||||
// label names the job family in log lines ("subtitle ai", "metadata translation").
|
||||
label string
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
cancels map[int64]context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New wires a runner. A nil appCtx falls back to context.Background(); a nil
|
||||
// sem gets a private default-size semaphore (used by tests; production passes
|
||||
// the shared one).
|
||||
func New(appCtx context.Context, sem chan struct{}, store Store, label string, logger *slog.Logger) *Runner {
|
||||
if appCtx == nil {
|
||||
appCtx = context.Background()
|
||||
}
|
||||
if sem == nil {
|
||||
sem = NewSemaphore(0)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Runner{
|
||||
baseCtx: appCtx,
|
||||
sem: sem,
|
||||
store: store,
|
||||
label: label,
|
||||
logger: logger,
|
||||
cancels: make(map[int64]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// Recover clears jobs orphaned by a crashed worker and starts a background
|
||||
// reaper that keeps doing so. Reaping is heartbeat-based (not "every active
|
||||
// job"), so it is safe when multiple instances share one database: a job
|
||||
// still being heartbeat-updated by a live worker is never reset. Call once at
|
||||
// startup; jobs and the reaper derive from the application context, so they
|
||||
// stop on shutdown.
|
||||
func (r *Runner) Recover() {
|
||||
r.reapStaleJobs()
|
||||
go r.reaperLoop()
|
||||
}
|
||||
|
||||
func (r *Runner) reaperLoop() {
|
||||
ticker := time.NewTicker(ReaperInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.baseCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.reapStaleJobs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) reapStaleJobs() {
|
||||
before := time.Now().Add(-StaleJobThreshold)
|
||||
n, err := r.store.ResetStaleJobs(context.WithoutCancel(r.baseCtx), before, "interrupted by server restart")
|
||||
if err != nil {
|
||||
r.logger.Warn("failed to reset stale jobs", "jobs", r.label, "error", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
r.logger.Info("reset stale jobs", "jobs", r.label, "count", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch launches a bounded background goroutine for job id. run executes
|
||||
// once a semaphore slot is acquired; onAbort runs instead if the job is
|
||||
// cancelled (user cancel or shutdown) while still waiting for a slot. Both
|
||||
// receive a context derived from the application context that is cancelled by
|
||||
// Cancel(id) or server shutdown.
|
||||
func (r *Runner) Dispatch(id int64, run func(ctx context.Context), onAbort func(ctx context.Context)) {
|
||||
runCtx, cancel := context.WithCancel(r.baseCtx)
|
||||
r.mu.Lock()
|
||||
r.cancels[id] = cancel
|
||||
r.mu.Unlock()
|
||||
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
delete(r.cancels, id)
|
||||
r.mu.Unlock()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Heartbeat for the whole lifetime — crucially including while queued
|
||||
// behind the semaphore — so the stale-job reaper never reaps a job that
|
||||
// is alive but merely waiting for a slot (which would otherwise mark it
|
||||
// failed and let it resurrect itself on acquire, or admit a duplicate).
|
||||
stopHeartbeat := make(chan struct{})
|
||||
defer close(stopHeartbeat)
|
||||
go r.heartbeatLoop(runCtx, id, stopHeartbeat)
|
||||
|
||||
select {
|
||||
case r.sem <- struct{}{}:
|
||||
case <-runCtx.Done():
|
||||
if onAbort != nil {
|
||||
onAbort(context.WithoutCancel(runCtx))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer func() { <-r.sem }()
|
||||
|
||||
run(runCtx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Cancel cancels the in-flight goroutine for id, returning false when none is
|
||||
// registered (job on another node, or never dispatched) so the caller can
|
||||
// fall back to a best-effort terminal transition in the database.
|
||||
func (r *Runner) Cancel(id int64) bool {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancels[id]
|
||||
r.mu.Unlock()
|
||||
if cancel == nil {
|
||||
return false
|
||||
}
|
||||
cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
// heartbeatLoop keeps a job's heartbeat_at fresh until the job ends or the
|
||||
// context is cancelled, so the stale-job reaper only ever reaps jobs orphaned
|
||||
// by a crashed worker.
|
||||
func (r *Runner) heartbeatLoop(ctx context.Context, jobID int64, stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = r.store.Heartbeat(context.WithoutCancel(ctx), jobID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package jobrunner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
heartbeats int
|
||||
resets int
|
||||
lastBefore time.Time
|
||||
}
|
||||
|
||||
func (s *fakeStore) Heartbeat(context.Context, int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.heartbeats++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) ResetStaleJobs(_ context.Context, before time.Time, _ string) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.resets++
|
||||
s.lastBefore = before
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) snapshot() (int, time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.resets, s.lastBefore
|
||||
}
|
||||
|
||||
func TestRecoverReapsImmediatelyWithStaleCutoff(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
approxNow := time.Now()
|
||||
New(ctx, nil, store, "test", nil).Recover()
|
||||
|
||||
resets, before := store.snapshot()
|
||||
if resets < 1 {
|
||||
t.Fatalf("Recover did not reap immediately: resets=%d", resets)
|
||||
}
|
||||
want := approxNow.Add(-StaleJobThreshold)
|
||||
if diff := before.Sub(want); diff > 2*time.Second || diff < -2*time.Second {
|
||||
t.Errorf("stale cutoff = %v, want ~%v", before, want)
|
||||
}
|
||||
}
|
||||
|
||||
// One shared semaphore bounds jobs across BOTH runners: with size 1, the
|
||||
// second runner's job must not start until the first runner's job finishes.
|
||||
func TestSharedSemaphoreBoundsAcrossRunners(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sem := NewSemaphore(1)
|
||||
r1 := New(ctx, sem, &fakeStore{}, "one", nil)
|
||||
r2 := New(ctx, sem, &fakeStore{}, "two", nil)
|
||||
|
||||
release := make(chan struct{})
|
||||
aStarted := make(chan struct{})
|
||||
var bStarted atomic.Bool
|
||||
bDone := make(chan struct{})
|
||||
|
||||
r1.Dispatch(1, func(context.Context) {
|
||||
close(aStarted)
|
||||
<-release
|
||||
}, nil)
|
||||
<-aStarted
|
||||
|
||||
r2.Dispatch(2, func(context.Context) {
|
||||
bStarted.Store(true)
|
||||
close(bDone)
|
||||
}, nil)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if bStarted.Load() {
|
||||
t.Fatal("job B ran while job A held the only slot")
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-bDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("job B never ran after the slot freed")
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling a job that is queued behind the semaphore aborts it without
|
||||
// running it, via the onAbort callback.
|
||||
func TestCancelWhileQueuedCallsOnAbort(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sem := NewSemaphore(1)
|
||||
r := New(ctx, sem, &fakeStore{}, "test", nil)
|
||||
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
started := make(chan struct{})
|
||||
r.Dispatch(1, func(context.Context) {
|
||||
close(started)
|
||||
<-release
|
||||
}, nil)
|
||||
<-started
|
||||
|
||||
var ran atomic.Bool
|
||||
aborted := make(chan struct{})
|
||||
r.Dispatch(2, func(context.Context) {
|
||||
ran.Store(true)
|
||||
}, func(context.Context) {
|
||||
close(aborted)
|
||||
})
|
||||
|
||||
if !r.Cancel(2) {
|
||||
t.Fatal("Cancel(2) found no in-flight goroutine")
|
||||
}
|
||||
select {
|
||||
case <-aborted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("onAbort never ran")
|
||||
}
|
||||
if ran.Load() {
|
||||
t.Fatal("cancelled queued job still ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelUnknownJobReturnsFalse(t *testing.T) {
|
||||
r := New(context.Background(), nil, &fakeStore{}, "test", nil)
|
||||
if r.Cancel(99) {
|
||||
t.Fatal("Cancel(99) = true for unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
// The run context is cancelled by Cancel(id) so an in-flight job can stop.
|
||||
func TestCancelRunningJobCancelsContext(t *testing.T) {
|
||||
r := New(context.Background(), NewSemaphore(1), &fakeStore{}, "test", nil)
|
||||
started := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
r.Dispatch(7, func(ctx context.Context) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
close(stopped)
|
||||
}, nil)
|
||||
<-started
|
||||
if !r.Cancel(7) {
|
||||
t.Fatal("Cancel(7) found no in-flight goroutine")
|
||||
}
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("running job did not observe cancellation")
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package jobrunner
|
||||
|
||||
// Status is the lifecycle state shared by every AI job table.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusRunning Status = "running"
|
||||
StatusCompleted Status = "completed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// Terminal reports whether a status is final.
|
||||
func (s Status) Terminal() bool {
|
||||
switch s {
|
||||
case StatusCompleted, StatusFailed, StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
// Package llm provides the shared OpenAI-compatible API client used by every
|
||||
// AI feature in Silo (subtitle translation, metadata translation, Whisper ASR).
|
||||
// One endpoint configuration, one retry/backoff implementation; the operator
|
||||
// can point it at OpenAI, Groq, a local Ollama/llama.cpp/faster-whisper
|
||||
// server, etc.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the connection settings for the shared OpenAI-compatible
|
||||
// endpoints. The ASR fields are optional overrides for operators who run a
|
||||
// separate Whisper-compatible server next to their chat endpoint; when empty,
|
||||
// transcription uses the chat endpoint's base URL and key.
|
||||
type Config struct {
|
||||
BaseURL string // e.g. "https://api.openai.com" (no trailing /v1)
|
||||
APIKey string // empty for keyless local servers
|
||||
ChatModel string // chat-completions model used for translation
|
||||
|
||||
ASRBaseURL string // optional; empty = BaseURL
|
||||
ASRAPIKey string // optional; empty = APIKey
|
||||
ASRModel string // audio-transcription model, e.g. "whisper-1"
|
||||
}
|
||||
|
||||
func (c Config) asrBaseURL() string {
|
||||
if c.ASRBaseURL != "" {
|
||||
return c.ASRBaseURL
|
||||
}
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
func (c Config) asrAPIKey() string {
|
||||
if c.ASRAPIKey != "" {
|
||||
return c.ASRAPIKey
|
||||
}
|
||||
return c.APIKey
|
||||
}
|
||||
|
||||
// ChatConfigured reports whether chat completions are minimally configured.
|
||||
func (c Config) ChatConfigured() bool { return c.BaseURL != "" && c.ChatModel != "" }
|
||||
|
||||
// ASRConfigured reports whether audio transcription is minimally configured.
|
||||
func (c Config) ASRConfigured() bool { return c.asrBaseURL() != "" && c.ASRModel != "" }
|
||||
|
||||
// Client is a minimal OpenAI-compatible API client with shared retry/backoff
|
||||
// conventions (429 with Retry-After, 5xx, transport errors, and gateway "200
|
||||
// with embedded error object" responses are retried; other 4xx fail fast).
|
||||
type Client struct {
|
||||
cfg Config
|
||||
// chatHTTP caps a single chat completion at 10 minutes. asrHTTP has no
|
||||
// client-level timeout: a transcription upload's deadline is set per request
|
||||
// (sized to the chunk duration), which a client timeout would silently cap.
|
||||
chatHTTP *http.Client
|
||||
asrHTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a client from the endpoint config.
|
||||
func NewClient(cfg Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
|
||||
asrHTTP: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
// Message is one chat-completions message.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatResponseFormat struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type chatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature float32 `json:"temperature"`
|
||||
ResponseFormat *chatResponseFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type chatCompletionResponse struct {
|
||||
Choices []struct {
|
||||
Message Message `json:"message"`
|
||||
} `json:"choices"`
|
||||
// Some OpenAI-compatible gateways (e.g. OpenRouter) return a 200 with an
|
||||
// error object instead of an HTTP error status when an upstream provider
|
||||
// fails. We surface and retry on it.
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Chat performs one chat completion and returns the first choice's content.
|
||||
// When jsonObject is true it requests response_format=json_object; providers
|
||||
// that ignore the field still work because the prompt itself demands JSON.
|
||||
func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) (string, error) {
|
||||
reqBody := chatCompletionRequest{
|
||||
Model: c.cfg.ChatModel,
|
||||
Messages: messages,
|
||||
Temperature: 0.2,
|
||||
}
|
||||
if jsonObject {
|
||||
reqBody.ResponseFormat = &chatResponseFormat{Type: "json_object"}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal chat request: %w", err)
|
||||
}
|
||||
|
||||
url := endpointURL(c.cfg.BaseURL, "chat/completions")
|
||||
|
||||
var content string
|
||||
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
|
||||
func() (*http.Request, error) {
|
||||
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if reqErr != nil {
|
||||
return nil, fmt.Errorf("create request: %w", reqErr)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if c.cfg.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||||
}
|
||||
return httpReq, nil
|
||||
},
|
||||
func(respBody []byte) error {
|
||||
var parsed chatCompletionResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return fmt.Errorf("decode chat response: %w", err)
|
||||
}
|
||||
if parsed.Error != nil && parsed.Error.Message != "" {
|
||||
// 200 with an upstream error object — transient on gateways.
|
||||
return fmt.Errorf("chat API error: %s", parsed.Error.Message)
|
||||
}
|
||||
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
|
||||
slog.Warn("AI chat returned no choices, retrying",
|
||||
"model", c.cfg.ChatModel, "response_bytes", len(respBody))
|
||||
return fmt.Errorf("chat API returned no choices")
|
||||
}
|
||||
content = parsed.Choices[0].Message.Content
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// SystemUserChat performs one chat completion from a system + user prompt
|
||||
// pair, requesting a JSON object response. Its signature matches
|
||||
// aitranslate.ChatFn so a client method reference wires straight in.
|
||||
func (c *Client) SystemUserChat(ctx context.Context, system, user string) (string, error) {
|
||||
return c.Chat(ctx, []Message{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
}, true)
|
||||
}
|
||||
|
||||
// permanentError marks a parse failure that retrying cannot fix (e.g. an
|
||||
// endpoint that structurally does not support the requested response format).
|
||||
type permanentError struct{ err error }
|
||||
|
||||
func (e *permanentError) Error() string { return e.err.Error() }
|
||||
func (e *permanentError) Unwrap() error { return e.err }
|
||||
|
||||
// doWithRetry runs the shared request/retry loop: build constructs a fresh
|
||||
// request per attempt, parse consumes a 200 body. Transport errors, read
|
||||
// errors, 429 (honoring Retry-After), 5xx, and retryable parse errors back
|
||||
// off and retry; other 4xx and permanentError parse failures return at once.
|
||||
func (c *Client) doWithRetry(ctx context.Context, httpClient *http.Client, label string,
|
||||
build func() (*http.Request, error), parse func(body []byte) error) error {
|
||||
const maxAttempts = 6
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
httpReq, err := build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, doErr := httpClient.Do(httpReq)
|
||||
if doErr != nil {
|
||||
lastErr = fmt.Errorf("%s request failed: %w", label, doErr)
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
// A truncated/failed read could otherwise be misparsed as a valid
|
||||
// (empty) response; treat it as a retryable transport error.
|
||||
lastErr = fmt.Errorf("read %s response: %w", label, readErr)
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
wait := rateLimitBackoff(resp, attempt)
|
||||
slog.Warn("rate limited by AI API, waiting", "api", label, "attempt", attempt+1, "wait", wait)
|
||||
lastErr = fmt.Errorf("%s returned 429: %s", label, Truncate(string(respBody), 300))
|
||||
if waitErr := sleepCtx(ctx, wait); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
case resp.StatusCode >= 500:
|
||||
lastErr = fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
// 4xx other than 429: not retryable.
|
||||
return fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
|
||||
}
|
||||
|
||||
parseErr := parse(respBody)
|
||||
if parseErr == nil {
|
||||
return nil
|
||||
}
|
||||
var perm *permanentError
|
||||
if errors.As(parseErr, &perm) {
|
||||
return perm.err
|
||||
}
|
||||
lastErr = parseErr
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("%s: retries exhausted", label)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// endpointURL joins a configured base URL with an OpenAI API path,
|
||||
// tolerating bases that already include the version segment (e.g.
|
||||
// DeepInfra's https://api.deepinfra.com/v1/openai) alongside bare hosts
|
||||
// (https://api.openai.com) and prefixed hosts (https://api.groq.com/openai).
|
||||
func endpointURL(base, path string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.Contains(base, "/v1") {
|
||||
return base + "/" + path
|
||||
}
|
||||
return base + "/v1/" + path
|
||||
}
|
||||
|
||||
// Truncate caps a string for inclusion in an error or log line.
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sleepCtx waits for d or until ctx is cancelled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(d):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// rateLimitBackoff returns how long to wait after a 429, honoring Retry-After
|
||||
// and otherwise backing off exponentially (capped at 60s).
|
||||
func rateLimitBackoff(resp *http.Response, attempt int) time.Duration {
|
||||
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
wait := 10 * time.Second * (1 << attempt)
|
||||
if wait > 60*time.Second {
|
||||
wait = 60 * time.Second
|
||||
}
|
||||
return wait
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func chatConfig(baseURL string) Config {
|
||||
return Config{BaseURL: baseURL, APIKey: "test-key", ChatModel: "test-model"}
|
||||
}
|
||||
|
||||
const chatOK = `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`
|
||||
|
||||
func TestChatSuccessSendsAuthAndModel(t *testing.T) {
|
||||
var gotAuth, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
gotBody = string(buf[:n])
|
||||
w.Write([]byte(chatOK))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if out != "hello" {
|
||||
t.Errorf("content = %q, want hello", out)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Errorf("auth = %q", gotAuth)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"model":"test-model"`) || !strings.Contains(gotBody, `"json_object"`) {
|
||||
t.Errorf("request body missing model/response_format: %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRetriesOn429HonoringRetryAfter(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(chatOK))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
start := time.Now()
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("calls = %d, want 2", calls.Load())
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < time.Second {
|
||||
t.Errorf("did not honor Retry-After: elapsed %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRetriesOn5xxAndEmbeddedErrorAndEmptyChoices(t *testing.T) {
|
||||
responses := []func(w http.ResponseWriter){
|
||||
func(w http.ResponseWriter) { w.WriteHeader(http.StatusBadGateway) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(`{"error":{"message":"upstream sad"}}`)) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(`{"choices":[]}`)) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(chatOK)) },
|
||||
}
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
responses[calls.Add(1)-1](w)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if out != "hello" || calls.Load() != 4 {
|
||||
t.Errorf("out=%q calls=%d, want hello/4", out, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFailsFastOnNon429ClientError(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("calls = %d, want 1 (no retry on 401)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
const verboseJSON = `{"language":"english","text":"hi there","segments":[{"start":0.0,"end":1.5,"text":" hi"},{"start":1.5,"end":3.0,"text":" there"}]}`
|
||||
|
||||
func TestTranscribeParsesSegmentsAndMultipart(t *testing.T) {
|
||||
var gotModel, gotFormat, gotLang, gotAuth, gotVAD string
|
||||
var gotGranularities []string
|
||||
var gotFile []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
t.Errorf("parse multipart: %v", err)
|
||||
}
|
||||
gotModel = r.FormValue("model")
|
||||
gotFormat = r.FormValue("response_format")
|
||||
gotLang = r.FormValue("language")
|
||||
gotVAD = r.FormValue("vad_filter")
|
||||
gotGranularities = r.MultipartForm.Value["timestamp_granularities[]"]
|
||||
f, _, err := r.FormFile("file")
|
||||
if err == nil {
|
||||
buf := make([]byte, 64)
|
||||
n, _ := f.Read(buf)
|
||||
gotFile = buf[:n]
|
||||
f.Close()
|
||||
}
|
||||
w.Write([]byte(verboseJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
c := NewClient(cfg)
|
||||
tr, err := c.Transcribe(context.Background(), TranscribeRequest{
|
||||
Filename: "chunk.wav", Audio: []byte("RIFFfake"), Language: "ja",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if gotModel != "whisper-test" || gotFormat != "verbose_json" || gotLang != "ja" {
|
||||
t.Errorf("fields model=%q format=%q lang=%q", gotModel, gotFormat, gotLang)
|
||||
}
|
||||
// Local (non-hosted) endpoint: faster-whisper VAD must be requested or
|
||||
// segment timestamps stretch wall-to-wall across silence.
|
||||
if gotVAD != "true" {
|
||||
t.Errorf("vad_filter = %q, want true for a self-hosted endpoint", gotVAD)
|
||||
}
|
||||
if fmt.Sprint(gotGranularities) != "[segment word]" {
|
||||
t.Errorf("timestamp_granularities[] = %v, want [segment word]", gotGranularities)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Errorf("auth = %q (should fall back to chat key)", gotAuth)
|
||||
}
|
||||
if string(gotFile) != "RIFFfake" {
|
||||
t.Errorf("file payload = %q", gotFile)
|
||||
}
|
||||
if tr.Language != "english" || len(tr.Segments) != 2 || tr.Segments[1].Text != " there" || tr.Segments[1].End != 3.0 {
|
||||
t.Errorf("unexpected transcription: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeOmitsVADFilterForStrictHostedEndpoints(t *testing.T) {
|
||||
for url, wantVAD := range map[string]bool{
|
||||
"https://api.openai.com": false,
|
||||
"https://myorg.openai.azure.com": false,
|
||||
"https://api.groq.com/openai": false,
|
||||
"http://192.168.1.10:8000": true,
|
||||
"https://whisper.example.com": true,
|
||||
"https://api.deepinfra.com/v1/openai": true,
|
||||
} {
|
||||
got := !hostMatchesAny(url, strictHostedASRHosts)
|
||||
if got != wantVAD {
|
||||
t.Errorf("vad_filter for %s = %v, want %v", url, got, wantVAD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeParsesPerSegmentWords(t *testing.T) {
|
||||
// speaches/faster-whisper shape: words nested inside each segment.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"en","text":"hi there","segments":[
|
||||
{"start":0.0,"end":3.0,"text":" hi there","words":[
|
||||
{"start":0.2,"end":0.5,"word":" hi"},{"start":0.6,"end":1.0,"word":" there"}]}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
words := tr.Segments[0].Words
|
||||
if len(words) != 2 || words[1].Text != " there" || words[1].Start != 0.6 || words[1].End != 1.0 {
|
||||
t.Errorf("segment words = %+v", words)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeAttachesTopLevelWordsBySegmentTime(t *testing.T) {
|
||||
// OpenAI shape: words in a top-level array, segments without words.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"en","text":"hi there friend","segments":[
|
||||
{"start":0.0,"end":1.5,"text":" hi there"},{"start":1.5,"end":3.0,"text":" friend"}],
|
||||
"words":[{"start":0.2,"end":0.5,"word":"hi"},{"start":0.6,"end":1.0,"word":"there"},
|
||||
{"start":1.8,"end":2.2,"word":"friend"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if len(tr.Segments[0].Words) != 2 || len(tr.Segments[1].Words) != 1 {
|
||||
t.Errorf("word distribution = %d/%d, want 2/1", len(tr.Segments[0].Words), len(tr.Segments[1].Words))
|
||||
}
|
||||
if tr.Segments[1].Words[0].Text != "friend" {
|
||||
t.Errorf("segment 1 word = %+v", tr.Segments[1].Words[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeEmptySegmentsIsNotAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"english","text":"","segments":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if len(tr.Segments) != 0 {
|
||||
t.Errorf("segments = %v, want empty", tr.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeMissingSegmentsFieldFailsFast(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Write([]byte(`{"text":"plain response without segments"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "verbose_json") {
|
||||
t.Fatalf("err = %v, want verbose_json complaint", err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("calls = %d, want 1 (permanent error must not retry)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeUsesASROverrides(t *testing.T) {
|
||||
var gotAuth atomic.Value
|
||||
asrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth.Store(r.Header.Get("Authorization"))
|
||||
w.Write([]byte(verboseJSON))
|
||||
}))
|
||||
defer asrSrv.Close()
|
||||
|
||||
cfg := Config{
|
||||
BaseURL: "http://chat.invalid", APIKey: "chat-key", ChatModel: "m",
|
||||
ASRBaseURL: asrSrv.URL, ASRAPIKey: "asr-key", ASRModel: "whisper-test",
|
||||
}
|
||||
if _, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if gotAuth.Load() != "Bearer asr-key" {
|
||||
t.Errorf("auth = %q, want asr-key", gotAuth.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRequiresConfig(t *testing.T) {
|
||||
c := NewClient(Config{BaseURL: "http://x", ChatModel: "m"}) // no ASR model
|
||||
if _, err := c.Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err == nil {
|
||||
t.Fatal("expected not-configured error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeChatOnlyGatewayGetsConfigHint(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":{"message":"invalid content-type: multipart/form-data","code":400}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "Whisper-compatible Transcription base URL") {
|
||||
t.Fatalf("err = %v, want configuration hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsChatOnlyGateway(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://openrouter.ai/api": true,
|
||||
"https://openrouter.ai": true,
|
||||
"openrouter.ai/api/v1": true,
|
||||
"https://gateway.openrouter.ai": true,
|
||||
"https://api.groq.com/openai": false,
|
||||
"https://api.openai.com": false,
|
||||
"http://localhost:8000": false,
|
||||
"https://my-openrouter.ai.example.io": false,
|
||||
"": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := IsChatOnlyGateway(in); got != want {
|
||||
t.Errorf("IsChatOnlyGateway(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRefusesChatOnlyGateway(t *testing.T) {
|
||||
cfg := Config{BaseURL: "https://openrouter.ai/api", ChatModel: "m", ASRModel: "whisper-1"}
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot produce timestamped transcriptions") {
|
||||
t.Fatalf("err = %v, want chat-only refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointURLToleratesVersionedBases(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://api.openai.com": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.groq.com/openai": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"https://api.deepinfra.com/v1/openai": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"http://localhost:8969": "http://localhost:8969/v1/chat/completions",
|
||||
"http://localhost:8969/v1": "http://localhost:8969/v1/chat/completions",
|
||||
"https://api.openai.com/": "https://api.openai.com/v1/chat/completions",
|
||||
}
|
||||
for base, want := range cases {
|
||||
if got := endpointURL(base, "chat/completions"); got != want {
|
||||
t.Errorf("endpointURL(%q) = %q, want %q", base, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultTranscribeTimeout bounds a single transcription request when the
|
||||
// caller does not size one to its chunk duration. Generous on purpose: local
|
||||
// Whisper servers on modest hardware can run well below realtime.
|
||||
const defaultTranscribeTimeout = 20 * time.Minute
|
||||
|
||||
// TranscribeRequest is one audio-transcription call. Audio is held in memory
|
||||
// so the request can be rebuilt across retries; callers chunk long files
|
||||
// (a 10-minute 16 kHz mono WAV is ~19 MB).
|
||||
type TranscribeRequest struct {
|
||||
Filename string // e.g. "chunk00001.wav"; the extension hints the container
|
||||
Audio []byte
|
||||
Language string // optional ISO-639-1 hint; empty lets the model detect
|
||||
Timeout time.Duration // per-request deadline; 0 uses defaultTranscribeTimeout
|
||||
}
|
||||
|
||||
// TranscriptionSegment is one timed segment of recognized speech, in seconds
|
||||
// relative to the start of the submitted audio.
|
||||
type TranscriptionSegment struct {
|
||||
Start float64
|
||||
End float64
|
||||
Text string
|
||||
// Words carries per-word timings when the endpoint honors
|
||||
// timestamp_granularities[]=word; empty otherwise. Word-level times let
|
||||
// cue building split paragraph-length segments and end cues when speech
|
||||
// actually stops instead of when the next segment begins.
|
||||
Words []TranscriptionWord
|
||||
}
|
||||
|
||||
// TranscriptionWord is one recognized word with timing, in seconds relative
|
||||
// to the start of the submitted audio.
|
||||
type TranscriptionWord struct {
|
||||
Start float64
|
||||
End float64
|
||||
Text string
|
||||
}
|
||||
|
||||
// Transcription is the parsed verbose_json transcription result.
|
||||
type Transcription struct {
|
||||
// Language is the detected (or hinted) language as reported by the
|
||||
// endpoint. OpenAI returns an English language name ("english"); other
|
||||
// servers return ISO codes. Callers must normalize.
|
||||
Language string
|
||||
// Segments may legitimately be empty for speech-free audio (silence,
|
||||
// music-only chunks).
|
||||
Segments []TranscriptionSegment
|
||||
}
|
||||
|
||||
type transcriptionWordJSON struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Word string `json:"word"`
|
||||
}
|
||||
|
||||
type transcriptionResponse struct {
|
||||
Language string `json:"language"`
|
||||
Text string `json:"text"`
|
||||
// Segments distinguishes "verbose_json honored, no speech" (empty array)
|
||||
// from "endpoint ignored verbose_json" (field absent) — the latter cannot
|
||||
// produce timed cues and must fail rather than silently emit nothing.
|
||||
Segments *[]struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Words []transcriptionWordJSON `json:"words"`
|
||||
} `json:"segments"`
|
||||
// Words is where OpenAI puts word timings; faster-whisper servers
|
||||
// (speaches) nest them inside each segment instead.
|
||||
Words []transcriptionWordJSON `json:"words"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// chatOnlyGatewayHosts lists OpenAI-compatible gateways that cannot serve
|
||||
// Silo's transcription needs: they either lack /v1/audio/transcriptions
|
||||
// entirely or (OpenRouter) return plain text with no segment timestamps,
|
||||
// which subtitle cues require. Matched by host suffix.
|
||||
var chatOnlyGatewayHosts = []string{
|
||||
"openrouter.ai",
|
||||
}
|
||||
|
||||
// strictHostedASRHosts lists hosted transcription providers that reject
|
||||
// multipart fields outside the OpenAI spec, so the faster-whisper-only
|
||||
// vad_filter field must be omitted for them. Nothing is lost: hosted Whisper
|
||||
// runs voice-activity detection server-side. Self-hosted servers (speaches,
|
||||
// faster-whisper) need the explicit field — without it segment timestamps
|
||||
// stretch wall-to-wall across silence. Matched by host suffix.
|
||||
var strictHostedASRHosts = []string{
|
||||
"api.openai.com",
|
||||
"openai.azure.com",
|
||||
"api.groq.com",
|
||||
"api.mistral.ai",
|
||||
}
|
||||
|
||||
// IsChatOnlyGateway reports whether baseURL points at a known chat-only
|
||||
// gateway that cannot produce timestamped transcriptions. Used to validate
|
||||
// the transcription settings and to disable ASR rather than fail jobs at
|
||||
// runtime.
|
||||
func IsChatOnlyGateway(baseURL string) bool {
|
||||
return hostMatchesAny(baseURL, chatOnlyGatewayHosts)
|
||||
}
|
||||
|
||||
// hostMatchesAny reports whether baseURL's hostname equals or is a subdomain
|
||||
// of any of the given host suffixes.
|
||||
func hostMatchesAny(baseURL string, hosts []string) bool {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(baseURL, "://") {
|
||||
baseURL = "https://" + baseURL
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
for _, h := range hosts {
|
||||
if host == h || strings.HasSuffix(host, "."+h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Transcribe performs one audio transcription against the ASR endpoint
|
||||
// (falling back to the chat endpoint's base URL/key when no ASR override is
|
||||
// configured), using the OpenAI-compatible /v1/audio/transcriptions API with
|
||||
// response_format=verbose_json for segment timestamps.
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error) {
|
||||
if !c.cfg.ASRConfigured() {
|
||||
return nil, fmt.Errorf("transcription endpoint is not configured")
|
||||
}
|
||||
if IsChatOnlyGateway(c.cfg.asrBaseURL()) {
|
||||
return nil, fmt.Errorf("the configured transcription endpoint (%s) cannot produce timestamped transcriptions; "+
|
||||
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", c.cfg.asrBaseURL())
|
||||
}
|
||||
if len(req.Audio) == 0 {
|
||||
return nil, fmt.Errorf("no audio data to transcribe")
|
||||
}
|
||||
|
||||
timeout := req.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTranscribeTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
url := endpointURL(c.cfg.asrBaseURL(), "audio/transcriptions")
|
||||
|
||||
var result *Transcription
|
||||
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
|
||||
func() (*http.Request, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", req.Filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create multipart file: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(req.Audio); err != nil {
|
||||
return nil, fmt.Errorf("write multipart audio: %w", err)
|
||||
}
|
||||
fields := [][2]string{
|
||||
{"model", c.cfg.ASRModel},
|
||||
{"response_format", "verbose_json"},
|
||||
{"temperature", "0"},
|
||||
// Word timings let cue building split paragraph-length
|
||||
// segments and end cues when speech actually stops; segment
|
||||
// granularity must be requested alongside or OpenAI omits it.
|
||||
{"timestamp_granularities[]", "segment"},
|
||||
{"timestamp_granularities[]", "word"},
|
||||
}
|
||||
if req.Language != "" {
|
||||
fields = append(fields, [2]string{"language", req.Language})
|
||||
}
|
||||
if !hostMatchesAny(c.cfg.asrBaseURL(), strictHostedASRHosts) {
|
||||
fields = append(fields, [2]string{"vad_filter", "true"})
|
||||
}
|
||||
for _, f := range fields {
|
||||
if err := w.WriteField(f[0], f[1]); err != nil {
|
||||
return nil, fmt.Errorf("write multipart field %s: %w", f[0], err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalize multipart body: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", w.FormDataContentType())
|
||||
if key := c.cfg.asrAPIKey(); key != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
return httpReq, nil
|
||||
},
|
||||
func(respBody []byte) error {
|
||||
var parsed transcriptionResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return fmt.Errorf("decode transcription response: %w", err)
|
||||
}
|
||||
if parsed.Error != nil && parsed.Error.Message != "" {
|
||||
return fmt.Errorf("transcription API error: %s", parsed.Error.Message)
|
||||
}
|
||||
if parsed.Segments == nil {
|
||||
return &permanentError{err: fmt.Errorf(
|
||||
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", c.cfg.ASRModel)}
|
||||
}
|
||||
out := &Transcription{Language: parsed.Language}
|
||||
for _, s := range *parsed.Segments {
|
||||
out.Segments = append(out.Segments, TranscriptionSegment{
|
||||
Start: s.Start, End: s.End, Text: s.Text, Words: wordsFromJSON(s.Words),
|
||||
})
|
||||
}
|
||||
attachTopLevelWords(out.Segments, wordsFromJSON(parsed.Words))
|
||||
result = out
|
||||
return nil
|
||||
})
|
||||
if doErr != nil {
|
||||
return nil, decorateTranscribeError(doErr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func wordsFromJSON(words []transcriptionWordJSON) []TranscriptionWord {
|
||||
out := make([]TranscriptionWord, 0, len(words))
|
||||
for _, w := range words {
|
||||
out = append(out, TranscriptionWord{Start: w.Start, End: w.End, Text: w.Word})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attachTopLevelWords distributes an OpenAI-style top-level word list onto
|
||||
// segments by word midpoint, for endpoints that report words separately from
|
||||
// segments. Segments that already carry their own words are left untouched.
|
||||
func attachTopLevelWords(segments []TranscriptionSegment, words []TranscriptionWord) {
|
||||
if len(segments) == 0 || len(words) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range segments {
|
||||
if len(s.Words) > 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
si := 0
|
||||
for _, w := range words {
|
||||
mid := (w.Start + w.End) / 2
|
||||
for si < len(segments)-1 && mid >= segments[si].End {
|
||||
si++
|
||||
}
|
||||
segments[si].Words = append(segments[si].Words, w)
|
||||
}
|
||||
}
|
||||
|
||||
// decorateTranscribeError appends a configuration hint to the errors a
|
||||
// chat-only gateway produces when it receives a transcription upload (no
|
||||
// /v1/audio/transcriptions route, or multipart rejected). Operators routinely
|
||||
// point the shared base URL at a chat-only provider; without the hint the raw
|
||||
// 400/404 reads like a pipeline bug instead of "set a Whisper endpoint".
|
||||
func decorateTranscribeError(err error) error {
|
||||
msg := err.Error()
|
||||
likelyUnsupported := strings.Contains(msg, "returned 400") ||
|
||||
strings.Contains(msg, "returned 404") ||
|
||||
strings.Contains(msg, "returned 405")
|
||||
if !likelyUnsupported {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w — the configured endpoint likely does not support audio transcription; "+
|
||||
"set a Whisper-compatible Transcription base URL (and model) under Admin Settings → AI Services "+
|
||||
"(e.g. a self-hosted faster-whisper/speaches server, api.groq.com/openai with whisper-large-v3-turbo, or api.openai.com with whisper-1)", err)
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
// Package aitranslate implements generic batched text translation over an
|
||||
// OpenAI-compatible chat model. Callers hand it ordered text segments and a
|
||||
// domain-specific system prompt; it owns the wire protocol (indexed-JSON
|
||||
// batches the model must echo back key-for-key), context carryover between
|
||||
// batches, and retries on malformed model output. Subtitle cues and metadata
|
||||
// descriptions both translate through this one implementation.
|
||||
package aitranslate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Segment is one unit of translatable text. ID is the caller's identifier and
|
||||
// is never sent to the model (batches are keyed by position), so any stable
|
||||
// string works.
|
||||
type Segment struct {
|
||||
ID string
|
||||
Text string
|
||||
}
|
||||
|
||||
// ChatFn performs one chat completion. Wiring this as a function keeps the
|
||||
// package independent of the client and trivially testable.
|
||||
type ChatFn func(ctx context.Context, system, user string) (string, error)
|
||||
|
||||
// Request describes one translation run.
|
||||
type Request struct {
|
||||
Segments []Segment
|
||||
SystemPrompt string // domain-specific; must demand same-keys JSON output
|
||||
// TargetName is the human-readable target language name used in the
|
||||
// per-batch user message.
|
||||
TargetName string
|
||||
// EntryNoun names the units in the user message ("cues", "descriptions");
|
||||
// empty defaults to "entries".
|
||||
EntryNoun string
|
||||
// BatchSize is the number of segments per chat request (required, > 0).
|
||||
BatchSize int
|
||||
// ContextNeighbors is how many preceding source segments are included,
|
||||
// untranslated, for continuity across batch boundaries.
|
||||
ContextNeighbors int
|
||||
}
|
||||
|
||||
// maxFormatRetries is how many times a batch is re-asked after the model
|
||||
// returns unparsable or incomplete JSON. Transport/API errors are retried
|
||||
// inside the chat client and are not part of this count.
|
||||
const maxFormatRetries = 2
|
||||
|
||||
// Translate translates req.Segments in order, preserving count and IDs and
|
||||
// only rewriting Text. onBatch, when non-nil, is called after each batch with
|
||||
// that batch's translated segments and overall progress (done/total
|
||||
// segments). Implementations must honor ctx cancellation between batches.
|
||||
func Translate(ctx context.Context, chat ChatFn, req Request, onBatch func(batch []Segment, done, total int)) ([]Segment, error) {
|
||||
if chat == nil {
|
||||
return nil, fmt.Errorf("chat function is nil")
|
||||
}
|
||||
if req.BatchSize <= 0 {
|
||||
return nil, fmt.Errorf("invalid batch size: %d", req.BatchSize)
|
||||
}
|
||||
total := len(req.Segments)
|
||||
if total == 0 {
|
||||
return nil, fmt.Errorf("no segments to translate")
|
||||
}
|
||||
noun := req.EntryNoun
|
||||
if noun == "" {
|
||||
noun = "entries"
|
||||
}
|
||||
|
||||
// Preserve IDs by copying input segments and only replacing Text.
|
||||
out := make([]Segment, total)
|
||||
copy(out, req.Segments)
|
||||
|
||||
for start := 0; start < total; start += req.BatchSize {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := min(start+req.BatchSize, total)
|
||||
|
||||
contextStart := max(0, start-req.ContextNeighbors)
|
||||
translated, err := translateBatch(ctx, chat, req, noun, req.Segments[contextStart:start], req.Segments[start:end])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("translate %s %d-%d: %w", noun, start+1, end, err)
|
||||
}
|
||||
for i, text := range translated {
|
||||
out[start+i].Text = text
|
||||
}
|
||||
if onBatch != nil {
|
||||
onBatch(out[start:end], end, total)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func translateBatch(ctx context.Context, chat ChatFn, req Request, noun string, contextSegs, batch []Segment) ([]string, error) {
|
||||
texts := make([]string, len(batch))
|
||||
for i, s := range batch {
|
||||
texts[i] = s.Text
|
||||
}
|
||||
payload, err := buildIndexedJSON(texts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user strings.Builder
|
||||
if len(contextSegs) > 0 {
|
||||
user.WriteString("Preceding lines for context only — do not translate or include them in your output:\n")
|
||||
for _, s := range contextSegs {
|
||||
user.WriteString(strings.ReplaceAll(s.Text, "\n", " "))
|
||||
user.WriteByte('\n')
|
||||
}
|
||||
user.WriteByte('\n')
|
||||
}
|
||||
target := req.TargetName
|
||||
if target == "" {
|
||||
target = "the target language"
|
||||
}
|
||||
fmt.Fprintf(&user, "Translate these %d %s into %s. Respond with only the JSON object:\n%s", len(batch), noun, target, payload)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxFormatRetries; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := chat(ctx, req.SystemPrompt, user.String())
|
||||
if err != nil {
|
||||
return nil, err // transport/API errors are already retried inside the client
|
||||
}
|
||||
|
||||
obj, err := extractJSONObject(content)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(obj), &m); err != nil {
|
||||
lastErr = fmt.Errorf("decode translation JSON: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
out := make([]string, len(batch))
|
||||
complete := true
|
||||
for i := range batch {
|
||||
v, ok := m[strconv.Itoa(i+1)]
|
||||
if !ok {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
if !complete {
|
||||
lastErr = fmt.Errorf("model omitted one or more %s", noun)
|
||||
continue
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid model response after %d attempts: %w", maxFormatRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// buildIndexedJSON renders texts as a JSON object {"1":..., "2":...} keyed by
|
||||
// 1-based position, escaping each value safely. It is built by hand rather
|
||||
// than json.Marshal'ing a map so the keys stay in numeric order — that reads
|
||||
// more naturally for the model than the lexicographic order Go emits for maps
|
||||
// ("1","10","11",...,"2"). Correctness doesn't depend on order (results are
|
||||
// mapped back by key), but ordered input gives the model better context.
|
||||
func buildIndexedJSON(texts []string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteByte('{')
|
||||
for i, text := range texts {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
key, err := json.Marshal(strconv.Itoa(i + 1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
val, err := json.Marshal(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.Write(key)
|
||||
b.WriteByte(':')
|
||||
b.Write(val)
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// extractJSONObject pulls the first balanced-looking JSON object out of a
|
||||
// model reply, tolerating ``` code fences and surrounding prose.
|
||||
func extractJSONObject(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "```") {
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
if nl := strings.IndexByte(s, '\n'); nl >= 0 {
|
||||
s = s[nl+1:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, "```"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
start := strings.IndexByte(s, '{')
|
||||
end := strings.LastIndexByte(s, '}')
|
||||
if start < 0 || end < 0 || end < start {
|
||||
return "", fmt.Errorf("no JSON object found in model response")
|
||||
}
|
||||
return s[start : end+1], nil
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package aitranslate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// echoChat translates by upper-casing every value of the indexed JSON payload
|
||||
// in the user message, mimicking a well-behaved model.
|
||||
func echoChat(t *testing.T) ChatFn {
|
||||
t.Helper()
|
||||
return func(_ context.Context, _ string, user string) (string, error) {
|
||||
obj, err := extractJSONObject(user)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("test chat: %w", err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(obj), &m); err != nil {
|
||||
return "", fmt.Errorf("test chat decode: %w", err)
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = strings.ToUpper(v)
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
|
||||
func segments(n int) []Segment {
|
||||
out := make([]Segment, n)
|
||||
for i := range out {
|
||||
out[i] = Segment{ID: strconv.Itoa(i + 1), Text: fmt.Sprintf("line %d", i+1)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTranslateBatchesAndPreservesIDs(t *testing.T) {
|
||||
var batches []int
|
||||
out, err := Translate(context.Background(), echoChat(t), Request{
|
||||
Segments: segments(7),
|
||||
BatchSize: 3,
|
||||
}, func(batch []Segment, done, total int) {
|
||||
batches = append(batches, len(batch))
|
||||
if total != 7 {
|
||||
t.Errorf("total = %d, want 7", total)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if len(out) != 7 {
|
||||
t.Fatalf("len(out) = %d", len(out))
|
||||
}
|
||||
for i, s := range out {
|
||||
if s.ID != strconv.Itoa(i+1) {
|
||||
t.Errorf("ID[%d] = %q, want %d", i, s.ID, i+1)
|
||||
}
|
||||
if want := strings.ToUpper(fmt.Sprintf("line %d", i+1)); s.Text != want {
|
||||
t.Errorf("Text[%d] = %q, want %q", i, s.Text, want)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(batches) != "[3 3 1]" {
|
||||
t.Errorf("batch sizes = %v, want [3 3 1]", batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateSendsContextNeighborsUntranslated(t *testing.T) {
|
||||
var secondBatchMsg string
|
||||
call := 0
|
||||
chat := func(ctx context.Context, system, user string) (string, error) {
|
||||
call++
|
||||
if call == 2 {
|
||||
secondBatchMsg = user
|
||||
}
|
||||
return echoChat(t)(ctx, system, user)
|
||||
}
|
||||
_, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(4),
|
||||
BatchSize: 2,
|
||||
ContextNeighbors: 2,
|
||||
TargetName: "French",
|
||||
EntryNoun: "cues",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if !strings.Contains(secondBatchMsg, "context only") ||
|
||||
!strings.Contains(secondBatchMsg, "line 1\nline 2") {
|
||||
t.Errorf("second batch missing context block:\n%s", secondBatchMsg)
|
||||
}
|
||||
if !strings.Contains(secondBatchMsg, "Translate these 2 cues into French.") {
|
||||
t.Errorf("second batch missing instruction:\n%s", secondBatchMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateToleratesCodeFences(t *testing.T) {
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
return "Sure! Here you go:\n```json\n{\"1\":\"ok\"}\n```", nil
|
||||
}
|
||||
out, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(1),
|
||||
BatchSize: 5,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if out[0].Text != "ok" {
|
||||
t.Errorf("Text = %q", out[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateRetriesOmittedKeysThenFails(t *testing.T) {
|
||||
calls := 0
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
calls++
|
||||
return `{"1":"only one"}`, nil // always omits key "2"
|
||||
}
|
||||
_, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(2),
|
||||
BatchSize: 5,
|
||||
}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "omitted") {
|
||||
t.Fatalf("err = %v, want omission failure", err)
|
||||
}
|
||||
if calls != maxFormatRetries+1 {
|
||||
t.Errorf("calls = %d, want %d", calls, maxFormatRetries+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStopsOnCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
calls++
|
||||
cancel() // cancel after the first batch completes
|
||||
return `{"1":"a"}`, nil
|
||||
}
|
||||
_, err := Translate(ctx, chat, Request{
|
||||
Segments: segments(3),
|
||||
BatchSize: 1,
|
||||
}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Fatalf("err = %v, want context cancellation", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,12 @@ type autoscanQueuer = autoscan.Queuer
|
||||
|
||||
// RequestIntegrationLookup adapts the Requests repository to the autoscan
|
||||
// connection resolver's RequestIntegrationLookup: it resolves a soft-linked
|
||||
// Requests integration to its base URL and api key (the repo decrypts the key
|
||||
// on read, so this returns plaintext).
|
||||
// Requests integration to its base URL and (write-only) api-key ref.
|
||||
type RequestIntegrationLookup struct {
|
||||
Repo *mediarequests.Repository
|
||||
}
|
||||
|
||||
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error) {
|
||||
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKeyRef string, err error) {
|
||||
integration, err := l.Repo.GetIntegration(ctx, integrationID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -65,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
|
||||
@@ -76,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
|
||||
}
|
||||
@@ -96,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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,22 +135,27 @@ func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
|
||||
}
|
||||
}
|
||||
|
||||
// AutoscanSecretResolver resolves an encrypted api-key reference to plaintext.
|
||||
// It is satisfied by the server settings repo (Get(ctx, key) (string, error)).
|
||||
type AutoscanSecretResolver interface {
|
||||
Get(ctx context.Context, ref string) (string, error)
|
||||
}
|
||||
|
||||
// BuildAutoscanService wires the v2 autoscan engine from its concrete
|
||||
// dependencies. Both the HTTP router (manual trigger) and the background poll
|
||||
// task share this constructor so the adapter wiring lives in exactly one place.
|
||||
// Credentials are now decrypted inline by the autoscan/requests repos, so there
|
||||
// is no separate secret resolver to thread.
|
||||
func BuildAutoscanService(
|
||||
repo *autoscan.Repository,
|
||||
pluginService *plugins.Service,
|
||||
installationStore *plugins.InstallationStore,
|
||||
requestsRepo *mediarequests.Repository,
|
||||
secrets AutoscanSecretResolver,
|
||||
folderRepo *catalog.FolderRepository,
|
||||
queue autoscanQueuer,
|
||||
redisClient *redis.Client,
|
||||
) *autoscan.Service {
|
||||
provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService})
|
||||
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo})
|
||||
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo}, secrets)
|
||||
svc := autoscan.NewService(
|
||||
repo,
|
||||
provider,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
|
||||
// connection is rejected when the integration is disabled or has no base_url, so
|
||||
@@ -26,31 +22,3 @@ func TestCheckRequestIntegrationUsable(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It
|
||||
// locks the autoscan→requests credential-reuse behavior after the
|
||||
// request_integrations table was generalized into the two-tier connection
|
||||
// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection
|
||||
// rows via the same enabled + base_url gate, regardless of the added
|
||||
// capability/installation columns.
|
||||
func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) {
|
||||
// Enabled + non-blank base_url -> usable (autoscan can reuse the connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil {
|
||||
t.Fatalf("enabled integration with base_url should be usable, got: %v", err)
|
||||
}
|
||||
// Disabled -> error (autoscan must not poll a disabled connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil {
|
||||
t.Fatal("disabled integration must be rejected")
|
||||
}
|
||||
// Blank base_url -> error.
|
||||
if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil {
|
||||
t.Fatal("blank base_url must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off
|
||||
// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks —
|
||||
// fail the build here rather than silently at runtime.
|
||||
var _ = func(in mediarequests.Integration) (string, string, bool) {
|
||||
return in.BaseURL, in.APIKeyRef, in.Enabled
|
||||
}
|
||||
|
||||
+229
-138
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -20,7 +21,6 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -102,17 +101,11 @@ func NewAdminHandler(
|
||||
pool *pgxpool.Pool,
|
||||
storeProv userstore.UserStoreProvider,
|
||||
) *AdminHandler {
|
||||
var groupResolver auth.GroupResolver
|
||||
if pool != nil {
|
||||
groupResolver = auth.NewGroupRepository(pool)
|
||||
}
|
||||
return &AdminHandler{
|
||||
userRepo: userRepo,
|
||||
pool: pool,
|
||||
storeProv: storeProv,
|
||||
// No settings dependency at construction time: nil settings makes the
|
||||
// provisioner fall back to the built-in users group for defaults.
|
||||
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv, nil, groupResolver),
|
||||
userRepo: userRepo,
|
||||
pool: pool,
|
||||
storeProv: storeProv,
|
||||
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,53 +113,115 @@ func NewAdminHandler(
|
||||
|
||||
// createUserRequest represents the JSON body for POST /admin/users.
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
CreateDefaultProfile bool `json:"create_default_profile"`
|
||||
DefaultProfileName string `json:"default_profile_name,omitempty"`
|
||||
// GroupIDs are the group memberships to assign. Omitted (nil) means the
|
||||
// configured default groups are used.
|
||||
GroupIDs []int `json:"group_ids"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Permissions createStringSliceField `json:"permissions"`
|
||||
CreateDefaultProfile bool `json:"create_default_profile"`
|
||||
DefaultProfileName string `json:"default_profile_name,omitempty"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
type createStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *createStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
type updateLibraryIDsField struct {
|
||||
Set bool
|
||||
Value []int
|
||||
}
|
||||
|
||||
func (f *updateLibraryIDsField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = nil
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
func (f updateLibraryIDsField) Ptr() *[]int {
|
||||
if !f.Set {
|
||||
return nil
|
||||
}
|
||||
value := append([]int(nil), f.Value...)
|
||||
return &value
|
||||
}
|
||||
|
||||
type updateStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *updateStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
func (f updateStringSliceField) Ptr() *[]string {
|
||||
if !f.Set {
|
||||
return nil
|
||||
}
|
||||
value := append([]string(nil), f.Value...)
|
||||
return &value
|
||||
}
|
||||
|
||||
// updateUserRequest represents the JSON body for PUT /admin/users/{id}.
|
||||
type updateUserRequest struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// GroupIDs replaces the user's group memberships. Omitted = unchanged.
|
||||
GroupIDs *[]int `json:"group_ids,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Permissions updateStringSliceField `json:"permissions,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
// adminGroupRef is a lightweight group reference in admin user responses.
|
||||
type adminGroupRef struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// adminUserResponse represents a user in admin JSON responses. The policy
|
||||
// fields are the effective values derived from the user's groups.
|
||||
// adminUserResponse represents a user in admin JSON responses.
|
||||
type adminUserResponse struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Groups []adminGroupRef `json:"groups"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams int `json:"max_streams"`
|
||||
MaxTranscodes int `json:"max_transcodes"`
|
||||
MaxProfiles int `json:"max_profiles"`
|
||||
DownloadAllowed bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
MaxStreams int `json:"max_streams"`
|
||||
MaxTranscodes int `json:"max_transcodes"`
|
||||
MaxProfiles int `json:"max_profiles"`
|
||||
DownloadAllowed bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
}
|
||||
|
||||
type adminPlaybackHistoryRow struct {
|
||||
@@ -212,21 +267,15 @@ func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// toAdminUserResponse converts a User model (with its group memberships
|
||||
// loaded into u.Groups at load time) to an admin API response.
|
||||
// toAdminUserResponse converts a User model to an admin API response.
|
||||
func toAdminUserResponse(u *models.User) adminUserResponse {
|
||||
refs := make([]adminGroupRef, 0, len(u.Groups))
|
||||
for _, g := range u.Groups {
|
||||
refs = append(refs, adminGroupRef{ID: g.ID, Slug: g.Slug, Name: g.Name})
|
||||
}
|
||||
return adminUserResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Role: auth.RoleForUser(u),
|
||||
Role: u.Role,
|
||||
Permissions: append([]string{}, u.Permissions...),
|
||||
Enabled: u.Enabled,
|
||||
Groups: refs,
|
||||
LibraryIDs: append([]int(nil), u.LibraryIDs...),
|
||||
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
|
||||
MaxStreams: u.MaxStreams,
|
||||
@@ -289,13 +338,10 @@ func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]adminUserResponse, 0, len(users))
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, u := range users {
|
||||
userIDs = append(userIDs, u.ID)
|
||||
}
|
||||
|
||||
resp := make([]adminUserResponse, 0, len(users))
|
||||
for _, u := range users {
|
||||
resp = append(resp, toAdminUserResponse(u))
|
||||
}
|
||||
lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs)
|
||||
@@ -345,17 +391,44 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
req.Username = auth.NormalizeUsername(req.Username)
|
||||
req.Email = auth.NormalizeEmail(req.Email)
|
||||
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required")
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" || req.Role == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and role are required")
|
||||
return
|
||||
}
|
||||
|
||||
maxPlaybackQuality, ok := access.ParsePlaybackQualityPreset(req.MaxPlaybackQuality)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
|
||||
return
|
||||
}
|
||||
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
|
||||
return
|
||||
}
|
||||
permissions := auth.DefaultUserPermissions()
|
||||
if req.Permissions.Set {
|
||||
permissions = req.Permissions.Value
|
||||
}
|
||||
permissions, err := auth.NormalizePermissions(permissions)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{
|
||||
User: models.CreateUserInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Role: req.Role,
|
||||
Permissions: permissions,
|
||||
LibraryIDs: req.LibraryIDs,
|
||||
MaxPlaybackQuality: maxPlaybackQuality,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
},
|
||||
DefaultProfile: auth.DefaultProfileOptions{
|
||||
Enabled: req.CreateDefaultProfile,
|
||||
@@ -363,10 +436,6 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUnknownGroup) {
|
||||
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
|
||||
return
|
||||
}
|
||||
@@ -390,12 +459,43 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
var maxPlaybackQuality *string
|
||||
if req.MaxPlaybackQuality != nil {
|
||||
normalized, ok := access.ParsePlaybackQualityPreset(*req.MaxPlaybackQuality)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
|
||||
return
|
||||
}
|
||||
maxPlaybackQuality = &normalized
|
||||
}
|
||||
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
|
||||
return
|
||||
}
|
||||
var permissions *[]string
|
||||
if req.Permissions.Set {
|
||||
normalized, err := auth.NormalizePermissions(req.Permissions.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
permissions = &normalized
|
||||
}
|
||||
|
||||
updateInput := models.UpdateUserInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Enabled: req.Enabled,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Role: req.Role,
|
||||
Permissions: permissions,
|
||||
Enabled: req.Enabled,
|
||||
LibraryIDs: req.LibraryIDs.Ptr(),
|
||||
MaxPlaybackQuality: maxPlaybackQuality,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
}
|
||||
|
||||
var currentUser *models.User
|
||||
@@ -417,14 +517,6 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusNotFound, "not_found", "User not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrLastAdministrator) {
|
||||
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrUnknownGroup) {
|
||||
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -455,10 +547,6 @@ func (h *AdminHandler) HandleDeleteUser(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
err = h.userRepo.Delete(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrLastAdministrator) {
|
||||
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete user")
|
||||
return
|
||||
}
|
||||
@@ -702,8 +790,10 @@ func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func updateMayRequireSessionRevocation(input models.UpdateUserInput) bool {
|
||||
return input.Password != nil ||
|
||||
input.Role != nil ||
|
||||
input.Enabled != nil ||
|
||||
input.GroupIDs != nil
|
||||
input.Permissions != nil ||
|
||||
input.MaxPlaybackQuality != nil
|
||||
}
|
||||
|
||||
func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool {
|
||||
@@ -713,28 +803,22 @@ func updateRequiresSessionRevocation(current *models.User, input models.UpdateUs
|
||||
if current == nil {
|
||||
return updateMayRequireSessionRevocation(input)
|
||||
}
|
||||
if input.Role != nil && *input.Role != current.Role {
|
||||
return true
|
||||
}
|
||||
if input.Enabled != nil && *input.Enabled != current.Enabled {
|
||||
return true
|
||||
}
|
||||
// Group membership changes alter the effective policy (permissions,
|
||||
// quality ceilings, ...), so a real change revokes sessions.
|
||||
if input.GroupIDs != nil && !equalIntSets(*input.GroupIDs, current.GroupIDs) {
|
||||
if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) {
|
||||
return true
|
||||
}
|
||||
if input.MaxPlaybackQuality != nil &&
|
||||
access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// equalIntSets reports whether two int slices contain the same set of values,
|
||||
// ignoring order and duplicates.
|
||||
func equalIntSets(a, b []int) bool {
|
||||
normalize := func(values []int) []int {
|
||||
out := append([]int(nil), values...)
|
||||
sort.Ints(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
return slices.Equal(normalize(a), normalize(b))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error {
|
||||
if h.pool == nil {
|
||||
return nil
|
||||
@@ -935,7 +1019,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
|
||||
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponseForViewer(r, job, nil, isAdminRequest(r, h.userRepo)))
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
|
||||
}
|
||||
|
||||
// UpdateItemMetadataRequest contains the fields that can be updated via
|
||||
@@ -1053,10 +1137,29 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
|
||||
// --- Server Settings endpoints ---
|
||||
|
||||
// sensitiveSettingKeys is the audited allowlist of secret-bearing settings
|
||||
// keys, shared with the at-rest encryption decorator so redaction and
|
||||
// encryption can never drift apart. See catalog.SensitiveSettingKeys.
|
||||
var sensitiveSettingKeys = catalog.SensitiveSettingKeys
|
||||
var sensitiveSettingKeys = map[string]bool{
|
||||
"auth.jwt_secret": true,
|
||||
"s3.public_access_key": true,
|
||||
"s3.public_secret_key": true,
|
||||
"s3.public_token_secret": true,
|
||||
"s3.private_access_key": true,
|
||||
"s3.private_secret_key": true,
|
||||
"s3.user_db_access_key": true,
|
||||
"s3.user_db_secret_key": true,
|
||||
"redis.url": true,
|
||||
"recommendations.openai_api_key": true,
|
||||
"recommendations.embedding_auth_token": true,
|
||||
"tmdb.api_key": true,
|
||||
"introdb.api_key": true,
|
||||
"mdblist.api_key": true,
|
||||
"requests.radarr.api_key": true,
|
||||
"requests.sonarr.api_key": true,
|
||||
"subtitle_ai.api_key": true,
|
||||
"watchsync.trakt.client_id": true,
|
||||
"watchsync.trakt.client_secret": true,
|
||||
"watchsync.simkl.client_id": true,
|
||||
"watchsync.simkl.client_secret": true,
|
||||
}
|
||||
|
||||
// HandleGetSettings handles GET /admin/settings.
|
||||
func (h *AdminHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1994,6 +2097,21 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if key == "defaults.max_playback_quality" {
|
||||
normalized, ok := access.ParsePlaybackQualityPreset(req.Value)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
|
||||
return
|
||||
}
|
||||
req.Value = normalized
|
||||
}
|
||||
if key == "defaults.max_profiles" {
|
||||
n, err := strconv.Atoi(req.Value)
|
||||
if err != nil || n < 1 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "defaults.max_profiles must be an integer >= 1")
|
||||
return
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case markers.SettingMode, markers.SettingLazyPlayback:
|
||||
if normalized, err := markers.NormalizeSetting(key, req.Value); err != nil {
|
||||
@@ -2002,33 +2120,6 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
} else {
|
||||
req.Value = normalized
|
||||
}
|
||||
case "ai.asr_base_url":
|
||||
if llm.IsChatOnlyGateway(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
|
||||
"Use a self-hosted Whisper server (faster-whisper/speaches), api.groq.com/openai, or api.openai.com.")
|
||||
return
|
||||
}
|
||||
case "metadata_ai.on_view":
|
||||
switch req.Value {
|
||||
case "off", "button", "auto":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"metadata_ai.on_view must be off, button, or auto")
|
||||
return
|
||||
}
|
||||
case "subtitle_ai.transcribe_quota_jobs":
|
||||
if n, err := strconv.Atoi(req.Value); err != nil || n < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"subtitle_ai.transcribe_quota_jobs must be an integer >= 0 (0 = unlimited)")
|
||||
return
|
||||
}
|
||||
case "subtitle_ai.transcribe_quota_period":
|
||||
if !subtitleai.ValidQuotaPeriod(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"subtitle_ai.transcribe_quota_period must be day, week, or month")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
// isAdminRequest reports whether the request's authenticated user currently
|
||||
// holds the admin permission. Admin authority is always derived from the
|
||||
// freshly loaded user (group-derived policy), never from token contents.
|
||||
// It first consults the user stashed by the RequireAdmin middleware and
|
||||
// falls back to loading via the given loader.
|
||||
func isAdminRequest(r *http.Request, loader auth.UserLoader) bool {
|
||||
ctx := r.Context()
|
||||
if user := apimw.GetUser(ctx); user != nil {
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
claims := apimw.GetClaims(ctx)
|
||||
if claims == nil || loader == nil {
|
||||
return false
|
||||
}
|
||||
user, err := loader.GetByID(ctx, claims.UserID)
|
||||
if err != nil || user == nil {
|
||||
return false
|
||||
}
|
||||
return user.Enabled && user.IsAdmin
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// GroupStore defines the group repository operations the AdminGroupsHandler
|
||||
// needs, so tests can substitute a fake.
|
||||
type GroupStore interface {
|
||||
List(ctx context.Context) ([]auth.GroupWithMemberCount, error)
|
||||
GetByID(ctx context.Context, id int) (*models.Group, error)
|
||||
MemberCount(ctx context.Context, groupID int) (int, error)
|
||||
Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error)
|
||||
Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
ListMembers(ctx context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error)
|
||||
AddMember(ctx context.Context, groupID, userID int) error
|
||||
RemoveMember(ctx context.Context, groupID, userID int) error
|
||||
}
|
||||
|
||||
// AdminGroupsHandler handles admin-only group management endpoints.
|
||||
type AdminGroupsHandler struct {
|
||||
store GroupStore
|
||||
}
|
||||
|
||||
// NewAdminGroupsHandler creates an AdminGroupsHandler backed by the given
|
||||
// group store.
|
||||
func NewAdminGroupsHandler(store GroupStore) *AdminGroupsHandler {
|
||||
return &AdminGroupsHandler{store: store}
|
||||
}
|
||||
|
||||
// --- Request/Response types ---
|
||||
|
||||
// createAdminGroupRequest represents the JSON body for POST /admin/groups.
|
||||
type createAdminGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
LibraryIDs []int `json:"library_ids"` // null/absent = all libraries
|
||||
MaxStreams *int `json:"max_streams"`
|
||||
MaxTranscodes *int `json:"max_transcodes"`
|
||||
MaxProfiles *int `json:"max_profiles"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality"`
|
||||
DownloadAllowed *bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed"`
|
||||
}
|
||||
|
||||
// updateAdminGroupRequest represents the JSON body for PATCH /admin/groups/{id}.
|
||||
// Absent fields are left unchanged.
|
||||
type updateAdminGroupRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Permissions optionalStringSliceField `json:"permissions,omitempty"`
|
||||
LibraryIDs optionalIntSliceField `json:"library_ids,omitempty"`
|
||||
MaxStreams *int `json:"max_streams,omitempty"`
|
||||
MaxTranscodes *int `json:"max_transcodes,omitempty"`
|
||||
MaxProfiles *int `json:"max_profiles,omitempty"`
|
||||
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
|
||||
DownloadAllowed *bool `json:"download_allowed,omitempty"`
|
||||
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
|
||||
}
|
||||
|
||||
// adminGroupResponse represents a group in admin JSON responses.
|
||||
type adminGroupResponse struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Permissions []string `json:"permissions"`
|
||||
LibraryIDs []int `json:"library_ids"` // null = all libraries
|
||||
MaxStreams int `json:"max_streams"`
|
||||
MaxTranscodes int `json:"max_transcodes"`
|
||||
MaxProfiles int `json:"max_profiles"`
|
||||
MaxPlaybackQuality string `json:"max_playback_quality"`
|
||||
DownloadAllowed bool `json:"download_allowed"`
|
||||
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
|
||||
MemberCount int `json:"member_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type adminGroupListResponse struct {
|
||||
Groups []adminGroupResponse `json:"groups"`
|
||||
}
|
||||
|
||||
type adminGroupMemberRow struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type adminGroupMembersResponse struct {
|
||||
Members []adminGroupMemberRow `json:"members"`
|
||||
Total int `json:"total"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func toAdminGroupResponse(g *models.Group, memberCount int) adminGroupResponse {
|
||||
return adminGroupResponse{
|
||||
ID: g.ID,
|
||||
Slug: g.Slug,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
BuiltIn: g.BuiltIn,
|
||||
Permissions: append([]string{}, g.Permissions...),
|
||||
LibraryIDs: g.LibraryIDs,
|
||||
MaxStreams: g.MaxStreams,
|
||||
MaxTranscodes: g.MaxTranscodes,
|
||||
MaxProfiles: g.MaxProfiles,
|
||||
MaxPlaybackQuality: g.MaxPlaybackQuality,
|
||||
DownloadAllowed: g.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: g.DownloadTranscodeAllowed,
|
||||
MemberCount: memberCount,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// writeGroupError maps group repository sentinel errors to API responses.
|
||||
func writeGroupError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrGroupNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "Group not found")
|
||||
case errors.Is(err, auth.ErrUnknownUser):
|
||||
writeError(w, http.StatusNotFound, "unknown_user", "User not found")
|
||||
case errors.Is(err, auth.ErrBuiltInGroup):
|
||||
writeError(w, http.StatusConflict, "built_in_group", "Built-in groups cannot be deleted")
|
||||
case errors.Is(err, auth.ErrAdminPermRequired):
|
||||
writeError(w, http.StatusConflict, "admin_permission_required", "The administrators group must keep the admin permission")
|
||||
case errors.Is(err, auth.ErrLastAdministrator):
|
||||
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
|
||||
case errors.Is(err, auth.ErrDuplicate):
|
||||
writeError(w, http.StatusConflict, "duplicate", "A group with that name already exists")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Group operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func parseGroupIDParam(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
|
||||
id, err := strconv.Atoi(chi.URLParam(r, name))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+name)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// --- Handler methods ---
|
||||
|
||||
// HandleList handles GET /admin/groups.
|
||||
func (h *AdminGroupsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := h.store.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list groups")
|
||||
return
|
||||
}
|
||||
resp := adminGroupListResponse{Groups: make([]adminGroupResponse, 0, len(groups))}
|
||||
for i := range groups {
|
||||
resp.Groups = append(resp.Groups, toAdminGroupResponse(&groups[i].Group, groups[i].MemberCount))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleCreate handles POST /admin/groups.
|
||||
func (h *AdminGroupsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req createAdminGroupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Group name is required")
|
||||
return
|
||||
}
|
||||
permissions, err := auth.NormalizePermissions(req.Permissions)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.store.Create(r.Context(), models.CreateGroupInput{
|
||||
Name: name,
|
||||
Description: req.Description,
|
||||
Permissions: permissions,
|
||||
LibraryIDs: req.LibraryIDs,
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
MaxPlaybackQuality: req.MaxPlaybackQuality,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
})
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAdminGroupResponse(group, 0))
|
||||
}
|
||||
|
||||
// HandleGet handles GET /admin/groups/{id}.
|
||||
func (h *AdminGroupsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
group, err := h.store.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
memberCount, err := h.store.MemberCount(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
|
||||
}
|
||||
|
||||
// HandleUpdate handles PATCH /admin/groups/{id}.
|
||||
func (h *AdminGroupsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateAdminGroupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Group name cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
input := models.UpdateGroupInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
LibraryIDs: req.LibraryIDs.Ptr(),
|
||||
MaxStreams: req.MaxStreams,
|
||||
MaxTranscodes: req.MaxTranscodes,
|
||||
MaxProfiles: req.MaxProfiles,
|
||||
MaxPlaybackQuality: req.MaxPlaybackQuality,
|
||||
DownloadAllowed: req.DownloadAllowed,
|
||||
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
|
||||
}
|
||||
if req.Permissions.Set {
|
||||
permissions, err := auth.NormalizePermissions(req.Permissions.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
input.Permissions = &permissions
|
||||
}
|
||||
|
||||
group, err := h.store.Update(r.Context(), id, input)
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
memberCount, err := h.store.MemberCount(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
|
||||
}
|
||||
|
||||
// HandleDelete handles DELETE /admin/groups/{id}.
|
||||
func (h *AdminGroupsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(r.Context(), id); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleListMembers handles GET /admin/groups/{id}/members.
|
||||
func (h *AdminGroupsHandler) HandleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.GetByID(r.Context(), id); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
offset := 0
|
||||
if v := q.Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
limit := 50
|
||||
if v := q.Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
members, total, err := h.store.ListMembers(r.Context(), id, offset, limit)
|
||||
if err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
resp := adminGroupMembersResponse{
|
||||
Members: make([]adminGroupMemberRow, 0, len(members)),
|
||||
Total: total,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
}
|
||||
for _, m := range members {
|
||||
resp.Members = append(resp.Members, adminGroupMemberRow{
|
||||
UserID: m.UserID,
|
||||
Username: m.Username,
|
||||
Email: m.Email,
|
||||
Enabled: m.Enabled,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleAddMember handles PUT /admin/groups/{id}/members/{userID}.
|
||||
func (h *AdminGroupsHandler) HandleAddMember(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := parseGroupIDParam(w, r, "userID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.AddMember(r.Context(), groupID, userID); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleRemoveMember handles DELETE /admin/groups/{id}/members/{userID}.
|
||||
func (h *AdminGroupsHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, ok := parseGroupIDParam(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := parseGroupIDParam(w, r, "userID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.RemoveMember(r.Context(), groupID, userID); err != nil {
|
||||
writeGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeGroupStore is a configurable in-memory GroupStore that records the
|
||||
// inputs handlers pass to it.
|
||||
type fakeGroupStore struct {
|
||||
listGroups []auth.GroupWithMemberCount
|
||||
listErr error
|
||||
getGroup *models.Group
|
||||
getErr error
|
||||
memberCount int
|
||||
countErr error
|
||||
|
||||
createInput *models.CreateGroupInput
|
||||
createGroup *models.Group
|
||||
createErr error
|
||||
|
||||
updateID int
|
||||
updateInput *models.UpdateGroupInput
|
||||
updateGroup *models.Group
|
||||
updateErr error
|
||||
|
||||
deleteID int
|
||||
deleteErr error
|
||||
|
||||
membersGroupID int
|
||||
membersOffset int
|
||||
membersLimit int
|
||||
members []auth.GroupMember
|
||||
membersTotal int
|
||||
membersErr error
|
||||
|
||||
addGroupID int
|
||||
addUserID int
|
||||
addErr error
|
||||
removeGroupID int
|
||||
removeUserID int
|
||||
removeErr error
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) List(context.Context) ([]auth.GroupWithMemberCount, error) {
|
||||
return f.listGroups, f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) GetByID(_ context.Context, id int) (*models.Group, error) {
|
||||
return f.getGroup, f.getErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) MemberCount(_ context.Context, groupID int) (int, error) {
|
||||
return f.memberCount, f.countErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) Create(_ context.Context, input models.CreateGroupInput) (*models.Group, error) {
|
||||
f.createInput = &input
|
||||
return f.createGroup, f.createErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) Update(_ context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
|
||||
f.updateID = id
|
||||
f.updateInput = &input
|
||||
return f.updateGroup, f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) Delete(_ context.Context, id int) error {
|
||||
f.deleteID = id
|
||||
return f.deleteErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) ListMembers(_ context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error) {
|
||||
f.membersGroupID = groupID
|
||||
f.membersOffset = offset
|
||||
f.membersLimit = limit
|
||||
return f.members, f.membersTotal, f.membersErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) AddMember(_ context.Context, groupID, userID int) error {
|
||||
f.addGroupID = groupID
|
||||
f.addUserID = userID
|
||||
return f.addErr
|
||||
}
|
||||
|
||||
func (f *fakeGroupStore) RemoveMember(_ context.Context, groupID, userID int) error {
|
||||
f.removeGroupID = groupID
|
||||
f.removeUserID = userID
|
||||
return f.removeErr
|
||||
}
|
||||
|
||||
// newGroupsTestRouter mounts the handler routes the same way the API router
|
||||
// does so tests exercise real chi URL params.
|
||||
func newGroupsTestRouter(store *fakeGroupStore) chi.Router {
|
||||
h := NewAdminGroupsHandler(store)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/groups", h.HandleList)
|
||||
r.Post("/groups", h.HandleCreate)
|
||||
r.Get("/groups/{id}", h.HandleGet)
|
||||
r.Patch("/groups/{id}", h.HandleUpdate)
|
||||
r.Delete("/groups/{id}", h.HandleDelete)
|
||||
r.Get("/groups/{id}/members", h.HandleListMembers)
|
||||
r.Put("/groups/{id}/members/{userID}", h.HandleAddMember)
|
||||
r.Delete("/groups/{id}/members/{userID}", h.HandleRemoveMember)
|
||||
return r
|
||||
}
|
||||
|
||||
func doGroupsRequest(t *testing.T, router chi.Router, method, target string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshaling request body: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(payload)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, target, reader)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeGroupsJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decoding response %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertGroupsError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("status = %d, want %d (body %q)", rec.Code, status, rec.Body.String())
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["error"] != code {
|
||||
t.Fatalf("error code = %v, want %q (body %q)", body["error"], code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func testGroup() *models.Group {
|
||||
return &models.Group{
|
||||
ID: 7,
|
||||
Slug: "film-club",
|
||||
Name: "Film Club",
|
||||
Description: "Movie nights",
|
||||
Permissions: []string{"marker_edit"},
|
||||
LibraryIDs: nil,
|
||||
MaxStreams: 2,
|
||||
MaxTranscodes: 1,
|
||||
MaxProfiles: 4,
|
||||
MaxPlaybackQuality: "1080p",
|
||||
CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsList(t *testing.T) {
|
||||
store := &fakeGroupStore{
|
||||
listGroups: []auth.GroupWithMemberCount{
|
||||
{Group: models.Group{ID: 1, Slug: "administrators", Name: "Administrators", BuiltIn: true, Permissions: []string{"admin"}}, MemberCount: 1},
|
||||
{Group: models.Group{ID: 2, Slug: "users", Name: "Users", BuiltIn: true, LibraryIDs: []int{3}}, MemberCount: 12},
|
||||
},
|
||||
}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
groups, ok := body["groups"].([]any)
|
||||
if !ok || len(groups) != 2 {
|
||||
t.Fatalf("groups = %v, want 2 entries", body["groups"])
|
||||
}
|
||||
first := groups[0].(map[string]any)
|
||||
if first["member_count"] != float64(1) {
|
||||
t.Fatalf("member_count = %v, want 1", first["member_count"])
|
||||
}
|
||||
if first["library_ids"] != nil {
|
||||
t.Fatalf("library_ids = %v, want null for all-libraries", first["library_ids"])
|
||||
}
|
||||
second := groups[1].(map[string]any)
|
||||
if second["member_count"] != float64(12) {
|
||||
t.Fatalf("member_count = %v, want 12", second["member_count"])
|
||||
}
|
||||
if ids, ok := second["library_ids"].([]any); !ok || len(ids) != 1 || ids[0] != float64(3) {
|
||||
t.Fatalf("library_ids = %v, want [3]", second["library_ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreate(t *testing.T) {
|
||||
store := &fakeGroupStore{createGroup: testGroup()}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": " Film Club ",
|
||||
"description": "Movie nights",
|
||||
"permissions": []string{"marker_edit"},
|
||||
"library_ids": []int{1, 2},
|
||||
"max_streams": 2,
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.createInput == nil {
|
||||
t.Fatal("store.Create was not called")
|
||||
}
|
||||
if store.createInput.Name != "Film Club" {
|
||||
t.Fatalf("Name = %q, want trimmed name", store.createInput.Name)
|
||||
}
|
||||
if len(store.createInput.LibraryIDs) != 2 {
|
||||
t.Fatalf("LibraryIDs = %v, want [1 2]", store.createInput.LibraryIDs)
|
||||
}
|
||||
if store.createInput.MaxStreams == nil || *store.createInput.MaxStreams != 2 {
|
||||
t.Fatalf("MaxStreams = %v, want 2", store.createInput.MaxStreams)
|
||||
}
|
||||
if store.createInput.MaxTranscodes != nil {
|
||||
t.Fatalf("MaxTranscodes = %v, want nil (omitted)", store.createInput.MaxTranscodes)
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["slug"] != "film-club" {
|
||||
t.Fatalf("slug = %v, want film-club", body["slug"])
|
||||
}
|
||||
if body["member_count"] != float64(0) {
|
||||
t.Fatalf("member_count = %v, want 0 for new group", body["member_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreateEmptyName(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": " ",
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
if store.createInput != nil {
|
||||
t.Fatal("store.Create should not be called for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreateUnknownPermission(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": "Film Club",
|
||||
"permissions": []string{"server_owner"},
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
if store.createInput != nil {
|
||||
t.Fatal("store.Create should not be called for unknown permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsCreateDuplicate(t *testing.T) {
|
||||
store := &fakeGroupStore{createErr: auth.ErrDuplicate}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
|
||||
"name": "Film Club",
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusConflict, "duplicate")
|
||||
}
|
||||
|
||||
func TestAdminGroupsGet(t *testing.T) {
|
||||
store := &fakeGroupStore{getGroup: testGroup(), memberCount: 5}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["id"] != float64(7) {
|
||||
t.Fatalf("id = %v, want 7", body["id"])
|
||||
}
|
||||
if body["member_count"] != float64(5) {
|
||||
t.Fatalf("member_count = %v, want 5", body["member_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsGetNotFound(t *testing.T) {
|
||||
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999", nil)
|
||||
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
func TestAdminGroupsGetInvalidID(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/abc", nil)
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchStripAdminPermission(t *testing.T) {
|
||||
store := &fakeGroupStore{updateErr: auth.ErrAdminPermRequired}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/1", map[string]any{
|
||||
"permissions": []string{"marker_edit"},
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusConflict, "admin_permission_required")
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchUnknownPermission(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/7", map[string]any{
|
||||
"permissions": []string{"server_owner"},
|
||||
})
|
||||
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
|
||||
if store.updateInput != nil {
|
||||
t.Fatal("store.Update should not be called for unknown permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchLibraryIDsSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want func(t *testing.T, input *models.UpdateGroupInput)
|
||||
}{
|
||||
{
|
||||
name: "absent means no change",
|
||||
body: `{"name":"Renamed"}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs != nil {
|
||||
t.Fatalf("LibraryIDs = %v, want nil (no change)", input.LibraryIDs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null means all libraries",
|
||||
body: `{"library_ids":null}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs == nil {
|
||||
t.Fatal("LibraryIDs = nil, want pointer to nil slice (all libraries)")
|
||||
}
|
||||
if *input.LibraryIDs != nil {
|
||||
t.Fatalf("*LibraryIDs = %v, want nil slice (all libraries)", *input.LibraryIDs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty array means no libraries",
|
||||
body: `{"library_ids":[]}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs == nil {
|
||||
t.Fatal("LibraryIDs = nil, want pointer to empty slice (no libraries)")
|
||||
}
|
||||
if *input.LibraryIDs == nil || len(*input.LibraryIDs) != 0 {
|
||||
t.Fatalf("*LibraryIDs = %v, want non-nil empty slice", *input.LibraryIDs)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit ids",
|
||||
body: `{"library_ids":[1,2]}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.LibraryIDs == nil {
|
||||
t.Fatal("LibraryIDs = nil, want pointer to [1 2]")
|
||||
}
|
||||
ids := *input.LibraryIDs
|
||||
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
|
||||
t.Fatalf("*LibraryIDs = %v, want [1 2]", ids)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := &fakeGroupStore{updateGroup: testGroup()}
|
||||
router := newGroupsTestRouter(store)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.updateInput == nil {
|
||||
t.Fatal("store.Update was not called")
|
||||
}
|
||||
tt.want(t, store.updateInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsDeleteBuiltIn(t *testing.T) {
|
||||
store := &fakeGroupStore{deleteErr: auth.ErrBuiltInGroup}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1", nil)
|
||||
assertGroupsError(t, rec, http.StatusConflict, "built_in_group")
|
||||
}
|
||||
|
||||
func TestAdminGroupsDelete(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.deleteID != 7 {
|
||||
t.Fatalf("deleted id = %d, want 7", store.deleteID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsListMembersPagination(t *testing.T) {
|
||||
store := &fakeGroupStore{
|
||||
members: []auth.GroupMember{
|
||||
{UserID: 3, Username: "alice", Email: "alice@example.com", Enabled: true},
|
||||
},
|
||||
membersTotal: 42,
|
||||
}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members?offset=10&limit=5", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.membersGroupID != 7 || store.membersOffset != 10 || store.membersLimit != 5 {
|
||||
t.Fatalf("ListMembers(%d, %d, %d), want (7, 10, 5)",
|
||||
store.membersGroupID, store.membersOffset, store.membersLimit)
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if body["total"] != float64(42) || body["offset"] != float64(10) || body["limit"] != float64(5) {
|
||||
t.Fatalf("pagination = total %v offset %v limit %v, want 42/10/5",
|
||||
body["total"], body["offset"], body["limit"])
|
||||
}
|
||||
members, ok := body["members"].([]any)
|
||||
if !ok || len(members) != 1 {
|
||||
t.Fatalf("members = %v, want 1 entry", body["members"])
|
||||
}
|
||||
member := members[0].(map[string]any)
|
||||
if member["user_id"] != float64(3) || member["username"] != "alice" ||
|
||||
member["email"] != "alice@example.com" || member["enabled"] != true {
|
||||
t.Fatalf("member = %v, want alice row", member)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsListMembersDefaults(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.membersOffset != 0 || store.membersLimit != 50 {
|
||||
t.Fatalf("ListMembers offset %d limit %d, want defaults 0/50",
|
||||
store.membersOffset, store.membersLimit)
|
||||
}
|
||||
body := decodeGroupsJSON(t, rec)
|
||||
if members, ok := body["members"].([]any); !ok || len(members) != 0 {
|
||||
t.Fatalf("members = %v, want empty array (not null)", body["members"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsAddMember(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/12", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.addGroupID != 7 || store.addUserID != 12 {
|
||||
t.Fatalf("AddMember(%d, %d), want (7, 12)", store.addGroupID, store.addUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsRemoveLastAdministrator(t *testing.T) {
|
||||
store := &fakeGroupStore{removeErr: auth.ErrLastAdministrator}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1/members/12", nil)
|
||||
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
|
||||
}
|
||||
|
||||
func TestAdminGroupsRemoveMember(t *testing.T) {
|
||||
store := &fakeGroupStore{}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7/members/12", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.removeGroupID != 7 || store.removeUserID != 12 {
|
||||
t.Fatalf("RemoveMember(%d, %d), want (7, 12)", store.removeGroupID, store.removeUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupsAddMemberUnknownUser(t *testing.T) {
|
||||
store := &fakeGroupStore{addErr: auth.ErrUnknownUser}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/999", nil)
|
||||
assertGroupsError(t, rec, http.StatusNotFound, "unknown_user")
|
||||
}
|
||||
|
||||
func TestAdminGroupsListMembersNotFound(t *testing.T) {
|
||||
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
|
||||
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999/members", nil)
|
||||
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
|
||||
}
|
||||
|
||||
func TestAdminGroupsPatchPermissionsSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want func(t *testing.T, input *models.UpdateGroupInput)
|
||||
}{
|
||||
{
|
||||
name: "explicit permissions reach store as pointer to slice",
|
||||
body: `{"permissions":["marker_edit"]}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.Permissions == nil {
|
||||
t.Fatal("Permissions = nil, want pointer to [\"marker_edit\"]")
|
||||
}
|
||||
perms := *input.Permissions
|
||||
if len(perms) != 1 || perms[0] != "marker_edit" {
|
||||
t.Fatalf("*Permissions = %v, want [\"marker_edit\"]", perms)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null permissions reaches store as pointer to empty slice",
|
||||
body: `{"permissions":null}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.Permissions == nil {
|
||||
t.Fatal("Permissions = nil, want pointer to empty slice")
|
||||
}
|
||||
if len(*input.Permissions) != 0 {
|
||||
t.Fatalf("*Permissions = %v, want empty slice", *input.Permissions)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "absent permissions reaches store as nil (no change)",
|
||||
body: `{"name":"Renamed"}`,
|
||||
want: func(t *testing.T, input *models.UpdateGroupInput) {
|
||||
if input.Permissions != nil {
|
||||
t.Fatalf("Permissions = %v, want nil (no change)", input.Permissions)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store := &fakeGroupStore{updateGroup: testGroup()}
|
||||
router := newGroupsTestRouter(store)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.updateInput == nil {
|
||||
t.Fatal("store.Update was not called")
|
||||
}
|
||||
tt.want(t, store.updateInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ type AdminJobsHandler struct {
|
||||
store AdminJobArtifactStore
|
||||
CancelRegistry *adminjob.CancelRegistry
|
||||
RealtimeHub *notifications.Hub
|
||||
Users auth.UserLoader // for server-side admin checks on non-admin-gated routes
|
||||
}
|
||||
|
||||
func NewAdminJobsHandler(repo adminJobRepository, store AdminJobArtifactStore) *AdminJobsHandler {
|
||||
@@ -123,14 +122,13 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
viewerUserID := apimw.GetUserID(r.Context())
|
||||
viewerIsAdmin := isAdminRequest(r, h.Users)
|
||||
if !canReadAdminJob(viewerUserID, viewerIsAdmin, job) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if !canReadAdminJob(claims, job) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminJobToResponseForViewer(r, job, h.store, viewerIsAdmin))
|
||||
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
|
||||
}
|
||||
|
||||
func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -266,19 +264,19 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt
|
||||
return resp
|
||||
}
|
||||
|
||||
func adminJobToResponseForViewer(
|
||||
func adminJobToResponseForClaims(
|
||||
r *http.Request,
|
||||
job *models.AdminJob,
|
||||
store AdminJobArtifactStore,
|
||||
viewerIsAdmin bool,
|
||||
claims *auth.Claims,
|
||||
) adminJobResponse {
|
||||
response := adminJobToResponse(r, job, store)
|
||||
sanitizeAdminJobResponseForViewer(&response, viewerIsAdmin)
|
||||
sanitizeAdminJobResponseForClaims(&response, claims)
|
||||
return response
|
||||
}
|
||||
|
||||
func sanitizeAdminJobResponseForViewer(response *adminJobResponse, viewerIsAdmin bool) {
|
||||
if response == nil || viewerIsAdmin {
|
||||
func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) {
|
||||
if response == nil || (claims != nil && claims.Role == "admin") {
|
||||
return
|
||||
}
|
||||
response.RequestPayload = json.RawMessage(`{}`)
|
||||
@@ -386,12 +384,12 @@ func currentAdminUserID(r *http.Request) int {
|
||||
return claims.UserID
|
||||
}
|
||||
|
||||
func canReadAdminJob(viewerUserID int, viewerIsAdmin bool, job *models.AdminJob) bool {
|
||||
if viewerUserID == 0 || job == nil {
|
||||
func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool {
|
||||
if claims == nil || job == nil {
|
||||
return false
|
||||
}
|
||||
if viewerIsAdmin {
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == viewerUserID
|
||||
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID
|
||||
}
|
||||
|
||||
@@ -13,40 +13,46 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 1, Role: "admin"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
|
||||
if !canReadAdminJob(1, true, job) {
|
||||
if !canReadAdminJob(claims, job) {
|
||||
t.Fatal("admin should be allowed to read any job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
|
||||
if !canReadAdminJob(2, false, job) {
|
||||
if !canReadAdminJob(claims, job) {
|
||||
t.Fatal("creator should be allowed to read own item refresh job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
|
||||
if canReadAdminJob(2, false, job) {
|
||||
if canReadAdminJob(claims, job) {
|
||||
t.Fatal("non-admin should not read non-item-refresh jobs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 3, Role: "user"}
|
||||
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
|
||||
if canReadAdminJob(3, false, job) {
|
||||
if canReadAdminJob(claims, job) {
|
||||
t.Fatal("non-admin should not read another user's item refresh job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
|
||||
func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
|
||||
claims := &auth.Claims{UserID: 2, Role: "user"}
|
||||
job := &models.AdminJob{
|
||||
JobType: adminjob.JobTypeItemRefresh,
|
||||
CreatedByUserID: 2,
|
||||
@@ -60,7 +66,7 @@ func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *tes
|
||||
PublicURL: "https://example.test/public",
|
||||
}
|
||||
|
||||
resp := adminJobToResponseForViewer(nil, job, nil, false)
|
||||
resp := adminJobToResponseForClaims(nil, job, nil, claims)
|
||||
|
||||
if string(resp.RequestPayload) != `{}` {
|
||||
t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
)
|
||||
@@ -120,11 +122,7 @@ func (h *AdminMarkerProvidersHandler) HandleUpdateProvider(w http.ResponseWriter
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
existing, ok := h.Config.Get(provider)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Unknown marker provider")
|
||||
@@ -181,11 +179,7 @@ func (h *AdminMarkerProvidersHandler) HandleValidateProvider(w http.ResponseWrit
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
var submitter markers.Submitter
|
||||
for _, p := range h.Registry.Providers() {
|
||||
if p.ID() == provider {
|
||||
|
||||
@@ -49,34 +49,6 @@ func TestValidateMarkerProviderUsesSnakeCaseStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fakePluginMarkerSubmitter struct{ fakeMarkerStatsSubmitter }
|
||||
|
||||
func (fakePluginMarkerSubmitter) ID() string { return "plugin:6:introdb" }
|
||||
|
||||
// Browsers send encodeURIComponent-escaped provider IDs (plugin%3A6%3Aintrodb),
|
||||
// and chi matches the raw path, so the handler must decode the route param.
|
||||
func TestValidateMarkerProviderDecodesEncodedID(t *testing.T) {
|
||||
reg := markers.NewRegistry(nil)
|
||||
if err := reg.Register(fakePluginMarkerSubmitter{}); err != nil {
|
||||
t.Fatalf("register provider: %v", err)
|
||||
}
|
||||
h := NewAdminMarkerProvidersHandler(reg, nil, nil, nil)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/admin/markers/providers/{provider}/validate", h.HandleValidateProvider)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/markers/providers/plugin%3A6%3Aintrodb/validate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"valid":true`) {
|
||||
t.Fatalf("unexpected response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerProviderResponseIncludesPluginMetadata(t *testing.T) {
|
||||
resp := toProviderConfigResponse(
|
||||
markers.ProviderConfig{Provider: "plugin:4:markers", FetchEnabled: true, FetchPriority: 25},
|
||||
|
||||
@@ -111,7 +111,7 @@ func (h *AdminPlaybackControlHandler) HandleMessageSession(w http.ResponseWriter
|
||||
return
|
||||
}
|
||||
command.Reason = req.Reason
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
|
||||
result := h.playback.CommandDispatcher.DispatchToSession(command, 0, nil)
|
||||
if result.DispatchErr != nil {
|
||||
@@ -173,7 +173,7 @@ func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter
|
||||
return
|
||||
}
|
||||
command.Reason = req.Reason
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
|
||||
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
|
||||
deadline := boundedPlaybackControlDeadline(req.DeadlineMS)
|
||||
command.DeadlineMS = int(deadline / time.Millisecond)
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@ type AdminStats struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
TotalUsers int `json:"total_users"`
|
||||
TotalMovies int `json:"total_movies"`
|
||||
TotalMovieFiles int `json:"total_movie_files"`
|
||||
TotalShows int `json:"total_shows"`
|
||||
TotalShowFiles int `json:"total_show_files"`
|
||||
ActiveStreams int `json:"active_streams"`
|
||||
TotalStorageBytes int64 `json:"total_storage_bytes"`
|
||||
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
|
||||
@@ -129,15 +127,13 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
}
|
||||
|
||||
var (
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalMovieFiles int64
|
||||
totalShows int64
|
||||
totalShowFiles int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalShows int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
)
|
||||
|
||||
row := pool.QueryRow(ctx, `
|
||||
@@ -155,34 +151,8 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
file_stats AS (
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'movie')::bigint AS total_movie_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'series')::bigint AS total_show_files,
|
||||
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
|
||||
FROM (
|
||||
SELECT
|
||||
media_files.file_size,
|
||||
CASE
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'movie'
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'series'
|
||||
THEN 'series'
|
||||
WHEN episodes.content_id IS NOT NULL
|
||||
THEN 'series'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
WHEN lower(trim(media_folders.type)) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(media_folders.type)) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
ELSE ''
|
||||
END AS file_kind
|
||||
FROM media_files
|
||||
JOIN media_folders ON media_folders.id = media_files.media_folder_id
|
||||
LEFT JOIN media_items ON media_items.content_id = media_files.content_id
|
||||
LEFT JOIN episodes ON episodes.content_id = media_files.episode_id
|
||||
) classified_files
|
||||
FROM media_files
|
||||
),
|
||||
session_stats AS (
|
||||
SELECT COUNT(*)::bigint AS active_streams
|
||||
@@ -193,9 +163,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
item_stats.total_items,
|
||||
file_stats.total_files,
|
||||
item_stats.total_movies,
|
||||
file_stats.total_movie_files,
|
||||
item_stats.total_shows,
|
||||
file_stats.total_show_files,
|
||||
session_stats.active_streams,
|
||||
file_stats.total_storage_bytes
|
||||
FROM user_stats
|
||||
@@ -208,9 +176,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
&totalItems,
|
||||
&totalFiles,
|
||||
&totalMovies,
|
||||
&totalMovieFiles,
|
||||
&totalShows,
|
||||
&totalShowFiles,
|
||||
&activeStreams,
|
||||
&totalStorage,
|
||||
); err != nil {
|
||||
@@ -228,9 +194,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
TotalItems: int(totalItems),
|
||||
TotalFiles: int(totalFiles),
|
||||
TotalMovies: int(totalMovies),
|
||||
TotalMovieFiles: int(totalMovieFiles),
|
||||
TotalShows: int(totalShows),
|
||||
TotalShowFiles: int(totalShowFiles),
|
||||
ActiveStreams: int(activeStreams),
|
||||
TotalStorageBytes: totalStorage,
|
||||
WatchProviderActivity: activity,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -56,11 +57,7 @@ func (h *AdminSubtitleHandler) HandleListProviders(w http.ResponseWriter, r *htt
|
||||
|
||||
// HandleUpdateProvider handles PUT /api/v1/admin/subtitle-providers/{provider}
|
||||
func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerName, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return
|
||||
}
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
|
||||
var req updateSubtitleProviderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -86,11 +83,7 @@ func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *ht
|
||||
|
||||
// HandleTestProvider handles POST /api/v1/admin/subtitle-providers/{provider}/test
|
||||
func (h *AdminSubtitleHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerName, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return
|
||||
}
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
|
||||
cfg, err := h.repo.GetProviderConfig(r.Context(), providerName)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,82 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeAdminUserRepo is a configurable in-memory UserRepository for handler
|
||||
// tests.
|
||||
type fakeAdminUserRepo struct {
|
||||
user *models.User
|
||||
updateErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserRepo) List(context.Context) ([]*models.User, error) { return nil, nil }
|
||||
|
||||
func (f *fakeAdminUserRepo) Create(context.Context, models.CreateUserInput) (*models.User, error) {
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserRepo) Update(context.Context, int, models.UpdateUserInput) error {
|
||||
return f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakeAdminUserRepo) Delete(context.Context, int) error { return f.deleteErr }
|
||||
|
||||
func (f *fakeAdminUserRepo) GetByID(context.Context, int) (*models.User, error) {
|
||||
if f.user == nil {
|
||||
return nil, auth.ErrNotFound
|
||||
}
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
// newAdminUsersTestRouter mounts the user-management routes the same way the
|
||||
// API router does so tests exercise real chi URL params.
|
||||
func newAdminUsersTestRouter(repo UserRepository) chi.Router {
|
||||
h := NewAdminHandler(repo, nil, nil)
|
||||
r := chi.NewRouter()
|
||||
r.Put("/admin/users/{id}", h.HandleUpdateUser)
|
||||
r.Delete("/admin/users/{id}", h.HandleDeleteUser)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAdminUpdateUserDisableLastAdministrator(t *testing.T) {
|
||||
repo := &fakeAdminUserRepo{
|
||||
user: &models.User{ID: 2, Enabled: true},
|
||||
updateErr: auth.ErrLastAdministrator,
|
||||
}
|
||||
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodPut,
|
||||
"/admin/users/2", map[string]any{"enabled": false})
|
||||
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
|
||||
}
|
||||
|
||||
func TestAdminDeleteUserLastAdministrator(t *testing.T) {
|
||||
repo := &fakeAdminUserRepo{deleteErr: auth.ErrLastAdministrator}
|
||||
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodDelete,
|
||||
"/admin/users/2", nil)
|
||||
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
|
||||
}
|
||||
|
||||
func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
role := "admin"
|
||||
sameRole := "user"
|
||||
enabled := true
|
||||
disabled := false
|
||||
libraryIDs := []int{1, 2}
|
||||
sameLibraryIDs := []int{1}
|
||||
emptyLibraryIDs := []int{}
|
||||
var allLibraryIDs []int
|
||||
maxPlaybackQuality := "1080p"
|
||||
sameMaxPlaybackQuality := "original"
|
||||
password := "new-password"
|
||||
username := "renamed"
|
||||
newGroups := []int{1, 2}
|
||||
sameGroups := []int{2, 1, 1} // order and duplicates must not matter
|
||||
emptyGroups := []int{}
|
||||
maxStreams := 4
|
||||
permissions := []string{"metadata_curation"}
|
||||
samePermissions := []string{"download"}
|
||||
|
||||
current := &models.User{
|
||||
Enabled: false,
|
||||
GroupIDs: []int{1, 2},
|
||||
Role: "user",
|
||||
Permissions: []string{"download"},
|
||||
Enabled: false,
|
||||
LibraryIDs: []int{1},
|
||||
MaxPlaybackQuality: "original",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -84,6 +36,26 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
in models.UpdateUserInput
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "permissions set",
|
||||
in: models.UpdateUserInput{Permissions: &permissions},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "permissions unchanged",
|
||||
in: models.UpdateUserInput{Permissions: &samePermissions},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
in: models.UpdateUserInput{Role: &role},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "role unchanged",
|
||||
in: models.UpdateUserInput{Role: &sameRole},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "enabled",
|
||||
in: models.UpdateUserInput{Enabled: &enabled},
|
||||
@@ -95,15 +67,30 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "groups unchanged",
|
||||
in: models.UpdateUserInput{GroupIDs: &sameGroups},
|
||||
name: "library ids does not revoke session",
|
||||
in: models.UpdateUserInput{LibraryIDs: &libraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "groups cleared",
|
||||
in: models.UpdateUserInput{GroupIDs: &emptyGroups},
|
||||
name: "library ids unchanged",
|
||||
in: models.UpdateUserInput{LibraryIDs: &sameLibraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "library ids nil does not revoke session",
|
||||
in: models.UpdateUserInput{LibraryIDs: &allLibraryIDs},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "max playback quality",
|
||||
in: models.UpdateUserInput{MaxPlaybackQuality: &maxPlaybackQuality},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "max playback quality unchanged",
|
||||
in: models.UpdateUserInput{MaxPlaybackQuality: &sameMaxPlaybackQuality},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
in: models.UpdateUserInput{Password: &password},
|
||||
@@ -111,7 +98,7 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "non access fields",
|
||||
in: models.UpdateUserInput{Username: &username},
|
||||
in: models.UpdateUserInput{Username: &username, MaxStreams: &maxStreams},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
@@ -129,18 +116,16 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
memberless := &models.User{Enabled: true, GroupIDs: nil}
|
||||
t.Run("groups assigned to memberless user", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(memberless, models.UpdateUserInput{GroupIDs: &newGroups}); !got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
|
||||
unrestrictedCurrent := *current
|
||||
unrestrictedCurrent.LibraryIDs = nil
|
||||
t.Run("library ids empty does not revoke session", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown current user falls back to may-require", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{GroupIDs: &sameGroups}); !got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
|
||||
}
|
||||
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{Username: &username}); got {
|
||||
t.Run("library ids nil unchanged", func(t *testing.T) {
|
||||
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &allLibraryIDs}); got {
|
||||
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
type audiobookGroupResponse struct {
|
||||
Name string `json:"name"`
|
||||
ItemCount int `json:"item_count"`
|
||||
TotalDurationSeconds int64 `json:"total_duration_seconds"`
|
||||
InProgressCount int `json:"in_progress_count"`
|
||||
FinishedCount int `json:"finished_count"`
|
||||
PosterURLs []string `json:"poster_urls"`
|
||||
}
|
||||
|
||||
type audiobookGroupsResponse struct {
|
||||
Total int `json:"total"`
|
||||
Groups []audiobookGroupResponse `json:"groups"`
|
||||
}
|
||||
|
||||
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
|
||||
//
|
||||
// Grouped browse for audiobook libraries: authors, narrators, or series with
|
||||
// aggregate stats (book count, total duration, per-profile progress counts)
|
||||
// and up to four poster URLs for cover stacks. Query parameters:
|
||||
// library_id=<id> (required), group_by=author|narrator|series (required),
|
||||
// sort=name|count|duration (default name), limit, offset.
|
||||
//
|
||||
// Group names round-trip into the corresponding catalog filter fields
|
||||
// (author / narrator / series), which match case-insensitively.
|
||||
func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.itemsH == nil || h.itemsH.browseRepo == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
libraryID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("library_id")))
|
||||
if err != nil || libraryID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer")
|
||||
return
|
||||
}
|
||||
|
||||
groupBy, ok := catalog.ParseAudiobookGroupBy(r.URL.Query().Get("group_by"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "group_by must be one of author, narrator, series")
|
||||
return
|
||||
}
|
||||
|
||||
sort := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
|
||||
switch sort {
|
||||
case "", "name", "count", "duration":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "sort must be one of name, count, duration")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 200
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
n, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil || n <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
|
||||
|
||||
groups, total, err := catalog.ListAudiobookGroups(
|
||||
r.Context(),
|
||||
h.itemsH.browseRepo.Pool(),
|
||||
catalog.AudiobookGroupsQuery{
|
||||
LibraryID: libraryID,
|
||||
GroupBy: groupBy,
|
||||
Sort: sort,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
},
|
||||
h.itemsH.accessFilter(r),
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list audiobook groups")
|
||||
return
|
||||
}
|
||||
|
||||
resp := audiobookGroupsResponse{Total: total, Groups: make([]audiobookGroupResponse, 0, len(groups))}
|
||||
for _, g := range groups {
|
||||
posterURLs := make([]string, 0, len(g.PosterPaths))
|
||||
for _, path := range g.PosterPaths {
|
||||
if url := h.itemsH.presignURL(r, cardThumbnailPath(path), "card"); url != "" {
|
||||
posterURLs = append(posterURLs, url)
|
||||
}
|
||||
}
|
||||
resp.Groups = append(resp.Groups, audiobookGroupResponse{
|
||||
Name: g.Name,
|
||||
ItemCount: g.ItemCount,
|
||||
TotalDurationSeconds: g.TotalDurationSeconds,
|
||||
InProgressCount: g.InProgressCount,
|
||||
FinishedCount: g.FinishedCount,
|
||||
PosterURLs: posterURLs,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
const ttl = 5 * time.Minute
|
||||
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.SessionID, ttl)
|
||||
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access")
|
||||
return
|
||||
@@ -527,7 +527,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Role: auth.RoleForUser(user),
|
||||
Role: user.Role,
|
||||
Permissions: auth.EffectivePermissions(user),
|
||||
DownloadAllowed: user.DownloadAllowed,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
@@ -90,7 +89,6 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
|
||||
h.itemsH.accessFilter(r),
|
||||
overlaySummaries,
|
||||
store,
|
||||
apimw.GetUserID(r.Context()),
|
||||
profileID,
|
||||
)
|
||||
items := make([]itemListResponse, 0, len(result.Items))
|
||||
@@ -223,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
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,35 @@ func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes)
|
||||
h.writeEpisodeResponses(w, r, episodes)
|
||||
|
||||
resp := make([]episodeResponse, 0, len(episodes))
|
||||
fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes)
|
||||
for _, ep := range episodes {
|
||||
epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID])
|
||||
if h.items.fileRepo != nil {
|
||||
files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID)
|
||||
if fileErr == nil {
|
||||
for _, f := range files {
|
||||
if !catalog.FileAllowedByAccess(f, filter) {
|
||||
continue
|
||||
}
|
||||
epResp.Files = append(epResp.Files, episodeFileResponse{
|
||||
FileID: f.ID,
|
||||
Resolution: f.Resolution,
|
||||
CodecVideo: f.CodecVideo,
|
||||
HDR: f.HDR,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Container: f.Container,
|
||||
FileSize: f.FileSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
epResp.UserData = h.items.getLeafUserData(r, ep.ContentID)
|
||||
resp = append(resp, epResp)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp})
|
||||
}
|
||||
|
||||
func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -411,7 +439,34 @@ func (h *CatalogResourceHandler) syntheticSeasonDetail(r *http.Request, seasonID
|
||||
}
|
||||
|
||||
func (h *CatalogResourceHandler) writeEpisodeResponses(w http.ResponseWriter, r *http.Request, episodes []*models.Episode) {
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: h.items.buildEpisodeResponses(r, episodes)})
|
||||
filter := h.items.accessFilter(r)
|
||||
resp := make([]episodeResponse, 0, len(episodes))
|
||||
fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes)
|
||||
for _, ep := range episodes {
|
||||
epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID])
|
||||
if h.items.fileRepo != nil {
|
||||
files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID)
|
||||
if fileErr == nil {
|
||||
for _, f := range files {
|
||||
if !catalog.FileAllowedByAccess(f, filter) {
|
||||
continue
|
||||
}
|
||||
epResp.Files = append(epResp.Files, episodeFileResponse{
|
||||
FileID: f.ID,
|
||||
Resolution: f.Resolution,
|
||||
CodecVideo: f.CodecVideo,
|
||||
HDR: f.HDR,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Container: f.Container,
|
||||
FileSize: f.FileSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
epResp.UserData = h.items.getLeafUserData(r, ep.ContentID)
|
||||
resp = append(resp, epResp)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp})
|
||||
}
|
||||
|
||||
func parseSyntheticSeasonID(contentID string) (string, int, bool) {
|
||||
@@ -442,8 +497,8 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
|
||||
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
|
||||
}
|
||||
}
|
||||
case "movie", "episode", "audiobook", "ebook":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID, detail.Type)
|
||||
case "movie", "episode":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
|
||||
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// Ebook read state lives exclusively in ebook_reader_progress: every consumer
|
||||
// (user-state Played, catalog watched/in_progress filters, Continue Reading,
|
||||
// sort metrics, recommendations) keys off progress crossing
|
||||
// models.EbookFinishedProgressThreshold. The native watched endpoints therefore
|
||||
// write that table instead of user_watch_progress/user_watch_history. The
|
||||
// helpers live in this package — next to the ebook progress store and types —
|
||||
// because the handler layer is the only producer today: the webhook-import
|
||||
// matcher (internal/historyimport) only matches movie/series/episode kinds, so
|
||||
// imported mark-played events can never resolve to an ebook content ID.
|
||||
|
||||
// EbookReadStateStore is the ebook_reader_progress surface needed to set or
|
||||
// clear an ebook's read state from the watched endpoints.
|
||||
// *PGEbookReaderProgressStore implements it.
|
||||
type EbookReadStateStore interface {
|
||||
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
|
||||
Upsert(ctx context.Context, progress EbookReaderProgress) error
|
||||
Delete(ctx context.Context, userID int, profileID string, contentID string) error
|
||||
}
|
||||
|
||||
// EbookReaderProgressReadWriter is the full progress-store surface the items
|
||||
// handler needs: batch reads for list/user-state responses plus read-state
|
||||
// writes for the watched endpoints.
|
||||
type EbookReaderProgressReadWriter interface {
|
||||
EbookReaderProgressLister
|
||||
EbookReadStateStore
|
||||
}
|
||||
|
||||
// markEbookRead marks an ebook as finished by upserting its reader-progress
|
||||
// row with progress = 1.0 (>= models.EbookFinishedProgressThreshold). An
|
||||
// existing row keeps its file_id and location so the reader still reopens at
|
||||
// the user's last position even though the book is marked read; a missing row
|
||||
// references the item's preferred reader file (resolved lazily via
|
||||
// defaultFileID, since ebook_reader_progress.file_id is NOT NULL) with an
|
||||
// empty location meaning "start at the beginning". A fresh updated_at also
|
||||
// resurfaces a previously hidden book, mirroring how re-watching resurfaces
|
||||
// hidden video history.
|
||||
func markEbookRead(
|
||||
ctx context.Context,
|
||||
store EbookReadStateStore,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
now time.Time,
|
||||
defaultFileID func(ctx context.Context) (int, error),
|
||||
) error {
|
||||
existing, err := store.Get(ctx, userID, profileID, contentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress := EbookReaderProgress{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: contentID,
|
||||
Progress: 1.0,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if existing != nil {
|
||||
progress.FileID = existing.FileID
|
||||
progress.Location = existing.Location
|
||||
} else {
|
||||
fileID, err := defaultFileID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress.FileID = fileID
|
||||
}
|
||||
return store.Upsert(ctx, progress)
|
||||
}
|
||||
|
||||
// markEbookUnread clears an ebook's read state by deleting its reader-progress
|
||||
// row — the direct analog of the video unwatch path, where
|
||||
// userstore.ClearProgress DELETEs the user_watch_progress row (dropping the
|
||||
// resume position along with the watched flag).
|
||||
func markEbookUnread(
|
||||
ctx context.Context,
|
||||
store EbookReadStateStore,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
) error {
|
||||
return store.Delete(ctx, userID, profileID, contentID)
|
||||
}
|
||||
|
||||
// setEbookReadState applies the watched toggle for an ebook target resolved by
|
||||
// resolveWatchedTargets, which has already enforced the access filter on the
|
||||
// item. Scoping matches the rest of the ebook progress code: user_id +
|
||||
// profile_id.
|
||||
func (h *ItemsHandler) setEbookReadState(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
read bool,
|
||||
filter catalog.AccessFilter,
|
||||
) error {
|
||||
if h.ebookReadStateStore == nil {
|
||||
return fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
if !read {
|
||||
return markEbookUnread(ctx, h.ebookReadStateStore, userID, profileID, contentID)
|
||||
}
|
||||
return markEbookRead(ctx, h.ebookReadStateStore, userID, profileID, contentID, time.Now().UTC(), func(ctx context.Context) (int, error) {
|
||||
return h.defaultEbookFileID(ctx, contentID, filter)
|
||||
})
|
||||
}
|
||||
|
||||
// defaultEbookFileID resolves the file a fresh mark-read progress row should
|
||||
// reference, respecting the caller's access filter.
|
||||
func (h *ItemsHandler) defaultEbookFileID(ctx context.Context, contentID string, filter catalog.AccessFilter) (int, error) {
|
||||
if h.fileRepo == nil {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
}
|
||||
files, err := h.fileRepo.GetByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file := preferredEbookReadFile(catalog.FilterMediaFilesByAccess(files, filter))
|
||||
if file == nil {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
}
|
||||
return file.ID, nil
|
||||
}
|
||||
|
||||
// preferredEbookReadFile mirrors the web reader's default file choice
|
||||
// (web/src/pages/ItemDetail/EbookContent.tsx preferredReadVersion): prefer an
|
||||
// EPUB version, otherwise the first reader-supported ebook file. Callers must
|
||||
// pass access-filtered files.
|
||||
func preferredEbookReadFile(files []*models.MediaFile) *models.MediaFile {
|
||||
var first *models.MediaFile
|
||||
for _, file := range files {
|
||||
if !isEbookFile(file) {
|
||||
continue
|
||||
}
|
||||
if ebookReaderFormat(file.FilePath, file.Container) == "epub" {
|
||||
return file
|
||||
}
|
||||
if first == nil {
|
||||
first = file
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeEbookReadStateStore is an in-memory EbookReaderProgressReadWriter keyed
|
||||
// by content ID for a single (user, profile) scope.
|
||||
type fakeEbookReadStateStore struct {
|
||||
rows map[string]EbookReaderProgress
|
||||
}
|
||||
|
||||
func newFakeEbookReadStateStore() *fakeEbookReadStateStore {
|
||||
return &fakeEbookReadStateStore{rows: map[string]EbookReaderProgress{}}
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) Get(_ context.Context, _ int, _ string, contentID string) (*EbookReaderProgress, error) {
|
||||
row, ok := f.rows[contentID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
|
||||
f.rows[progress.ContentID] = progress
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) Delete(_ context.Context, _ int, _ string, contentID string) error {
|
||||
delete(f.rows, contentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookReadStateStore) ListByContentIDs(_ context.Context, _ int, _ string, contentIDs []string) (map[string]EbookReaderProgress, error) {
|
||||
result := make(map[string]EbookReaderProgress, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
if row, ok := f.rows[contentID]; ok {
|
||||
result[contentID] = row
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fakeEbookFileProvider implements EpisodeFileProvider for ebook file lookups.
|
||||
type fakeEbookFileProvider struct {
|
||||
files map[string][]*models.MediaFile
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEbookFileProvider) GetByContentID(_ context.Context, contentID string) ([]*models.MediaFile, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.files[contentID], nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookFileProvider) GetByEpisodeID(context.Context, string) ([]*models.MediaFile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeEbookFileProvider) ListByContentIDs(context.Context, []string) (map[string][]*models.MediaFile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestMarkEbookReadPreservesExistingFileAndLocation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
store.rows["ebook-1"] = EbookReaderProgress{
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
FileID: 7,
|
||||
Location: "epubcfi(/6/14!/4/2/14)",
|
||||
Progress: 0.42,
|
||||
UpdatedAt: time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC),
|
||||
}
|
||||
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
|
||||
t.Fatal("defaultFileID must not be called when a progress row exists")
|
||||
return 0, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("markEbookRead: %v", err)
|
||||
}
|
||||
|
||||
row := store.rows["ebook-1"]
|
||||
if row.Progress != 1.0 {
|
||||
t.Fatalf("progress = %v, want 1.0", row.Progress)
|
||||
}
|
||||
if row.FileID != 7 || row.Location != "epubcfi(/6/14!/4/2/14)" {
|
||||
t.Fatalf("file/location not preserved: %+v", row)
|
||||
}
|
||||
if !row.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("updated_at = %v, want %v", row.UpdatedAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEbookReadUsesDefaultFileWhenNoProgressExists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
|
||||
return 12, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("markEbookRead: %v", err)
|
||||
}
|
||||
|
||||
row, ok := store.rows["ebook-1"]
|
||||
if !ok {
|
||||
t.Fatal("expected a progress row to be created")
|
||||
}
|
||||
if row.Progress != 1.0 || row.FileID != 12 || row.Location != "" {
|
||||
t.Fatalf("row = %+v, want progress 1.0 with file 12 and empty location", row)
|
||||
}
|
||||
if row.UserID != 42 || row.ProfileID != "profile-1" {
|
||||
t.Fatalf("row scope = user %d profile %q, want user 42 profile-1", row.UserID, row.ProfileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEbookReadPropagatesDefaultFileError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
|
||||
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", time.Now().UTC(), func(context.Context) (int, error) {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
})
|
||||
if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
|
||||
}
|
||||
if len(store.rows) != 0 {
|
||||
t.Fatalf("no row must be written on error, got %+v", store.rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEbookUnreadDeletesProgressRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
store.rows["ebook-1"] = EbookReaderProgress{ContentID: "ebook-1", FileID: 7, Progress: 1.0}
|
||||
|
||||
if err := markEbookUnread(ctx, store, 42, "profile-1", "ebook-1"); err != nil {
|
||||
t.Fatalf("markEbookUnread: %v", err)
|
||||
}
|
||||
if _, ok := store.rows["ebook-1"]; ok {
|
||||
t.Fatal("progress row must be deleted on mark unread")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredEbookReadFilePrefersEpub(t *testing.T) {
|
||||
pdf := &models.MediaFile{ID: 1, BaseType: "ebook", FilePath: "/books/a.pdf", Container: "pdf"}
|
||||
epub := &models.MediaFile{ID: 2, BaseType: "ebook", FilePath: "/books/a.epub", Container: "epub"}
|
||||
video := &models.MediaFile{ID: 3, BaseType: "movie", FilePath: "/movies/a.mkv", Container: "mkv"}
|
||||
|
||||
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf, epub}); got == nil || got.ID != 2 {
|
||||
t.Fatalf("preferredEbookReadFile = %+v, want epub file 2", got)
|
||||
}
|
||||
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf}); got == nil || got.ID != 1 {
|
||||
t.Fatalf("preferredEbookReadFile = %+v, want first reader-supported file 1", got)
|
||||
}
|
||||
if got := preferredEbookReadFile([]*models.MediaFile{video}); got != nil {
|
||||
t.Fatalf("preferredEbookReadFile = %+v, want nil without ebook files", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEbookReadStateMarkReadShowsPlayedUserState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: store,
|
||||
ebookReadStateStore: store,
|
||||
fileRepo: &fakeEbookFileProvider{files: map[string][]*models.MediaFile{
|
||||
"ebook-1": {{ID: 5, BaseType: "ebook", FilePath: "/books/b.epub", Container: "epub"}},
|
||||
}},
|
||||
}
|
||||
|
||||
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{}); err != nil {
|
||||
t.Fatalf("setEbookReadState(read): %v", err)
|
||||
}
|
||||
|
||||
userStore := newProfileTestStore(t)
|
||||
items := []*models.MediaItem{{ContentID: "ebook-1", Type: "ebook", Title: "Book"}}
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, userStore, "profile-1", nil, items, itemUserStateOptions{
|
||||
UserID: 42,
|
||||
EbookProgressStore: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
|
||||
}
|
||||
if states["ebook-1"] == nil || !states["ebook-1"].Played {
|
||||
t.Fatalf("user state = %+v, want played after mark read", states["ebook-1"])
|
||||
}
|
||||
|
||||
// A marked-read book is finished, not in progress: the reader-progress
|
||||
// response derived from the row must exclude it from Continue Reading.
|
||||
row := store.rows["ebook-1"]
|
||||
if row.Progress < models.EbookFinishedProgressThreshold {
|
||||
t.Fatalf("progress %v must cross the finished threshold", row.Progress)
|
||||
}
|
||||
|
||||
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", false, catalog.AccessFilter{}); err != nil {
|
||||
t.Fatalf("setEbookReadState(unread): %v", err)
|
||||
}
|
||||
if _, ok := store.rows["ebook-1"]; ok {
|
||||
t.Fatal("mark unread must delete the reader progress row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEbookReadStateFailsWithoutAccessibleFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeEbookReadStateStore()
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: store,
|
||||
ebookReadStateStore: store,
|
||||
fileRepo: &fakeEbookFileProvider{},
|
||||
}
|
||||
|
||||
err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{})
|
||||
if !errors.Is(err, catalog.ErrItemNotFound) {
|
||||
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,897 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func newEbookReaderAuthRequest(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withEbookReaderRouteParams(req *http.Request, contentID, fileID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
routeCtx.URLParams.Add("file_id", fileID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func withEbookReaderContentRouteParam(req *http.Request, contentID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func withEbookReaderAnnotationRouteParams(req *http.Request, contentID, annotationID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
routeCtx.URLParams.Add("annotation_id", annotationID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
type fakeEbookProgressStore struct {
|
||||
progress *EbookReaderProgress
|
||||
upserted *EbookReaderProgress
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeEbookProgressStore) Get(
|
||||
context.Context,
|
||||
int,
|
||||
string,
|
||||
string,
|
||||
) (*EbookReaderProgress, error) {
|
||||
return s.progress, s.err
|
||||
}
|
||||
|
||||
func (s *fakeEbookProgressStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.upserted = &progress
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEbookReaderConfigStore struct {
|
||||
config *EbookReaderConfig
|
||||
saved *EbookReaderConfig
|
||||
getErr error
|
||||
saveErr error
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderConfigStore) Get(
|
||||
context.Context,
|
||||
int,
|
||||
string,
|
||||
string,
|
||||
) (*EbookReaderConfig, error) {
|
||||
return s.config, s.getErr
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderConfigStore) Upsert(_ context.Context, config EbookReaderConfig) error {
|
||||
if s.saveErr != nil {
|
||||
return s.saveErr
|
||||
}
|
||||
s.saved = &config
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEbookReaderAnnotationStore struct {
|
||||
items []EbookReaderAnnotation
|
||||
existing *EbookReaderAnnotation
|
||||
created *EbookReaderAnnotation
|
||||
updated *EbookReaderAnnotation
|
||||
mergedFrom *EbookReaderAnnotation
|
||||
mergeCalls int
|
||||
deleted string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) List(context.Context, int, string, string) ([]EbookReaderAnnotation, error) {
|
||||
return s.items, s.err
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) Create(_ context.Context, annotation EbookReaderAnnotation) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.created = &annotation
|
||||
s.items = append(s.items, annotation)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update models the PG store contract: read the stored row, invoke merge with
|
||||
// it, and persist the merged result, all as one atomic operation.
|
||||
func (s *fakeEbookReaderAnnotationStore) Update(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
_ string,
|
||||
annotationID string,
|
||||
merge func(existing EbookReaderAnnotation) (EbookReaderAnnotation, error),
|
||||
) (*EbookReaderAnnotation, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.existing == nil || s.existing.ID != annotationID {
|
||||
return nil, nil
|
||||
}
|
||||
existing := *s.existing
|
||||
s.mergedFrom = &existing
|
||||
s.mergeCalls++
|
||||
merged, err := merge(existing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.updated = &merged
|
||||
stored := merged
|
||||
s.existing = &stored
|
||||
return &merged, nil
|
||||
}
|
||||
|
||||
func (s *fakeEbookReaderAnnotationStore) Delete(_ context.Context, userID int, profileID, contentID, annotationID string) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.deleted = annotationID
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEbookReaderServesEbookInlineWithRangeSupport(t *testing.T) {
|
||||
filePath := writePlaybackTestMediaFile(t, "book.epub")
|
||||
if err := os.WriteFile(filePath, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatalf("write ebook: %v", err)
|
||||
}
|
||||
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: filePath,
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req.Header.Set("Range", "bytes=2-5")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
|
||||
t.Fatalf("content-type = %q", got)
|
||||
}
|
||||
if got := rr.Header().Get("Content-Disposition"); got != "inline; filename=book.epub" {
|
||||
t.Fatalf("content-disposition = %q", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("x-content-type-options = %q, want nosniff", got)
|
||||
}
|
||||
if rr.Body.String() != "2345" {
|
||||
t.Fatalf("body = %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderServesContainerMimeForMismatchedExtension(t *testing.T) {
|
||||
filePath := writePlaybackTestMediaFile(t, "book.txt")
|
||||
if err := os.WriteFile(filePath, []byte("<html>not really</html>"), 0o644); err != nil {
|
||||
t.Fatalf("write ebook: %v", err)
|
||||
}
|
||||
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: filePath,
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// The file is admitted via its validated container, so the response must
|
||||
// carry that container's MIME type, never application/octet-stream.
|
||||
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
|
||||
t.Fatalf("content-type = %q, want application/epub+zip", got)
|
||||
}
|
||||
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("x-content-type-options = %q, want nosniff", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookMimeTypeNeverReturnsOctetStreamForAdmittedFiles(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
container string
|
||||
wantMime string
|
||||
}{
|
||||
{"/library/book.txt", "epub", "application/epub+zip"},
|
||||
{"/library/book", "pdf", "application/pdf"},
|
||||
{"/library/book.epub", "pdf", "application/epub+zip"}, // known extension wins
|
||||
}
|
||||
for _, tc := range cases {
|
||||
file := &models.MediaFile{FilePath: tc.path, Container: tc.container, BaseType: "ebook"}
|
||||
if !isEbookFile(file) {
|
||||
t.Fatalf("isEbookFile(%q, %q) = false, want admitted", tc.path, tc.container)
|
||||
}
|
||||
if got := ebookMimeType(tc.path, tc.container); got != tc.wantMime {
|
||||
t.Fatalf("ebookMimeType(%q, %q) = %q, want %q", tc.path, tc.container, got, tc.wantMime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsNonEbookFile(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: "/tmp/movie.mkv",
|
||||
Container: "mkv",
|
||||
BaseType: "movie",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/movie-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "movie-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRecognizesReadestFormats(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"book.epub": "application/epub+zip",
|
||||
"book.pdf": "application/pdf",
|
||||
"book.mobi": "application/x-mobipocket-ebook",
|
||||
"book.azw": "application/vnd.amazon.ebook",
|
||||
"book.azw3": "application/vnd.amazon.mobi8-ebook",
|
||||
"book.cbz": "application/vnd.comicbook+zip",
|
||||
"book.cbr": "application/vnd.comicbook-rar",
|
||||
"book.fb2": "application/x-fictionbook+xml",
|
||||
"book.fbz": "application/x-zip-compressed-fb2",
|
||||
"book.fb2.zip": "application/x-zip-compressed-fb2",
|
||||
"book.md": "application/octet-stream",
|
||||
"book.unknown": "application/octet-stream",
|
||||
}
|
||||
rejected := map[string]bool{"book.md": true, "book.unknown": true}
|
||||
|
||||
for name, wantMime := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
container := name
|
||||
if name == "book.fb2.zip" {
|
||||
container = "fbz"
|
||||
}
|
||||
file := &models.MediaFile{FilePath: "/library/" + name, Container: container}
|
||||
if rejected[name] {
|
||||
file.BaseType = "ebook"
|
||||
if isEbookFile(file) {
|
||||
t.Fatalf("%s should not be treated as an ebook reader format", name)
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
|
||||
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
|
||||
}
|
||||
return
|
||||
}
|
||||
file.BaseType = "ebook"
|
||||
if !isEbookFile(file) {
|
||||
t.Fatal("expected ebook reader format")
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
|
||||
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsPlainText(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
FilePath: "/library/book.txt",
|
||||
BaseType: "ebook",
|
||||
}
|
||||
|
||||
if isEbookFile(file) {
|
||||
t.Fatal("plain text should not be treated as an ebook reader format")
|
||||
}
|
||||
if got := ebookMimeType(file.FilePath, file.Container); got == "text/plain; charset=utf-8" {
|
||||
t.Fatal("plain text should not have an ebook reader MIME type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRecognizesFBZFromCompoundFilenameWithoutContainer(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
FilePath: "/library/book.fb2.zip",
|
||||
BaseType: "ebook",
|
||||
}
|
||||
|
||||
if !isEbookFile(file) {
|
||||
t.Fatal("expected .fb2.zip path to be treated as an ebook reader format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderMapsAccessFailureToNotFound(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: "/tmp/book.epub",
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsInternalErrorForUnexpectedAuthorizeFailure(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{err: errors.New("db down")},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
|
||||
req = withEbookReaderRouteParams(req, "ebook-1", "42")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReadFile(rr, req)
|
||||
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
|
||||
store := &fakeEbookProgressStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "ebook-1",
|
||||
FilePath: "/tmp/book.epub",
|
||||
Container: "epub",
|
||||
BaseType: "ebook",
|
||||
},
|
||||
},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/progress",
|
||||
strings.NewReader(`{"file_id":42,"location":"epubcfi(/6/4)","progress":0.42}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.upserted == nil {
|
||||
t.Fatal("expected progress to be saved")
|
||||
}
|
||||
if store.upserted.UserID != 1 || store.upserted.ProfileID != "profile-9" {
|
||||
t.Fatalf("scope = user %d profile %q", store.upserted.UserID, store.upserted.ProfileID)
|
||||
}
|
||||
if store.upserted.ContentID != "ebook-1" || store.upserted.FileID != 42 {
|
||||
t.Fatalf("target = content %q file %d", store.upserted.ContentID, store.upserted.FileID)
|
||||
}
|
||||
if store.upserted.Location != "epubcfi(/6/4)" || store.upserted.Progress != 0.42 {
|
||||
t.Fatalf("progress = %+v", store.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsSavedProgress(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = &fakeEbookProgressStore{
|
||||
progress: &EbookReaderProgress{
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
FileID: 42,
|
||||
Location: "epubcfi(/6/8)",
|
||||
Progress: 0.75,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/progress")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
`"content_id":"ebook-1"`,
|
||||
`"file_id":42`,
|
||||
`"location":"epubcfi(/6/8)"`,
|
||||
`"progress":0.75`,
|
||||
`"updated_at":"2026-06-08T12:00:00Z"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response %s missing %s", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsEmptyConfigWhenNoneSaved(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ConfigStore = &fakeEbookReaderConfigStore{}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := strings.TrimSpace(rr.Body.String()); got != `{"config":{}}` {
|
||||
t.Fatalf("body = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderSavesConfigForAccessibleEbook(t *testing.T) {
|
||||
store := &fakeEbookReaderConfigStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ConfigStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/reader-config",
|
||||
strings.NewReader(`{"config":{"theme":"dark","flow":"scrolled","fontSize":120}}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.saved == nil {
|
||||
t.Fatal("expected config to be saved")
|
||||
}
|
||||
if store.saved.UserID != 1 || store.saved.ProfileID != "profile-9" || store.saved.ContentID != "ebook-1" {
|
||||
t.Fatalf("scope = %+v", store.saved)
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(store.saved.Config, &config); err != nil {
|
||||
t.Fatalf("config json: %v", err)
|
||||
}
|
||||
if config["theme"] != "dark" || config["flow"] != "scrolled" || config["fontSize"].(float64) != 120 {
|
||||
t.Fatalf("config = %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsNonObjectConfig(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ConfigStore = &fakeEbookReaderConfigStore{}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/reader-config")
|
||||
req.Body = http.NoBody
|
||||
req = httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/ebooks/ebook-1/reader-config",
|
||||
strings.NewReader(`{"config":["bad"]}`),
|
||||
).WithContext(req.Context())
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderConfigRequiresAccessibleContent(t *testing.T) {
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
|
||||
})
|
||||
handler.ConfigStore = &fakeEbookReaderConfigStore{}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderCreatesAnnotationForAccessibleEbook(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = store
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/ebooks/ebook-1/annotations",
|
||||
strings.NewReader(`{"kind":"highlight","cfi_range":"epubcfi(/6/4,/1:0,/1:12)","selected_text":"sample text","note":"note text","style":"highlight","color":"#facc15"}`),
|
||||
)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleCreateAnnotation(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.created == nil {
|
||||
t.Fatal("expected annotation to be created")
|
||||
}
|
||||
if store.created.UserID != 1 || store.created.ProfileID != "profile-9" || store.created.ContentID != "ebook-1" {
|
||||
t.Fatalf("scope = %+v", store.created)
|
||||
}
|
||||
if store.created.Kind != "highlight" || store.created.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
|
||||
t.Fatalf("annotation = %+v", store.created)
|
||||
}
|
||||
if store.created.SelectedText != "sample text" || store.created.Note != "note text" {
|
||||
t.Fatalf("annotation text = %+v", store.created)
|
||||
}
|
||||
if store.created.ID == "" {
|
||||
t.Fatal("expected generated annotation id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsAnnotations(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = &fakeEbookReaderAnnotationStore{
|
||||
items: []EbookReaderAnnotation{
|
||||
{
|
||||
ID: "ann-1",
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
Kind: "bookmark",
|
||||
Location: "epubcfi(/6/8)",
|
||||
SelectedText: "",
|
||||
Note: "Saved spot",
|
||||
Style: "highlight",
|
||||
Color: "#facc15",
|
||||
Metadata: json.RawMessage(`{}`),
|
||||
CreatedAt: updatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/annotations")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleListAnnotations(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{`"items":[`, `"id":"ann-1"`, `"kind":"bookmark"`, `"location":"epubcfi(/6/8)"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response %s missing %s", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func existingEbookReaderAnnotation() *EbookReaderAnnotation {
|
||||
createdAt := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||
return &EbookReaderAnnotation{
|
||||
ID: "ann-1",
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
Kind: "highlight",
|
||||
CFIRange: "epubcfi(/6/4,/1:0,/1:12)",
|
||||
SelectedText: "sample text",
|
||||
Note: "original note",
|
||||
Style: "highlight",
|
||||
Color: "#facc15",
|
||||
Metadata: json.RawMessage(`{"chapter":"one"}`),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func patchEbookReaderAnnotation(t *testing.T, store *fakeEbookReaderAnnotationStore, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = store
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodPatch, "/ebooks/ebook-1/annotations/ann-1")
|
||||
req.Body = ioNopCloser{strings.NewReader(body)}
|
||||
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateAnnotation(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchKeepsAbsentFields(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated","color":"#bfdbfe"}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated == nil || store.updated.ID != "ann-1" || store.updated.Note != "updated" || store.updated.Color != "#bfdbfe" {
|
||||
t.Fatalf("updated = %+v", store.updated)
|
||||
}
|
||||
if string(store.updated.Metadata) != `{"chapter":"one"}` {
|
||||
t.Fatalf("metadata should be preserved when absent, got %s", store.updated.Metadata)
|
||||
}
|
||||
if store.updated.Kind != "highlight" || store.updated.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
|
||||
t.Fatalf("absent fields should keep stored values, got %+v", store.updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchClearsExplicitlyEmptyFields(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"","metadata":{}}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated == nil || store.updated.Note != "" {
|
||||
t.Fatalf("note should be cleared, got %+v", store.updated)
|
||||
}
|
||||
if string(store.updated.Metadata) != `{}` {
|
||||
t.Fatalf("metadata should be cleared to {}, got %s", store.updated.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchValidatesResultingState(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"kind change without location": `{"kind":"bookmark"}`,
|
||||
"clearing required cfi_range": `{"cfi_range":""}`,
|
||||
"unknown kind": `{"kind":"doodle"}`,
|
||||
"non-object metadata": `{"metadata":["bad"]}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
rr := patchEbookReaderAnnotation(t, store, body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated != nil {
|
||||
t.Fatalf("invalid patch must not reach the store, got %+v", store.updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchAllowsKindChangeWithRequiredFields(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"kind":"bookmark","location":"epubcfi(/6/8)"}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.updated == nil || store.updated.Kind != "bookmark" || store.updated.Location != "epubcfi(/6/8)" {
|
||||
t.Fatalf("updated = %+v", store.updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchMergesOntoRowReadInsideStoreUpdate(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"merged note"}`)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// The handler must not pre-read and merge outside the store: the merge
|
||||
// runs exactly once, on the row handed over by the (locked) store read.
|
||||
if store.mergeCalls != 1 {
|
||||
t.Fatalf("merge calls = %d, want 1", store.mergeCalls)
|
||||
}
|
||||
if store.mergedFrom == nil || store.mergedFrom.Note != "original note" {
|
||||
t.Fatalf("merge input = %+v, want the stored row", store.mergedFrom)
|
||||
}
|
||||
if store.updated == nil || store.updated.Note != "merged note" {
|
||||
t.Fatalf("written row = %+v, want merged note", store.updated)
|
||||
}
|
||||
if store.updated.CFIRange != store.mergedFrom.CFIRange || store.updated.Kind != store.mergedFrom.Kind {
|
||||
t.Fatalf("written row %+v must keep unpatched fields from merge input %+v", store.updated, store.mergedFrom)
|
||||
}
|
||||
if !store.updated.UpdatedAt.After(store.mergedFrom.UpdatedAt) {
|
||||
t.Fatalf("updated_at %v must advance past %v", store.updated.UpdatedAt, store.mergedFrom.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderPatchReturnsNotFoundForMissingAnnotation(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
|
||||
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated"}`)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderRejectsOversizedProgressBody(t *testing.T) {
|
||||
store := &fakeEbookProgressStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = store
|
||||
|
||||
body := `{"file_id":42,"location":"` + strings.Repeat("x", int(ebookReaderProgressMaxBodySize)) + `","progress":0.5}`
|
||||
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/progress")
|
||||
req.Body = ioNopCloser{strings.NewReader(body)}
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.upserted != nil {
|
||||
t.Fatalf("oversized body must not be saved, got %+v", store.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineContentDispositionProducesParseableHeaders(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"book.epub": "book.epub",
|
||||
`bo"ok\.epub`: `bo"ok\.epub`,
|
||||
`trailing-slash\`: `trailing-slash\`,
|
||||
"börk \U0001F4DA.epub": "börk \U0001F4DA.epub",
|
||||
"": "ebook",
|
||||
".": "ebook",
|
||||
}
|
||||
for name, want := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := inlineContentDisposition(name)
|
||||
mediaType, params, err := mime.ParseMediaType(got)
|
||||
if err != nil {
|
||||
t.Fatalf("header %q is malformed: %v", got, err)
|
||||
}
|
||||
if mediaType != "inline" {
|
||||
t.Fatalf("media type = %q", mediaType)
|
||||
}
|
||||
if params["filename"] != want {
|
||||
t.Fatalf("filename = %q, want %q", params["filename"], want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderDeletesAnnotation(t *testing.T) {
|
||||
store := &fakeEbookReaderAnnotationStore{}
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.AnnotationStore = store
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodDelete, "/ebooks/ebook-1/annotations/ann-1")
|
||||
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleDeleteAnnotation(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.deleted != "ann-1" {
|
||||
t.Fatalf("deleted = %q", store.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
type ioNopCloser struct {
|
||||
*strings.Reader
|
||||
}
|
||||
|
||||
func (c ioNopCloser) Close() error { return nil }
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -32,10 +31,6 @@ type activeScanLister interface {
|
||||
ListActive(ctx context.Context) ([]evt.ScanRun, error)
|
||||
}
|
||||
|
||||
// viewerRevalidateInterval is how often an open events WebSocket re-loads its
|
||||
// user to confirm the account is still enabled and admin status is unchanged.
|
||||
const viewerRevalidateInterval = 60 * time.Second
|
||||
|
||||
type EventsHandler struct {
|
||||
hub *evt.Hub
|
||||
jobs *AdminJobsHandler
|
||||
@@ -44,11 +39,6 @@ type EventsHandler struct {
|
||||
scans *evt.ScanRegistry
|
||||
persistedScans activeScanLister
|
||||
historyImports historyImportActiveLister
|
||||
users auth.UserLoader
|
||||
|
||||
// revalidateInterval overrides viewerRevalidateInterval in tests; zero
|
||||
// means the default.
|
||||
revalidateInterval time.Duration
|
||||
}
|
||||
|
||||
func NewEventsHandler(
|
||||
@@ -59,7 +49,6 @@ func NewEventsHandler(
|
||||
scans *evt.ScanRegistry,
|
||||
persistedScans *scanqueue.Service,
|
||||
historyImports historyImportActiveLister,
|
||||
users auth.UserLoader,
|
||||
) *EventsHandler {
|
||||
return &EventsHandler{
|
||||
hub: hub,
|
||||
@@ -69,7 +58,6 @@ func NewEventsHandler(
|
||||
scans: scans,
|
||||
persistedScans: persistedScans,
|
||||
historyImports: historyImports,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +73,6 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Admin status is checked server-side once per connection; it never comes
|
||||
// from token contents.
|
||||
viewerIsAdmin := isAdminRequest(r, h.users)
|
||||
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -105,7 +89,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return writeWebSocketControl(conn, websocket.PingMessage, nil)
|
||||
})
|
||||
|
||||
allowedChannels := allowedChannelsForViewer(viewerIsAdmin)
|
||||
allowedChannels := allowedChannelsForRole(claims.Role)
|
||||
connectionID := ulid.Make().String()
|
||||
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
|
||||
Type: "hello",
|
||||
@@ -137,17 +121,6 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
|
||||
// Channel authorization is decided once at upgrade, so a long-lived
|
||||
// connection must periodically re-load its user: a disabled account or a
|
||||
// changed admin role would otherwise keep its channel set until the
|
||||
// client disconnects on its own.
|
||||
revalidateEvery := h.revalidateInterval
|
||||
if revalidateEvery <= 0 {
|
||||
revalidateEvery = viewerRevalidateInterval
|
||||
}
|
||||
revalidate := time.NewTicker(revalidateEvery)
|
||||
defer revalidate.Stop()
|
||||
|
||||
subscriptions := make(map[evt.EventChannel]struct{})
|
||||
subscribedOnce := false
|
||||
|
||||
@@ -157,33 +130,6 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
case <-readDone:
|
||||
return
|
||||
case <-revalidate.C:
|
||||
if h.users == nil {
|
||||
// No loader wired: the viewer was never granted admin
|
||||
// channels (isAdminRequest fails closed), so there is no
|
||||
// stale privilege to revoke.
|
||||
continue
|
||||
}
|
||||
user, err := h.users.GetByID(ctx, claims.UserID)
|
||||
if err != nil && !auth.IsNotFound(err) {
|
||||
// Transient lookup failure: keep the connection and retry on
|
||||
// the next tick rather than dropping every viewer on a DB
|
||||
// blip. A definitive miss (ErrNotFound) leaves user nil and
|
||||
// closes below.
|
||||
slog.Warn("events: viewer revalidation failed; retrying next tick",
|
||||
"user_id", claims.UserID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if closeConn, reason := revalidateViewer(user, viewerIsAdmin); closeConn {
|
||||
_ = writeWebSocketControl(
|
||||
conn,
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, reason),
|
||||
)
|
||||
return
|
||||
}
|
||||
case <-deadline.C:
|
||||
if subscribedOnce {
|
||||
continue
|
||||
@@ -196,7 +142,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
)
|
||||
return
|
||||
case data := <-readMessages:
|
||||
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, viewerIsAdmin, data, allowedChannels)
|
||||
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -218,10 +164,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
if _, subscribed := subscriptions[env.Channel]; !subscribed {
|
||||
continue
|
||||
}
|
||||
if !allowsEventForViewer(claims.UserID, viewerIsAdmin, env) {
|
||||
if !allowsEventForClaims(claims, env) {
|
||||
continue
|
||||
}
|
||||
if err := h.writeEventFrame(conn, r, claims, viewerIsAdmin, env); err != nil {
|
||||
if err := h.writeEventFrame(conn, r, claims, env); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -232,7 +178,6 @@ func (h *EventsHandler) handleEventsClientMessage(
|
||||
conn *websocket.Conn,
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
data []byte,
|
||||
allowed []evt.EventChannel,
|
||||
) (map[evt.EventChannel]struct{}, bool, bool) {
|
||||
@@ -317,7 +262,7 @@ func (h *EventsHandler) handleEventsClientMessage(
|
||||
}
|
||||
|
||||
for _, channel := range accepted {
|
||||
if err := h.writeSnapshotFrame(conn, r, claims, viewerIsAdmin, channel); err != nil {
|
||||
if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
}
|
||||
@@ -325,32 +270,13 @@ func (h *EventsHandler) handleEventsClientMessage(
|
||||
return nextSubs, true, true
|
||||
}
|
||||
|
||||
// revalidateViewer decides whether an open events WebSocket must be closed
|
||||
// after its user is re-loaded. The connection closes when the account is gone
|
||||
// or disabled, and when admin status changed in either direction — the
|
||||
// allowed channel set is fixed at upgrade, so the client must reconnect to
|
||||
// receive the correct set; closing on change is simpler and safer than
|
||||
// re-assigning channels mid-stream. Other policy changes (AccessPolicyRevision
|
||||
// bumps from group/library edits) deliberately keep the connection: event
|
||||
// authorization (allowedChannelsForViewer, allowsEventForViewer) depends only
|
||||
// on admin status and user ID, never on per-user library or rating policy.
|
||||
func revalidateViewer(user *models.User, wasAdmin bool) (closeConn bool, reason string) {
|
||||
if user == nil || !user.Enabled {
|
||||
return true, "account disabled"
|
||||
}
|
||||
if user.IsAdmin != wasAdmin {
|
||||
return true, "permissions changed"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func allowedChannelsForViewer(viewerIsAdmin bool) []evt.EventChannel {
|
||||
func allowedChannelsForRole(role string) []evt.EventChannel {
|
||||
channels := []evt.EventChannel{
|
||||
evt.ChannelCatalog,
|
||||
evt.ChannelHistoryImport,
|
||||
evt.ChannelUserState,
|
||||
}
|
||||
if viewerIsAdmin {
|
||||
if role == "admin" {
|
||||
channels = append(channels,
|
||||
evt.ChannelJobs,
|
||||
evt.ChannelSessions,
|
||||
@@ -361,14 +287,14 @@ func allowedChannelsForViewer(viewerIsAdmin bool) []evt.EventChannel {
|
||||
return channels
|
||||
}
|
||||
|
||||
func allowsEventForViewer(viewerUserID int, viewerIsAdmin bool, env evt.Envelope) bool {
|
||||
if viewerUserID == 0 {
|
||||
func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
if env.AdminOnly && !viewerIsAdmin {
|
||||
if env.AdminOnly && claims.Role != "admin" {
|
||||
return false
|
||||
}
|
||||
if env.UserID > 0 && !viewerIsAdmin && env.UserID != viewerUserID {
|
||||
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -388,7 +314,6 @@ func marshalJSON(value any) json.RawMessage {
|
||||
func (h *EventsHandler) snapshotForChannel(
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
channel evt.EventChannel,
|
||||
) (json.RawMessage, error) {
|
||||
switch channel {
|
||||
@@ -441,7 +366,7 @@ func (h *EventsHandler) snapshotForChannel(
|
||||
if h == nil || h.historyImports == nil {
|
||||
return json.RawMessage("[]"), nil
|
||||
}
|
||||
if viewerIsAdmin {
|
||||
if claims != nil && claims.Role == "admin" {
|
||||
runs, err := h.historyImports.ListAdminActiveRuns(r.Context(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -462,10 +387,9 @@ func (h *EventsHandler) writeSnapshotFrame(
|
||||
conn *websocket.Conn,
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
channel evt.EventChannel,
|
||||
) error {
|
||||
data, err := h.snapshotForChannel(r, claims, viewerIsAdmin, channel)
|
||||
data, err := h.snapshotForChannel(r, claims, channel)
|
||||
if err != nil {
|
||||
slog.Error(
|
||||
"events: failed to build initial snapshot",
|
||||
@@ -491,12 +415,11 @@ func (h *EventsHandler) writeEventFrame(
|
||||
conn *websocket.Conn,
|
||||
r *http.Request,
|
||||
claims *auth.Claims,
|
||||
viewerIsAdmin bool,
|
||||
env evt.Envelope,
|
||||
) error {
|
||||
data := env.Data
|
||||
if len(data) == 0 || (env.Channel == evt.ChannelSessions && env.Event == "sessions.replaced") {
|
||||
snapshot, err := h.snapshotForChannel(r, claims, viewerIsAdmin, env.Channel)
|
||||
snapshot, err := h.snapshotForChannel(r, claims, env.Channel)
|
||||
if err != nil {
|
||||
slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err)
|
||||
return err
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestRevalidateViewer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user *models.User
|
||||
wasAdmin bool
|
||||
wantClose bool
|
||||
}{
|
||||
{
|
||||
name: "user deleted",
|
||||
user: nil,
|
||||
wasAdmin: false,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "user disabled",
|
||||
user: &models.User{ID: 1, Enabled: false},
|
||||
wasAdmin: false,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "admin disabled keeps no access even with admin flag",
|
||||
user: &models.User{ID: 1, Enabled: false, IsAdmin: true},
|
||||
wasAdmin: true,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "admin demoted",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
|
||||
wasAdmin: true,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "viewer promoted to admin",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
|
||||
wasAdmin: false,
|
||||
wantClose: true,
|
||||
},
|
||||
{
|
||||
name: "enabled non-admin unchanged",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
|
||||
wasAdmin: false,
|
||||
wantClose: false,
|
||||
},
|
||||
{
|
||||
name: "enabled admin unchanged",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
|
||||
wasAdmin: true,
|
||||
wantClose: false,
|
||||
},
|
||||
{
|
||||
// Policy revision bumps (group/library edits) do not affect event
|
||||
// authorization, which depends only on admin status and user ID.
|
||||
name: "policy revision change without admin change",
|
||||
user: &models.User{ID: 1, Enabled: true, IsAdmin: false, AccessPolicyRevision: 42},
|
||||
wasAdmin: false,
|
||||
wantClose: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
closeConn, reason := revalidateViewer(tc.user, tc.wasAdmin)
|
||||
if closeConn != tc.wantClose {
|
||||
t.Fatalf("revalidateViewer() close = %v, want %v", closeConn, tc.wantClose)
|
||||
}
|
||||
if closeConn && reason == "" {
|
||||
t.Fatal("expected a close reason")
|
||||
}
|
||||
if !closeConn && reason != "" {
|
||||
t.Fatalf("expected empty reason for kept connection, got %q", reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mutableUserLoader is a swappable auth.UserLoader for revalidation tests.
|
||||
type mutableUserLoader struct {
|
||||
mu sync.Mutex
|
||||
user *models.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *mutableUserLoader) GetByID(_ context.Context, id int) (*models.User, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.err != nil {
|
||||
return nil, l.err
|
||||
}
|
||||
if l.user == nil || l.user.ID != id {
|
||||
return nil, auth.ErrNotFound
|
||||
}
|
||||
userCopy := *l.user
|
||||
return &userCopy, nil
|
||||
}
|
||||
|
||||
func (l *mutableUserLoader) set(user *models.User, err error) {
|
||||
l.mu.Lock()
|
||||
l.user = user
|
||||
l.err = err
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection covers the
|
||||
// revalidation ticker end to end: an open connection is closed by the server
|
||||
// once the periodic user re-load reports the account disabled.
|
||||
func TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection(t *testing.T) {
|
||||
loader := &mutableUserLoader{user: &models.User{ID: 1, Enabled: true}}
|
||||
handler := NewEventsHandler(evt.NewHub("test", nil), nil, nil, nil, nil, nil, nil, loader)
|
||||
handler.revalidateInterval = 20 * time.Millisecond
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
|
||||
handler.HandleWebSocket(w, r.WithContext(ctx))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial websocket: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Complete the handshake: hello, subscribe, subscribed + snapshot.
|
||||
var hello evt.EventsHelloMessage
|
||||
if err := conn.ReadJSON(&hello); err != nil {
|
||||
t.Fatalf("read hello: %v", err)
|
||||
}
|
||||
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
|
||||
Type: "subscribe",
|
||||
Channels: []evt.EventChannel{evt.ChannelCatalog},
|
||||
}); err != nil {
|
||||
t.Fatalf("write subscribe: %v", err)
|
||||
}
|
||||
|
||||
// The account is disabled while the connection is open; the next
|
||||
// revalidation tick must close it with a normal close frame.
|
||||
loader.set(&models.User{ID: 1, Enabled: false}, nil)
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
for {
|
||||
var frame json.RawMessage
|
||||
if err := conn.ReadJSON(&frame); err != nil {
|
||||
var closeErr *websocket.CloseError
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
||||
return // server closed the connection as required
|
||||
}
|
||||
t.Fatalf("expected normal close, got %v (%T %v)", err, err, closeErr)
|
||||
}
|
||||
// Drain subscribed/snapshot frames until the close arrives.
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ type PersonalDataHandler struct {
|
||||
localFavoriteDispatcher LocalFavoriteEventDispatcher
|
||||
profileStaler ProfileStaler
|
||||
profileRefreshRequester ProfileRefreshRequester
|
||||
ebookProgressStore EbookReaderProgressLister
|
||||
}
|
||||
|
||||
// NewPersonalDataHandler creates a new PersonalDataHandler.
|
||||
@@ -56,10 +55,6 @@ func (h *PersonalDataHandler) SetDetailService(svc *catalog.DetailService) {
|
||||
h.detailSvc = svc
|
||||
}
|
||||
|
||||
func (h *PersonalDataHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
|
||||
h.ebookProgressStore = store
|
||||
}
|
||||
|
||||
// SetProfileStaler configures an optional staleness trigger for taste profiles.
|
||||
func (h *PersonalDataHandler) SetProfileStaler(ps ProfileStaler) {
|
||||
h.profileStaler = ps
|
||||
@@ -557,10 +552,7 @@ func resolveItemsByIDs(h *PersonalDataHandler, r *http.Request, ids []string) ([
|
||||
|
||||
userStates := map[string]*itemUserStateResponse{}
|
||||
if store, profileID, ok := h.userStoreForRequest(r); ok {
|
||||
if resolvedStates, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, accessibleItems, itemUserStateOptions{
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
EbookProgressStore: h.ebookProgressStore,
|
||||
}); err == nil {
|
||||
if resolvedStates, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, accessibleItems); err == nil {
|
||||
userStates = resolvedStates
|
||||
}
|
||||
}
|
||||
@@ -677,11 +669,7 @@ func (h *PersonalDataHandler) resolveHistoryRemovalMediaItemIDs(
|
||||
return nil, err
|
||||
}
|
||||
switch item.Type {
|
||||
case "movie", "ebook":
|
||||
// Hiding an ebook gates ebook_reader_progress reads via
|
||||
// user_history_hidden_items (updated_at <= hidden_before)
|
||||
// without deleting the progress row, so the reader position
|
||||
// survives: hidden is not the same as unread.
|
||||
case "movie":
|
||||
return []string{item.ContentID}, nil
|
||||
case "series":
|
||||
return h.seriesEpisodeIDs(ctx, item.ContentID)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
type fakeHistoryItemRepo struct {
|
||||
items map[string]*models.MediaItem
|
||||
inaccessible map[string]bool
|
||||
}
|
||||
|
||||
func (r *fakeHistoryItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
|
||||
item, ok := r.items[contentID]
|
||||
if !ok {
|
||||
return nil, catalog.ErrItemNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *fakeHistoryItemRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.MediaItem, error) {
|
||||
result := make([]*models.MediaItem, 0, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
if item, ok := r.items[contentID]; ok {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *fakeHistoryItemRepo) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error {
|
||||
if r.inaccessible[contentID] {
|
||||
return catalog.ErrItemNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandleRemoveHistoryAcceptsEbookTargets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
|
||||
items: map[string]*models.MediaItem{
|
||||
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
|
||||
},
|
||||
})
|
||||
|
||||
// Seed a history entry keyed by the ebook content ID so the removal has
|
||||
// something to hide. RemoveHistoryItems only touches watch history,
|
||||
// watch progress, and the hidden-items gate — PersonalDataHandler has no
|
||||
// write access to ebook_reader_progress (its store interface is read-only),
|
||||
// so the reader position survives: hidden is not the same as unread.
|
||||
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
|
||||
ProfileID: "profile-1",
|
||||
MediaItemID: "ebook-1",
|
||||
WatchedAt: "2026-06-01T10:00:00Z",
|
||||
Completed: true,
|
||||
Source: userstore.WatchHistorySourceManual,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddHistory: %v", err)
|
||||
}
|
||||
|
||||
req := newAuthorizedProfileRequestWithRole(
|
||||
http.MethodPost,
|
||||
"/history/remove",
|
||||
`{"targets":[{"content_id":"ebook-1"}]}`,
|
||||
"user",
|
||||
"profile-1",
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleRemoveHistory(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
entries, err := store.ListHistory(ctx, "profile-1", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListHistory: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("history entries = %+v, want hidden after removal", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRemoveHistoryRejectsInaccessibleEbook(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
|
||||
items: map[string]*models.MediaItem{
|
||||
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
|
||||
},
|
||||
inaccessible: map[string]bool{"ebook-1": true},
|
||||
})
|
||||
|
||||
req := newAuthorizedProfileRequestWithRole(
|
||||
http.MethodPost,
|
||||
"/history/remove",
|
||||
`{"targets":[{"content_id":"ebook-1"}]}`,
|
||||
"user",
|
||||
"profile-1",
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleRemoveHistory(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
+30
-332
@@ -46,10 +46,6 @@ type LocalWatchEventDispatcher interface {
|
||||
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
|
||||
}
|
||||
|
||||
type EbookReaderProgressLister interface {
|
||||
ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error)
|
||||
}
|
||||
|
||||
// ItemsHandler handles browse, search, item detail, and series endpoints.
|
||||
type ItemsHandler struct {
|
||||
browseRepo *catalog.BrowseRepository
|
||||
@@ -66,8 +62,6 @@ type ItemsHandler struct {
|
||||
profileRefreshRequester ProfileRefreshRequester
|
||||
metadataRefreshRequester MetadataRefreshRequester
|
||||
localWatchDispatcher LocalWatchEventDispatcher
|
||||
ebookProgressStore EbookReaderProgressLister
|
||||
ebookReadStateStore EbookReadStateStore
|
||||
EventsHub *evt.Hub
|
||||
UserRepo *auth.UserRepository
|
||||
}
|
||||
@@ -120,11 +114,6 @@ func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDi
|
||||
h.localWatchDispatcher = dispatcher
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressReadWriter) {
|
||||
h.ebookProgressStore = store
|
||||
h.ebookReadStateStore = store
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) {
|
||||
if h == nil || detail == nil || h.metadataRefreshRequester == nil {
|
||||
return
|
||||
@@ -236,9 +225,6 @@ type sortMetricsResponse struct {
|
||||
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
|
||||
ViewedAt string `json:"viewed_at,omitempty"`
|
||||
PlayCount *int `json:"play_count,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Narrator string `json:"narrator,omitempty"`
|
||||
SeriesName string `json:"series_name,omitempty"`
|
||||
}
|
||||
|
||||
// browseResponse is the paginated response for the /items endpoint.
|
||||
@@ -387,8 +373,8 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" {
|
||||
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
|
||||
if detail.Type == "movie" || detail.Type == "episode" {
|
||||
detail.UserData = h.getLeafUserData(r, detail.ContentID)
|
||||
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
@@ -443,8 +429,7 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
filter := h.accessFilter(r)
|
||||
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, filter)
|
||||
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, h.accessFilter(r))
|
||||
if err != nil {
|
||||
switch {
|
||||
case isNotFound(err):
|
||||
@@ -455,16 +440,12 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case targetType == "ebook":
|
||||
// Ebook read state lives in ebook_reader_progress, not in
|
||||
// user_watch_progress/user_watch_history; watch providers do not sync
|
||||
// books, so no local watch event is dispatched.
|
||||
err = h.setEbookReadState(r.Context(), userID, profileID, id, played, filter)
|
||||
case h.watchState == nil:
|
||||
if h.watchState == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
||||
return
|
||||
case played:
|
||||
}
|
||||
|
||||
if played {
|
||||
leafTargets := make([]watchstate.LeafWatchTarget, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
leafTargets = append(leafTargets, watchstate.LeafWatchTarget{
|
||||
@@ -478,7 +459,7 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
if err == nil {
|
||||
h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedWatched, userID, profileID, result)
|
||||
}
|
||||
default:
|
||||
} else {
|
||||
targetIDs := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
targetIDs = append(targetIDs, target.ContentID)
|
||||
@@ -490,10 +471,6 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state")
|
||||
return
|
||||
}
|
||||
@@ -762,10 +739,7 @@ func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.Media
|
||||
if !ok {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
EbookProgressStore: h.ebookProgressStore,
|
||||
})
|
||||
states, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, items)
|
||||
if err != nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
@@ -783,14 +757,6 @@ func (h *ItemsHandler) toEpisodeResponseWithFallback(r *http.Request, ep *models
|
||||
ep = localized
|
||||
}
|
||||
}
|
||||
resp, stillPath := episodeResponseShell(ep, fallback)
|
||||
resp.StillURL = h.presignURL(r, stillPath, "card")
|
||||
return resp
|
||||
}
|
||||
|
||||
// episodeResponseShell maps an already-localized episode onto the response
|
||||
// shape, returning the card-variant still path for the caller to presign.
|
||||
func episodeResponseShell(ep *models.Episode, fallback episodeImageFallback) (episodeResponse, string) {
|
||||
stillPath := ep.StillPath
|
||||
stillThumbhash := ep.StillThumbhash
|
||||
if strings.TrimSpace(stillPath) == "" && strings.TrimSpace(fallback.Path) != "" {
|
||||
@@ -814,94 +780,8 @@ func episodeResponseShell(ep *models.Episode, fallback episodeImageFallback) (ep
|
||||
resp.AirDate = ep.AirDate.Format("2006-01-02")
|
||||
}
|
||||
|
||||
return resp, cardThumbnailPath(stillPath)
|
||||
}
|
||||
resp.StillURL = h.presignURL(r, cardThumbnailPath(stillPath), "card")
|
||||
|
||||
// buildEpisodeResponses converts episodes to API responses using batched
|
||||
// lookups — localization, media files, watch progress, and image presigning
|
||||
// each resolve in one round-trip for the whole list instead of per episode.
|
||||
func (h *ItemsHandler) buildEpisodeResponses(r *http.Request, episodes []*models.Episode) []episodeResponse {
|
||||
ctx := r.Context()
|
||||
filter := h.accessFilter(r)
|
||||
|
||||
if h.detailSvc != nil {
|
||||
if localized, err := h.detailSvc.LocalizeEpisodeModels(ctx, episodes, filter); err == nil && len(localized) == len(episodes) {
|
||||
episodes = localized
|
||||
}
|
||||
}
|
||||
|
||||
fallbacks := h.episodeImageFallbacks(ctx, episodes)
|
||||
|
||||
episodeIDs := make([]string, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep != nil && ep.ContentID != "" {
|
||||
episodeIDs = append(episodeIDs, ep.ContentID)
|
||||
}
|
||||
}
|
||||
filesByEpisode := h.listEpisodeFiles(ctx, episodeIDs)
|
||||
userData := h.listLeafUserData(r, episodeIDs)
|
||||
|
||||
resp := make([]episodeResponse, 0, len(episodes))
|
||||
stillPaths := make([]string, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if ep == nil {
|
||||
continue
|
||||
}
|
||||
shell, stillPath := episodeResponseShell(ep, fallbacks[ep.SeriesID])
|
||||
resp = append(resp, shell)
|
||||
stillPaths = append(stillPaths, stillPath)
|
||||
}
|
||||
|
||||
stillURLs := map[string]catalog.ResolvedImageURL{}
|
||||
if h.detailSvc != nil {
|
||||
stillURLs = h.detailSvc.PresignURLsWithExpiry(ctx, stillPaths, "card")
|
||||
}
|
||||
for i := range resp {
|
||||
resp[i].StillURL = stillURLs[stillPaths[i]].URL
|
||||
resp[i].Files = episodeFileResponses(filesByEpisode[resp[i].ContentID], filter)
|
||||
resp[i].UserData = userData[resp[i].ContentID]
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// listEpisodeFiles batch-fetches media files keyed by episode ID, falling back
|
||||
// to per-episode lookups when the repository lacks batch support.
|
||||
func (h *ItemsHandler) listEpisodeFiles(ctx context.Context, episodeIDs []string) map[string][]*models.MediaFile {
|
||||
if h.fileRepo == nil || len(episodeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if batchProvider, ok := h.fileRepo.(batchEpisodeFileProvider); ok {
|
||||
files, err := batchProvider.ListByEpisodeIDs(ctx, episodeIDs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return files
|
||||
}
|
||||
files := make(map[string][]*models.MediaFile, len(episodeIDs))
|
||||
for _, id := range episodeIDs {
|
||||
if list, err := h.fileRepo.GetByEpisodeID(ctx, id); err == nil {
|
||||
files[id] = list
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func episodeFileResponses(files []*models.MediaFile, filter catalog.AccessFilter) []episodeFileResponse {
|
||||
var resp []episodeFileResponse
|
||||
for _, f := range files {
|
||||
if !catalog.FileAllowedByAccess(f, filter) {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, episodeFileResponse{
|
||||
FileID: f.ID,
|
||||
Resolution: f.Resolution,
|
||||
CodecVideo: f.CodecVideo,
|
||||
HDR: f.HDR,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Container: f.Container,
|
||||
FileSize: f.FileSize,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -970,7 +850,6 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
filter catalog.AccessFilter,
|
||||
overlaySummaries map[string]*models.OverlaySummary,
|
||||
store userstore.UserStore,
|
||||
userID int,
|
||||
profileID string,
|
||||
) map[string]*sortMetricsResponse {
|
||||
metrics := make(map[string]*sortMetricsResponse, len(items))
|
||||
@@ -1011,97 +890,16 @@ func (h *ItemsHandler) listSortMetrics(
|
||||
}
|
||||
}
|
||||
case "progress", "date_viewed", "plays":
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics)
|
||||
case "author", "narrator", "series":
|
||||
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
|
||||
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listAudiobookSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
contentIDs := uniqueItemContentIDs(items)
|
||||
if len(contentIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var query string
|
||||
switch sortField {
|
||||
case "author":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
|
||||
case "narrator":
|
||||
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
|
||||
case "series":
|
||||
query = `
|
||||
SELECT target.content_id, BTRIM(s.series_name) AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN audiobook_series s ON s.content_id = target.content_id
|
||||
WHERE BTRIM(s.series_name) <> ''`
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var contentID, value string
|
||||
if err := rows.Scan(&contentID, &value); err != nil {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if contentID == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
resp := metrics[contentID]
|
||||
if resp == nil {
|
||||
resp = &sortMetricsResponse{}
|
||||
metrics[contentID] = resp
|
||||
}
|
||||
switch sortField {
|
||||
case "author":
|
||||
resp.Author = value
|
||||
case "narrator":
|
||||
resp.Narrator = value
|
||||
case "series":
|
||||
resp.SeriesName = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func audiobookPersonSortMetricQuery(kind int) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT target.content_id, person.name AS value
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
|
||||
JOIN LATERAL (
|
||||
SELECT BTRIM(p.name) AS name
|
||||
FROM item_people ip
|
||||
JOIN people p ON p.id = ip.person_id
|
||||
WHERE ip.content_id = target.content_id
|
||||
AND ip.kind = %d
|
||||
AND p.name IS NOT NULL
|
||||
AND BTRIM(p.name) <> ''
|
||||
ORDER BY p.name ASC
|
||||
LIMIT 1
|
||||
) person ON TRUE`, kind)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listUserSortMetrics(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
sortField string,
|
||||
store userstore.UserStore,
|
||||
userID int,
|
||||
profileID string,
|
||||
metrics map[string]*sortMetricsResponse,
|
||||
) {
|
||||
@@ -1117,10 +915,6 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch sortField {
|
||||
case "progress":
|
||||
@@ -1129,16 +923,10 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
continue
|
||||
}
|
||||
progress, ok := progressMap[item.ContentID]
|
||||
if ok && !progress.Completed && progress.PositionSeconds > 0 && progress.DurationSeconds > 0 {
|
||||
ratio := progress.PositionSeconds / progress.DurationSeconds
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
if !ok || progress.Completed || progress.PositionSeconds <= 0 || progress.DurationSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
ebookProgress, ok := ebookProgressMap[item.ContentID]
|
||||
if !ok || ebookProgress.Progress <= 0 || ebookProgress.Progress >= models.EbookFinishedProgressThreshold {
|
||||
continue
|
||||
}
|
||||
ratio := ebookProgress.Progress
|
||||
ratio := progress.PositionSeconds / progress.DurationSeconds
|
||||
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
|
||||
}
|
||||
case "date_viewed", "plays":
|
||||
@@ -1167,12 +955,6 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt {
|
||||
viewedAt = progress.UpdatedAt
|
||||
}
|
||||
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold {
|
||||
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
if ebookViewedAt > viewedAt {
|
||||
viewedAt = ebookViewedAt
|
||||
}
|
||||
}
|
||||
if viewedAt == "" {
|
||||
continue
|
||||
}
|
||||
@@ -1182,9 +964,6 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold && playCount < 1 {
|
||||
playCount = 1
|
||||
}
|
||||
if playCount <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -1195,18 +974,6 @@ func (h *ItemsHandler) listUserSortMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) listEbookReaderProgressByContentIDs(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
profileID string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
if h == nil || h.ebookProgressStore == nil || userID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return h.ebookProgressStore.ListByContentIDs(ctx, userID, profileID, contentIDs)
|
||||
}
|
||||
|
||||
func uniqueItemContentIDs(items []*models.MediaItem) []string {
|
||||
contentIDs := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
@@ -1343,11 +1110,7 @@ func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *mod
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemType ...string) *catalog.SeasonUserData {
|
||||
if len(itemType) > 0 && itemType[0] == "ebook" {
|
||||
return h.getEbookLeafUserData(r, contentID)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
|
||||
store, profileID, ok := h.userStoreForRequest(r)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -1358,34 +1121,10 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemTy
|
||||
return nil
|
||||
}
|
||||
|
||||
return leafUserDataFromProgress(*progress)
|
||||
}
|
||||
|
||||
// listLeafUserData batch-fetches watch progress for the given content IDs in a
|
||||
// single query; the result only holds entries for items with progress rows.
|
||||
func (h *ItemsHandler) listLeafUserData(r *http.Request, contentIDs []string) map[string]*catalog.SeasonUserData {
|
||||
store, profileID, ok := h.userStoreForRequest(r)
|
||||
if !ok || len(contentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
progressMap, err := store.ListProgressByMediaItems(r.Context(), profileID, contentIDs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]*catalog.SeasonUserData, len(progressMap))
|
||||
for contentID, progress := range progressMap {
|
||||
result[contentID] = leafUserDataFromProgress(progress)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func leafUserDataFromProgress(progress userstore.WatchProgress) *catalog.SeasonUserData {
|
||||
return &catalog.SeasonUserData{
|
||||
PositionSeconds: progress.PositionSeconds,
|
||||
DurationSeconds: progress.DurationSeconds,
|
||||
IsInProgress: progress.PositionSeconds > 0,
|
||||
IsInProgress: !progress.Completed && progress.PositionSeconds > 0,
|
||||
Played: progress.Completed,
|
||||
LastFileID: progress.LastFileID,
|
||||
LastResolution: progress.LastResolution,
|
||||
@@ -1395,38 +1134,6 @@ func leafUserDataFromProgress(progress userstore.WatchProgress) *catalog.SeasonU
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) getEbookLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
|
||||
if h == nil || h.ebookProgressStore == nil {
|
||||
return nil
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := requestProfileID(r)
|
||||
if userID <= 0 || profileID == "" || contentID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
progress, err := h.ebookProgressStore.ListByContentIDs(r.Context(), userID, profileID, []string{contentID})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
row, ok := progress[contentID]
|
||||
if !ok || row.Progress <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ebooks have no playback duration, so PositionSeconds/DurationSeconds
|
||||
// encode the 0..1 reading ratio (position=ratio, duration=1). Clients can
|
||||
// derive percent-complete from the pair as usual, but rendering them as
|
||||
// absolute times is meaningless for ebooks. Changing the wire shape needs
|
||||
// coordinated Android/Apple client updates.
|
||||
return &catalog.SeasonUserData{
|
||||
PositionSeconds: row.Progress,
|
||||
DurationSeconds: 1,
|
||||
IsInProgress: row.Progress < models.EbookFinishedProgressThreshold,
|
||||
Played: row.Progress >= models.EbookFinishedProgressThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) {
|
||||
if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" {
|
||||
return
|
||||
@@ -1471,16 +1178,6 @@ func (h *ItemsHandler) getAggregateUserData(r *http.Request, episodes []*models.
|
||||
}
|
||||
}
|
||||
|
||||
// requestProfileID resolves the active profile for a request, falling back to
|
||||
// the X-Profile-Id header when the auth context does not carry one.
|
||||
func requestProfileID(r *http.Request) string {
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
return profileID
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) {
|
||||
if h.storeProvider == nil {
|
||||
return nil, "", false
|
||||
@@ -1491,7 +1188,10 @@ func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
profileID := requestProfileID(r)
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if profileID == "" {
|
||||
return nil, "", false
|
||||
}
|
||||
@@ -1615,15 +1315,14 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
|
||||
|
||||
if scope, ok := access.GetScope(r.Context()); ok {
|
||||
return catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
PresentationLibraryID: presentationLibraryID,
|
||||
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
|
||||
SelectedFileID: selectedFileID,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
PresentationLibraryID: presentationLibraryID,
|
||||
SelectedFileID: selectedFileID,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1686,10 +1385,6 @@ func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID stri
|
||||
ContentID: item.ContentID,
|
||||
DurationSeconds: h.contentDurationSeconds(ctx, item.ContentID, "", item.Runtime),
|
||||
}}, nil
|
||||
case "ebook":
|
||||
// Ebooks have no playback duration; read state is keyed off
|
||||
// ebook_reader_progress, so the leaf target only carries the ID.
|
||||
return "ebook", []watchedLeafTarget{{ContentID: item.ContentID}}, nil
|
||||
case "series":
|
||||
if h.episodeRepo == nil {
|
||||
return "", nil, catalog.ErrItemNotFound
|
||||
@@ -1800,6 +1495,9 @@ func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if h == nil || h.UserRepo == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -40,145 +40,20 @@ func TestListSortMetricsIncludesUserStateSorts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
progress := progressMetrics["movie-progress"]
|
||||
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.25 {
|
||||
t.Fatalf("progress metrics = %#v", progress)
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
if got := viewedMetrics["movie-viewed"]; got == nil || got.ViewedAt != "2026-05-29T18:30:00Z" {
|
||||
t.Fatalf("viewed metrics = %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 0, "profile-1")
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, "profile-1")
|
||||
plays := playsMetrics["movie-plays"]
|
||||
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 2 {
|
||||
t.Fatalf("plays metrics = %#v", plays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSortMetricsIncludesEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-progress": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.37,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
"ebook-complete": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-progress", Type: "ebook", Title: "Progress"},
|
||||
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete"},
|
||||
}
|
||||
|
||||
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
progress := progressMetrics["ebook-progress"]
|
||||
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.37 {
|
||||
t.Fatalf("progress metrics = %#v", progress)
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := viewedMetrics["ebook-complete"]; got == nil || got.ViewedAt != "2026-06-03T10:15:00Z" {
|
||||
t.Fatalf("viewed metrics = %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
plays := playsMetrics["ebook-complete"]
|
||||
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 1 {
|
||||
t.Fatalf("plays metrics = %#v", plays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSortMetricsExcludesHiddenEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-hidden": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-hidden",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
"ebook-reread": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-reread",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: updatedAt.Add(2 * time.Hour),
|
||||
},
|
||||
},
|
||||
hiddenBefore: map[string]time.Time{
|
||||
"ebook-hidden": updatedAt.Add(time.Hour),
|
||||
"ebook-reread": updatedAt.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-hidden", Type: "ebook", Title: "Hidden"},
|
||||
{ContentID: "ebook-reread", Type: "ebook", Title: "Reread"},
|
||||
}
|
||||
|
||||
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := viewedMetrics["ebook-hidden"]; got != nil {
|
||||
t.Fatalf("hidden ebook must have no viewed metrics, got %#v", got)
|
||||
}
|
||||
if got := viewedMetrics["ebook-reread"]; got == nil || got.ViewedAt != "2026-06-03T11:15:00Z" {
|
||||
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
|
||||
}
|
||||
|
||||
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
|
||||
if got := playsMetrics["ebook-hidden"]; got != nil {
|
||||
t.Fatalf("hidden ebook must have no play count, got %#v", got)
|
||||
}
|
||||
if got := playsMetrics["ebook-reread"]; got == nil || got.PlayCount == nil || *got.PlayCount != 1 {
|
||||
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEbookReaderProgressLister struct {
|
||||
progress map[string]EbookReaderProgress
|
||||
// hiddenBefore models user_history_hidden_items: like the PG store, a
|
||||
// row is excluded while progress.UpdatedAt <= hidden_before and counts
|
||||
// again once the progress row is updated after the hide.
|
||||
hiddenBefore map[string]time.Time
|
||||
}
|
||||
|
||||
func (f *fakeEbookReaderProgressLister) ListByContentIDs(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
result := make(map[string]EbookReaderProgress, len(contentIDs))
|
||||
for _, contentID := range contentIDs {
|
||||
progress, ok := f.progress[contentID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if hiddenBefore, hidden := f.hiddenBefore[contentID]; hidden && !progress.UpdatedAt.After(hiddenBefore) {
|
||||
continue
|
||||
}
|
||||
result[contentID] = progress
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
|
||||
handler := &ItemsHandler{
|
||||
ebookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-progress": {
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.42,
|
||||
UpdatedAt: time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
"ebook-complete": {
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.93,
|
||||
UpdatedAt: time.Date(2026, 6, 5, 11, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/items/ebook-progress", nil)
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
progress := handler.getLeafUserData(req, "ebook-progress", "ebook")
|
||||
if progress == nil || progress.Played || !progress.IsInProgress || progress.PositionSeconds != 0.42 || progress.DurationSeconds != 1 {
|
||||
t.Fatalf("partial ebook user data = %#v", progress)
|
||||
}
|
||||
|
||||
complete := handler.getLeafUserData(req, "ebook-complete", "ebook")
|
||||
if complete == nil || !complete.Played || complete.IsInProgress {
|
||||
t.Fatalf("completed ebook user data = %#v", complete)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// This file holds tri-state JSON field wrappers for PATCH-style request
|
||||
// bodies. Plain Go pointers can only distinguish two states (absent/nil vs.
|
||||
// value); these wrappers track three:
|
||||
//
|
||||
// - absent: the key was not present in the JSON body (Set = false) —
|
||||
// the caller should leave the current value unchanged
|
||||
// - null: the key was present with an explicit JSON null (Set = true)
|
||||
// - value: the key was present with a concrete value (Set = true)
|
||||
//
|
||||
// How null is decoded depends on the wrapper: optionalIntSliceField keeps a
|
||||
// nil slice (e.g. "all libraries"), optionalStringSliceField normalizes null
|
||||
// to an empty slice.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// optionalIntSliceField distinguishes an absent JSON field from an explicit
|
||||
// null or array value. Absent = don't update; null = nil slice ("all
|
||||
// libraries"); [] = empty slice ("none").
|
||||
type optionalIntSliceField struct {
|
||||
Set bool
|
||||
Value []int
|
||||
}
|
||||
|
||||
func (f *optionalIntSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = nil
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
|
||||
// Ptr returns nil when the field was absent, otherwise a pointer to the
|
||||
// decoded slice (which may itself be nil for JSON null).
|
||||
func (f optionalIntSliceField) Ptr() *[]int {
|
||||
if !f.Set {
|
||||
return nil
|
||||
}
|
||||
value := f.Value
|
||||
return &value
|
||||
}
|
||||
|
||||
// optionalStringSliceField distinguishes an absent JSON field from an
|
||||
// explicit null or array value. JSON null decodes to an empty slice.
|
||||
type optionalStringSliceField struct {
|
||||
Set bool
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (f *optionalStringSliceField) UnmarshalJSON(data []byte) error {
|
||||
f.Set = true
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
f.Value = []string{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &f.Value)
|
||||
}
|
||||
@@ -192,7 +192,6 @@ type updateLibraryRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
MetadataLanguage *string `json:"metadata_language,omitempty"`
|
||||
AutoTranslateMetadata *bool `json:"auto_translate_metadata,omitempty"`
|
||||
ChapterThumbnailsEnabled *bool `json:"chapter_thumbnails_enabled,omitempty"`
|
||||
IntroDetectionEnabled *bool `json:"intro_detection_enabled,omitempty"`
|
||||
}
|
||||
@@ -227,7 +226,6 @@ type libraryResponse struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MetadataLanguage string `json:"metadata_language"`
|
||||
AutoTranslateMetadata bool `json:"auto_translate_metadata"`
|
||||
ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"`
|
||||
ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"`
|
||||
IntroDetectionEnabled bool `json:"intro_detection_enabled"`
|
||||
@@ -350,7 +348,6 @@ func toLibraryResponse(f *models.MediaFolder) libraryResponse {
|
||||
Name: f.Name,
|
||||
Enabled: f.Enabled,
|
||||
MetadataLanguage: f.MetadataLanguage,
|
||||
AutoTranslateMetadata: f.AutoTranslateMetadata,
|
||||
ChapterThumbnailsEnabled: f.ChapterThumbnailsEnabled,
|
||||
ChapterThumbnailsSupported: false,
|
||||
IntroDetectionEnabled: f.IntroDetectionEnabled,
|
||||
@@ -577,11 +574,6 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil {
|
||||
slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
if sections.IsAudiobookLibraryType(folder.Type) {
|
||||
if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil {
|
||||
slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
}
|
||||
if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil {
|
||||
slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr)
|
||||
}
|
||||
@@ -655,7 +647,6 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
|
||||
Name: req.Name,
|
||||
Enabled: req.Enabled,
|
||||
MetadataLanguage: req.MetadataLanguage,
|
||||
AutoTranslateMetadata: req.AutoTranslateMetadata,
|
||||
ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled,
|
||||
IntroDetectionEnabled: req.IntroDetectionEnabled,
|
||||
})
|
||||
@@ -2038,8 +2029,16 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
return nil
|
||||
}
|
||||
|
||||
levels := metadataContentLevelsForLibraryType(libraryType)
|
||||
if len(levels) == 0 {
|
||||
// Determine which content levels apply.
|
||||
var levels []string
|
||||
switch libraryType {
|
||||
case "series":
|
||||
levels = []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
levels = []string{"movie"}
|
||||
case "mixed":
|
||||
levels = []string{"movie", "series", "season", "episode"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2054,7 +2053,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
var candidates []candidate
|
||||
|
||||
for _, c := range caps {
|
||||
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
|
||||
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level)
|
||||
if defaultPriority > 0 {
|
||||
candidates = append(candidates, candidate{
|
||||
installationID: c.PluginInstallationID,
|
||||
@@ -2092,23 +2091,6 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
|
||||
return entries
|
||||
}
|
||||
|
||||
func metadataContentLevelsForLibraryType(libraryType string) []string {
|
||||
switch libraryType {
|
||||
case "series":
|
||||
return []string{"series", "season", "episode"}
|
||||
case "movies", "movie":
|
||||
return []string{"movie"}
|
||||
case "audiobooks", "audiobook":
|
||||
return []string{"audiobook"}
|
||||
case "ebooks", "ebook":
|
||||
return []string{"ebook"}
|
||||
case "mixed":
|
||||
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListStaleIDs handles GET /libraries/stale-ids.
|
||||
func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) {
|
||||
if h.StaleIDRepo == nil {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
libraryType string
|
||||
want []string
|
||||
}{
|
||||
{name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}},
|
||||
{name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}},
|
||||
{name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := metadataContentLevelsForLibraryType(tc.libraryType); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("metadataContentLevelsForLibraryType(%q) = %#v, want %#v", tc.libraryType, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,9 @@ func (h *MarkersHandler) requireMarkerEdit(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return false
|
||||
}
|
||||
if claims.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if h == nil || h.Users == nil {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Marker editing permission required")
|
||||
return false
|
||||
|
||||
@@ -90,6 +90,7 @@ func markerItemPutRequest(body string) *http.Request {
|
||||
func withMarkerAdminClaims(req *http.Request) *http.Request {
|
||||
return req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
SessionID: "session-1",
|
||||
}))
|
||||
@@ -97,12 +98,7 @@ func withMarkerAdminClaims(req *http.Request) *http.Request {
|
||||
|
||||
func newMarkersHandler(writer ManualMarkerWriter) *MarkersHandler {
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{
|
||||
1: {ID: 1, Enabled: true, IsAdmin: true},
|
||||
7: {ID: 7, Enabled: true, IsAdmin: true},
|
||||
}
|
||||
return h
|
||||
return NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
func TestSetFileMarkersWritesManual(t *testing.T) {
|
||||
@@ -135,7 +131,6 @@ func TestSetItemMarkersWritesPrimaryEpisodeFile(t *testing.T) {
|
||||
episodeFiles: []*models.MediaFile{file},
|
||||
}
|
||||
h := NewMarkersHandler(files, writer, nil, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetItemMarkers(rec, markerItemPutRequest(`{"recap":{"start":0,"end":45}}`))
|
||||
@@ -214,13 +209,13 @@ func TestGetItemMarkersUsesFirstAuthorizedFile(t *testing.T) {
|
||||
func TestGetFileMarkersDoesNotRequireMarkerEditPermission(t *testing.T) {
|
||||
h := newMarkersHandler(nil)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/markers/files/5", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("fileId", "5")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleGetFileMarkers(rec, req)
|
||||
@@ -234,10 +229,10 @@ func TestSetFileMarkersRejectsUserWithoutMarkerEditPermission(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Enabled: true, Permissions: nil},
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: nil},
|
||||
}
|
||||
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, req)
|
||||
@@ -254,10 +249,10 @@ func TestSetFileMarkersAllowsUserWithMarkerEditPermission(t *testing.T) {
|
||||
writer := &fakeMarkerWriter{}
|
||||
h := newMarkersHandler(writer)
|
||||
h.Users = fakeMarkerUsers{
|
||||
7: &models.User{ID: 7, Enabled: true, Permissions: []string{"marker_edit"}},
|
||||
7: &models.User{ID: 7, Role: "user", Enabled: true, Permissions: []string{"marker_edit"}},
|
||||
}
|
||||
req := markerPutRequest(`{"intro":{"start":0,"end":60}}`)
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}))
|
||||
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7, Role: "user", TokenType: auth.TokenTypeAccess}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, req)
|
||||
@@ -279,6 +274,7 @@ func TestSetFileMarkersPassesAuditContextToWriter(t *testing.T) {
|
||||
ctx := clientip.SetContext(req.Context(), "203.0.113.10")
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{
|
||||
UserID: 7,
|
||||
Role: "admin",
|
||||
TokenType: auth.TokenTypeAPIKey,
|
||||
APIKeyID: apiKeyID,
|
||||
})
|
||||
@@ -386,7 +382,6 @@ func TestSetFileMarkersTriggersBackgroundContribution(t *testing.T) {
|
||||
called := make(chan markers.ContributeOptions, 1)
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"intro":{"start":0,"end":60}}`))
|
||||
@@ -408,7 +403,6 @@ func TestSetFileMarkersClearOnlyDoesNotContribute(t *testing.T) {
|
||||
called := make(chan markers.ContributeOptions, 1)
|
||||
files := fakeMarkerFiles{file: &models.MediaFile{ID: 5, Duration: 1800}}
|
||||
h := NewMarkersHandler(files, &fakeMarkerWriter{}, signalContributor{called: called}, nil, nil, nil)
|
||||
h.Users = fakeMarkerUsers{1: {ID: 1, Enabled: true, IsAdmin: true}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetFileMarkers(rec, markerPutRequest(`{"credits":null}`))
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/metadata/translation"
|
||||
)
|
||||
|
||||
// MetadataAIHandler exposes AI translation of catalog descriptions into the
|
||||
// localization tables. The admin routes are mounted under the per-item
|
||||
// metadata curation guard; the on-view route is viewer-facing and enforces
|
||||
// item access itself.
|
||||
type MetadataAIHandler struct {
|
||||
service *translation.Service
|
||||
// ItemAccess authorizes the viewer-facing on-view route; nil disables it.
|
||||
ItemAccess *catalog.ItemRepository
|
||||
}
|
||||
|
||||
// NewMetadataAIHandler creates a handler backed by the given service.
|
||||
func NewMetadataAIHandler(service *translation.Service) *MetadataAIHandler {
|
||||
return &MetadataAIHandler{service: service}
|
||||
}
|
||||
|
||||
// HandleStatus reports whether metadata AI translation is available and the
|
||||
// viewer-facing on-view mode, so the metadata editor and detail pages can
|
||||
// show or hide their entry points.
|
||||
// GET /api/v1/metadata/ai/status
|
||||
func (h *MetadataAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": h.service.Enabled(),
|
||||
"on_view": h.service.OnViewMode(),
|
||||
})
|
||||
}
|
||||
|
||||
// WriteMetadataAIDisabledStatus answers the status probe with a clean negative
|
||||
// when no metadata AI handler is wired.
|
||||
func WriteMetadataAIDisabledStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": false, "on_view": "off"})
|
||||
}
|
||||
|
||||
type translateDescriptionRequest struct {
|
||||
// TargetLanguage echoes the detail response's pending_translation_language.
|
||||
TargetLanguage string `json:"target_language"`
|
||||
}
|
||||
|
||||
// HandleTranslateOnView is the viewer-facing on-demand description
|
||||
// translation: any profile that can access the item may request its
|
||||
// descriptions in the language the detail response reported missing. Gated by
|
||||
// metadata_ai.on_view; duplicate viewers collapse onto one job and recently
|
||||
// failed targets are not retried (cooldown in the service).
|
||||
// POST /api/v1/items/{id}/translate-description
|
||||
func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
var req translateDescriptionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TargetLanguage == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
|
||||
return
|
||||
}
|
||||
|
||||
scope, ok := access.GetScope(r.Context())
|
||||
if !ok || h.ItemAccess == nil {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Viewer access is required")
|
||||
return
|
||||
}
|
||||
filter := catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
UserID: scope.UserID,
|
||||
ProfileID: scope.ProfileID,
|
||||
}
|
||||
if err := h.ItemAccess.EnsureAccessible(r.Context(), contentID, filter); err != nil {
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item")
|
||||
return
|
||||
}
|
||||
|
||||
var requestedBy *int
|
||||
if userID := apimw.GetUserID(r.Context()); userID != 0 {
|
||||
requestedBy = &userID
|
||||
}
|
||||
|
||||
job, err := h.service.RequestOnView(r.Context(), contentID, req.TargetLanguage, requestedBy)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, translation.ErrNotConfigured):
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured",
|
||||
"On-view translation is not enabled on this server")
|
||||
case errors.Is(err, translation.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to request on-view translation",
|
||||
"content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
type translateMetadataRequest struct {
|
||||
TargetLanguage string `json:"target_language"`
|
||||
IncludeChildren *bool `json:"include_children"` // default true
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
// HandleTranslate enqueues a translation job for an item.
|
||||
// POST /api/v1/admin/items/{id}/metadata-translation
|
||||
func (h *MetadataAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Request) {
|
||||
contentID := chi.URLParam(r, "id")
|
||||
var req translateMetadataRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TargetLanguage == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
|
||||
return
|
||||
}
|
||||
includeChildren := true
|
||||
if req.IncludeChildren != nil {
|
||||
includeChildren = *req.IncludeChildren
|
||||
}
|
||||
|
||||
var requestedBy *int
|
||||
if userID := apimw.GetUserID(r.Context()); userID != 0 {
|
||||
requestedBy = &userID
|
||||
}
|
||||
|
||||
job, err := h.service.Enqueue(r.Context(), translation.JobRequest{
|
||||
TargetKind: translation.TargetItem,
|
||||
ContentID: contentID,
|
||||
TargetLanguage: req.TargetLanguage,
|
||||
IncludeChildren: includeChildren,
|
||||
Force: req.Force,
|
||||
RequestedBy: requestedBy,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, translation.ErrNotConfigured):
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured",
|
||||
"Metadata AI translation is not configured on this server")
|
||||
case errors.Is(err, translation.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
slog.Error("failed to enqueue metadata translation",
|
||||
"content_id", contentID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
// HandleListJobs lists recent translation jobs for an item; the metadata
|
||||
// editor polls this for progress.
|
||||
// GET /api/v1/admin/items/{id}/metadata-translation/jobs
|
||||
func (h *MetadataAIHandler) HandleListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
jobs, err := h.service.ListJobs(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list jobs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs})
|
||||
}
|
||||
|
||||
// HandleCancelJob cancels a job belonging to the item in the URL.
|
||||
// POST /api/v1/admin/items/{id}/metadata-translation/jobs/{job_id}/cancel
|
||||
func (h *MetadataAIHandler) HandleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
jobID, err := strconv.ParseInt(chi.URLParam(r, "job_id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid job ID")
|
||||
return
|
||||
}
|
||||
job, err := h.service.GetJob(r.Context(), jobID)
|
||||
if err != nil {
|
||||
if errors.Is(err, translation.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load job")
|
||||
return
|
||||
}
|
||||
// The curation guard authorized {id}; the job must belong to it.
|
||||
if job.ContentID != chi.URLParam(r, "id") {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
if err := h.service.Cancel(r.Context(), jobID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel job")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type NodeRepository interface {
|
||||
Create(ctx context.Context, input nodepool.CreateNodeInput) (*nodepool.Node, error)
|
||||
Update(ctx context.Context, id int, input nodepool.UpdateNodeInput) (*nodepool.Node, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
UpdateHealth(ctx context.Context, id int, healthy bool, activeJobs, egressKbps int) error
|
||||
UpdateHealth(ctx context.Context, id int, healthy bool, activeJobs int) error
|
||||
}
|
||||
|
||||
// NodeListEnabled queries enabled nodes by type for pool reload.
|
||||
@@ -70,7 +70,6 @@ type ForceReloadResult struct {
|
||||
type checkNodeResult struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
ActiveJobs int `json:"active_jobs"`
|
||||
EgressKbps int `json:"egress_kbps"`
|
||||
}
|
||||
|
||||
// HandleListNodes handles GET /admin/nodes.
|
||||
@@ -182,16 +181,15 @@ func (h *NodeHandler) HandleCheckNode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
healthy, activeJobs, egressKbps := nodepool.CheckNode(r.Context(), node)
|
||||
healthy, activeJobs := nodepool.CheckNode(r.Context(), node)
|
||||
|
||||
if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs, egressKbps); err != nil {
|
||||
if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs); err != nil {
|
||||
slog.Error("persisting health check result", "node_id", id, "error", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, checkNodeResult{
|
||||
Healthy: healthy,
|
||||
ActiveJobs: activeJobs,
|
||||
EgressKbps: egressKbps,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,6 @@ type SessionManagerInterface interface {
|
||||
SetEffectiveMediaFileID(sessionID string, fileID int) error
|
||||
SetTranscodeNodeURL(sessionID, url string) error
|
||||
SetWebSocket(sessionID string, connected bool) error
|
||||
SetRealtimeConnection(sessionID string, connected bool) error
|
||||
SetProgressPersistenceDisabled(sessionID string, disabled bool) error
|
||||
StopSession(sessionID string) error
|
||||
GetSession(sessionID string) (*playback.Session, error)
|
||||
}
|
||||
@@ -59,6 +57,17 @@ type sessionStarterWithFilesContext interface {
|
||||
StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
||||
}
|
||||
|
||||
// ProxyPicker selects a proxy node for stream routing.
|
||||
type ProxyPicker interface {
|
||||
Pick() *nodepool.Node
|
||||
}
|
||||
|
||||
// TranscodeAcquirer selects a transcode node for transcoding jobs.
|
||||
type TranscodeAcquirer interface {
|
||||
Acquire() *nodepool.Node
|
||||
FindByURL(url string) *nodepool.Node
|
||||
}
|
||||
|
||||
type PlaybackItemAccessChecker interface {
|
||||
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
|
||||
}
|
||||
@@ -108,7 +117,8 @@ type PlaybackHandler struct {
|
||||
SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view
|
||||
EventsHub *evt.Hub
|
||||
MissingMarker MissingFileMarker
|
||||
NodePlanner nodepool.SessionPlanner // optional; enables proxy/transcode node selection
|
||||
ProxyPool ProxyPicker // optional; enables proxy-based stream URLs
|
||||
TranscodePool TranscodeAcquirer // optional; enables transcode node selection
|
||||
JWTSecret string // needed for signing stream tokens
|
||||
ItemAccess PlaybackItemAccessChecker // optional; enables file authorization checks
|
||||
EpisodeLookup PlaybackEpisodeLookup // optional; resolves episode files to their series
|
||||
@@ -247,7 +257,6 @@ type startPlaybackRequest struct {
|
||||
StartPosition *float64 `json:"start_position,omitempty"`
|
||||
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
|
||||
PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"`
|
||||
DisableProgressPersistence bool `json:"disable_progress_persistence,omitempty"`
|
||||
CodecsVideo []string `json:"codecs_video"`
|
||||
CodecsAudio []string `json:"codecs_audio"`
|
||||
Containers []string `json:"containers"`
|
||||
@@ -433,13 +442,6 @@ func playbackStreamURL(s *playback.Session) string {
|
||||
return fmt.Sprintf("/stream/%s", s.ID)
|
||||
}
|
||||
|
||||
func fileBitrateKbps(file *models.MediaFile) int {
|
||||
if file == nil || file.Bitrate <= 0 {
|
||||
return 0
|
||||
}
|
||||
return file.Bitrate
|
||||
}
|
||||
|
||||
func buildPlaybackInfo(session *playback.Session, file *models.MediaFile) *playbackInfoResult {
|
||||
if session == nil {
|
||||
return nil
|
||||
@@ -759,9 +761,6 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback
|
||||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||||
return
|
||||
}
|
||||
if session == nil || session.DisableProgressPersistence {
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||||
targetID := playbackProgressTarget(file)
|
||||
@@ -800,7 +799,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl
|
||||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
if session == nil || session.DisableProgressPersistence || session.Position <= 0 {
|
||||
if session == nil || session.Position <= 0 {
|
||||
return watchstate.PlaybackStopResult{}
|
||||
}
|
||||
|
||||
@@ -1282,13 +1281,6 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
setPlaybackSessionLogContext(r, session.ID)
|
||||
if req.DisableProgressPersistence {
|
||||
if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil {
|
||||
slog.Error("failed to disable progress persistence", "session", session.ID, "error", err)
|
||||
} else {
|
||||
session.DisableProgressPersistence = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil {
|
||||
slog.Error("failed to set audio track", "session", session.ID, "error", err)
|
||||
@@ -1328,7 +1320,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
} else {
|
||||
h.restoreSessionProgress(r.Context(), session, file)
|
||||
}
|
||||
if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
if h.WatchScrobbler != nil && effectiveFile != nil {
|
||||
targetID := playbackProgressTarget(effectiveFile)
|
||||
if targetID != "" {
|
||||
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
|
||||
@@ -1367,14 +1359,10 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
resp.SubtitleURLs = buildSubtitleURLs(session.ID, effectiveFile, downloadedSubs)
|
||||
|
||||
// If stream nodes are available, generate proxy-based stream URLs.
|
||||
// Remux and transcode both use HLS via a transcode node, so the planner
|
||||
// picks the transcode node and its group's proxy together.
|
||||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||||
needsTranscode := session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux
|
||||
plan := h.NodePlanner.PlanSession(session.ID, "", needsTranscode, fileBitrateKbps(effectiveFile))
|
||||
proxyNode := plan.ProxyNode
|
||||
if proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||||
// If proxy nodes are available, generate proxy-based stream URLs.
|
||||
if h.ProxyPool != nil && h.JWTSecret != "" {
|
||||
proxyNode := h.ProxyPool.Pick()
|
||||
if proxyNode != nil {
|
||||
tokenClaims := streamtoken.Claims{
|
||||
SessionID: session.ID,
|
||||
PlayMethod: string(session.PlayMethod),
|
||||
@@ -1388,9 +1376,12 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
tokenClaims.TranscodeAudio = session.TranscodeAudio
|
||||
tokenClaims.AudioTrackIndex = session.AudioTrackIndex
|
||||
|
||||
if plan.TranscodeNode != nil {
|
||||
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(session.ID, plan.TranscodeNode.URL)
|
||||
// Remux and transcode both use HLS via a transcode node.
|
||||
if (session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux) && h.TranscodePool != nil {
|
||||
if tcNode := h.TranscodePool.Acquire(); tcNode != nil {
|
||||
tokenClaims.TranscodeNode = tcNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(session.ID, tcNode.URL)
|
||||
}
|
||||
}
|
||||
|
||||
token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, 24*time.Hour)
|
||||
@@ -1558,7 +1549,7 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re
|
||||
// Persist progress to UserStore (best-effort).
|
||||
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
|
||||
h.persistProgress(r.Context(), sess)
|
||||
if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||||
if h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||||
if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil {
|
||||
targetID := playbackProgressTarget(file)
|
||||
if targetID != "" {
|
||||
@@ -1767,14 +1758,8 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
PlaybackInfo: buildPlaybackInfo(&updatedSession, file),
|
||||
}
|
||||
|
||||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||||
needsTranscode := updatedSession.PlayMethod == playback.PlayTranscode
|
||||
estKbps := updatedSession.TargetBitrateKbps
|
||||
if estKbps <= 0 {
|
||||
estKbps = fileBitrateKbps(file)
|
||||
}
|
||||
plan := h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, needsTranscode, estKbps)
|
||||
if proxyNode := plan.ProxyNode; proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||||
if h.ProxyPool != nil && h.JWTSecret != "" {
|
||||
if proxyNode := h.ProxyPool.Pick(); proxyNode != nil {
|
||||
tokenClaims := streamtoken.Claims{
|
||||
SessionID: sessionID,
|
||||
PlayMethod: string(updatedSession.PlayMethod),
|
||||
@@ -1782,9 +1767,11 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
TranscodeAudio: updatedSession.TranscodeAudio,
|
||||
AudioTrackIndex: req.AudioTrackIndex,
|
||||
}
|
||||
if plan.TranscodeNode != nil {
|
||||
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(sessionID, plan.TranscodeNode.URL)
|
||||
if updatedSession.PlayMethod == playback.PlayTranscode && h.TranscodePool != nil {
|
||||
if tcNode := h.TranscodePool.Acquire(); tcNode != nil {
|
||||
tokenClaims.TranscodeNode = tcNode.URL
|
||||
_ = h.sessionMgr.SetTranscodeNodeURL(sessionID, tcNode.URL)
|
||||
}
|
||||
}
|
||||
if token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, 24*time.Hour); signErr == nil {
|
||||
switch updatedSession.PlayMethod {
|
||||
@@ -2037,15 +2024,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Determine whether to run locally or forward to a remote transcode node.
|
||||
var plan nodepool.Plan
|
||||
if h.NodePlanner != nil {
|
||||
estKbps := req.TargetBitrateKbps
|
||||
if estKbps <= 0 {
|
||||
estKbps = fileBitrateKbps(file)
|
||||
}
|
||||
plan = h.NodePlanner.PlanSession(req.SessionID, session.TranscodeNodeURL, true, estKbps)
|
||||
}
|
||||
tcNode := plan.TranscodeNode
|
||||
tcNode := h.PickTranscodeNode(session.TranscodeNodeURL)
|
||||
|
||||
if tcNode != nil {
|
||||
// Remote transcode: forward to the assigned node.
|
||||
@@ -2110,7 +2089,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel)
|
||||
}
|
||||
|
||||
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL, plan.ProxyNode)
|
||||
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL)
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{
|
||||
req: req,
|
||||
file: file,
|
||||
@@ -2123,13 +2102,6 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Local transcode (integrated mode — no transcode nodes available).
|
||||
// In distributed mode admins can disable this fallback so the API server
|
||||
// never transcodes when no eligible node exists.
|
||||
if h.NodePlanner != nil && !nodepool.LocalTranscodeFallbackAllowed(r.Context(), h.SettingsRepo) {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_transcode_node",
|
||||
"No transcode node is available and local transcode fallback is disabled")
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory")
|
||||
return
|
||||
@@ -2471,11 +2443,40 @@ func (h *PlaybackHandler) closeTranscodeSession(sessionID, transcodeNodeURL stri
|
||||
}
|
||||
}
|
||||
|
||||
// buildProxyManifestURL signs a stream token and builds the proxy-based
|
||||
// manifest URL. proxyNode is the planner's pick for this session; when nil
|
||||
// the URL falls back to the API-local path.
|
||||
func (h *PlaybackHandler) buildProxyManifestURL(sessionID string, session *playback.Session, transcodeNodeURL string, proxyNode *nodepool.Node) string {
|
||||
// PickTranscodeNode selects a transcode node with soft affinity.
|
||||
// If currentURL is set and that node is healthy, reuse it unless another node
|
||||
// has significantly fewer active jobs (2+ difference).
|
||||
func (h *PlaybackHandler) PickTranscodeNode(currentURL string) *nodepool.Node {
|
||||
if h.TranscodePool == nil {
|
||||
return nil
|
||||
}
|
||||
best := h.TranscodePool.Acquire()
|
||||
if best == nil {
|
||||
return nil
|
||||
}
|
||||
if currentURL == "" || best.URL == currentURL {
|
||||
return best
|
||||
}
|
||||
// Reuse current node unless the best alternative has significantly fewer jobs.
|
||||
current := h.TranscodePool.FindByURL(currentURL)
|
||||
if current != nil && current.Healthy && current.Enabled &&
|
||||
best.ActiveJobs+2 <= current.ActiveJobs {
|
||||
return best // switch to less-loaded node
|
||||
}
|
||||
if current != nil && current.Healthy && current.Enabled {
|
||||
return current // soft affinity — stay on current
|
||||
}
|
||||
return best // current node is gone or unhealthy
|
||||
}
|
||||
|
||||
// buildProxyManifestURL signs a stream token and builds the proxy-based manifest URL.
|
||||
func (h *PlaybackHandler) buildProxyManifestURL(sessionID string, session *playback.Session, transcodeNodeURL string) string {
|
||||
if h.ProxyPool == nil {
|
||||
return fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID)
|
||||
}
|
||||
proxyNode := h.ProxyPool.Pick()
|
||||
if proxyNode == nil {
|
||||
// No proxy — fall back to API-local path.
|
||||
return fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID)
|
||||
}
|
||||
|
||||
|
||||
@@ -129,10 +129,6 @@ func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return
|
||||
|
||||
func (failingSessionManager) SetWebSocket(string, bool) error { return nil }
|
||||
|
||||
func (failingSessionManager) SetRealtimeConnection(string, bool) error { return nil }
|
||||
|
||||
func (failingSessionManager) SetProgressPersistenceDisabled(string, bool) error { return nil }
|
||||
|
||||
func (failingSessionManager) StopSession(string) error { return nil }
|
||||
|
||||
func (failingSessionManager) GetSession(string) (*playback.Session, error) { return nil, nil }
|
||||
@@ -162,7 +158,7 @@ func newPlaybackTestStore(t *testing.T) userstore.UserStore {
|
||||
|
||||
func newAuthorizedPlaybackContext() context.Context {
|
||||
ctx := context.Background()
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
|
||||
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
return apimw.SetProfileID(ctx, "profile-1")
|
||||
}
|
||||
|
||||
@@ -287,64 +283,6 @@ func TestHandleStartPlayback_UsesExplicitZeroStartPositionInsteadOfStoredResume(
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSessionProgressPersistenceCanBeDisabled(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
if err := store.SetProgress(context.Background(), "profile-1", "book-1", 500, 1200, userstore.ProgressThresholds{}); err != nil {
|
||||
t.Fatalf("seed progress: %v", err)
|
||||
}
|
||||
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "book-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "book.m4b"),
|
||||
Duration: 600,
|
||||
}
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.StoreProvider = testUserStoreProvider{store: store}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","start_position":120,"disable_progress_persistence":true}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(rr, req)
|
||||
if rr.Code != 201 {
|
||||
t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp playbackSessionResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
progressReq := httptest.NewRequest("POST", "/api/v1/playback/"+resp.SessionID+"/progress", strings.NewReader(`{"position":240,"is_paused":false}`))
|
||||
progressReq = progressReq.WithContext(newAuthorizedPlaybackContext())
|
||||
progressReq = withPlaybackRouteParam(progressReq, "session_id", resp.SessionID)
|
||||
progressRR := httptest.NewRecorder()
|
||||
handler.HandleUpdateProgress(progressRR, progressReq)
|
||||
if progressRR.Code != 204 {
|
||||
t.Fatalf("progress status = %d, body = %s", progressRR.Code, progressRR.Body.String())
|
||||
}
|
||||
|
||||
stopReq := httptest.NewRequest("DELETE", "/api/v1/playback/"+resp.SessionID, nil)
|
||||
stopReq = stopReq.WithContext(newAuthorizedPlaybackContext())
|
||||
stopReq = withPlaybackRouteParam(stopReq, "session_id", resp.SessionID)
|
||||
stopRR := httptest.NewRecorder()
|
||||
handler.HandleStopPlayback(stopRR, stopReq)
|
||||
if stopRR.Code != 204 {
|
||||
t.Fatalf("stop status = %d, body = %s", stopRR.Code, stopRR.Body.String())
|
||||
}
|
||||
|
||||
progress, err := store.GetProgress(context.Background(), "profile-1", "book-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get progress: %v", err)
|
||||
}
|
||||
if progress == nil || progress.PositionSeconds != 500 || progress.DurationSeconds != 1200 {
|
||||
t.Fatalf("progress after disabled session = %+v, want 500/1200", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
|
||||
file := &models.MediaFile{Duration: 3600}
|
||||
|
||||
@@ -1117,7 +1055,7 @@ func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *test
|
||||
Healthy: true,
|
||||
ActiveJobs: 0,
|
||||
}})
|
||||
handler.NodePlanner = nodepool.NewPlanner(nodepool.NewProxyPool(), pool)
|
||||
handler.TranscodePool = pool
|
||||
|
||||
transcodeReq := httptest.NewRequest(
|
||||
"POST",
|
||||
|
||||
@@ -169,27 +169,22 @@ type pluginConfigSchemaJSON struct {
|
||||
}
|
||||
|
||||
type pluginAdminFormJSON struct {
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormFieldJSON struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
DynamicOptions bool `json:"dynamic_options,omitempty"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormOptionJSON struct {
|
||||
@@ -198,31 +193,6 @@ type pluginAdminFormOptionJSON struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormConditionJSON struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
}
|
||||
|
||||
type pluginAdminFormValidationJSON struct {
|
||||
HasMin bool `json:"has_min,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
HasMax bool `json:"has_max,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
MinLength int32 `json:"min_length,omitempty"`
|
||||
MaxLength int32 `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormSectionJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
}
|
||||
|
||||
type pluginCapabilityJSON struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
@@ -690,7 +660,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation
|
||||
continue
|
||||
}
|
||||
if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) int {
|
||||
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
|
||||
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, level)
|
||||
}); err != nil {
|
||||
slog.Warn("failed to append provider to library chains",
|
||||
"installation_id", installation.ID,
|
||||
@@ -1372,78 +1342,26 @@ func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
|
||||
if field.GetDefaultValue() != nil {
|
||||
defaultValue = field.GetDefaultValue().AsInterface()
|
||||
}
|
||||
var validation *pluginAdminFormValidationJSON
|
||||
if v := field.GetValidation(); v != nil {
|
||||
validation = &pluginAdminFormValidationJSON{
|
||||
HasMin: v.GetHasMin(),
|
||||
Min: v.GetMin(),
|
||||
HasMax: v.GetHasMax(),
|
||||
Max: v.GetMax(),
|
||||
Pattern: v.GetPattern(),
|
||||
MinLength: v.GetMinLength(),
|
||||
MaxLength: v.GetMaxLength(),
|
||||
}
|
||||
}
|
||||
fields = append(fields, pluginAdminFormFieldJSON{
|
||||
Key: field.GetKey(),
|
||||
Label: field.GetLabel(),
|
||||
Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(),
|
||||
Required: field.GetRequired(),
|
||||
Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(),
|
||||
DefaultValue: defaultValue,
|
||||
Options: options,
|
||||
Rows: field.GetRows(),
|
||||
DynamicOptions: field.GetDynamicOptions(),
|
||||
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
|
||||
Validation: validation,
|
||||
ExclusiveGroupField: field.GetExclusiveGroupField(),
|
||||
})
|
||||
}
|
||||
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, pluginAdminFormSectionJSON{
|
||||
Key: section.GetKey(),
|
||||
Title: section.GetTitle(),
|
||||
Description: section.GetDescription(),
|
||||
Collapsible: section.GetCollapsible(),
|
||||
CollapsedDefault: section.GetCollapsedDefault(),
|
||||
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
|
||||
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
|
||||
Key: field.GetKey(),
|
||||
Label: field.GetLabel(),
|
||||
Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(),
|
||||
Required: field.GetRequired(),
|
||||
Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(),
|
||||
DefaultValue: defaultValue,
|
||||
Options: options,
|
||||
Rows: field.GetRows(),
|
||||
})
|
||||
}
|
||||
return &pluginAdminFormJSON{
|
||||
Fields: fields,
|
||||
SubmitLabel: form.GetSubmitLabel(),
|
||||
Sections: sections,
|
||||
}
|
||||
}
|
||||
|
||||
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
|
||||
if len(conditions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
|
||||
for _, condition := range conditions {
|
||||
if condition == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, pluginAdminFormConditionJSON{
|
||||
Field: condition.GetField(),
|
||||
Equals: append([]string(nil), condition.GetEquals()...),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
|
||||
response := make([]pluginCapabilityJSON, 0, len(descriptors))
|
||||
for _, descriptor := range descriptors {
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// TestAdminFormToJSONSchemaFields locks the bridge between the SDK proto
|
||||
// AdminForm descriptor and the JSON the frontend SchemaForm consumes. Without
|
||||
// this, the schema-driven request-connection form silently loses its
|
||||
// dynamic dropdowns (dynamic_options), conditional visibility (show_when),
|
||||
// validation, sections, and the MULTI_SELECT control mapping, which makes the
|
||||
// new form non-functional (required fields can never be set, save stays
|
||||
// disabled).
|
||||
func TestAdminFormToJSONSchemaFields(t *testing.T) {
|
||||
descriptor := &pluginv1.AdminFormDescriptor{
|
||||
SubmitLabel: "Connect",
|
||||
Fields: []*pluginv1.AdminFormField{
|
||||
{
|
||||
Key: "quality_profile_ids",
|
||||
Label: "Quality Profiles",
|
||||
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT,
|
||||
DynamicOptions: true,
|
||||
ExclusiveGroupField: "service_kind",
|
||||
ShowWhen: []*pluginv1.AdminFormCondition{
|
||||
{Field: "service_type", Equals: []string{"radarr", "sonarr"}},
|
||||
},
|
||||
Validation: &pluginv1.AdminFormValidation{
|
||||
HasMin: true,
|
||||
Min: 1,
|
||||
HasMax: true,
|
||||
Max: 10,
|
||||
Pattern: "^[0-9]+$",
|
||||
MinLength: 1,
|
||||
MaxLength: 64,
|
||||
},
|
||||
},
|
||||
},
|
||||
Sections: []*pluginv1.AdminFormSection{
|
||||
{
|
||||
Key: "advanced",
|
||||
Title: "Advanced",
|
||||
Description: "Optional tuning",
|
||||
Collapsible: true,
|
||||
CollapsedDefault: true,
|
||||
FieldKeys: []string{"quality_profile_ids"},
|
||||
ShowWhen: []*pluginv1.AdminFormCondition{
|
||||
{Field: "service_type", Equals: []string{"radarr"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
form := adminFormToJSON(descriptor)
|
||||
if form == nil {
|
||||
t.Fatal("adminFormToJSON returned nil")
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(form)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal admin form: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Fields []struct {
|
||||
Control string `json:"control"`
|
||||
DynamicOptions bool `json:"dynamic_options"`
|
||||
ShowWhen []struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
} `json:"show_when"`
|
||||
Validation *struct {
|
||||
HasMin bool `json:"has_min"`
|
||||
Min float64 `json:"min"`
|
||||
HasMax bool `json:"has_max"`
|
||||
Max float64 `json:"max"`
|
||||
Pattern string `json:"pattern"`
|
||||
MinLength int32 `json:"min_length"`
|
||||
MaxLength int32 `json:"max_length"`
|
||||
} `json:"validation"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field"`
|
||||
} `json:"fields"`
|
||||
Sections []struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
} `json:"show_when"`
|
||||
} `json:"sections"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal admin form: %v\njson: %s", err, raw)
|
||||
}
|
||||
|
||||
if len(decoded.Fields) != 1 {
|
||||
t.Fatalf("expected 1 field, got %d", len(decoded.Fields))
|
||||
}
|
||||
field := decoded.Fields[0]
|
||||
|
||||
// Control must serialize to the SHORT form the frontend switches on.
|
||||
if field.Control != "MULTI_SELECT" {
|
||||
t.Errorf("control = %q, want %q", field.Control, "MULTI_SELECT")
|
||||
}
|
||||
if !field.DynamicOptions {
|
||||
t.Error("dynamic_options = false, want true")
|
||||
}
|
||||
if field.ExclusiveGroupField != "service_kind" {
|
||||
t.Errorf("exclusive_group_field = %q, want %q", field.ExclusiveGroupField, "service_kind")
|
||||
}
|
||||
if len(field.ShowWhen) != 1 || field.ShowWhen[0].Field != "service_type" ||
|
||||
len(field.ShowWhen[0].Equals) != 2 {
|
||||
t.Errorf("field show_when not serialized correctly: %+v", field.ShowWhen)
|
||||
}
|
||||
if field.Validation == nil {
|
||||
t.Fatal("validation not serialized")
|
||||
}
|
||||
if !field.Validation.HasMin || field.Validation.Min != 1 ||
|
||||
!field.Validation.HasMax || field.Validation.Max != 10 ||
|
||||
field.Validation.Pattern != "^[0-9]+$" ||
|
||||
field.Validation.MinLength != 1 || field.Validation.MaxLength != 64 {
|
||||
t.Errorf("validation not serialized correctly: %+v", field.Validation)
|
||||
}
|
||||
|
||||
if len(decoded.Sections) != 1 {
|
||||
t.Fatalf("expected 1 section, got %d", len(decoded.Sections))
|
||||
}
|
||||
section := decoded.Sections[0]
|
||||
if section.Key != "advanced" || section.Title != "Advanced" ||
|
||||
section.Description != "Optional tuning" || !section.Collapsible ||
|
||||
!section.CollapsedDefault {
|
||||
t.Errorf("section scalar fields not serialized correctly: %+v", section)
|
||||
}
|
||||
if len(section.FieldKeys) != 1 || section.FieldKeys[0] != "quality_profile_ids" {
|
||||
t.Errorf("section field_keys not serialized correctly: %+v", section.FieldKeys)
|
||||
}
|
||||
if len(section.ShowWhen) != 1 || section.ShowWhen[0].Field != "service_type" ||
|
||||
len(section.ShowWhen[0].Equals) != 1 {
|
||||
t.Errorf("section show_when not serialized correctly: %+v", section.ShowWhen)
|
||||
}
|
||||
}
|
||||
|
||||
// adminFormDTOMappingRenames records intentional proto-field -> DTO-field name
|
||||
// renames. The DTO field names currently mirror the proto field names 1:1, so
|
||||
// this is empty; add an entry only when a DTO field is deliberately named
|
||||
// differently from its proto source (the test then accepts the renamed target).
|
||||
var adminFormDTOMappingRenames = map[string]string{}
|
||||
|
||||
// protoInternalFields are the protobuf-runtime bookkeeping fields every
|
||||
// generated message struct carries. They are never serialized to the client
|
||||
// DTO, so the completeness guard skips them.
|
||||
var protoInternalFields = map[string]bool{
|
||||
"state": true,
|
||||
"sizeCache": true,
|
||||
"unknownFields": true,
|
||||
}
|
||||
|
||||
// TestAdminFormDTOCompleteness is a guard against the C1-class bug: the
|
||||
// hand-written adminFormToJSON allowlist (pluginAdminFormFieldJSON /
|
||||
// pluginAdminFormSectionJSON / pluginAdminFormJSON) silently dropping a NEW
|
||||
// proto field added to AdminFormField / AdminFormDescriptor / AdminFormSection
|
||||
// later. It reflects over each proto message's exported fields and asserts the
|
||||
// corresponding JSON DTO struct has a field mapping to it (by identical Go name,
|
||||
// or via an explicit rename in adminFormDTOMappingRenames). When the next proto
|
||||
// field lands, this FAILS naming the un-mapped field, forcing the serializer +
|
||||
// DTO to be updated in lockstep. (We deliberately do not switch the serializer
|
||||
// to protojson — the short-form control-string MULTI_SELECT mapping makes that
|
||||
// risky; this guard is the proportionate fix.)
|
||||
func TestAdminFormDTOCompleteness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
proto reflect.Type
|
||||
dto reflect.Type
|
||||
}{
|
||||
{"AdminFormField", reflect.TypeOf(pluginv1.AdminFormField{}), reflect.TypeOf(pluginAdminFormFieldJSON{})},
|
||||
{"AdminFormDescriptor", reflect.TypeOf(pluginv1.AdminFormDescriptor{}), reflect.TypeOf(pluginAdminFormJSON{})},
|
||||
{"AdminFormSection", reflect.TypeOf(pluginv1.AdminFormSection{}), reflect.TypeOf(pluginAdminFormSectionJSON{})},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dtoFields := map[string]bool{}
|
||||
for i := 0; i < tc.dto.NumField(); i++ {
|
||||
dtoFields[tc.dto.Field(i).Name] = true
|
||||
}
|
||||
|
||||
for i := 0; i < tc.proto.NumField(); i++ {
|
||||
pf := tc.proto.Field(i)
|
||||
if !pf.IsExported() {
|
||||
continue // unexported protobuf-internal state
|
||||
}
|
||||
if protoInternalFields[pf.Name] || strings.HasPrefix(pf.Name, "XXX_") {
|
||||
continue
|
||||
}
|
||||
want := pf.Name
|
||||
if mapped, ok := adminFormDTOMappingRenames[pf.Name]; ok {
|
||||
want = mapped
|
||||
}
|
||||
if !dtoFields[want] {
|
||||
t.Errorf("proto field %s.%s has no corresponding field %q in the JSON DTO %s; "+
|
||||
"adminFormToJSON would silently drop it. Add the field to the DTO + serializer "+
|
||||
"(or record an intentional rename in adminFormDTOMappingRenames).",
|
||||
tc.name, pf.Name, want, tc.dto.Name())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ type createProfileRequest struct {
|
||||
MaxContentRating string `json:"max_content_rating,omitempty"`
|
||||
QualityPreference string `json:"quality_preference,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
|
||||
SubtitleLanguage string `json:"subtitle_language,omitempty"`
|
||||
SubtitleMode string `json:"subtitle_mode,omitempty"`
|
||||
AutoSkipIntro bool `json:"auto_skip_intro"`
|
||||
@@ -69,7 +68,6 @@ type updateProfileRequest struct {
|
||||
MaxContentRating *string `json:"max_content_rating,omitempty"`
|
||||
QualityPreference *string `json:"quality_preference,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
PreferredMetadataLanguage *string `json:"preferred_metadata_language,omitempty"`
|
||||
SubtitleLanguage *string `json:"subtitle_language,omitempty"`
|
||||
SubtitleMode *string `json:"subtitle_mode,omitempty"`
|
||||
AutoSkipIntro *bool `json:"auto_skip_intro,omitempty"`
|
||||
@@ -98,7 +96,6 @@ type profileResponse struct {
|
||||
MaxContentRating string `json:"max_content_rating,omitempty"`
|
||||
QualityPreference string `json:"quality_preference,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
|
||||
SubtitleLanguage string `json:"subtitle_language,omitempty"`
|
||||
SubtitleMode string `json:"subtitle_mode,omitempty"`
|
||||
AutoSkipIntro bool `json:"auto_skip_intro"`
|
||||
@@ -124,10 +121,9 @@ type verifyPINResponse struct {
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// isAdminProfileRequest reports whether the request user currently holds the
|
||||
// admin permission, checked server-side against the loaded user.
|
||||
func (h *ProfileHandler) isAdminProfileRequest(r *http.Request) bool {
|
||||
return isAdminRequest(r, h.UserRepo)
|
||||
func isAdminProfileRequest(ctx context.Context) bool {
|
||||
claims := apimw.GetClaims(ctx)
|
||||
return claims != nil && claims.Role == "admin"
|
||||
}
|
||||
|
||||
// canManageHouseholdProfiles reports whether the caller may create/update/delete
|
||||
@@ -139,7 +135,7 @@ func (h *ProfileHandler) isAdminProfileRequest(r *http.Request) bool {
|
||||
// bypass the profile lock by sending only X-Profile-Id.
|
||||
func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) {
|
||||
ctx := r.Context()
|
||||
if h.isAdminProfileRequest(r) {
|
||||
if isAdminProfileRequest(ctx) {
|
||||
return true, nil
|
||||
}
|
||||
activeProfileID := apimw.GetProfileID(ctx)
|
||||
@@ -317,7 +313,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
|
||||
// Access-policy fields only make sense when set by a manager on a managed
|
||||
// profile. On bootstrap the caller is becoming primary themselves, so non-
|
||||
// admin bootstrap creations must leave those fields at their defaults.
|
||||
if isBootstrap && !h.isAdminProfileRequest(r) &&
|
||||
if isBootstrap && !isAdminProfileRequest(r.Context()) &&
|
||||
(req.IsChild || req.MaxContentRating != "" ||
|
||||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
|
||||
req.MaxPlaybackQuality != "") {
|
||||
@@ -360,7 +356,6 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
|
||||
MaxContentRating: req.MaxContentRating,
|
||||
QualityPreference: req.QualityPreference,
|
||||
Language: req.Language,
|
||||
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
|
||||
SubtitleLanguage: req.SubtitleLanguage,
|
||||
SubtitleMode: req.SubtitleMode,
|
||||
AutoSkipIntro: req.AutoSkipIntro,
|
||||
@@ -510,7 +505,6 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
|
||||
MaxContentRating: req.MaxContentRating,
|
||||
QualityPreference: req.QualityPreference,
|
||||
Language: req.Language,
|
||||
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
|
||||
SubtitleLanguage: req.SubtitleLanguage,
|
||||
SubtitleMode: req.SubtitleMode,
|
||||
AutoSkipIntro: req.AutoSkipIntro,
|
||||
@@ -690,7 +684,6 @@ func (h *ProfileHandler) toProfileResponse(ctx context.Context, p userstore.Prof
|
||||
MaxContentRating: p.MaxContentRating,
|
||||
QualityPreference: p.QualityPreference,
|
||||
Language: p.Language,
|
||||
PreferredMetadataLanguage: p.PreferredMetadataLanguage,
|
||||
SubtitleLanguage: p.SubtitleLanguage,
|
||||
SubtitleMode: p.SubtitleMode,
|
||||
AutoSkipIntro: p.AutoSkipIntro,
|
||||
|
||||
@@ -45,6 +45,7 @@ func newAuthorizedProfileRequestWithSession(
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: role,
|
||||
SessionID: sessionID,
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
@@ -98,7 +99,7 @@ func TestHandleCreateProfile_EnforcesUserProfileLimit(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
||||
handler.UserRepo = testProfileUserRepo{
|
||||
user: &models.User{ID: 1, MaxProfiles: 1, Enabled: true, IsAdmin: true},
|
||||
user: &models.User{ID: 1, MaxProfiles: 1},
|
||||
}
|
||||
|
||||
req := newAuthorizedProfileRequestWithRole(
|
||||
|
||||
@@ -29,13 +29,6 @@ type rateLimitConfigResponse struct {
|
||||
IPReqPerMinute float64 `json:"ip_requests_per_minute"`
|
||||
IPBurst int `json:"ip_burst"`
|
||||
AuthEndpoints map[string]authEndpointConfigResponse `json:"auth_endpoints"`
|
||||
// Active reports whether a limiter is running in this process. The
|
||||
// limiter is constructed at startup, so config saved while it is absent
|
||||
// (or a backend change while it is running) needs a restart to apply.
|
||||
Active bool `json:"active"`
|
||||
// ActiveBackend is the backend the running limiter actually uses, which
|
||||
// can differ from Backend until the server restarts.
|
||||
ActiveBackend string `json:"active_backend,omitempty"`
|
||||
}
|
||||
|
||||
type tierConfigResponse struct {
|
||||
@@ -71,10 +64,6 @@ func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Reques
|
||||
IPReqPerMinute: cfg.IPReqPerMinute,
|
||||
IPBurst: cfg.IPBurst,
|
||||
AuthEndpoints: make(map[string]authEndpointConfigResponse),
|
||||
Active: h.mw != nil,
|
||||
}
|
||||
if h.mw != nil {
|
||||
resp.ActiveBackend = h.mw.ActiveBackend()
|
||||
}
|
||||
for name, tier := range cfg.Tiers {
|
||||
resp.Tiers[name] = tierConfigResponse{
|
||||
@@ -162,21 +151,10 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
}
|
||||
|
||||
// The limiter is constructed at startup, so enabling while it is absent
|
||||
// or switching backend while it runs only takes effect after a restart.
|
||||
// Everything else hot-reloads below.
|
||||
restartRequired := false
|
||||
if h.mw == nil {
|
||||
restartRequired = req.Enabled
|
||||
} else {
|
||||
if (req.Backend == "memory" || req.Backend == "redis") && req.Backend != h.mw.ActiveBackend() {
|
||||
restartRequired = true
|
||||
}
|
||||
// Hot-reload: apply new config immediately on this instance
|
||||
if err := h.mw.Reload(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed")
|
||||
return
|
||||
}
|
||||
// Hot-reload: apply new config immediately on this instance
|
||||
if err := h.mw.Reload(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Publish for multi-instance reload (if EventBus is available/backed by Redis)
|
||||
@@ -187,5 +165,5 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "restart_required": restartRequired})
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
)
|
||||
|
||||
type fakeRateLimitStore struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func newFakeRateLimitStore() *fakeRateLimitStore {
|
||||
return &fakeRateLimitStore{values: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) Get(_ context.Context, key string) (string, error) {
|
||||
return s.values[key], nil
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) Set(_ context.Context, key, value string) error {
|
||||
s.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeRateLimitStore) GetAll(_ context.Context) (map[string]string, error) {
|
||||
out := make(map[string]string, len(s.values))
|
||||
for k, v := range s.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newRunningRateLimitMiddleware(t *testing.T, store ratelimit.SettingsStore) *ratelimit.Middleware {
|
||||
t.Helper()
|
||||
perKey := ratelimit.NewMemoryLimiter()
|
||||
global := ratelimit.NewMemoryLimiter()
|
||||
t.Cleanup(func() {
|
||||
perKey.Close()
|
||||
global.Close()
|
||||
})
|
||||
mw := ratelimit.NewMiddleware(perKey, global, store, true)
|
||||
if err := mw.Init(context.Background()); err != nil {
|
||||
t.Fatalf("init middleware: %v", err)
|
||||
}
|
||||
return mw
|
||||
}
|
||||
|
||||
func putRateLimitConfig(t *testing.T, h *RateLimitHandler, body string) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleUpdateConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode PUT response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func getRateLimitConfig(t *testing.T, h *RateLimitHandler) rateLimitConfigResponse {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/rate-limits/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleGetConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp rateLimitConfigResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode GET response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) {
|
||||
// The limiter is nil when rate limiting was disabled at boot. The config
|
||||
// endpoints must still work so an admin can re-enable it from the UI.
|
||||
store := newFakeRateLimitStore()
|
||||
store.values["ratelimit.enabled"] = "false"
|
||||
h := NewRateLimitHandler(store, nil, nil)
|
||||
|
||||
got := getRateLimitConfig(t, h)
|
||||
if got.Enabled {
|
||||
t.Error("GET enabled = true, want false")
|
||||
}
|
||||
if got.Active {
|
||||
t.Error("GET active = true, want false when no limiter is running")
|
||||
}
|
||||
|
||||
resp := putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
|
||||
if resp["restart_required"] != true {
|
||||
t.Errorf("enabling without a running limiter: restart_required = %v, want true", resp["restart_required"])
|
||||
}
|
||||
if store.values["ratelimit.enabled"] != "true" {
|
||||
t.Errorf("ratelimit.enabled = %q, want \"true\"", store.values["ratelimit.enabled"])
|
||||
}
|
||||
if store.values["ratelimit.backend"] != "redis" {
|
||||
t.Errorf("ratelimit.backend = %q, want \"redis\"", store.values["ratelimit.backend"])
|
||||
}
|
||||
|
||||
resp = putRateLimitConfig(t, h, `{"enabled":false,"backend":"redis"}`)
|
||||
if resp["restart_required"] != false {
|
||||
t.Errorf("saving disabled config without a running limiter: restart_required = %v, want false", resp["restart_required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitHandlerWithRunningLimiter(t *testing.T) {
|
||||
store := newFakeRateLimitStore()
|
||||
mw := newRunningRateLimitMiddleware(t, store)
|
||||
h := NewRateLimitHandler(store, mw, nil)
|
||||
|
||||
got := getRateLimitConfig(t, h)
|
||||
if !got.Active {
|
||||
t.Error("GET active = false, want true with a running limiter")
|
||||
}
|
||||
if got.ActiveBackend != "memory" {
|
||||
t.Errorf("GET active_backend = %q, want \"memory\"", got.ActiveBackend)
|
||||
}
|
||||
|
||||
// Toggling enabled hot-reloads; no restart needed when the backend matches.
|
||||
resp := putRateLimitConfig(t, h, `{"enabled":false,"backend":"memory"}`)
|
||||
if resp["restart_required"] != false {
|
||||
t.Errorf("toggle on running limiter: restart_required = %v, want false", resp["restart_required"])
|
||||
}
|
||||
|
||||
// Switching backend only takes effect at boot.
|
||||
resp = putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
|
||||
if resp["restart_required"] != true {
|
||||
t.Errorf("backend change: restart_required = %v, want true", resp["restart_required"])
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ type RecommendationsHandler struct {
|
||||
EpisodeRepo *catalog.EpisodeRepository
|
||||
WatchTonightFetcher watchTonightSectionFetcher
|
||||
CastFetcher cardsCastFetcher
|
||||
EbookProgress EbookReaderProgressLister
|
||||
// RecWorker enqueues asynchronous taste-profile refreshes after writes
|
||||
// (taste seeding). Optional — when nil, refresh is simply skipped.
|
||||
RecWorker ProfileRefreshRequester
|
||||
@@ -644,10 +643,7 @@ func (h *RecommendationsHandler) loadItemEnrichment(
|
||||
if h.storeProvider != nil {
|
||||
store, storeErr := h.storeProvider.ForUser(ctx, userID)
|
||||
if storeErr == nil && store != nil {
|
||||
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
|
||||
if stateErr == nil {
|
||||
out.states = states
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
@@ -39,7 +38,7 @@ type RequestService interface {
|
||||
CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
|
||||
DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (map[string][]mediarequests.RouterOption, error)
|
||||
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
|
||||
|
||||
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
||||
@@ -51,7 +50,6 @@ type RequestService interface {
|
||||
|
||||
type RequestsHandler struct {
|
||||
service RequestService
|
||||
Users auth.UserLoader // for server-side admin checks
|
||||
}
|
||||
|
||||
func NewRequestsHandler(service RequestService) *RequestsHandler {
|
||||
@@ -59,7 +57,7 @@ func NewRequestsHandler(service RequestService) *RequestsHandler {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -82,7 +80,7 @@ func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -97,7 +95,7 @@ func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -114,7 +112,7 @@ func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -129,7 +127,7 @@ func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -144,7 +142,7 @@ func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -159,7 +157,7 @@ func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -178,7 +176,7 @@ func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -197,7 +195,7 @@ func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -217,7 +215,7 @@ func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -236,7 +234,7 @@ func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -254,7 +252,7 @@ func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -269,7 +267,7 @@ func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -282,7 +280,7 @@ func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -297,7 +295,7 @@ func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -310,7 +308,7 @@ func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -332,7 +330,7 @@ func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -354,7 +352,7 @@ func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -367,7 +365,7 @@ func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, true)
|
||||
viewer, ok := requestViewer(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -380,7 +378,7 @@ func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -393,7 +391,7 @@ func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -411,7 +409,7 @@ func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -426,7 +424,7 @@ func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -444,7 +442,7 @@ func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -463,7 +461,7 @@ func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -475,7 +473,7 @@ func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -498,7 +496,7 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -515,7 +513,7 @@ func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
|
||||
viewer, ok := h.requestViewer(w, r, false)
|
||||
viewer, ok := requestViewer(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -537,7 +535,7 @@ func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.R
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *RequestsHandler) requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
|
||||
func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if claims == nil || claims.UserID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
@@ -551,7 +549,7 @@ func (h *RequestsHandler) requestViewer(w http.ResponseWriter, r *http.Request,
|
||||
return mediarequests.Viewer{
|
||||
UserID: claims.UserID,
|
||||
ProfileID: profileID,
|
||||
IsAdmin: isAdminRequest(r, h.Users),
|
||||
IsAdmin: claims.Role == "admin",
|
||||
}, true
|
||||
}
|
||||
|
||||
@@ -590,36 +588,62 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i
|
||||
}
|
||||
|
||||
type requestIntegrationResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
SupportedMediaTypes []string `json:"supported_media_types"`
|
||||
PluginConfig map[string]any `json:"plugin_config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL string `json:"base_url"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
||||
LastCheckError string `json:"last_check_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Is4K bool `json:"is_4k"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsDefault4K bool `json:"is_default_4k"`
|
||||
AnimeEnabled bool `json:"anime_enabled"`
|
||||
AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"`
|
||||
AnimeRootFolder string `json:"anime_root_folder,omitempty"`
|
||||
AnimeTags []int `json:"anime_tags"`
|
||||
BaseURL string `json:"base_url"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
RootFolder string `json:"root_folder"`
|
||||
QualityProfileID *int `json:"quality_profile_id,omitempty"`
|
||||
Tags []int `json:"tags"`
|
||||
Options map[string]any `json:"options"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastCheckStatus string `json:"last_check_status,omitempty"`
|
||||
LastCheckError string `json:"last_check_error,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func requestIntegrationResponseFrom(integration mediarequests.Integration) requestIntegrationResponse {
|
||||
// Normalize nil slices so they serialize as [] (not null); the frontend
|
||||
// types them as number[] and indexes/maps over them.
|
||||
tags := integration.Tags
|
||||
if tags == nil {
|
||||
tags = []int{}
|
||||
}
|
||||
animeTags := integration.AnimeTags
|
||||
if animeTags == nil {
|
||||
animeTags = []int{}
|
||||
}
|
||||
return requestIntegrationResponse{
|
||||
ID: integration.ID,
|
||||
Name: integration.Name,
|
||||
CapabilityID: integration.CapabilityID,
|
||||
InstallationID: integration.InstallationID,
|
||||
SupportedMediaTypes: integration.SupportedMediaTypes,
|
||||
PluginConfig: integration.PluginConfig,
|
||||
Enabled: integration.Enabled,
|
||||
BaseURL: integration.BaseURL,
|
||||
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
|
||||
LastCheckAt: integration.LastCheckAt,
|
||||
LastCheckStatus: integration.LastCheckStatus,
|
||||
LastCheckError: integration.LastCheckError,
|
||||
UpdatedAt: integration.UpdatedAt,
|
||||
ID: integration.ID,
|
||||
Name: integration.Name,
|
||||
Kind: integration.Kind,
|
||||
Enabled: integration.Enabled,
|
||||
Is4K: integration.Is4K,
|
||||
IsDefault: integration.IsDefault,
|
||||
IsDefault4K: integration.IsDefault4K,
|
||||
AnimeEnabled: integration.AnimeEnabled,
|
||||
AnimeQualityProfileID: integration.AnimeQualityProfileID,
|
||||
AnimeRootFolder: integration.AnimeRootFolder,
|
||||
AnimeTags: animeTags,
|
||||
BaseURL: integration.BaseURL,
|
||||
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
|
||||
RootFolder: integration.RootFolder,
|
||||
QualityProfileID: integration.QualityProfileID,
|
||||
Tags: tags,
|
||||
Options: integration.Options,
|
||||
LastCheckAt: integration.LastCheckAt,
|
||||
LastCheckStatus: integration.LastCheckStatus,
|
||||
LastCheckError: integration.LastCheckError,
|
||||
UpdatedAt: integration.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,18 +656,6 @@ func toIntegrationResponses(integrations []mediarequests.Integration) []requestI
|
||||
}
|
||||
|
||||
func writeRequestServiceError(w http.ResponseWriter, err error) {
|
||||
// Plugin/instance validation failures carry inline field/form errors; surface
|
||||
// them as a structured 400 so any handler routing through here renders them
|
||||
// inline. Checked first because *ValidationError does not wrap a sentinel.
|
||||
var verr *mediarequests.ValidationError
|
||||
if errors.As(err, &verr) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"error": "validation_failed",
|
||||
"field_errors": verr.FieldErrors,
|
||||
"form_error": verr.FormError,
|
||||
})
|
||||
return
|
||||
}
|
||||
var quota mediarequests.QuotaError
|
||||
switch {
|
||||
case errors.As(err, "a):
|
||||
|
||||
@@ -139,7 +139,7 @@ func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Vi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) {
|
||||
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ func authedRequest(method, target string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
|
||||
@@ -32,9 +32,8 @@ type SectionHandler struct {
|
||||
StoreProvider userstore.UserStoreProvider
|
||||
UserRepo *auth.UserRepository
|
||||
DetailSvc *catalog.DetailService
|
||||
Settings catalog.SettingsStore
|
||||
Settings *catalog.ServerSettingsRepo
|
||||
CollectionRepo *catalog.LibraryCollectionRepository
|
||||
EbookProgress EbookReaderProgressLister
|
||||
}
|
||||
|
||||
// NewSectionHandler creates a new SectionHandler.
|
||||
@@ -158,11 +157,8 @@ func validateSectionConfig(sectionType sections.SectionType, config json.RawMess
|
||||
}
|
||||
|
||||
cfg := sections.ParseConfigFilters(config)
|
||||
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" && cfg.FilterType != "audiobook" {
|
||||
return "filter_type must be 'movie', 'series', or 'audiobook'", false
|
||||
}
|
||||
if _, err := sections.ParseContinueType(config); err != nil {
|
||||
return err.Error(), false
|
||||
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" {
|
||||
return "filter_type must be 'movie' or 'series'", false
|
||||
}
|
||||
|
||||
var rawLibraryConfig struct {
|
||||
@@ -891,8 +887,8 @@ func (h *SectionHandler) HandleSaveProfileOverrides(w http.ResponseWriter, r *ht
|
||||
allowCustom = v == "true"
|
||||
}
|
||||
isAdmin := false
|
||||
if h.UserRepo != nil {
|
||||
isAdmin = isAdminRequest(r, h.UserRepo)
|
||||
if claims := apimw.GetClaims(r.Context()); claims != nil {
|
||||
isAdmin = claims.Role == "admin"
|
||||
}
|
||||
|
||||
for _, o := range req.Overrides {
|
||||
@@ -1381,10 +1377,7 @@ func (h *SectionHandler) listSectionItemUserStates(r *http.Request, items []*mod
|
||||
if err != nil || store == nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.EpisodeRepo, items, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
states, err := resolveItemUserStates(r.Context(), store, profileID, h.EpisodeRepo, items)
|
||||
if err != nil {
|
||||
return map[string]*itemUserStateResponse{}
|
||||
}
|
||||
@@ -1415,8 +1408,8 @@ func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []secti
|
||||
return resolved
|
||||
}
|
||||
|
||||
// injectNextUpSection inserts a synthetic SectionNextUp entry after the
|
||||
// contiguous continue rows that start with the video Continue Watching row.
|
||||
// injectNextUpSection inserts a synthetic SectionNextUp entry immediately after
|
||||
// SectionContinueWatching in the resolved sections list.
|
||||
func injectNextUpSection(resolved []sections.ResolvedSection) []sections.ResolvedSection {
|
||||
nextUp := sections.ResolvedSection{
|
||||
ID: "system-next-up",
|
||||
@@ -1425,20 +1418,6 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
|
||||
ItemLimit: 20,
|
||||
}
|
||||
|
||||
for i, s := range resolved {
|
||||
if s.SectionType == sections.SectionContinueWatching && sections.ContinueTypeFromConfig(s.Config) == sections.ContinueTypeWatching {
|
||||
insertAt := i + 1
|
||||
for insertAt < len(resolved) && resolved[insertAt].SectionType == sections.SectionContinueWatching {
|
||||
insertAt++
|
||||
}
|
||||
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
|
||||
result = append(result, resolved[:insertAt]...)
|
||||
result = append(result, nextUp)
|
||||
result = append(result, resolved[insertAt:]...)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
for i, s := range resolved {
|
||||
if s.SectionType == sections.SectionContinueWatching {
|
||||
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
|
||||
@@ -1449,7 +1428,7 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
|
||||
}
|
||||
}
|
||||
|
||||
// If no resume row is found, prepend.
|
||||
// If no continue_watching found, prepend
|
||||
return append([]sections.ResolvedSection{nextUp}, resolved...)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const SectionsAllowProfileCustomSettingKey = "sections.allow_profile_custom_sect
|
||||
|
||||
// SectionSettingsHandler exposes GET/PUT for the sections-related server setting.
|
||||
type SectionSettingsHandler struct {
|
||||
Settings catalog.SettingsStore
|
||||
Settings *catalog.ServerSettingsRepo
|
||||
}
|
||||
|
||||
type sectionsSettingResponse struct {
|
||||
|
||||
@@ -282,66 +282,6 @@ func TestBuildSectionsResponseBatchResolvesImageURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSectionConfigAcceptsContinueTypes(t *testing.T) {
|
||||
tests := []string{
|
||||
`{"continue_type":"watching"}`,
|
||||
`{"continue_type":"listening"}`,
|
||||
`{"continue_type":"reading"}`,
|
||||
`{"filter_type":"audiobook"}`,
|
||||
}
|
||||
|
||||
for _, config := range tests {
|
||||
t.Run(config, func(t *testing.T) {
|
||||
if msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(config)); !ok {
|
||||
t.Fatalf("validateSectionConfig(%s) rejected config: %s", config, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSectionConfigRejectsUnknownContinueType(t *testing.T) {
|
||||
msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(`{"continue_type":"scrolling"}`))
|
||||
if ok {
|
||||
t.Fatal("validateSectionConfig accepted unknown continue_type")
|
||||
}
|
||||
if msg != "continue_type must be 'watching', 'listening', or 'reading'" {
|
||||
t.Fatalf("message = %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectNextUpAfterContiguousContinueRows(t *testing.T) {
|
||||
in := []sections.ResolvedSection{
|
||||
{
|
||||
ID: "cw",
|
||||
SectionType: sections.SectionContinueWatching,
|
||||
Title: "Continue Watching",
|
||||
Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching),
|
||||
},
|
||||
{
|
||||
ID: "cl",
|
||||
SectionType: sections.SectionContinueWatching,
|
||||
Title: "Continue Listening",
|
||||
Config: sections.ContinueTypeConfig(sections.ContinueTypeListening),
|
||||
},
|
||||
{ID: "recent", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added"},
|
||||
}
|
||||
|
||||
got := injectNextUpSection(in)
|
||||
gotIDs := make([]string, 0, len(got))
|
||||
for _, section := range got {
|
||||
gotIDs = append(gotIDs, section.ID)
|
||||
}
|
||||
wantIDs := []string{"cw", "cl", "system-next-up", "recent"}
|
||||
if len(gotIDs) != len(wantIDs) {
|
||||
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
for i := range wantIDs {
|
||||
if gotIDs[i] != wantIDs[i] {
|
||||
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropEmptySeasonalSectionsRemovesOnlyEmptySeasonal(t *testing.T) {
|
||||
in := []sections.SectionWithItems{
|
||||
// empty seasonal — drop
|
||||
@@ -452,7 +392,7 @@ func TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled(t *testin
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Authenticate as a non-admin profile.
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -471,7 +411,7 @@ func TestSaveProfileOverridesUnknownRecipeReturnsBadRequest(t *testing.T) {
|
||||
body := []byte(`{"scope":"home","library_id":"","overrides":[{"is_user_added":true,"user_section_type":"no_such_recipe","user_config":{}}]}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -495,7 +435,7 @@ func TestSaveProfileOverridesLegacyShapeCannotBypassGate(t *testing.T) {
|
||||
body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"admin_curated_list","config":{"item_ids":["a"]}}]}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -515,7 +455,7 @@ func TestSaveProfileOverridesLegacyShapeUnknownRecipeIsRejected(t *testing.T) {
|
||||
body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"no_such_recipe","config":{}}]}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1})
|
||||
ctx = apimw.SetProfileID(ctx, "p1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -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: playback.IssuedByKindAdmin}
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func TestHandleSessionWebSocket_RequiresHelloBeforeRealtimeReady(t *testing.T) {
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Get("/playback/ws/{session_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
|
||||
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
handler.HandleSessionWebSocket(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ const subtitleAppearanceSettingKey = "subtitle_appearance"
|
||||
const (
|
||||
libraryPageStateSettingKey = "ui.library_page_state"
|
||||
rememberLibraryPageStateSettingKey = "ui.remember_library_page_state"
|
||||
searchMediaScopeSettingKey = "search.media_scope"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -172,14 +171,6 @@ var settingsRegistry = map[string]settingSpec{
|
||||
DefaultValue: "true",
|
||||
Validate: validateBoolSetting(rememberLibraryPageStateSettingKey),
|
||||
},
|
||||
// Preferred default scope for global/catalog search. "video" keeps
|
||||
// results to movies and series; "all" mixes audiobooks in.
|
||||
searchMediaScopeSettingKey: {
|
||||
Scope: scopeUser,
|
||||
DefaultValue: "video",
|
||||
Validate: validateEnumSetting(searchMediaScopeSettingKey,
|
||||
"all", "video", "audiobook"),
|
||||
},
|
||||
"player.hdr_enabled": {
|
||||
Scope: scopeDevice,
|
||||
DefaultValue: "true",
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -11,10 +10,8 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// SubtitleAIHandler exposes on-demand AI subtitle translation backed by the
|
||||
@@ -24,12 +21,6 @@ import (
|
||||
type SubtitleAIHandler struct {
|
||||
service *ai.Service
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
// StoreProvider resolves household profiles for the transcription quota
|
||||
// exemption check; when nil the whole admin account is exempt.
|
||||
StoreProvider userstore.UserStoreProvider
|
||||
// Users loads the account server-side for the quota exemption's admin
|
||||
// check; admin status is never read from token contents.
|
||||
Users auth.UserLoader
|
||||
}
|
||||
|
||||
// NewSubtitleAIHandler creates a handler backed by the given service.
|
||||
@@ -57,12 +48,7 @@ func authorizeMediaFileAccess(w http.ResponseWriter, r *http.Request, authorizer
|
||||
}
|
||||
|
||||
type translateSubtitleRequest struct {
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
// Kind selects the job: "translate" (default), "transcribe", or
|
||||
// "transcribe_translate". For ASR kinds, source_index is the audio track
|
||||
// index (-1 = default track) and target_language is optional for plain
|
||||
// transcribe (acts as a language hint).
|
||||
Kind string `json:"kind"`
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
SourceIndex int `json:"source_index"`
|
||||
SourceLanguage string `json:"source_language"`
|
||||
TargetLanguage string `json:"target_language"`
|
||||
@@ -70,14 +56,10 @@ type translateSubtitleRequest struct {
|
||||
StartPosition float64 `json:"start_position"`
|
||||
}
|
||||
|
||||
// HandleStatus reports whether AI subtitle translation / ASR generation are
|
||||
// available, so the player can show or hide the entry points.
|
||||
// GET /api/v1/subtitles/ai/status
|
||||
// HandleStatus reports whether AI subtitle translation is available, so the
|
||||
// player can show or hide the entry point. GET /api/v1/subtitles/ai/status
|
||||
func (h *SubtitleAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": h.service.Enabled(),
|
||||
"transcribe_enabled": h.service.TranscribeEnabled(),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": h.service.Enabled()})
|
||||
}
|
||||
|
||||
// WriteSubtitleAIDisabledStatus answers the AI status capability probe with a
|
||||
@@ -85,7 +67,7 @@ func (h *SubtitleAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request)
|
||||
// negative instead of a 404 (the 2-segment /ai/status path is not shadowed by the
|
||||
// 1-segment /{media_file_id} route — they never compete in chi's router).
|
||||
func WriteSubtitleAIDisabledStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": false, "transcribe_enabled": false})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||
}
|
||||
|
||||
// HandleTranslate enqueues a translation job. POST /api/v1/subtitles/ai/translate
|
||||
@@ -99,13 +81,7 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "media_file_id is required")
|
||||
return
|
||||
}
|
||||
kind := ai.JobKind(req.Kind)
|
||||
if kind == "" {
|
||||
kind = ai.JobKindTranslate
|
||||
}
|
||||
// A plain transcribe produces a track in the spoken language, so the
|
||||
// target is optional; every other kind needs one.
|
||||
if req.TargetLanguage == "" && kind != ai.JobKindTranscribe {
|
||||
if req.TargetLanguage == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
|
||||
return
|
||||
}
|
||||
@@ -121,12 +97,11 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
job, err := h.service.Enqueue(r.Context(), ai.JobRequest{
|
||||
MediaFileID: req.MediaFileID,
|
||||
Kind: kind,
|
||||
Kind: ai.JobKindTranslate,
|
||||
SourceIndex: req.SourceIndex,
|
||||
SourceLanguage: req.SourceLanguage,
|
||||
TargetLanguage: req.TargetLanguage,
|
||||
RequestedBy: requestedBy,
|
||||
QuotaExempt: h.quotaExempt(r),
|
||||
SessionID: req.SessionID,
|
||||
StartPosition: req.StartPosition,
|
||||
})
|
||||
@@ -135,8 +110,6 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
case errors.Is(err, ai.ErrEngineNotConfigured):
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured",
|
||||
"AI subtitle translation is not configured on this server")
|
||||
case errors.Is(err, ai.ErrQuotaExceeded):
|
||||
writeError(w, http.StatusTooManyRequests, "quota_exceeded", quotaExceededMessage(err))
|
||||
case errors.Is(err, ai.ErrInvalidRequest):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
@@ -150,60 +123,6 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
|
||||
}
|
||||
|
||||
// quotaExempt reports whether the caller is exempt from the transcription
|
||||
// quota: an admin account acting as itself (no profile context) or through
|
||||
// its primary (household parent) profile. Other profiles on an admin account
|
||||
// — e.g. a kid's profile — stay subject to the quota even though the account
|
||||
// holds the admin role. X-Profile-Id is client-asserted, so this is a
|
||||
// budgeting gate, not a security boundary; the quota itself is account-scoped.
|
||||
func (h *SubtitleAIHandler) quotaExempt(r *http.Request) bool {
|
||||
ctx := r.Context()
|
||||
if !isAdminRequest(r, h.Users) {
|
||||
return false
|
||||
}
|
||||
profileID := apimw.GetProfileID(ctx)
|
||||
if profileID == "" {
|
||||
profileID = r.Header.Get("X-Profile-Id")
|
||||
}
|
||||
if profileID == "" || h.StoreProvider == nil {
|
||||
return true
|
||||
}
|
||||
store, err := h.StoreProvider.ForUser(ctx, apimw.GetUserID(ctx))
|
||||
if err != nil {
|
||||
return false // fail closed: the quota still applies
|
||||
}
|
||||
profile, err := store.GetProfile(ctx, profileID)
|
||||
if err != nil || profile == nil {
|
||||
return false
|
||||
}
|
||||
return profile.IsPrimary
|
||||
}
|
||||
|
||||
// quotaExceededMessage turns a quota error into a user-facing message with the
|
||||
// limit and window when available.
|
||||
func quotaExceededMessage(err error) string {
|
||||
var qe *ai.QuotaExceededError
|
||||
if errors.As(err, &qe) {
|
||||
return fmt.Sprintf(
|
||||
"You've reached your transcription limit (%d per %s). Try again later or ask an admin to raise it.",
|
||||
qe.Limit, qe.Period)
|
||||
}
|
||||
return "You've reached your transcription limit. Try again later or ask an admin to raise it."
|
||||
}
|
||||
|
||||
// HandleQuota reports the calling user's transcription quota usage, so the
|
||||
// player can show how many jobs they have left before they start one.
|
||||
// GET /api/v1/subtitles/ai/quota
|
||||
func (h *SubtitleAIHandler) HandleQuota(w http.ResponseWriter, r *http.Request) {
|
||||
quota, err := h.service.TranscribeQuota(r.Context(), apimw.GetUserID(r.Context()), h.quotaExempt(r))
|
||||
if err != nil {
|
||||
slog.Error("failed to load transcription quota", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load transcription quota")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, quota)
|
||||
}
|
||||
|
||||
// HandleGetJob returns a job's current state. GET /api/v1/subtitles/ai/jobs/{job_id}
|
||||
func (h *SubtitleAIHandler) HandleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||
job, ok := h.loadAuthorizedJob(w, r)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strings"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -64,7 +63,6 @@ type SubtitleSearchHandler struct {
|
||||
repo subtitles.Repository
|
||||
mediaResolver SubtitleMediaResolver
|
||||
FileAuthorizer *MediaFileAuthorizer
|
||||
Users auth.UserLoader // for server-side admin checks
|
||||
}
|
||||
|
||||
// NewSubtitleSearchHandler creates a new SubtitleSearchHandler.
|
||||
@@ -336,8 +334,8 @@ func (h *SubtitleSearchHandler) HandleDelete(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
isAdmin := claims != nil && claims.Role == "admin"
|
||||
isOwner := sub.DownloadedBy != nil && claims != nil && *sub.DownloadedBy == claims.UserID
|
||||
isAdmin := !isOwner && isAdminRequest(r, h.Users)
|
||||
if !isAdmin && !isOwner {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Not authorized to delete this subtitle")
|
||||
return
|
||||
|
||||
@@ -56,6 +56,7 @@ func newSubtitleAuthRequest(method, path string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
||||
UserID: 1,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
return req.WithContext(ctx)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user