Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe934b59d3 |
@@ -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
|
||||
|
||||
+24
-138
@@ -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"
|
||||
@@ -69,12 +68,12 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/pluginhost"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/presence"
|
||||
"github.com/Silo-Server/silo-server/internal/proxy"
|
||||
"github.com/Silo-Server/silo-server/internal/push"
|
||||
"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"
|
||||
@@ -122,7 +121,7 @@ func resolvePluginCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "silo-plugins")
|
||||
}
|
||||
|
||||
func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
|
||||
func buildBaseHandler(format string, level slog.Level) slog.Handler {
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
if strings.EqualFold(format, "json") {
|
||||
return slog.NewJSONHandler(os.Stderr, opts)
|
||||
@@ -130,19 +129,6 @@ func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
|
||||
return slog.NewTextHandler(os.Stderr, opts)
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) slog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func mustGetSetting(store interface {
|
||||
Get(context.Context, string) (string, error)
|
||||
}, ctx context.Context, key, fallback string) string {
|
||||
@@ -159,7 +145,8 @@ func configureOperationalLogging(
|
||||
settingsRepo catalog.SettingsStore,
|
||||
redisCfg config.RedisConfig,
|
||||
logStreamHub *logstream.Hub,
|
||||
filteredHandler slog.Handler,
|
||||
baseHandler slog.Handler,
|
||||
logQuiet string,
|
||||
nodeID string,
|
||||
) (opslog.Writer, *opslog.Repo, *partman.Manager) {
|
||||
if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil {
|
||||
@@ -196,6 +183,7 @@ func configureOperationalLogging(
|
||||
opsCaptureLevel = slog.LevelError
|
||||
}
|
||||
|
||||
filteredHandler := logfilter.New(baseHandler, logQuiet)
|
||||
slog.SetDefault(slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID)))
|
||||
|
||||
return operationalWriter, opslog.NewRepo(pool), opsPM
|
||||
@@ -470,14 +458,20 @@ func main() {
|
||||
log.Fatalf("config validation: %v", err)
|
||||
}
|
||||
|
||||
// Step 10: Configure log level. The level var and quiet filter are
|
||||
// shared with the operational-logging handler chain and hot-reloaded by
|
||||
// the config watcher in integrated mode.
|
||||
logLevelVar := new(slog.LevelVar)
|
||||
logLevelVar.Set(parseLogLevel(cfg.Server.LogLevel))
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar)
|
||||
quietFilter := logfilter.New(baseHandler, cfg.Server.LogQuiet)
|
||||
slog.SetDefault(slog.New(quietFilter))
|
||||
// Step 10: Configure log level
|
||||
var slogLevel slog.Level
|
||||
switch strings.ToLower(cfg.Server.LogLevel) {
|
||||
case "debug":
|
||||
slogLevel = slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
slogLevel = slog.LevelWarn
|
||||
case "error":
|
||||
slogLevel = slog.LevelError
|
||||
default:
|
||||
slogLevel = slog.LevelInfo
|
||||
}
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, slogLevel)
|
||||
slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet)))
|
||||
|
||||
mode := cfg.Server.Mode
|
||||
maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode)
|
||||
@@ -498,19 +492,8 @@ func main() {
|
||||
log.Fatalf("realtime hub start: %v", err)
|
||||
}
|
||||
eventsHub := realtimeHub.EventsHub()
|
||||
var presenceRegistry presence.Registry
|
||||
if cfg.Redis.URL != "" {
|
||||
if rc, err := cache.NewRedisClient(cfg.Redis); err == nil {
|
||||
presenceRegistry = presence.NewRedisRegistry(rc, 60*time.Second)
|
||||
} else {
|
||||
slog.Warn("presence: redis unavailable, falling back to in-memory registry", "error", err)
|
||||
}
|
||||
}
|
||||
if presenceRegistry == nil {
|
||||
presenceRegistry = presence.NewMemoryRegistry()
|
||||
}
|
||||
scanRegistry := evt.NewScanRegistry()
|
||||
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, quietFilter, nodeID)
|
||||
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, baseHandler, cfg.Server.LogQuiet, nodeID)
|
||||
defer func() {
|
||||
if err := eventBus.Close(); err != nil {
|
||||
slog.Warn("event bus close error", "error", err)
|
||||
@@ -530,7 +513,6 @@ func main() {
|
||||
Mode: cfg.Server.Mode,
|
||||
DatabaseURL: cfg.Database.URL,
|
||||
JFListen: cfg.JellyfinCompat.Listen,
|
||||
RedisURL: bc.RedisURL,
|
||||
}
|
||||
watcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, bootstrap)
|
||||
if err := watcher.Start(appCtx); err != nil {
|
||||
@@ -572,29 +554,6 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Hot-reload config watcher for integrated/api mode. Reloads on
|
||||
// EventSettingsChanged (Redis) with a 60s poll fallback, so settings
|
||||
// changes apply without restart even on Redis-less deployments. The
|
||||
// watcher's config supersedes the startup snapshot from here on.
|
||||
configWatcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, nodeconfig.BootstrapOverrides{
|
||||
Listen: bc.Listen,
|
||||
Mode: bc.Mode,
|
||||
DatabaseURL: bc.DatabaseURL,
|
||||
JFListen: bc.JFListen,
|
||||
RedisURL: bc.RedisURL,
|
||||
})
|
||||
if err := configWatcher.Start(appCtx); err != nil {
|
||||
log.Fatalf("config watcher start: %v", err)
|
||||
}
|
||||
cfg = configWatcher.Config()
|
||||
|
||||
// Apply server.log_level / server.log_quiet changes live. Both feed the
|
||||
// shared level var and quiet filter inside the default logger chain.
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
logLevelVar.Set(parseLogLevel(updated.Server.LogLevel))
|
||||
quietFilter.SetQuiet(updated.Server.LogQuiet)
|
||||
})
|
||||
|
||||
// Determine which components to initialize based on mode.
|
||||
needsS3 := mode == "integrated" || mode == "api"
|
||||
needsScanner := mode == "integrated" || mode == "api"
|
||||
@@ -610,8 +569,6 @@ func main() {
|
||||
|
||||
deps := api.Dependencies{
|
||||
Config: cfg,
|
||||
LiveConfig: configWatcher.Config,
|
||||
OnConfigChange: configWatcher.OnChange,
|
||||
BootstrapSensitiveConfigured: bootstrapSensitiveConfigured,
|
||||
BootstrapSensitiveValues: bootstrapSensitiveValues,
|
||||
AppContext: appCtx,
|
||||
@@ -632,37 +589,7 @@ func main() {
|
||||
restartReqCh <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
OnServerSettingUpdated: func(_ context.Context, _, _ string) {
|
||||
// Nudge the hot-reload watcher so same-process settings changes
|
||||
// apply immediately even without Redis (the event bus is a no-op
|
||||
// then, leaving only the 60s poll).
|
||||
configWatcher.RequestReload()
|
||||
},
|
||||
}
|
||||
// Notifications subsystem: store → service → materializer.
|
||||
// pool is always non-nil here (proxy/transcode returned early above).
|
||||
notificationsStore := notifications.NewRepository(pool)
|
||||
notificationsSvc := notifications.NewService(notificationsStore, eventsHub)
|
||||
notificationsMaterializer := notifications.NewMaterializer(eventsHub, notificationsSvc, notifications.NewContentResolverRepo(pool))
|
||||
if err := notificationsMaterializer.Start(appCtx); err != nil {
|
||||
log.Fatalf("notifications materializer start: %v", err)
|
||||
}
|
||||
deps.NotificationsService = notificationsSvc
|
||||
deps.PresenceRegistry = presenceRegistry
|
||||
|
||||
// Push subsystem: store → config → enqueuer → transports → worker.
|
||||
pushStore := push.NewStore(pool)
|
||||
pushConfig := push.NewConfig(settingsRepo)
|
||||
notificationsSvc.SetPushEnqueuer(push.NewEnqueuer(pushStore, 30*time.Second, time.Now))
|
||||
pushTransports := []push.Transport{
|
||||
push.NewWebPushTransport(pushConfig.WebPush),
|
||||
push.NewAPNsTransport(pushConfig.APNs),
|
||||
push.NewFCMTransport(pushConfig.FCM),
|
||||
}
|
||||
pushWorker := push.NewWorker(push.NewStoreQueue(pushStore), presenceRegistry, pushTransports, time.Now)
|
||||
deps.PushStore = pushStore
|
||||
deps.PushConfig = pushConfig
|
||||
|
||||
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
|
||||
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
|
||||
if err != nil {
|
||||
@@ -744,7 +671,6 @@ func main() {
|
||||
|
||||
deps.ProxyPool = proxyPool
|
||||
deps.TranscodePool = transcodePool
|
||||
deps.NodePlanner = nodepool.NewPlanner(proxyPool, transcodePool)
|
||||
|
||||
healthChecker := nodepool.NewHealthChecker(proxyPool, transcodePool, nodeRepo)
|
||||
healthChecker.Start(appCtx)
|
||||
@@ -781,9 +707,6 @@ func main() {
|
||||
|
||||
ffprobePath := scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)
|
||||
s := scanner.NewScanner(fileRepo, ffprobePath, deps.S3Public, cfg.Scanner.Workers, cfg.Scanner.EmptyTrashAfterScan)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
s.SetWorkers(updated.Scanner.Workers)
|
||||
})
|
||||
deps.Scanner = s
|
||||
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
|
||||
slog.Info("scanner initialized")
|
||||
@@ -1020,7 +943,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)
|
||||
@@ -1102,14 +1024,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.
|
||||
@@ -1121,9 +1035,6 @@ func main() {
|
||||
imageCacher := imagecache.New(deps.S3Public)
|
||||
metadataService.SetImageCacher(imageCacher)
|
||||
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
metadataService.SetAutoCacheImages(updated.Metadata.CacheImages)
|
||||
})
|
||||
if deps.Scanner != nil {
|
||||
deps.Scanner.SetImageCacher(imageCacher)
|
||||
}
|
||||
@@ -1135,16 +1046,9 @@ func main() {
|
||||
audiobookEnricher.SetImageCacher(imageCacher)
|
||||
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
ebookEnricher.SetImageCacher(imageCacher)
|
||||
}
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
mwForReload := matchWorker
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
mwForReload.SetConcurrency(updated.Matcher.Workers, updated.Matcher.BatchSize)
|
||||
})
|
||||
matchWorker.SetRealtimeHub(deps.RealtimeHub)
|
||||
if movieQueueRepo != nil {
|
||||
matchWorker.SetMovieFileClaimer(movieQueueRepo)
|
||||
@@ -1579,10 +1483,6 @@ func main() {
|
||||
deps.UserCollectionSync = userSync
|
||||
deps.UserCollectionScheduler = userCollectionScheduler
|
||||
deps.MDBListClient = mdblist.NewClient(cfg.MDBListAPIKey, nil)
|
||||
mdblistForReload := deps.MDBListClient
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
mdblistForReload.SetAPIKey(updated.MDBListAPIKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1641,9 +1541,7 @@ func main() {
|
||||
catalog.NewProviderIDRepository(deps.DB),
|
||||
),
|
||||
)
|
||||
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
requestReconcileSvc.SetEventsHub(eventsHub)
|
||||
api.AttachRequestRouter(requestReconcileSvc, pluginService)
|
||||
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
if userStoreProvider != nil {
|
||||
reconcileResolver := access.NewResolver(
|
||||
auth.NewUserRepository(deps.DB),
|
||||
@@ -1689,9 +1587,6 @@ func main() {
|
||||
if audiobookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
|
||||
if err != nil {
|
||||
@@ -1701,9 +1596,6 @@ func main() {
|
||||
taskMgr.Register(pluginTask)
|
||||
}
|
||||
}
|
||||
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationsStore, pushStore))
|
||||
taskMgr.Register(tasks.NewNotificationsDigestTask(notificationsSvc))
|
||||
taskMgr.Register(push.NewPushDeliveryTask(pushWorker))
|
||||
|
||||
taskMgr.Start(appCtx)
|
||||
defer taskMgr.Stop()
|
||||
@@ -1723,9 +1615,6 @@ func main() {
|
||||
cfg.Auth.AccessTokenExpiry,
|
||||
cfg.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
absJWTService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
absAuthSvc := auth.NewService(
|
||||
auth.NewLocalProvider(absUserRepo, absSessionRepo),
|
||||
absJWTService,
|
||||
@@ -1990,11 +1879,11 @@ func main() {
|
||||
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Listen != "" {
|
||||
compatDeps := jellycompat.Dependencies{
|
||||
Config: cfg,
|
||||
LiveConfig: configWatcher.Config,
|
||||
DB: deps.DB,
|
||||
SecretCipher: dataCipher,
|
||||
ClientIPResolver: ipResolver,
|
||||
NodePlanner: deps.NodePlanner,
|
||||
ProxyPool: deps.ProxyPool,
|
||||
TranscodePool: deps.TranscodePool,
|
||||
JWTSecret: cfg.Auth.JWTSecret,
|
||||
RecWorker: recWorker,
|
||||
}
|
||||
@@ -2058,9 +1947,6 @@ func main() {
|
||||
cfg.Auth.AccessTokenExpiry,
|
||||
cfg.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
jwtService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,145 @@
|
||||
# First-Party Audio API
|
||||
|
||||
This API is the Silo-native contract for audiobook playback. Clients should use
|
||||
it for Silo first-party audio experiences instead of the Audiobookshelf
|
||||
compatibility API. Music can reuse the same session, track, and progress
|
||||
concepts when the music UX lands.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require normal Silo user authentication. Playback start, sync,
|
||||
close, and bookmark writes also require the active profile headers used by the
|
||||
first-party clients.
|
||||
|
||||
Track stream URLs are returned from the start response and are scoped to the
|
||||
created audio session. The server validates that the session still belongs to
|
||||
the authenticated user, and to the active profile when a profile is attached.
|
||||
|
||||
## Start Playback
|
||||
|
||||
`POST /api/v1/audio/playback/start`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"content_id": "128814341867700228",
|
||||
"start_position": 120,
|
||||
"restart": false
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"content_id": "128814341867700228",
|
||||
"type": "audiobook",
|
||||
"title": "Book Title",
|
||||
"subtitle": "Author or series subtitle",
|
||||
"poster_url": "https://server/api/v1/images/...",
|
||||
"backdrop_url": "https://server/api/v1/images/...",
|
||||
"total_duration_seconds": 8746,
|
||||
"resume_position_seconds": 120,
|
||||
"tracks": [
|
||||
{
|
||||
"index": 0,
|
||||
"file_id": 123,
|
||||
"file_name": "Part 1.mp3",
|
||||
"duration_seconds": 3600,
|
||||
"start_offset_seconds": 0,
|
||||
"stream_url": "/api/v1/audio/playback/{session_id}/tracks/0",
|
||||
"stream_type": "progressive",
|
||||
"play_method": "direct",
|
||||
"codec": "mp3",
|
||||
"container": "mp3",
|
||||
"bitrate": 128000,
|
||||
"chapters": []
|
||||
}
|
||||
],
|
||||
"chapters": [
|
||||
{
|
||||
"index": 0,
|
||||
"title": "Chapter 1",
|
||||
"start_seconds": 0,
|
||||
"end_seconds": 1800,
|
||||
"track_index": 0
|
||||
}
|
||||
],
|
||||
"bookmarks": []
|
||||
}
|
||||
```
|
||||
|
||||
`total_duration_seconds`, `resume_position_seconds`, global chapter times, and
|
||||
sync positions are cumulative book seconds, not per-file seconds. Clients map
|
||||
global time to the active track by finding the last track whose
|
||||
`start_offset_seconds` is less than or equal to the target time.
|
||||
|
||||
Tracks are ordered by presentation part index when available, then by path and
|
||||
file id. The server direct-plays AVFoundation-safe formats and returns
|
||||
`play_method: "aac"` for fallback streams that are remuxed/transcoded to an
|
||||
Apple-safe AAC container.
|
||||
|
||||
## Stream Track
|
||||
|
||||
`GET /api/v1/audio/playback/{session_id}/tracks/{track_index}`
|
||||
|
||||
The response is the media stream for the selected track. Clients should request
|
||||
the track for the current global time and seek within that track using:
|
||||
|
||||
`local_seconds = global_seconds - track.start_offset_seconds`
|
||||
|
||||
## Sync Progress
|
||||
|
||||
`PATCH /api/v1/audio/playback/{session_id}/sync`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 1234,
|
||||
"duration": 8746,
|
||||
"is_paused": false
|
||||
}
|
||||
```
|
||||
|
||||
The server persists `position` as the audiobook progress position. The client
|
||||
should send cumulative book seconds on a throttle while playing, on manual
|
||||
seeks, and once more before closing the session.
|
||||
|
||||
## Close Playback
|
||||
|
||||
`POST /api/v1/audio/playback/{session_id}/close`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 1234,
|
||||
"duration": 8746,
|
||||
"is_paused": true
|
||||
}
|
||||
```
|
||||
|
||||
Close performs a final progress sync and expires the in-memory audio session.
|
||||
|
||||
## Bookmarks
|
||||
|
||||
`GET /api/v1/audio/items/{content_id}/bookmarks`
|
||||
|
||||
`POST /api/v1/audio/items/{content_id}/bookmarks`
|
||||
|
||||
`DELETE /api/v1/audio/items/{content_id}/bookmarks/{time_seconds}`
|
||||
|
||||
Create request:
|
||||
|
||||
```json
|
||||
{
|
||||
"time_seconds": 1234,
|
||||
"title": "Chapter note"
|
||||
}
|
||||
```
|
||||
|
||||
Bookmark positions are cumulative book seconds so they remain stable across
|
||||
multi-file audiobooks and client platforms.
|
||||
@@ -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,100 +0,0 @@
|
||||
# Notifications & Push — Test Checklist
|
||||
|
||||
A repeatable regression pass for the core notifications + push subsystems. Organized by layer: automated (fast, run anytime), server pipeline (API, no browser), real browser (human-in-the-loop), and not-yet-testable (future work). Run the automated layer on every change; run the browser layer before shipping a user-facing change.
|
||||
|
||||
Specs/plans this exercises live under `docs/superpowers/specs|plans/2026-06-10-core-notifications-*` and `*-push-*`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Automated tests (fast, no live instance)
|
||||
|
||||
Run from the repo root. `export GOWORK=off` first (or run inside the worktree).
|
||||
|
||||
```bash
|
||||
# Go — materializer, matchers, worker state machine, request events, presence, handlers, tasks
|
||||
GOWORK=off go test ./internal/notifications/ ./internal/push/ ./internal/presence/ \
|
||||
./internal/requests/ ./internal/api/handlers/ ./internal/taskmanager/tasks/
|
||||
|
||||
# Web — bell, inbox, settings, admin page, usePushDevice, SW logic
|
||||
cd web && pnpm exec vitest run
|
||||
```
|
||||
|
||||
Expected: all green. The Go suites cover the outcome state machine (sent/skipped/failed-with-backoff/dead + dead-token prune), child-suppression SQL, the `notifications.send` plugin contract, request lifecycle publishing, and presence refcounting. The web suites cover the UI and the `usePushDevice` lifecycle/race-guard.
|
||||
|
||||
What this does NOT cover: real WebSocket delivery, real push banners, real migrations against production-shaped data. Use layers 2–3 for those.
|
||||
|
||||
---
|
||||
|
||||
## 2. Server pipeline (API — proves source → materializer → enqueuer → worker, no browser)
|
||||
|
||||
Needs an admin API key and the instance reachable (on silo-new the API is `http://localhost:8090`; create a temp key, delete it after — see the snippet at the bottom). Observe results in the `notifications` and `push_deliveries` tables.
|
||||
|
||||
For each notification **source**, trigger it and confirm a row lands (and, if push is configured + a device registered, a `push_deliveries` row):
|
||||
|
||||
- [ ] **Announcement** — `POST /api/v1/admin/announcements {title, body, audience:{all:true}}` → confirm a `notifications` row (category `announcement`) for each targeted user. ⚠️ `audience:{all:true}` fans out to **every** user's inbox — use `{user_ids:[<your id>]}` when testing on a live instance.
|
||||
- [ ] **Request lifecycle** — submit a media request, then `POST /api/v1/requests/{id}/approve` (admin) → confirm a `request.approved` notification for the requester. Decline/complete/fail similarly produce `request.declined` / `request.completed` / `request.failed`.
|
||||
- [ ] **System** — admin user-update that changes a password → confirm a `system.password_changed` notification for that user.
|
||||
- [ ] **Content** — add an item to a profile's watchlist/favorites, then add/scan a matching library item → confirm a `content.added` notification for that profile (respects the 48h recency gate + per-day burst dedup).
|
||||
- [ ] **Admin alert** — force a failed job/scan → confirm an `admin`-category notification fans out to admin users only (throttled 1/hour per source).
|
||||
- [ ] **Digest** — opt a user into `content_digest`, then run the `notifications_digest` task (or wait for 08:00) → one digest notification if there were additions.
|
||||
- [ ] **Push test** — `POST /api/v1/admin/push/test` → 202; a `system.test_push` notification + a `push_deliveries` row per the caller's registered push devices; the worker transitions it within ~30–45s (grace + tick). With a real subscription it sends; with a bogus one it fails-with-backoff (still proves the worker path).
|
||||
|
||||
Push config endpoints to verify:
|
||||
- [ ] `GET /api/v1/admin/push/status` → `{apns,fcm,webpush}` booleans (flip after saving creds).
|
||||
- [ ] `POST /api/v1/admin/push/generate-vapid-keys` → `{vapid_public, vapid_private}` (admin only).
|
||||
- [ ] `GET /api/v1/notifications/push/webpush-key` → the configured public key (empty when unconfigured).
|
||||
- [ ] `PUT /api/v1/notifications/push/device {transport:"webpush", token:<subscription JSON>}` with `X-Profile-Id` → 204 (400 without the profile header — the route is profile-gated).
|
||||
- [ ] `GET /api/v1/notifications/push/devices` → lists the user's push devices; `PUT .../devices/{id} {enabled}` toggles.
|
||||
|
||||
---
|
||||
|
||||
## 3. Real browser (the only way to validate the UI + actual push banner)
|
||||
|
||||
Open the web app over a secure context (`localhost` or HTTPS — service workers require it). On silo-new that's `http://localhost:8090` (tunnel if remote).
|
||||
|
||||
In-app notifications:
|
||||
- [ ] **Bell + badge** — top of the app; unread count badge.
|
||||
- [ ] **Dropdown** — latest notifications, "Mark all read", deep links.
|
||||
- [ ] **Inbox page** (`/notifications`) — category tabs, dismiss, mark-on-view, "Load more".
|
||||
- [ ] **Live toast** — with the app open, trigger a notification (announcement to yourself, or the push-test) → a toast appears in real time (via the WebSocket), unless that category is muted.
|
||||
- [ ] **Preferences** — Settings → Notifications → per-category mute toggles persist; "Daily digest" defaults off; the admin category shows only for admins.
|
||||
|
||||
Push (the headline manual test):
|
||||
- [ ] **Admin config** — Admin → Settings → Push Notifications → "Generate keys", save the VAPID public/private + a `mailto:` subject → the Web Push badge flips to "Ready".
|
||||
- [ ] **Enable on this device** — Settings → Notifications → "Push on this device" → grant the browser permission prompt → a device row appears in the list.
|
||||
- [ ] **Background banner** — **close the app tab**, then `POST /admin/push/test` (or trigger any notification) → within ~30–45s an OS notification banner appears.
|
||||
- [ ] **Click-through** — click the banner → the app opens/focuses and navigates to the notification's deep link.
|
||||
- [ ] **Presence gating** — with the app tab open and focused, the same notification should NOT push a redundant banner (you saw it in-app); after closing the tab for >30s it does.
|
||||
- [ ] **Per-device toggle / revoke** — disable the device in the list → no more banners; re-enable restores.
|
||||
|
||||
---
|
||||
|
||||
## 4. Not yet testable (tracked future work)
|
||||
|
||||
- **APNs (Apple) / FCM (Android)** — no mobile clients exist to register tokens, so these transports are only unit-tested (pure result-mappers). End-to-end needs silo-apple / silo-android (Phase 3 mobile).
|
||||
- **Plugin `notifications.send`** — the ebook/audiobook request plugins don't publish it yet. Until those one-line `PublishEvent("notifications.send", …)` calls are added, plugin-sourced notifications can't be exercised end to end (the contract itself is unit-tested).
|
||||
- **iOS Safari web push** — only works for an installed PWA, not a normal tab; the client degrades to an "unsupported" state otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — temp admin key on silo-new (create, use, delete)
|
||||
|
||||
```bash
|
||||
# create
|
||||
KEY="sa_$(openssl rand -hex 32)"
|
||||
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
|
||||
"INSERT INTO api_keys (user_id,label,api_key) VALUES (1,'manual-test','$KEY');"
|
||||
echo "$KEY" # use as: Authorization: Bearer $KEY against http://localhost:8090/api/v1/...
|
||||
|
||||
# observe
|
||||
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
|
||||
"SELECT id,category,type,title FROM notifications ORDER BY id DESC LIMIT 10;"
|
||||
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
|
||||
"SELECT status,transport,attempts,left(last_error,40) FROM push_deliveries ORDER BY id DESC LIMIT 10;"
|
||||
|
||||
# clean up (delete the key + any test artifacts you created)
|
||||
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
|
||||
"DELETE FROM api_keys WHERE label='manual-test';"
|
||||
```
|
||||
|
||||
Always remove test notifications/devices you create on a real user's account; `push_deliveries` cascades when its `notifications` row is deleted.
|
||||
@@ -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,809 +0,0 @@
|
||||
# Core Notifications — Web UI Implementation Plan (Phase 2 of 3)
|
||||
|
||||
> **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:** Notifications UI in the silo-server web app: unread badge (sidebar + mobile header bell with dropdown), live toasts from the events WebSocket, an inbox page, per-category preference toggles in settings, and an admin announcements page.
|
||||
|
||||
**Architecture:** A `notifications` query-hook module talks to the Phase-1 REST API; one live-wiring hook subscribes to the `notifications` WS channel via the existing `RealtimeEventsProvider` ref-counted `useEventChannel`, firing sonner toasts and patching React Query caches. UI components consume only the hooks. Desktop surface is an `AppSidebar` item with badge (no desktop top bar exists); mobile surface is a bell + popover in the `Layout` header.
|
||||
|
||||
**Tech Stack:** React 19, React Router v7, TanStack Query, sonner, lucide-react, shadcn/ui primitives (Popover, Switch, Dialog), Vitest + RTL.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-10-core-notifications-design.md` (Web UI section). Server API shipped in Phase 1.
|
||||
|
||||
Commands assume the repository root is the cwd; web commands run in `web/`. `pnpm` is the package manager.
|
||||
|
||||
---
|
||||
|
||||
## Server API contract (frozen, Phase 1)
|
||||
|
||||
The `api()` client prefixes `/api/v1` and adds auth + `X-Profile-Id` headers automatically.
|
||||
|
||||
```
|
||||
GET /notifications?unread=1&category=&cursor=&limit= → {"items": Notification[], "next_cursor": number|null}
|
||||
GET /notifications/unread-count → {"count": number}
|
||||
POST /notifications/read {"ids":[...]} | {"all":true} → 204
|
||||
POST /notifications/{id}/dismiss → 204 | 404
|
||||
GET /notifications/preferences → {"preferences":[{"category","enabled"}]}
|
||||
PUT /notifications/preferences {"preferences":[...]} → 204 | 400
|
||||
GET /admin/announcements → {"items": Announcement[]}
|
||||
POST /admin/announcements {title, body, audience:{all|user_ids|library_ids}, expires_at?} → 201
|
||||
DELETE /admin/announcements/{id} → 204 | 404
|
||||
```
|
||||
|
||||
Notification JSON: `{id, user_id, profile_id?, category, type, title, body, link?, item_id?, created_at, read_at?, expires_at?}`. Categories: `request | content | announcement | system | admin`; preference categories additionally `content_digest` (never a notification category). WS: channel `"notifications"`, event `"notification.created"`, `data` = the Notification; subscribe snapshot `data` = `{"unread_count": number}`.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `web/src/api/types.ts` | modify | `AppNotification`, `NotificationPreference`, `Announcement`, `AnnouncementAudience` types; add `"notifications"` to `EventChannel` |
|
||||
| `web/src/hooks/queries/keys.ts` | modify | `notificationKeys` factory |
|
||||
| `web/src/hooks/queries/notifications.ts` | create | all query/mutation hooks (user + admin) |
|
||||
| `web/src/hooks/useNotificationsLive.ts` | create | WS subscription → toast + cache patching |
|
||||
| `web/src/hooks/useNotificationsLive.test.ts` | create | live-hook tests |
|
||||
| `web/src/components/NotificationBell.tsx` | create | badge + popover dropdown (mobile header) |
|
||||
| `web/src/components/NotificationBell.test.tsx` | create | bell tests |
|
||||
| `web/src/components/Layout.tsx` | modify | mount bell in mobile header; mount live hook |
|
||||
| `web/src/components/AppSidebar.tsx` | modify | Notifications nav item with badge |
|
||||
| `web/src/pages/Notifications.tsx` | create | inbox page |
|
||||
| `web/src/pages/Notifications.test.tsx` | create | inbox tests |
|
||||
| `web/src/pages/settings/NotificationsSettings.tsx` | create | preference toggles |
|
||||
| `web/src/pages/SettingsLayout.tsx` | modify | nav section entry |
|
||||
| `web/src/pages/AdminAnnouncements.tsx` | create | admin CRUD page |
|
||||
| `web/src/components/AdminSidebar.tsx` | modify | announcements nav item |
|
||||
| `web/src/App.tsx` | modify | routes: `/notifications`, admin `announcements`, settings `notifications` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: API types and event channel
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/api/types.ts` (EventChannel union ~line 2198; types appended near other domain types)
|
||||
|
||||
- [ ] **Step 1: Add the types**
|
||||
|
||||
Add `"notifications"` to the `EventChannel` union. Then add (named `AppNotification` to avoid colliding with the DOM `Notification` type — verify no existing `AppNotification` symbol first with grep):
|
||||
|
||||
```typescript
|
||||
export type NotificationCategory = "request" | "content" | "announcement" | "system" | "admin";
|
||||
|
||||
export type NotificationPreferenceCategory = NotificationCategory | "content_digest";
|
||||
|
||||
export interface AppNotification {
|
||||
id: number;
|
||||
user_id: number;
|
||||
profile_id?: string;
|
||||
category: NotificationCategory;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
link?: string;
|
||||
item_id?: string;
|
||||
created_at: string;
|
||||
read_at?: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
items: AppNotification[] | null;
|
||||
next_cursor: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
category: NotificationPreferenceCategory;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AnnouncementAudience {
|
||||
all?: boolean;
|
||||
user_ids?: number[];
|
||||
library_ids?: number[];
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
audience: AnnouncementAudience;
|
||||
created_by?: number;
|
||||
created_at: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck and commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
|
||||
git add src/api/types.ts && git commit -m "feat(web): notification API types and event channel"
|
||||
```
|
||||
|
||||
(If the repo's typecheck command differs — check package.json scripts for `typecheck`/`build` — use that; `pnpm run build` also typechecks via `tsc -b`.)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Query keys and hooks
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/hooks/queries/keys.ts`
|
||||
- Create: `web/src/hooks/queries/notifications.ts`
|
||||
|
||||
- [ ] **Step 1: Keys** — follow the existing factory style in keys.ts:
|
||||
|
||||
```typescript
|
||||
export const notificationKeys = {
|
||||
all: ["notifications"] as const,
|
||||
list: (filters: { unread?: boolean; category?: string }) =>
|
||||
[...notificationKeys.all, "list", filters] as const,
|
||||
unreadCount: () => [...notificationKeys.all, "unread-count"] as const,
|
||||
preferences: () => [...notificationKeys.all, "preferences"] as const,
|
||||
announcements: () => ["admin", "announcements"] as const,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Hooks** — `web/src/hooks/queries/notifications.ts`. Mirror the conventions in `web/src/hooks/queries/admin/users.ts` (api client import path, toast usage, invalidation):
|
||||
|
||||
```typescript
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementAudience,
|
||||
AppNotification,
|
||||
NotificationListResponse,
|
||||
NotificationPreference,
|
||||
} from "@/api/types";
|
||||
import { notificationKeys } from "@/hooks/queries/keys";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function useNotificationsList(filters: { unread?: boolean; category?: string } = {}) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: notificationKeys.list(filters),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
if (filters.unread) params.set("unread", "1");
|
||||
if (filters.category) params.set("category", filters.category);
|
||||
if (pageParam) params.set("cursor", String(pageParam));
|
||||
return api<NotificationListResponse>(`/notifications?${params.toString()}`);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount() {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.unreadCount(),
|
||||
queryFn: () => api<{ count: number }>("/notifications/unread-count"),
|
||||
select: (d) => d.count,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkRead() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { ids?: number[]; all?: boolean }) =>
|
||||
api("/notifications/read", { method: "POST", body: JSON.stringify(body) }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to mark read");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDismissNotification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api(`/notifications/${id}/dismiss`, { method: "POST" }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to dismiss");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationPreferences() {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.preferences(),
|
||||
queryFn: () => api<{ preferences: NotificationPreference[] }>("/notifications/preferences"),
|
||||
select: (d) => d.preferences,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetNotificationPreferences() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (preferences: NotificationPreference[]) =>
|
||||
api("/notifications/preferences", { method: "PUT", body: JSON.stringify({ preferences }) }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.preferences() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save preferences");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAnnouncements() {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.announcements(),
|
||||
queryFn: () => api<{ items: Announcement[] | null }>("/admin/announcements"),
|
||||
select: (d) => d.items ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAnnouncement() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: {
|
||||
title: string;
|
||||
body: string;
|
||||
audience: AnnouncementAudience;
|
||||
expires_at?: string;
|
||||
}) => api<Announcement>("/admin/announcements", { method: "POST", body: JSON.stringify(body) }),
|
||||
onSuccess: () => {
|
||||
toast.success("Announcement published");
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to publish");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAnnouncement() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api(`/admin/announcements/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
toast.success("Announcement deleted");
|
||||
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Check `api()`'s actual error behavior (does it throw on non-2xx with a message? read `web/src/api/client.ts:343-380`) and the import alias style (`@/` vs relative — match the queries directory's existing imports).
|
||||
|
||||
- [ ] **Step 3: Typecheck + commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
|
||||
git add src/hooks/queries/ && git commit -m "feat(web): notification query hooks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Live wiring hook (WS → toasts + cache)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/useNotificationsLive.ts`
|
||||
- Test: `web/src/hooks/useNotificationsLive.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Mock `useEventChannel` (from `@/components/realtimeEventsContext`) to capture handlers, mock `sonner`'s toast, wrap in a QueryClientProvider. Follow the mocking style of `web/src/components/RealtimeEventsProvider.test.tsx` (vi.mock + vi.hoisted).
|
||||
|
||||
```typescript
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { capturedHandlers, toastMock } = vi.hoisted(() => ({
|
||||
capturedHandlers: {} as { onEvent?: (m: unknown) => void; onSnapshot?: (m: unknown) => void },
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/realtimeEventsContext", () => ({
|
||||
useEventChannel: (_channel: string, handlers: typeof capturedHandlers) => {
|
||||
capturedHandlers.onEvent = handlers?.onEvent;
|
||||
capturedHandlers.onSnapshot = handlers?.onSnapshot;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: toastMock }));
|
||||
|
||||
const profileState = vi.hoisted(() => ({ profile: { id: "p-1", is_child: false } }));
|
||||
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => profileState }));
|
||||
|
||||
import { useNotificationsLive } from "./useNotificationsLive";
|
||||
import { notificationKeys } from "@/hooks/queries/keys";
|
||||
|
||||
function setup() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(notificationKeys.unreadCount(), { count: 1 });
|
||||
renderHook(() => useNotificationsLive(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
function frame(data: object) {
|
||||
return { type: "event", channel: "notifications", event: "notification.created", data };
|
||||
}
|
||||
|
||||
describe("useNotificationsLive", () => {
|
||||
it("toasts and bumps unread count on notification.created", () => {
|
||||
const qc = setup();
|
||||
capturedHandlers.onEvent?.(
|
||||
frame({ id: 5, category: "request", title: "Request approved", body: "Dune", profile_id: "p-1" }),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledWith("Request approved", expect.anything());
|
||||
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 2 });
|
||||
});
|
||||
|
||||
it("ignores frames for another profile", () => {
|
||||
setup();
|
||||
capturedHandlers.onEvent?.(
|
||||
frame({ id: 6, category: "content", title: "New content", body: "", profile_id: "p-other" }),
|
||||
);
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses restricted categories on child profiles", () => {
|
||||
profileState.profile = { id: "p-1", is_child: true };
|
||||
setup();
|
||||
capturedHandlers.onEvent?.(
|
||||
frame({ id: 7, category: "request", title: "Request approved", body: "", profile_id: undefined }),
|
||||
);
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
profileState.profile = { id: "p-1", is_child: false };
|
||||
});
|
||||
|
||||
it("seeds unread count from snapshot", () => {
|
||||
const qc = setup();
|
||||
capturedHandlers.onSnapshot?.({ type: "snapshot", channel: "notifications", data: { unread_count: 9 } });
|
||||
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 9 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(The test file needs `.tsx` extension if JSX is used in the wrapper — name it `useNotificationsLive.test.tsx`.)
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: FAIL — module `./useNotificationsLive` not found.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```typescript
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { AppNotification } from "@/api/types";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import { notificationKeys } from "@/hooks/queries/keys";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
const CHILD_HIDDEN_CATEGORIES = new Set(["request", "system", "admin"]);
|
||||
|
||||
/**
|
||||
* Subscribes to the notifications WS channel: fires a toast for live
|
||||
* notifications addressed to the active profile, keeps the unread-count
|
||||
* cache current, and seeds it from the subscribe snapshot.
|
||||
* Mount exactly once inside the authenticated layout.
|
||||
*/
|
||||
export function useNotificationsLive() {
|
||||
const queryClient = useQueryClient();
|
||||
const { profile } = useAuth();
|
||||
const profileId = profile?.id;
|
||||
const isChild = profile?.is_child ?? false;
|
||||
|
||||
const onEvent = useCallback(
|
||||
(message: unknown) => {
|
||||
const frame = message as { event?: string; data?: AppNotification };
|
||||
if (frame.event !== "notification.created" || !frame.data) return;
|
||||
const n = frame.data;
|
||||
if (n.profile_id && n.profile_id !== profileId) return;
|
||||
if (isChild && CHILD_HIDDEN_CATEGORIES.has(n.category)) return;
|
||||
|
||||
queryClient.setQueryData<{ count: number }>(notificationKeys.unreadCount(), (prev) => ({
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
}));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [...notificationKeys.all, "list"],
|
||||
refetchType: "none",
|
||||
});
|
||||
toast(n.title, { description: n.body });
|
||||
},
|
||||
[queryClient, profileId, isChild],
|
||||
);
|
||||
|
||||
const onSnapshot = useCallback(
|
||||
(message: unknown) => {
|
||||
const frame = message as { data?: { unread_count?: number } };
|
||||
if (typeof frame.data?.unread_count !== "number") return;
|
||||
queryClient.setQueryData(notificationKeys.unreadCount(), { count: frame.data.unread_count });
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
useEventChannel("notifications", { onEvent, onSnapshot });
|
||||
}
|
||||
```
|
||||
|
||||
NOTE on handler identity: `useEventChannel`'s effect re-subscribes when the handlers object changes — pass a stable object. Check how existing consumers handle this (grep `useEventChannel(` usages); if they memoize the handlers object, do the same: `useMemo(() => ({ onEvent, onSnapshot }), [onEvent, onSnapshot])`.
|
||||
|
||||
The toast call shape (`toast(title, { description })`) must match the test's `expect.anything()` second arg. If `useAuth` returns a different profile field shape, adapt (read `useAuth.tsx`).
|
||||
|
||||
- [ ] **Step 4: Run tests until green**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/hooks/useNotificationsLive.* && git commit -m "feat(web): live notifications wiring (toasts, unread cache)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: NotificationBell component + Layout mount
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/components/NotificationBell.tsx`
|
||||
- Test: `web/src/components/NotificationBell.test.tsx`
|
||||
- Modify: `web/src/components/Layout.tsx` (mobile header actions ~lines 151-168; also mount `useNotificationsLive()` once in the Layout component body)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```typescript
|
||||
// Mocks: @/hooks/queries/notifications (useUnreadCount → 3, useNotificationsList → two items,
|
||||
// useMarkRead → mutate spy), react-router (MemoryRouter wrapper).
|
||||
// Asserts: badge renders "3"; opening the popover (click bell button, role="button" name /notifications/i)
|
||||
// shows both item titles; "Mark all read" calls markRead with {all:true};
|
||||
// item with link renders an anchor/Link to that link.
|
||||
```
|
||||
|
||||
Write it concretely following an existing component test's structure (see `web/src/components/AppSidebar.test.tsx` for provider wrapping conventions). Badge cap: 4+ digits render "99+" when count > 99 — include one assertion with count 120.
|
||||
|
||||
- [ ] **Step 2: Implement NotificationBell**
|
||||
|
||||
```tsx
|
||||
import { Bell } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
useMarkRead,
|
||||
useNotificationsList,
|
||||
useUnreadCount,
|
||||
} from "@/hooks/queries/notifications";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { data: count = 0 } = useUnreadCount();
|
||||
const list = useNotificationsList({});
|
||||
const markRead = useMarkRead();
|
||||
const items = (list.data?.pages ?? []).flatMap((p) => p.items ?? []).slice(0, 10);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
{count > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]">
|
||||
{count > 99 ? "99+" : count}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Notifications</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={count === 0}
|
||||
onClick={() => markRead.mutate({ all: true })}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="max-h-96 list-none overflow-y-auto">
|
||||
{items.length === 0 && (
|
||||
<li className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
You're all caught up
|
||||
</li>
|
||||
)}
|
||||
{items.map((n) => (
|
||||
<li key={n.id} className={n.read_at ? "opacity-60" : ""}>
|
||||
<Link to={n.link ?? "/notifications"} className="hover:bg-accent block px-3 py-2">
|
||||
<div className="text-sm font-medium">{n.title}</div>
|
||||
{n.body && <div className="text-muted-foreground truncate text-xs">{n.body}</div>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t px-3 py-2 text-center">
|
||||
<Link to="/notifications" className="text-primary text-sm">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Verify the ui primitives exist with these export names (`badge.tsx`, `button.tsx`, `popover.tsx` under `components/ui/`); adjust imports/props to the actual variants. Match `react-router` import name used elsewhere (the repo uses `react-router` v7 — check whether components import `Link` from `react-router` or `react-router-dom`).
|
||||
|
||||
Dropdown-open toast suppression (spec requirement): export a tiny module-level flag from NotificationBell —
|
||||
|
||||
```tsx
|
||||
let bellOpenFlag = false;
|
||||
export function isNotificationDropdownOpen() {
|
||||
return bellOpenFlag;
|
||||
}
|
||||
// in the component: <Popover onOpenChange={(open) => { bellOpenFlag = open; }}>
|
||||
```
|
||||
|
||||
and in `useNotificationsLive`'s onEvent, before toasting: `if (isNotificationDropdownOpen()) return;` (cache updates still run — only the toast is suppressed; the open dropdown shows the new item via the list invalidation). Add one live-hook test: set the flag via a popover-open simulation or export a test-only setter, assert no toast while open.
|
||||
|
||||
- [ ] **Step 3: Mount in Layout**
|
||||
|
||||
In `Layout.tsx`: call `useNotificationsLive()` at the top of the component (single mount point for the live hook), and render `<NotificationBell />` in the mobile header's right-side action cluster (next to the search icon button).
|
||||
|
||||
- [ ] **Step 4: Tests green, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/components/NotificationBell.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add src/components/NotificationBell.* src/components/Layout.tsx src/hooks/
|
||||
git commit -m "feat(web): notification bell with live badge in mobile header"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Sidebar nav item with badge (desktop)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/AppSidebar.tsx` (nav list — insert after the History item, ~line 645)
|
||||
- Test: extend `web/src/components/AppSidebar.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add the nav item**
|
||||
|
||||
Follow the exact ViewTransitionLink pattern of the History item (active indicator span, icon sizing, `SidebarLabel`):
|
||||
|
||||
```tsx
|
||||
<li>
|
||||
<ViewTransitionLink
|
||||
to="/notifications"
|
||||
onClick={onNavigate}
|
||||
className={navLinkClass("/notifications")}
|
||||
aria-current={isActive("/notifications") ? "page" : undefined}
|
||||
>
|
||||
{isActive("/notifications") && (
|
||||
<span
|
||||
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
|
||||
style={{ background: "var(--primary)" }}
|
||||
/>
|
||||
)}
|
||||
<Bell className="h-[18px] w-[18px] shrink-0" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-primary text-primary-foreground absolute top-1 left-6 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px]">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<SidebarLabel show={showLabels}>Notifications</SidebarLabel>
|
||||
</ViewTransitionLink>
|
||||
</li>
|
||||
```
|
||||
|
||||
`const { data: unreadCount = 0 } = useUnreadCount();` at the top of AppSidebar (import from `@/hooks/queries/notifications`; `Bell` from lucide-react — add to the existing lucide import list at line ~56). Badge positioning: verify visually against the collapsed-sidebar state (`showLabels` false) — the absolute chip must remain visible on the icon in both states; tweak offsets if the icon container differs.
|
||||
|
||||
- [ ] **Step 2: Test**
|
||||
|
||||
Extend AppSidebar.test.tsx: mock `@/hooks/queries/notifications` `useUnreadCount` to return `{ data: 5 }`; assert the rendered sidebar contains a link to `/notifications` showing "5". Follow the file's existing mock arrangement for other hooks.
|
||||
|
||||
- [ ] **Step 3: Run, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/components/AppSidebar.test.tsx 2>&1 | tail -4
|
||||
git add src/components/AppSidebar.* && git commit -m "feat(web): notifications sidebar item with unread badge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Inbox page + route
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/Notifications.tsx`
|
||||
- Test: `web/src/pages/Notifications.test.tsx`
|
||||
- Modify: `web/src/App.tsx` (user routes block ~line 331+)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
Mock the notifications query hooks. Assertions:
|
||||
- renders rows for items across categories with title/body/relative time;
|
||||
- category filter tabs (All, Requests, Content, Announcements, System — admin tab only when `useAuth().user?.role === "admin"`) change the `category` filter passed to `useNotificationsList` (capture via mock);
|
||||
- unread rows visually distinct (e.g. a `data-unread="true"` attribute — assert on it);
|
||||
- per-row dismiss button calls `useDismissNotification().mutate(id)`;
|
||||
- "Load more" appears when `hasNextPage` and calls `fetchNextPage`;
|
||||
- on mount, visible unread ids are marked read: `useMarkRead().mutate({ ids: [...] })` (assert called once with the unread ids from page one).
|
||||
|
||||
- [ ] **Step 2: Implement**
|
||||
|
||||
Page structure (use the repo's page conventions — check an existing simple page like the History/Collections page for header/container classes):
|
||||
|
||||
```tsx
|
||||
const CATEGORY_TABS: { key: string; label: string; adminOnly?: boolean }[] = [
|
||||
{ key: "", label: "All" },
|
||||
{ key: "request", label: "Requests" },
|
||||
{ key: "content", label: "Content" },
|
||||
{ key: "announcement", label: "Announcements" },
|
||||
{ key: "system", label: "System" },
|
||||
{ key: "admin", label: "Admin", adminOnly: true },
|
||||
];
|
||||
```
|
||||
|
||||
- `useState` for active category; `useNotificationsList({ category })` (omit when "");
|
||||
- mark-on-view effect:
|
||||
|
||||
```tsx
|
||||
const firstPage = list.data?.pages?.[0];
|
||||
const markRead = useMarkRead();
|
||||
const markedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (markedRef.current || !firstPage) return;
|
||||
const unreadIds = (firstPage.items ?? []).filter((n) => !n.read_at).map((n) => n.id);
|
||||
if (unreadIds.length > 0) markRead.mutate({ ids: unreadIds });
|
||||
markedRef.current = true;
|
||||
}, [firstPage, markRead]);
|
||||
```
|
||||
|
||||
- rows: title, body, `created_at` rendered with the repo's existing relative-time helper (grep `formatDistance\|relativeTime\|timeAgo` in web/src/lib — use what exists; if nothing, use `Intl.RelativeTimeFormat` inline helper in the page);
|
||||
- dismiss button (X icon) per row; "Mark all read" header button; "Load more" via `fetchNextPage` when `hasNextPage`;
|
||||
- empty state per tab.
|
||||
|
||||
- [ ] **Step 3: Route**
|
||||
|
||||
In `App.tsx`, register alongside the other authenticated user pages (match the guard used by e.g. the favorites/history page — likely inside the layout route with profile required):
|
||||
|
||||
```tsx
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
```
|
||||
|
||||
(Lazy-load if neighboring pages are lazy — match the file's import pattern.)
|
||||
|
||||
- [ ] **Step 4: Tests, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/pages/Notifications.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add src/pages/Notifications.* src/App.tsx && git commit -m "feat(web): notifications inbox page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Settings preferences section
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/settings/NotificationsSettings.tsx`
|
||||
- Modify: `web/src/pages/SettingsLayout.tsx` (NAV_SECTIONS), `web/src/App.tsx` (settings child route)
|
||||
|
||||
- [ ] **Step 1: Implement the settings page**
|
||||
|
||||
Template: `web/src/pages/settings/PlaybackSettings.tsx` (SettingsGroup/SettingRow/Switch composition). Content:
|
||||
|
||||
```tsx
|
||||
const PREF_LABELS: Record<string, { label: string; description: string; adminOnly?: boolean }> = {
|
||||
request: { label: "Requests", description: "Updates when your requests are approved, declined, or ready" },
|
||||
content: { label: "New content", description: "New arrivals matching your watchlist, favorites, and shows in progress" },
|
||||
system: { label: "Account & security", description: "Password changes and account notices" },
|
||||
admin: { label: "Admin alerts", description: "Failed jobs and scans (admins only)", adminOnly: true },
|
||||
content_digest: { label: "Daily digest", description: "One daily summary of everything added to your libraries" },
|
||||
};
|
||||
```
|
||||
|
||||
- `useNotificationPreferences()` for current values; render a `Switch` per category in PREF_LABELS order, skipping `adminOnly` entries when `useAuth().user?.role !== "admin"`;
|
||||
- on toggle: `useSetNotificationPreferences().mutate([{ category, enabled }])` (single-category PUT — server upserts per row);
|
||||
- note under the group: "Announcements from your server admin can't be turned off."
|
||||
|
||||
- [ ] **Step 2: Register in SettingsLayout NAV_SECTIONS**
|
||||
|
||||
Add a "Notifications" section (icon `Bell`) pointing at path `notifications`, following the structure at SettingsLayout.tsx lines 39-100. Register the child route in App.tsx where other settings pages are routed (find `PlaybackSettings` route registration and mirror it).
|
||||
|
||||
- [ ] **Step 3: Test**
|
||||
|
||||
Extend `web/src/pages/SettingsLayout.test.tsx` if it asserts nav items (check); add `NotificationsSettings` rendering test only if sibling settings pages have tests (check `ls web/src/pages/settings/*.test.tsx`) — match local convention; if none exist, rely on the layout test + typecheck (note this in the commit message).
|
||||
|
||||
- [ ] **Step 4: Typecheck, test, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/SettingsLayout.test.tsx 2>&1 | tail -4
|
||||
git add src/pages/settings/NotificationsSettings.tsx src/pages/SettingsLayout.tsx src/App.tsx
|
||||
git commit -m "feat(web): notification preference toggles in settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Admin announcements page
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/AdminAnnouncements.tsx`
|
||||
- Modify: `web/src/components/AdminSidebar.tsx` (nav items ~lines 81-134), `web/src/App.tsx` (admin routes ~lines 363-398)
|
||||
|
||||
- [ ] **Step 1: Implement the page**
|
||||
|
||||
Template: `web/src/pages/AdminUsers.tsx` for the table + dialog patterns (read it first; reuse its Table/Dialog/Button composition). Features:
|
||||
|
||||
- Table of announcements: title, audience summary ("Everyone" / "N users" / "Libraries: a, b"), created date, expires date, delete button with confirm dialog ("Deleting dismisses unread copies from user inboxes.").
|
||||
- "New announcement" button → Dialog with: title (Input, required), body (Textarea), audience radio group (Everyone / Specific users / Libraries) — for "Specific users" a multi-select of users (check how AdminUsers fetches the user list — reuse that hook; if a multi-select primitive is missing, comma-separated ids input is NOT acceptable — use checkboxes in a scrollable list), for Libraries the same against the libraries list hook (grep `useLibraries` in hooks/queries) — optional `expires_at` datetime-local input.
|
||||
- Submit via `useCreateAnnouncement()`; disable submit until title non-empty and a valid audience selection exists.
|
||||
|
||||
- [ ] **Step 2: Nav + route**
|
||||
|
||||
AdminSidebar: add item (icon `Megaphone` from lucide-react) labeled "Announcements" → `/admin/announcements`, following the existing items' structure. App.tsx admin routes: `<Route path="announcements" element={<AdminAnnouncements />} />`.
|
||||
|
||||
- [ ] **Step 3: Test**
|
||||
|
||||
`web/src/pages/AdminAnnouncements.test.tsx` — mock the three admin hooks; assert: table renders rows incl. audience summaries; create dialog validates (submit disabled with empty title); submitting with Everyone calls `useCreateAnnouncement().mutate` with `{audience: {all: true}, ...}`; delete confirm calls `useDeleteAnnouncement().mutate(id)`.
|
||||
|
||||
- [ ] **Step 4: Tests, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/pages/AdminAnnouncements.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add src/pages/AdminAnnouncements.* src/components/AdminSidebar.tsx src/App.tsx
|
||||
git commit -m "feat(web): admin announcements management page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Full verification
|
||||
|
||||
- [ ] **Step 1: Web checks**
|
||||
|
||||
```bash
|
||||
cd web && pnpm run lint && pnpm run format:check && pnpm exec vitest run 2>&1 | tail -6 && pnpm run build 2>&1 | tail -4
|
||||
```
|
||||
|
||||
All green; build also refreshes `web/dist` so the Go embed keeps working.
|
||||
|
||||
- [ ] **Step 2: Backend still green**
|
||||
|
||||
```bash
|
||||
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ 2>&1 | tail -2
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
- [ ] **Step 3: E2E smoke against the dev stack**
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres redis
|
||||
GOWORK=off go build -o /tmp/silo-web-e2e ./cmd/silo && /tmp/silo-web-e2e &
|
||||
```
|
||||
|
||||
In a browser (or report for manual check): log in, confirm the sidebar Notifications item + mobile bell render; create an announcement in /admin/announcements; verify toast fires live (WS) and badge increments without reload; open inbox, rows mark read, badge clears; toggle a preference off and verify it persists after reload.
|
||||
|
||||
- [ ] **Step 4: Commit anything outstanding; report**
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- Desktop bell+dropdown from the spec adapted to a sidebar nav item + badge (no desktop top bar exists in this app); the dropdown ships on the mobile header bell. Spec intent (always-visible unread indicator + 1-click access) preserved.
|
||||
- Toast child/profile filtering is client-side by necessity (WS frames are user-scoped; see spec Future work on claims enrichment) — `useNotificationsLive` suppresses other-profile and child-restricted frames.
|
||||
- `content_digest` rows arrive as category `content` (server stores digest as content/content.digest) — the inbox Content tab includes them; the preferences page exposes the separate `content_digest` toggle. No UI filters by `content_digest` category (final server review noted this trap).
|
||||
- Mark-on-view marks only page one's unread ids once per mount — deliberate; deeper pages mark via "Mark all read".
|
||||
- Several steps include verify-greps (ui primitive names, Link import source, relative-time helper, settings test conventions, multi-select availability) — implementers must align with what exists rather than invent.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,838 +0,0 @@
|
||||
# Core Push — Web Client & Admin Config — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make push operable end-to-end from the browser: a Web Push client (service worker, subscribe, receive, per-device control, device list) plus an admin provider-config page (APNs/FCM/WebPush credentials, VAPID keygen, readiness), on top of the frozen push API.
|
||||
|
||||
**Architecture:** Five web units + two small Go endpoints. A static service worker receives pushes; a `usePushDevice` hook owns the browser subscription lifecycle; the existing notifications-settings page gains a push control + device list; a new admin page reuses the existing encrypted-settings write path for credentials. Two net-new server endpoints: VAPID keygen and a self-targeted test push.
|
||||
|
||||
**Tech Stack:** React 19, TanStack Query, sonner, the existing api client (auto device/profile headers), browser Push API / Service Worker / Notifications API, Go (chi), webpush-go (already a dep). Vitest + RTL.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-10-core-push-web-client-design.md`. The push registration API + delivery worker are frozen (Phase-3-server).
|
||||
|
||||
Commands assume the repository root is the cwd; web commands run in `web/` with `pnpm`. Go: `export GOWORK=off`.
|
||||
|
||||
## Frozen API the client calls
|
||||
|
||||
```
|
||||
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
|
||||
PUT /api/v1/notifications/push/device {transport, token} → 204 (needs X-Profile-Id; api client sends it)
|
||||
DELETE /api/v1/notifications/push/device → 204
|
||||
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,transport,push_enabled,registered_at}]}
|
||||
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
|
||||
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
|
||||
PUT /api/v1/admin/settings/{key} {value} → writes an encrypted push.* key (existing)
|
||||
GET /api/v1/admin/settings/sensitive-status → {configured:[keys]} (existing)
|
||||
```
|
||||
|
||||
Net-new this plan: `POST /api/v1/admin/push/generate-vapid-keys` and `POST /api/v1/admin/push/test`.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `internal/api/handlers/push.go` | modify | add `HandleGenerateVAPIDKeys`, `HandleSendTestPush`; add a `notifier` dep |
|
||||
| `internal/api/router.go` | modify | register the two admin routes |
|
||||
| `cmd/silo/main.go` | modify | pass the notifications service into the push handler |
|
||||
| `web/public/sw.js` | create | service worker: push, notificationclick, pushsubscriptionchange |
|
||||
| `web/src/lib/pushSw.ts` | create | pure SW logic (notification options, click URL) — tested; mirrored in sw.js |
|
||||
| `web/src/lib/push.ts` | create | `urlBase64ToUint8Array`, VAPID-key cache helpers |
|
||||
| `web/src/main.tsx` | modify | register `/sw.js` when supported |
|
||||
| `web/src/api/types.ts` | modify | `PushDeviceInfo`, `VapidKeyPair` |
|
||||
| `web/src/hooks/queries/push.ts` | create | query/mutation hooks (webpush-key, devices, toggle, register/revoke, admin keygen/test) |
|
||||
| `web/src/hooks/usePushDevice.ts` | create | browser subscription lifecycle state machine |
|
||||
| `web/src/hooks/usePushDevice.test.tsx` | create | hook tests |
|
||||
| `web/src/pages/settings/NotificationsSettings.tsx` | modify | push control + device list |
|
||||
| `web/src/pages/admin-settings/PushSettings.tsx` | create | three credential cards + keygen + test |
|
||||
| `web/src/pages/admin-settings/AdminSettingsLayout.tsx` | modify | nav entry |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Server endpoints — VAPID keygen + test push
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/push.go`, `internal/api/router.go`, `cmd/silo/main.go`
|
||||
- Test: `internal/api/handlers/push_test.go`
|
||||
|
||||
- [ ] **Step 1: Add a notifier dep + the two handlers**
|
||||
|
||||
In `push.go`, add a narrow interface and a field on `PushHandler`:
|
||||
|
||||
```go
|
||||
// pushNotifier creates a notification for the calling user (test push).
|
||||
type pushNotifier interface {
|
||||
CreateSystem(ctx context.Context, userID int, typ, title, body string)
|
||||
}
|
||||
```
|
||||
|
||||
Add `notifier pushNotifier` to `PushHandler` and a parameter to `NewPushHandler` (nil-tolerant — test-push 503s if absent). `*notifications.Service` satisfies `pushNotifier` (it has `CreateSystem(ctx, userID int, typ, title, body string)` from Phase-1).
|
||||
|
||||
Handlers:
|
||||
|
||||
```go
|
||||
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
|
||||
priv, pub, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
|
||||
}
|
||||
|
||||
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
|
||||
if h.notifier == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
|
||||
return
|
||||
}
|
||||
h.notifier.CreateSystem(r.Context(), userID,
|
||||
"system.test_push", "Test push",
|
||||
"If you can see this on a device, push delivery is working.")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
```
|
||||
|
||||
Import `webpush "github.com/SherClockHolmes/webpush-go"`. **Verify the `GenerateVAPIDKeys()` return order** against the installed version — webpush-go returns `(privateKey, publicKey string, err error)`. Confirm by reading the vendored signature or `go doc`; if it's `(public, private, err)` swap accordingly. Get this right — swapping the keys silently breaks push.
|
||||
|
||||
- [ ] **Step 2: Routes**
|
||||
|
||||
In `router.go`, in the admin push block (next to `r.Get("/push/status", ...)`, ~line 2295, behind `deps.PushStore != nil`):
|
||||
|
||||
```go
|
||||
r.Post("/push/generate-vapid-keys", pushHandler.HandleGenerateVAPIDKeys)
|
||||
r.Post("/push/test", pushHandler.HandleSendTestPush)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire the notifier**
|
||||
|
||||
Update every `NewPushHandler(...)` call site (find with grep) to pass the notifications service. In `cmd/silo/main.go` and/or `router.go` where the push handler is constructed, add `deps.NotificationsService` (or the in-scope `notificationsSvc`) as the third arg. Build will tell you the sites.
|
||||
|
||||
- [ ] **Step 4: Tests**
|
||||
|
||||
Add to `push_test.go` (reuse the package's fakes / chi-request helper from Task-13 push tests):
|
||||
|
||||
```go
|
||||
func TestPushHandleGenerateVAPIDKeys_ReturnsPair(t *testing.T) {
|
||||
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleGenerateVAPIDKeys(rr, httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rr.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &body)
|
||||
if body["vapid_public"] == "" || body["vapid_private"] == "" || body["vapid_public"] == body["vapid_private"] {
|
||||
t.Fatalf("bad key pair: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeNotifier struct{ called bool; userID int; typ string }
|
||||
|
||||
func (f *fakeNotifier) CreateSystem(_ context.Context, userID int, typ, _, _ string) {
|
||||
f.called = true
|
||||
f.userID = userID
|
||||
f.typ = typ
|
||||
}
|
||||
|
||||
func TestPushHandleSendTestPush_CreatesForCaller(t *testing.T) {
|
||||
n := &fakeNotifier{}
|
||||
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, n)
|
||||
r := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
r = r.WithContext(apimw.SetUserID(r.Context(), 7)) // use the package's claims-injection helper if SetUserID differs
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleSendTestPush(rr, r)
|
||||
if rr.Code != http.StatusAccepted || !n.called || n.userID != 7 || n.typ != "system.test_push" {
|
||||
t.Fatalf("unexpected: code=%d notifier=%+v", rr.Code, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleSendTestPush_NilNotifier503(t *testing.T) {
|
||||
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleSendTestPush(rr, httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d", rr.Code)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Check how the existing push tests inject the user id into context (Task-13 used a claims/`apimw` helper — mirror it; `apimw.SetUserID` may not exist, use whatever the package's tests already use to set `GetUserID`).
|
||||
|
||||
- [ ] **Step 5: Build, test, commit**
|
||||
|
||||
```bash
|
||||
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush -v 2>&1 | tail -8 && GOWORK=off go vet ./internal/api/handlers/
|
||||
git add internal/api/handlers/push.go internal/api/handlers/push_test.go internal/api/router.go cmd/silo/main.go
|
||||
git commit -m "feat(push): admin VAPID keygen and self-test endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Service worker + registration + helpers
|
||||
|
||||
**Files:**
|
||||
- Create: `web/public/sw.js`, `web/src/lib/pushSw.ts`, `web/src/lib/push.ts`
|
||||
- Test: `web/src/lib/pushSw.test.ts`, `web/src/lib/push.test.ts`
|
||||
- Modify: `web/src/main.tsx`
|
||||
|
||||
- [ ] **Step 1: Pure SW logic (tested) — `web/src/lib/pushSw.ts`**
|
||||
|
||||
```ts
|
||||
export interface PushPayload {
|
||||
id?: number;
|
||||
title?: string;
|
||||
body?: string;
|
||||
link?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface SwNotification {
|
||||
title: string;
|
||||
options: { body: string; data: { link: string }; tag: string };
|
||||
}
|
||||
|
||||
/** Maps a raw push payload to showNotification args; tolerant of partial/missing fields. */
|
||||
export function buildNotification(payload: PushPayload | null): SwNotification {
|
||||
const p = payload ?? {};
|
||||
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
|
||||
const tag = p.id != null ? `n${p.id}` : "n";
|
||||
return {
|
||||
title: p.title && p.title.length > 0 ? p.title : "Silo",
|
||||
options: { body: p.body ?? "", data: { link }, tag },
|
||||
};
|
||||
}
|
||||
|
||||
/** The URL a notification click should open/focus. */
|
||||
export function clickUrl(data: unknown): string {
|
||||
const d = (data ?? {}) as { link?: string };
|
||||
return d.link && d.link.length > 0 ? d.link : "/notifications";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test it — `web/src/lib/pushSw.test.ts`**
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildNotification, clickUrl } from "./pushSw";
|
||||
|
||||
describe("buildNotification", () => {
|
||||
it("maps a full payload", () => {
|
||||
const n = buildNotification({ id: 5, title: "Approved", body: "Dune", link: "/requests" });
|
||||
expect(n.title).toBe("Approved");
|
||||
expect(n.options).toEqual({ body: "Dune", data: { link: "/requests" }, tag: "n5" });
|
||||
});
|
||||
it("falls back on missing fields", () => {
|
||||
const n = buildNotification(null);
|
||||
expect(n.title).toBe("Silo");
|
||||
expect(n.options.body).toBe("");
|
||||
expect(n.options.data.link).toBe("/notifications");
|
||||
expect(n.options.tag).toBe("n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clickUrl", () => {
|
||||
it("uses link when present", () => expect(clickUrl({ link: "/items/9" })).toBe("/items/9"));
|
||||
it("falls back to inbox", () => expect(clickUrl({})).toBe("/notifications"));
|
||||
});
|
||||
```
|
||||
|
||||
Run: `pnpm exec vitest run src/lib/pushSw.test.ts` → green.
|
||||
|
||||
- [ ] **Step 3: The static service worker — `web/public/sw.js`**
|
||||
|
||||
Self-contained (served statically, not bundled). Mirror the pure logic exactly (the canonical, tested source is `pushSw.ts`):
|
||||
|
||||
```js
|
||||
// web/public/sw.js — static service worker for Web Push.
|
||||
// NOTE: the buildNotification/clickUrl logic mirrors src/lib/pushSw.ts (the
|
||||
// tested source of truth). Keep them in sync.
|
||||
|
||||
function buildNotification(payload) {
|
||||
const p = payload || {};
|
||||
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
|
||||
const tag = p.id != null ? "n" + p.id : "n";
|
||||
return {
|
||||
title: p.title && p.title.length > 0 ? p.title : "Silo",
|
||||
options: { body: p.body || "", data: { link: link }, tag: tag },
|
||||
};
|
||||
}
|
||||
function clickUrl(data) {
|
||||
const d = data || {};
|
||||
return d.link && d.link.length > 0 ? d.link : "/notifications";
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = event.data ? event.data.json() : null;
|
||||
} catch (e) {
|
||||
payload = null;
|
||||
}
|
||||
const n = buildNotification(payload);
|
||||
event.waitUntil(self.registration.showNotification(n.title, n.options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = clickUrl(event.notification.data);
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((wins) => {
|
||||
for (const c of wins) {
|
||||
if ("focus" in c) {
|
||||
c.postMessage({ type: "notification-click", link: url });
|
||||
return c.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("pushsubscriptionchange", (event) => {
|
||||
// Best-effort re-subscribe using the cached VAPID key; the app re-registers
|
||||
// the new subscription on next load if this fails.
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cache = await caches.open("silo-push");
|
||||
const res = await cache.match("vapid-public-key");
|
||||
if (!res) return;
|
||||
const vapid = await res.text();
|
||||
const sub = await self.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: vapid,
|
||||
});
|
||||
await fetch("/api/v1/notifications/push/device", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
|
||||
});
|
||||
} catch (e) {
|
||||
// swallow; app re-registers on next load
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
Note: the `pushsubscriptionchange` re-register relies on cookie auth (`credentials: include`) and the device/profile headers it can't set from the SW — it is genuinely best-effort and the app heals it on next load (documented in the spec). `applicationServerKey` accepts a base64url string in modern browsers; if the target browsers need a Uint8Array here, the app-side path (Task 4) converts it — the SW path is the fallback and may no-op on stricter browsers, which is acceptable.
|
||||
|
||||
- [ ] **Step 4: App-side helpers — `web/src/lib/push.ts`**
|
||||
|
||||
```ts
|
||||
/** Convert a base64url VAPID public key to the Uint8Array applicationServerKey wants. */
|
||||
export function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(b64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cache the raw VAPID public key so the SW can re-subscribe without the app. */
|
||||
export async function cacheVapidKey(base64: string): Promise<void> {
|
||||
if (!("caches" in self)) return;
|
||||
const cache = await caches.open("silo-push");
|
||||
await cache.put("vapid-public-key", new Response(base64));
|
||||
}
|
||||
|
||||
export function pushSupported(): boolean {
|
||||
return (
|
||||
typeof navigator !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
typeof window !== "undefined" &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Test the converter — `web/src/lib/push.test.ts`**
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { urlBase64ToUint8Array } from "./push";
|
||||
|
||||
describe("urlBase64ToUint8Array", () => {
|
||||
it("decodes a url-safe base64 key to bytes", () => {
|
||||
// "AQID" → [1,2,3]
|
||||
expect(Array.from(urlBase64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
it("handles url-safe chars and missing padding", () => {
|
||||
const out = urlBase64ToUint8Array("a-_9"); // '-'→'+', '_'→'/'
|
||||
expect(out.length).toBe(3);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Register the SW — `web/src/main.tsx`**
|
||||
|
||||
After `createRoot(...).render(...)`, add:
|
||||
|
||||
```tsx
|
||||
if ("serviceWorker" in navigator && "PushManager" in window) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
// registration failure is non-fatal; push just won't be available
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run, build, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/lib/pushSw.test.ts src/lib/push.test.ts 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add web/public/sw.js web/src/lib/pushSw.ts web/src/lib/pushSw.test.ts web/src/lib/push.ts web/src/lib/push.test.ts web/src/main.tsx
|
||||
git commit -m "feat(web): web push service worker, registration, and helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Push query hooks + types
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/api/types.ts`
|
||||
- Create: `web/src/hooks/queries/push.ts`
|
||||
- Modify: `web/src/hooks/queries/keys.ts` (add `pushKeys`)
|
||||
|
||||
- [ ] **Step 1: Types** — append to `types.ts`:
|
||||
|
||||
```ts
|
||||
export interface PushDeviceInfo {
|
||||
device_id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
transport: string;
|
||||
push_enabled: boolean;
|
||||
registered_at?: string;
|
||||
}
|
||||
|
||||
export interface VapidKeyPair {
|
||||
vapid_public: string;
|
||||
vapid_private: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Keys** — in `keys.ts`:
|
||||
|
||||
```ts
|
||||
export const pushKeys = {
|
||||
all: ["push"] as const,
|
||||
webpushKey: () => [...pushKeys.all, "webpush-key"] as const,
|
||||
devices: () => [...pushKeys.all, "devices"] as const,
|
||||
status: () => [...pushKeys.all, "status"] as const,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Hooks — `web/src/hooks/queries/push.ts`**
|
||||
|
||||
Mirror `admin/users.ts` conventions (api import, toast, invalidation).
|
||||
|
||||
```ts
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import type { PushDeviceInfo, VapidKeyPair } from "@/api/types";
|
||||
import { pushKeys } from "@/hooks/queries/keys";
|
||||
|
||||
export function useWebPushPublicKey() {
|
||||
return useQuery({
|
||||
queryKey: pushKeys.webpushKey(),
|
||||
queryFn: () => api<{ vapid_public_key: string }>("/notifications/push/webpush-key"),
|
||||
select: (d) => d.vapid_public_key,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePushDevices() {
|
||||
return useQuery({
|
||||
queryKey: pushKeys.devices(),
|
||||
queryFn: () => api<{ devices: PushDeviceInfo[] | null }>("/notifications/push/devices"),
|
||||
select: (d) => d.devices ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useTogglePushDevice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ deviceId, enabled }: { deviceId: string; enabled: boolean }) =>
|
||||
api(`/notifications/push/devices/${deviceId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: pushKeys.devices() }),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to update device"),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePushStatus() {
|
||||
return useQuery({
|
||||
queryKey: pushKeys.status(),
|
||||
queryFn: () => api<{ apns: boolean; fcm: boolean; webpush: boolean }>("/admin/push/status"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateVapidKeys() {
|
||||
return useMutation({
|
||||
mutationFn: () => api<VapidKeyPair>("/admin/push/generate-vapid-keys", { method: "POST" }),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to generate keys"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendTestPush() {
|
||||
return useMutation({
|
||||
mutationFn: () => api("/admin/push/test", { method: "POST" }),
|
||||
onSuccess: () => toast.success("Test push queued — watch for a banner"),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to send test"),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
(Register/revoke device calls live inside `usePushDevice` (Task 4) since they're tied to the subscription, not standalone query hooks.)
|
||||
|
||||
- [ ] **Step 4: Typecheck + commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add web/src/api/types.ts web/src/hooks/queries/keys.ts web/src/hooks/queries/push.ts
|
||||
git commit -m "feat(web): push query hooks and types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `usePushDevice` lifecycle hook
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/usePushDevice.ts`, `web/src/hooks/usePushDevice.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
Mock `navigator.serviceWorker`, `window.PushManager`, `Notification`, the api client, and the push helpers. Assert the status machine and the enable/disable flows.
|
||||
|
||||
```tsx
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { apiMock, cacheVapidMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
cacheVapidMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/client", () => ({ api: apiMock }));
|
||||
vi.mock("@/lib/push", async (orig) => ({
|
||||
...(await orig<typeof import("@/lib/push")>()),
|
||||
cacheVapidKey: cacheVapidMock,
|
||||
}));
|
||||
|
||||
import { usePushDevice } from "./usePushDevice";
|
||||
|
||||
function wrap() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
let subscribeMock: ReturnType<typeof vi.fn>;
|
||||
let getSubMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
cacheVapidMock.mockReset();
|
||||
subscribeMock = vi.fn(async () => ({ toJSON: () => ({ endpoint: "https://x", keys: {} }) }));
|
||||
getSubMock = vi.fn(async () => null);
|
||||
(globalThis as any).PushManager = function () {};
|
||||
(navigator as any).serviceWorker = {
|
||||
register: vi.fn(),
|
||||
ready: Promise.resolve({ pushManager: { subscribe: subscribeMock, getSubscription: getSubMock } }),
|
||||
};
|
||||
(globalThis as any).Notification = { permission: "default", requestPermission: vi.fn(async () => "granted") };
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("usePushDevice", () => {
|
||||
it("reports unsupported when PushManager missing", () => {
|
||||
delete (globalThis as any).PushManager;
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
expect(result.current.status).toBe("unsupported");
|
||||
});
|
||||
|
||||
it("reports blocked when permission denied", () => {
|
||||
(globalThis as any).Notification.permission = "denied";
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
expect(result.current.status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("enable(): permission → key → subscribe → PUT device, caches key, status on", async () => {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.includes("webpush-key")) return Promise.resolve({ vapid_public_key: "AQID" });
|
||||
return Promise.resolve(undefined); // PUT device
|
||||
});
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
await act(async () => { await result.current.enable(); });
|
||||
expect(subscribeMock).toHaveBeenCalledOnce();
|
||||
expect(cacheVapidMock).toHaveBeenCalledWith("AQID");
|
||||
const putCall = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device");
|
||||
expect(putCall?.[1]).toMatchObject({ method: "PUT" });
|
||||
expect(JSON.parse(putCall![1].body)).toMatchObject({ transport: "webpush" });
|
||||
await waitFor(() => expect(result.current.status).toBe("on"));
|
||||
});
|
||||
|
||||
it("enable(): permission denied short-circuits, no subscribe", async () => {
|
||||
(globalThis as any).Notification.requestPermission = vi.fn(async () => "denied");
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
await act(async () => { await result.current.enable(); });
|
||||
expect(subscribeMock).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("disable(): unsubscribe + DELETE, status off", async () => {
|
||||
const unsub = vi.fn(async () => true);
|
||||
getSubMock.mockResolvedValue({ unsubscribe: unsub });
|
||||
apiMock.mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
|
||||
await act(async () => { await result.current.disable(); });
|
||||
expect(unsub).toHaveBeenCalled();
|
||||
const del = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device" && c[1]?.method === "DELETE");
|
||||
expect(del).toBeTruthy();
|
||||
await waitFor(() => expect(result.current.status).toBe("off"));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify fail**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement — `web/src/hooks/usePushDevice.ts`**
|
||||
|
||||
```ts
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import { cacheVapidKey, pushSupported, urlBase64ToUint8Array } from "@/lib/push";
|
||||
|
||||
export type PushDeviceStatus = "unsupported" | "blocked" | "off" | "on" | "pending";
|
||||
|
||||
export function usePushDevice() {
|
||||
const [status, setStatus] = useState<PushDeviceStatus>("pending");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!pushSupported()) {
|
||||
setStatus("unsupported");
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === "denied") {
|
||||
setStatus("blocked");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
setStatus(sub ? "on" : "off");
|
||||
} catch {
|
||||
setStatus("off");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const enable = useCallback(async () => {
|
||||
if (!pushSupported()) {
|
||||
setStatus("unsupported");
|
||||
return;
|
||||
}
|
||||
setStatus("pending");
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== "granted") {
|
||||
setStatus(perm === "denied" ? "blocked" : "off");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const { vapid_public_key } = await api<{ vapid_public_key: string }>(
|
||||
"/notifications/push/webpush-key",
|
||||
);
|
||||
if (!vapid_public_key) {
|
||||
setStatus("off");
|
||||
return;
|
||||
}
|
||||
await cacheVapidKey(vapid_public_key);
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapid_public_key),
|
||||
});
|
||||
await api("/notifications/push/device", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
|
||||
});
|
||||
setStatus("on");
|
||||
} catch {
|
||||
setStatus("off");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disable = useCallback(async () => {
|
||||
setStatus("pending");
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) await sub.unsubscribe();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
try {
|
||||
await api("/notifications/push/device", { method: "DELETE" });
|
||||
} catch {
|
||||
/* server may already lack the row */
|
||||
}
|
||||
setStatus("off");
|
||||
}, []);
|
||||
|
||||
return { status, enable, disable, refresh };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run green, typecheck, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6 && pnpm exec tsc -b --noEmit 2>&1 | head -3
|
||||
git add web/src/hooks/usePushDevice.ts web/src/hooks/usePushDevice.test.tsx
|
||||
git commit -m "feat(web): usePushDevice subscription lifecycle hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: NotificationsSettings — push control + device list
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/settings/NotificationsSettings.tsx`
|
||||
- Test: extend the page's test if one exists (check `ls web/src/pages/settings/*.test.tsx`)
|
||||
|
||||
- [ ] **Step 1: Add a push section**
|
||||
|
||||
Read the current `NotificationsSettings.tsx` (it uses `SettingsGroup`/`SettingRow`/`Switch`). Add, below the category-preference group, a new "Push notifications" group:
|
||||
|
||||
- A row driven by `usePushDevice()`:
|
||||
- `status === "unsupported"` → static text "This browser doesn't support push notifications."
|
||||
- `status === "blocked"` → text "Notifications are blocked for this site. Re-enable them in your browser's site settings."
|
||||
- else → a `Switch` checked when `status === "on"`, disabled when `status === "pending"`, `onCheckedChange` calls `enable()` / `disable()`.
|
||||
- Below it, a device list from `usePushDevices()`: one row per device (`name || device_id`, `platform`/`transport`, `registered_at`), each with a `Switch` bound to `useTogglePushDevice().mutate({ deviceId, enabled })` reflecting `push_enabled`. Empty state: "No devices registered for push yet."
|
||||
|
||||
Match the page's existing component composition and classes. Keep `content_digest` and the existing toggles untouched.
|
||||
|
||||
- [ ] **Step 2: Test (if the page is tested)**
|
||||
|
||||
If `NotificationsSettings.test.tsx` exists, mock `usePushDevice` (return `{status:"off", enable, disable}`) and `usePushDevices` (two devices); assert: toggling the device-level switch calls `useTogglePushDevice().mutate` with the device id; the main push switch calls `enable` when off. If no sibling test exists, rely on typecheck + the hook tests and note it in the commit.
|
||||
|
||||
- [ ] **Step 3: Typecheck, test, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/settings 2>&1 | tail -4
|
||||
git add web/src/pages/settings/NotificationsSettings.tsx web/src/pages/settings/*.test.tsx
|
||||
git commit -m "feat(web): push on-this-device control and device list in settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Admin PushSettings page + nav
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/admin-settings/PushSettings.tsx`
|
||||
- Modify: `web/src/pages/admin-settings/AdminSettingsLayout.tsx`
|
||||
- Test: `web/src/pages/admin-settings/PushSettings.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Build the page**
|
||||
|
||||
Template: `IntegrationsSettings.tsx` (credential card = `CredentialStatus` badge + `SettingField` + `useUpdateServerSetting` + `useAdminSensitiveStatus`). Three cards. Header badge per card from `usePushStatus()`.
|
||||
|
||||
- **Web Push card** (leads): fields `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A "Generate keys" button → `useGenerateVapidKeys().mutateAsync()` fills the public + private field state (local `useState`, not auto-saved). Subject field hint: `mailto:you@example.com`. Each field saves via `updateSetting.mutateAsync({ key, value })`. A "Send test push" button → `useSendTestPush().mutate()`.
|
||||
- **APNs card**: `push.apns.p8_key` (textarea via `SettingField` multiline if supported, else a `<textarea>`), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
|
||||
- **FCM card**: `push.fcm.service_account_json` (textarea).
|
||||
|
||||
`configured` per field from `new Set(sensitive?.configured ?? []).has(key)`. Page header + intro like IntegrationsSettings.
|
||||
|
||||
- [ ] **Step 2: Register nav**
|
||||
|
||||
In `AdminSettingsLayout.tsx`, add to the "Connections" group's `items` (import the component + a lucide icon, e.g. `BellRing`):
|
||||
|
||||
```tsx
|
||||
{ id: "push", label: "Push Notifications", icon: BellRing, component: PushSettings },
|
||||
```
|
||||
|
||||
(`id: "push"` is stable URL state.)
|
||||
|
||||
- [ ] **Step 3: Test — `PushSettings.test.tsx`**
|
||||
|
||||
Mock `useUpdateServerSetting` (capture mutate args), `useAdminSensitiveStatus` (some keys configured), `usePushStatus` (webpush true), `useGenerateVapidKeys` (returns a pair), `useSendTestPush`. Assert: three cards render; webpush badge shows ready; saving the VAPID public field calls `updateSetting.mutateAsync({ key: "push.webpush.vapid_public", value })`; "Generate keys" populates the public/private fields; "Send test push" calls the mutation. Follow the admin-settings test conventions (check a sibling `*.test.tsx` in that dir; if none, write a minimal RTL test with the mocks).
|
||||
|
||||
- [ ] **Step 4: Typecheck, test, build, commit**
|
||||
|
||||
```bash
|
||||
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/admin-settings 2>&1 | tail -5
|
||||
git add web/src/pages/admin-settings/PushSettings.tsx web/src/pages/admin-settings/PushSettings.test.tsx web/src/pages/admin-settings/AdminSettingsLayout.tsx
|
||||
git commit -m "feat(web): admin push provider config page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Full verification + manual smoke
|
||||
|
||||
- [ ] **Step 1: Web gates**
|
||||
|
||||
```bash
|
||||
cd web && pnpm run lint 2>&1 | tail -3 && pnpm run format:check 2>&1 | tail -2 && pnpm exec vitest run 2>&1 | tail -5 && pnpm run build 2>&1 | tail -3
|
||||
```
|
||||
|
||||
All green (lint warnings allowed, errors not). Fix any new-code lint/format issues (prettier --write the new files if format:check flags them). `build` also confirms `public/sw.js` is copied to `dist/` (Vite copies `public/` verbatim).
|
||||
|
||||
- [ ] **Step 2: Backend gates**
|
||||
|
||||
```bash
|
||||
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush 2>&1 | tail -3 && make verify-local-paths
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual browser smoke (documented; requires a real browser over HTTPS or localhost)**
|
||||
|
||||
Service workers + Push require a secure context — `localhost` qualifies. Build the server with the web assets and run on a localhost port.
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
```bash
|
||||
cd web && pnpm run build && cd ..
|
||||
GOWORK=off go build -o ./bin/silo-pushweb ./cmd/silo && (PORT=18080 JF_PORT=18096 ./bin/silo-pushweb > ./silo-pushweb.log 2>&1 &)
|
||||
```
|
||||
|
||||
Then in a Chromium browser at `http://localhost:18080`:
|
||||
1. Log in as admin. Go to Admin → Settings → Push Notifications. Click "Generate keys", Save the VAPID public/private + a `mailto:` subject. Confirm the Web Push badge flips to "Ready" (and `GET /admin/push/status` → webpush:true).
|
||||
2. Go to Settings → Notifications. Toggle "Push on this device" on → grant the browser permission prompt. Confirm a row appears in the device list and the toggle stays on.
|
||||
3. Click "Send test push" on the admin page (or create an announcement). Within ~30–45s (grace + worker tick) a desktop notification banner appears. Close the app tab first to prove background delivery.
|
||||
4. Click the banner → the app opens/focuses at the deep link.
|
||||
5. Toggle push off → confirm `DELETE` and the device drops from the list.
|
||||
|
||||
Record the outcome. (If a real browser isn't available in the harness, run steps 1–2 via curl to confirm the API wiring — register a webpush device, confirm `push_deliveries` row + status — and note the visual steps as operator-verified.)
|
||||
|
||||
- [ ] **Step 4: Commit any verification fixes; report.** Work stays on `feat/core-notifications-server` (or a dedicated branch off it).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (resolved during writing)
|
||||
|
||||
- `webpush.GenerateVAPIDKeys()` returns `(privateKey, publicKey, err)` in webpush-go — Task 1 Step 1 flags verifying the order so the keys aren't swapped.
|
||||
- The SW's pure logic is duplicated between `public/sw.js` (untestable static file) and the tested `src/lib/pushSw.ts`; the duplication is small and intentional, with sw.js annotated as mirroring the tested source. The reviewer should diff the two functions for drift.
|
||||
- `usePushDevice` owns register/revoke (tied to the live subscription); standalone query hooks (`push.ts`) cover read/list/toggle/admin. No overlap.
|
||||
- Test push reuses `notifications.Service.CreateSystem` (Phase-1) → a real system notification → exercises the full enqueue→worker→transport pipeline to the caller's own devices. No bespoke push path.
|
||||
- Admin credential writes use the existing `PUT /admin/settings/{key}` (encrypted, redacted) — no new write endpoint; only keygen + test are net-new server surface.
|
||||
- iOS Safari web push (installed-PWA only) is handled by `pushSupported()` returning false in a normal tab → `unsupported` state; documented, not specially coded.
|
||||
- Several steps embed verify-greps (NewPushHandler call sites, GenerateVAPIDKeys order, SettingField multiline support, sibling test conventions, the user-id context injection helper in push_test.go) — align with reality.
|
||||
@@ -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,188 +0,0 @@
|
||||
# Core Notifications System — Design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved (design); implementation plan to follow
|
||||
|
||||
## Summary
|
||||
|
||||
A first-party, whole-system notification layer for Silo: persisted per-user notifications with an inbox, read state, per-category preferences, real-time toasts over the existing event WebSocket, and admin announcements. Ships across the server, web UI, and both mobile apps (in-app only — no APNs/FCM push in this project). External delivery (Discord, email, webhooks) remains the job of the existing `silo.notifications` plugin, which consumes the same events.
|
||||
|
||||
## Goals
|
||||
|
||||
- Persisted per-user notifications: inbox, unread badge, read/dismiss state.
|
||||
- Real-time in-app delivery to connected clients via the existing `events.Hub` / `/api/v1/events/ws` pipeline.
|
||||
- Five categories: `request`, `content`, `announcement`, `system`, `admin` (operational alerts for admin users), plus opt-in `content_digest`.
|
||||
- Per-category user preferences (announcements not mutable).
|
||||
- Admin announcements with audience targeting and expiry.
|
||||
- A documented `notifications.send` event contract so any plugin can create in-app notifications (and, via the existing external plugin's subscription to the same event, external ones).
|
||||
- Core request lifecycle events (`request.*`) — net-new publishing from `internal/requests`, consumed by notifications and available to the requests UI.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Mobile push (APNs/FCM) — future project; nothing here blocks it.
|
||||
- Request-system unification (`request_router.v1`, migrating ebook/audiobook request plugins onto `media_requests`) — separate future project. When it lands, the book plugins' `notifications.send` publishes are replaced by core `request.*` events with no notifications schema/API change.
|
||||
- Absorbing external delivery channels into core.
|
||||
- Fine-grained per-show/per-author subscription rules (watchlist/favorites are the v1 signal).
|
||||
|
||||
## Architecture
|
||||
|
||||
New package `internal/notifications` in silo-server:
|
||||
|
||||
1. **Materializer** — in-process consumer registered on `events.Hub` at startup (same pattern as `internal/plugins/event_dispatcher.go`). Runs events through per-category matchers; writes `notifications` rows for each recipient.
|
||||
2. **Store** — Postgres repository: insert (idempotent), list (cursor), unread count, mark read, dismiss, preferences CRUD.
|
||||
3. **Notifier** — after insert, publishes `notification.created` on a new `ChannelNotifications` event channel, user-scoped like `ChannelUserState`. The WS handler (`internal/api/handlers/events_ws.go`) adds this channel to the per-user subscribe set; frames carry the full notification object. Subscribe-time snapshot carries unread count only.
|
||||
4. **API handlers** — REST endpoints below.
|
||||
5. **Service API** — `notifications.Create(...)` for direct in-process callers (system/security events).
|
||||
|
||||
Creation is DB-first and synchronous: a notification exists when the row commits. WS fan-out is best-effort; clients that miss frames reconcile on next fetch. No delivery queue or retry in core — "delivery" is reading your own table.
|
||||
|
||||
## Data model
|
||||
|
||||
One migration, three tables:
|
||||
|
||||
```sql
|
||||
notifications (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id text NULL REFERENCES user_profiles(id) ON DELETE CASCADE, -- NULL = user-wide
|
||||
category text NOT NULL, -- 'request' | 'content' | 'announcement' | 'system' | 'admin'
|
||||
type text NOT NULL, -- e.g. 'request.approved', 'content.watchlist_episode'
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
link text NULL, -- in-app deep link
|
||||
item_id bigint NULL, -- optional content reference (artwork, dedup)
|
||||
source_event text NULL, -- originating event name (audit)
|
||||
dedup_ref text NULL, -- idempotency: UNIQUE (user_id, type, dedup_ref) WHERE dedup_ref IS NOT NULL
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
read_at timestamptz NULL,
|
||||
dismissed_at timestamptz NULL,
|
||||
expires_at timestamptz NULL
|
||||
);
|
||||
-- index: (user_id, created_at DESC) partial WHERE dismissed_at IS NULL
|
||||
-- partial unique index for dedup as above; inserts use ON CONFLICT DO NOTHING
|
||||
|
||||
notification_preferences (
|
||||
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
category text NOT NULL, -- 'request' | 'content' | 'system' | 'admin' | 'content_digest'
|
||||
enabled bool NOT NULL,
|
||||
PRIMARY KEY (user_id, category)
|
||||
);
|
||||
-- absent row = enabled, except content_digest which defaults OFF
|
||||
-- 'announcement' is not a valid category here (not mutable)
|
||||
|
||||
announcements (
|
||||
id bigserial PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
audience jsonb NOT NULL, -- {"all":true} | {"user_ids":[...]} | {"library_ids":[...]}
|
||||
created_by int REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NULL
|
||||
);
|
||||
```
|
||||
|
||||
Announcements fan out to `notifications` rows at publish time (audience resolved then), so the inbox is always a single-table query and read state is per-user.
|
||||
|
||||
Child profiles: rows with `category IN ('request','system','admin')` are filtered out of child-profile sessions server-side; content matching never targets a child profile unless the matched watchlist/favorite belongs to that profile.
|
||||
|
||||
Retention (nightly job, existing scheduler pattern): dismissed rows purged after 30 days; all rows after 90 days; unread rows from expired announcements at expiry.
|
||||
|
||||
## API
|
||||
|
||||
User endpoints (authenticated, profile from claims):
|
||||
|
||||
```
|
||||
GET /api/v1/notifications ?unread=1&category=&cursor=&limit=
|
||||
GET /api/v1/notifications/unread-count → {count}
|
||||
POST /api/v1/notifications/read {ids:[...]} | {all:true}
|
||||
POST /api/v1/notifications/{id}/dismiss
|
||||
GET /api/v1/notifications/preferences
|
||||
PUT /api/v1/notifications/preferences {category: enabled, ...}
|
||||
```
|
||||
|
||||
List returns user-wide rows plus active-profile rows, newest first, cursor-paginated in the standard envelope. Child-profile filtering server-side.
|
||||
|
||||
Admin endpoints (RequireAdmin group):
|
||||
|
||||
```
|
||||
GET /api/v1/admin/announcements
|
||||
POST /api/v1/admin/announcements {title, body, audience, expires_at?}
|
||||
DELETE /api/v1/admin/announcements/{id} -- also dismisses unread fanned-out rows
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
### New: core request lifecycle events
|
||||
|
||||
`internal/requests/service.go` and the reconciliation task publish on a new `ChannelRequests`:
|
||||
|
||||
- `request.submitted`, `request.approved`, `request.declined`, `request.completed`, `request.failed`
|
||||
- Payload: `request_id, user_id, profile_id, title, media_type, quality, status`
|
||||
|
||||
`ChannelRequests` is added to WS subscribe sets (requesting user + admins), giving the requests UI live updates as a side benefit. This is the only change to the requests subsystem in this project.
|
||||
|
||||
### New: `notifications.send` plugin contract
|
||||
|
||||
The materializer consumes `notifications.send` events:
|
||||
|
||||
```json
|
||||
{ "user_id": 1, "profile_id": null, "category": "request",
|
||||
"type": "request.fulfilled", "title": "...", "body": "...",
|
||||
"link": null, "dedup_ref": "ebook-req-123:fulfilled" }
|
||||
```
|
||||
|
||||
Payloads are validated; malformed events are dropped with a logged warning. The external `silo.notifications` plugin already subscribes to this event name, so one publish reaches both in-app and external channels. The ebook/audiobook request plugins each add one `PublishEvent("notifications.send", ...)` at their acknowledged/fulfilled/failed transitions (their existing status events do not carry `user_id`; the plugins own that context).
|
||||
|
||||
### Matchers
|
||||
|
||||
- **request** — consumes core `request.*` events only. Dedup ref `(request_id, status)`.
|
||||
- **content** — consumes `library.item_added` / `catalog.library.changed`. For each added item: resolve series/author; match per profile against watchlist, favorites (author/series link), and in-progress series (last play within 21 days); union, dedup, filter by library access. Burst guard: one notification per series per profile per hour (season imports collapse).
|
||||
- **content_digest** — nightly job (not event-driven), opt-in: one row per user summarizing the previous day's additions to accessible libraries.
|
||||
- **system** — not matched from the hub; emitted via `notifications.Create()` directly at the source (password change, new-device login, guest-pass redemption). Sites also publish their events as today; the explicit call means security notices never depend on matcher wiring.
|
||||
- **announcement** — fan-out at publish time.
|
||||
- **admin** — consumes existing `ChannelJobs` / `ChannelScans` / `ChannelTasks` failure events plus plugin-host crash/install failures; targets admin users; repeat-failure throttle (same source, max one per hour).
|
||||
|
||||
Preference checks run in the materializer before insert (cached per-user prefs lookup).
|
||||
|
||||
## Web UI
|
||||
|
||||
1. **Bell + dropdown** in the top bar: unread badge from `unread-count`, kept live by `ChannelNotifications` frames via the existing `RealtimeEventsProvider` socket. Dropdown: latest ~10, mark-all-read, deep links.
|
||||
2. **Toasts** — WS notification frames fire sonner toasts; suppressed while the dropdown is open or when the frame targets another profile.
|
||||
3. **Inbox page** (`/notifications`) — paginated list, category filter tabs, dismiss, mark-read on view.
|
||||
4. **Settings** — per-category toggles (`content_digest` default off; `admin` visible to admins only). Admin: Announcements page (list/compose/expire) under admin settings.
|
||||
|
||||
## Mobile (in-app, both platforms)
|
||||
|
||||
Same three features in silo-apple and silo-android against the frozen REST API: inbox screen (list, mark-read, dismiss), unread badge on existing navigation, preferences screen. Transport: REST refresh of unread-count + first page on app foreground and pull-to-refresh. No WS subscription in v1 (current app sockets are playback-scoped; no background delivery exists without push anyway). TV variants: badge + read-only inbox, no preferences editing.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Matcher isolation: a panic/error in one matcher logs and skips that event for that matcher only; never blocks the hub or other matchers (same containment as the plugin event dispatcher).
|
||||
- Idempotent inserts via the dedup partial unique index + `ON CONFLICT DO NOTHING`; replayed events cannot double-notify.
|
||||
- WS fan-out is fire-and-forget; DB rows are the source of truth.
|
||||
- `notifications.send` validation failures: drop + warn (plugin bugs must not break core paths).
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests per matcher with synthetic events; content-matcher edge cases: child profiles, library access, burst guard, dedup.
|
||||
- Store tests against Postgres (repo convention).
|
||||
- One integration test: event → materializer → row → WS frame through a real hub.
|
||||
- API handler tests: auth, profile scoping, child filtering, admin gates.
|
||||
- Web: component tests for bell/inbox; extend the RealtimeEventsProvider test pattern for the new channel.
|
||||
|
||||
## Implementation sequencing
|
||||
|
||||
1. **Server** — migration, store, materializer + matchers, request event publishing, API, WS channel, retention job.
|
||||
2. **Web** — bell, toasts, inbox, settings, announcements admin.
|
||||
3. **Mobile** — apple + android in-app surfaces against the frozen API.
|
||||
4. (Separate projects, later: request unification via `request_router.v1`; mobile push.)
|
||||
|
||||
## Future work
|
||||
|
||||
- Request unification: core `request.*` events replace the book plugins' `notifications.send` publishes; notifications unchanged.
|
||||
- Push: APNs/FCM device registry; the notifier gains a push fan-out alongside WS.
|
||||
- Digest scheduling preferences (weekly, time-of-day).
|
||||
- Profile claims enrichment: embed `is_child` in profile-scoped auth claims. Fixes two implementation-time findings: the WS pipeline cannot child-filter (snapshot and live frames use client-asserted profile identity; REST remains authoritative and fails closed — see the snapshot comment in `internal/api/handlers/events_ws.go`), and the REST `childSafe` check costs one profile lookup per request.
|
||||
- Admin-alert coverage: only `job.failed` and `scan.failed` events exist today. `task.failed` and plugin-host crash events are not published by their subsystems yet; when they are, the admin matcher needs only a map entry.
|
||||
- Dedicated `item_added` catalog event: the content matcher keys on `catalog.item.changed` (`metadata_updated`) plus a 48-hour `media_items.created_at` recency gate, because the scanner publishes no per-item add event. An explicit ingest-time event would remove the proxy heuristic.
|
||||
- New-device login notifications: deferred from v1 (no device-fingerprint store exists); password-change notifications shipped.
|
||||
@@ -1,173 +0,0 @@
|
||||
# Core Push Notifications — Design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved (design); implementation plan to follow
|
||||
|
||||
## Summary
|
||||
|
||||
A server-side push subsystem that mirrors the in-app notifications created in Phase 1 out to registered devices via APNs (Apple), FCM (Android), and Web Push (browsers). Delivery is durable (a DB-backed queue with a retry worker) and presence-gated (a notification only pushes to devices whose user has no live WebSocket connection after a short grace delay). This is a core-server project: the mobile clients have no push code yet, so the deliverable is the registry, queue, worker, transports, and APIs — built transport-agnostic so APNs/FCM are drop-in alongside the immediately-testable Web Push.
|
||||
|
||||
## Goals
|
||||
|
||||
- Deliver Phase-1 notifications to devices over APNs, FCM, and Web Push.
|
||||
- Durable delivery: survive restarts, retry with backoff, prune dead tokens, observable in the admin task view.
|
||||
- Presence-gated: don't buzz a device for a notification the user is actively seeing in-app.
|
||||
- Reuse the existing per-category mute preferences; add per-device push on/off. No new per-channel matrix.
|
||||
- Encrypted provider credentials via the existing settings mechanism.
|
||||
- Transport-agnostic core: one `Transport` interface, three implementations, selected by device platform.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Mobile/web client integration (separate per-client projects; this ships the server contract).
|
||||
- A separate in-app-vs-push preference matrix (category mutes + per-device toggle only).
|
||||
- Rich push (images, action buttons) — title/body/link/category payload only in v1.
|
||||
- New-device-login / security pushes beyond what the notification categories already produce.
|
||||
|
||||
## Architecture
|
||||
|
||||
New `internal/push` package and a new `internal/presence` package, plus touches to three existing seams (`notifications.Service.Create`, the events WS handler, `cmd/silo/main.go` wiring). Five independently-testable units:
|
||||
|
||||
1. **Device/token registry** — extends `user_devices` with push columns; a registration endpoint clients call to enroll/refresh/revoke a token and toggle per-device push. Owns "which devices want push and how to reach them."
|
||||
2. **Presence registry** (`internal/presence`) — process-local `user_id → live-connection refcount`, incremented/decremented by the events WS handler on connect/disconnect. Answers "does this user have a live connection right now?" Cluster-aware via a short-TTL Redis key; degrades to per-node if Redis is absent.
|
||||
3. **Enqueuer** — called from `notifications.Service.Create()` after the row commits. Resolves push-eligible devices, writes one `push_deliveries` row per device with `not_before = now + grace`.
|
||||
4. **Delivery worker** — a `PushDeliveryTask` on the existing task framework; claims due rows, re-checks presence, dispatches via the platform's transport, records outcome, prunes dead tokens.
|
||||
5. **Transports** — a `Transport` interface (`Send(ctx, target, payload) → Result`) with APNs, FCM, and Web Push implementations selected by device platform; credentials from the encrypted settings repo.
|
||||
|
||||
Data flow: `Create()` → enqueuer writes deliveries → worker (interval) claims due & un-present → transport sends → outcome / dead-token prune. The in-app path (immediate WS publish) is untouched; push is the deferred, gated mirror.
|
||||
|
||||
### Seam reference (current code)
|
||||
|
||||
- Fan-out hook: `internal/notifications/service.go` `Create()` — after `created, err := s.store.Insert(...)` succeeds and before/alongside the `hub.PublishJSON(... "notification.created" ...)` call. Enqueue is best-effort and must not block or fail `Create()`.
|
||||
- Presence increment: `internal/api/handlers/events_ws.go` — after a successful subscribe, `release := presence.Add(claims.UserID)`, `defer release()`.
|
||||
- No presence tracking exists today (confirmed); the registry is net-new.
|
||||
- `user_devices` exists (`migrations/sql/180_user_devices.sql`, PK `user_id, profile_id, device_id`) with no token column.
|
||||
- Credential encryption: `internal/catalog/encrypted_settings_repo.go` `SensitiveSettingKeys`.
|
||||
- Background-worker precedent: `internal/taskmanager/tasks/*` (e.g. the request reconciler, notifications digest/retention).
|
||||
|
||||
## Data model
|
||||
|
||||
Extend `user_devices` (a push token belongs to a device):
|
||||
|
||||
```sql
|
||||
ALTER TABLE user_devices
|
||||
ADD COLUMN push_token text NULL,
|
||||
ADD COLUMN push_transport text NULL, -- 'apns' | 'fcm' | 'webpush'
|
||||
ADD COLUMN push_enabled boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN push_token_at timestamptz NULL,
|
||||
ADD COLUMN push_failures integer NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
A device is push-eligible when `push_token IS NOT NULL AND push_enabled`. For `webpush`, `push_token` holds the JSON subscription object (endpoint + p256dh + auth); APNs/FCM store the bare token string. Each transport interprets its own format.
|
||||
|
||||
Delivery queue:
|
||||
|
||||
```sql
|
||||
CREATE TABLE push_deliveries (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
notification_id bigint NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
user_id integer NOT NULL,
|
||||
device_id text NOT NULL,
|
||||
transport text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending', -- pending|sent|failed|skipped|dead
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
not_before timestamptz NOT NULL,
|
||||
last_error text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX push_deliveries_claim_idx
|
||||
ON push_deliveries (not_before)
|
||||
WHERE status IN ('pending','failed');
|
||||
```
|
||||
|
||||
Worker claim: `status IN ('pending','failed') AND not_before <= now()`, ordered by `not_before`, `FOR UPDATE SKIP LOCKED` (safe across nodes/concurrent workers).
|
||||
|
||||
Outcomes:
|
||||
- **sent** — terminal success.
|
||||
- **skipped** — user was present (live WS) at claim time; the in-app delivery sufficed. Terminal.
|
||||
- **failed** — soft error (timeout, 5xx, rate-limit); bump `attempts`, push `not_before` out by the backoff schedule (1m, 5m, 30m). Once the schedule is exhausted (4 total attempts) → `dead`.
|
||||
- **dead** — hard error / unregistered token; terminal. Also clears `push_token` and sets `push_enabled=false` on the `user_devices` row so the dead device stops being enqueued.
|
||||
|
||||
Deliveries do not duplicate notification content — the worker joins `notifications` at send time so an edited/expired notification sends current content (and an expired/dismissed one can be skipped). Retention: terminal deliveries older than 7 days are deleted by a pass on the existing notifications retention task.
|
||||
|
||||
Provider config (added to `SensitiveSettingKeys`, encrypted at rest):
|
||||
`push.apns.p8_key`, `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`, `push.fcm.service_account_json`, `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A transport with missing/blank config reports itself unconfigured; its deliveries short-circuit to `skipped` (reason: unconfigured) rather than erroring in a loop.
|
||||
|
||||
## API
|
||||
|
||||
Device push registration (authenticated user routes; device identified by the existing `X-Silo-Device-Id` header + `user_id` from claims — never from the body):
|
||||
|
||||
```
|
||||
PUT /api/v1/notifications/push/device {transport, token} → 204
|
||||
enroll/refresh this device's token (upsert onto the user_devices row);
|
||||
resets push_failures, sets push_enabled=true, push_token_at=now
|
||||
DELETE /api/v1/notifications/push/device → 204
|
||||
revoke: clear token, disable push for this device
|
||||
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,push_enabled,registered_at}]}
|
||||
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
|
||||
per-device on/off toggle
|
||||
GET /api/v1/notifications/push/webpush-key → {vapid_public_key} (empty when web push unconfigured)
|
||||
```
|
||||
|
||||
Admin provider config uses the existing admin settings mechanism (encrypted `push.*` keys) — no bespoke write endpoints. One read for the admin UI:
|
||||
|
||||
```
|
||||
GET /api/v1/admin/push/status → {apns:{configured:bool}, fcm:{configured:bool}, webpush:{configured:bool}}
|
||||
```
|
||||
|
||||
Status reports booleans only; secrets are never returned.
|
||||
|
||||
## Presence registry
|
||||
|
||||
`presence.Registry` interface: `Connected(userID int) bool` and `Add(userID int) (release func())`. The events WS handler calls `release := registry.Add(claims.UserID)` after a successful subscribe and `defer release()` on disconnect.
|
||||
|
||||
- In-process implementation: `map[int]int` refcount under a mutex.
|
||||
- Cluster-aware implementation: `INCR push:presence:{userID}` in Redis with a 60s TTL refreshed on the WS heartbeat; `DECR` on release; `Connected` is key `> 0`. Redis is already used by the events hub.
|
||||
- Presence failure (Redis down) fails **open** — we push — because a missed push is worse than a redundant one.
|
||||
|
||||
The worker re-checks `Connected` at claim time; a present user's due delivery becomes `skipped`. The `not_before` grace (default 30s) means an actively-in-app user almost never gets a redundant push, while a user who closed the app moments ago does.
|
||||
|
||||
## Error handling & delivery semantics
|
||||
|
||||
- Enqueuer is best-effort; failure to write deliveries logs and returns. `Create()` is never blocked or failed by push.
|
||||
- Worker isolates per-delivery: one device's failure never blocks the batch.
|
||||
- Soft errors → `failed` + exponential backoff on `not_before` (1m, 5m, 30m), then `dead` after 4 total attempts.
|
||||
- Hard errors (`Unregistered`/`InvalidToken`/FCM `NOT_FOUND`) → immediate `dead` + clear token + disable push on `user_devices`.
|
||||
- Unconfigured transport → its deliveries short-circuit to `skipped` (reason logged once, not per-row spam).
|
||||
- Rate-limit responses park that transport briefly (honor `Retry-After`).
|
||||
- Child-profile suppression applied at enqueue: categories in {request, system, admin} are never enqueued for a child profile (same rule as the web toast suppression).
|
||||
- APNs/FCM payloads set a collapse/thread key = notification id to avoid duplicate banners across retries.
|
||||
|
||||
## Security
|
||||
|
||||
- Provider secrets live only in the encrypted settings repo; never logged, never returned by any read endpoint. `/admin/push/status` reports booleans.
|
||||
- Token registration is authenticated and device-scoped to the caller's `user_id` (from claims).
|
||||
- Push payload carries title/body/link/category + notification id — no content beyond what the notification already holds.
|
||||
|
||||
## Testing
|
||||
|
||||
- Worker tested with a fake `Transport` asserting outcome transitions: sent, skipped (present), failed-with-backoff, dead-prunes-token. No network.
|
||||
- Each real transport unit-tested against a mocked HTTP endpoint (request shape, auth header, error-code mapping) — standard repo pattern.
|
||||
- Enqueuer tested with a fake device store + presence stub: eligible-device resolution, child suppression, per-device disable, `not_before` grace.
|
||||
- Presence registry: refcount add/release, Connected transitions, Redis-absent fallback.
|
||||
- Store tests against Postgres: claim query with `SKIP LOCKED`, dead-token prune, retention.
|
||||
- One integration test: `Create() → delivery row → worker → fake transport → sent`.
|
||||
- Handler tests: registration auth-scoping, per-device toggle, `/admin/push/status` unconfigured states, `webpush-key` empty when unconfigured.
|
||||
|
||||
## Implementation sequencing
|
||||
|
||||
1. Migration (user_devices columns + push_deliveries) and domain types.
|
||||
2. `internal/presence` registry (in-process + Redis) and WS-handler wiring.
|
||||
3. Device registry store + registration/toggle endpoints.
|
||||
4. Enqueuer + hook into `Create()`.
|
||||
5. `Transport` interface + fake; delivery worker + task registration; outcome/backoff/dead-prune.
|
||||
6. Real transports (Web Push first — end-to-end testable today; then APNs, then FCM) + encrypted config keys + `/admin/push/status`.
|
||||
7. Retention pass + full verification.
|
||||
|
||||
## Future work
|
||||
|
||||
- Client integration: Apple (APNs token registration + handling), Android (FCM), web (service worker + VAPID subscription). Each binds to the frozen registration API.
|
||||
- Presence-aware in-app→push escalation tuning (per-category grace, quiet hours).
|
||||
- Rich push (images from `item_id` artwork, action buttons).
|
||||
- Per-channel preference matrix if category+device proves too coarse.
|
||||
- Shared-device push semantics: a physical device registers one OS token but one user_devices row per profile. For user-wide notifications, EligibleDevices currently returns one delivery per profile-row → the same token is sent to more than once (the per-notification collapse key dedupes the on-device banner, so the user sees one, but the sends are redundant). And child-category suppression on a device shared by a child and an adult profile is ambiguous because push cannot know the active profile at send time. Both resolve with the active-profile/claims-enrichment work also needed for WS child-filtering: carry the active profile identity so EligibleDevices can dedupe by token and apply the correct profile's suppression.
|
||||
@@ -1,120 +0,0 @@
|
||||
# Core Push — Web Client & Admin Config — Design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved (design); implementation plan to follow
|
||||
|
||||
## Summary
|
||||
|
||||
The core-buildable slice of Phase 3 (client work): a Web Push browser client and an admin push-provider config UI, both living in the silo-server web app plus one ~10-line Go endpoint. This makes the push subsystem operable end to end from the browser — an operator enters provider credentials in an admin page, a user enables push on their browser, and notifications arrive as OS banners with the app closed. The mobile clients (silo-apple APNs, silo-android FCM) remain out of scope; they are separate per-repo projects that consume the same frozen registration API.
|
||||
|
||||
This delivers user-facing notification delivery to browsers. It is not a plugin-communication channel; plugins relate to notifications only via the existing `notifications.send` event contract (a plugin produces a user notification, which then flows to in-app + push).
|
||||
|
||||
## Goals
|
||||
|
||||
- Web Push delivery to browsers: service worker, subscription, receive, click-through.
|
||||
- A per-device "push on this device" control and a device-management list in user settings.
|
||||
- An admin page to configure APNs / FCM / Web Push provider credentials with readiness status.
|
||||
- VAPID key generation from the admin UI.
|
||||
- Reuse everything already built: the frozen push registration API, the encrypted settings write path, the Phase-2 notifications settings page and api client.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Mobile client integration (silo-apple / silo-android) — separate projects against the frozen API.
|
||||
- A PWA framework / offline caching (vite-plugin-pwa) — a static service worker covers push; offline is out of scope.
|
||||
- Any change to the push delivery worker, transports, or registration API (all frozen).
|
||||
- Rich push (images, action buttons).
|
||||
|
||||
## Frozen dependencies (already on this branch)
|
||||
|
||||
Push registration API (`internal/api/handlers/push.go`, routes in `router.go`):
|
||||
|
||||
```
|
||||
PUT /api/v1/notifications/push/device {transport, token} → 204 (RequireProfile)
|
||||
DELETE /api/v1/notifications/push/device → 204 (RequireProfile)
|
||||
GET /api/v1/notifications/push/devices → {devices:[DeviceInfo]}
|
||||
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
|
||||
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
|
||||
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
|
||||
```
|
||||
|
||||
`DeviceInfo`: `{device_id, name, platform, transport, push_enabled, registered_at}`. The api client (`web/src/api/client.ts`) auto-attaches `X-Silo-Device-Id` and `X-Profile-Id`. WebPush tokens are stored as the JSON subscription object.
|
||||
|
||||
Admin settings write path: `PUT /api/v1/admin/settings/{key}` `{value}` (`admin.go` HandleUpdateSetting) — all `push.*` keys are pre-registered sensitive in `encrypted_settings_repo.go` (encrypted at rest, value redacted from reads). No new write endpoint needed. Sensitive-status hook (`useAdminSensitiveStatus`) reports which keys are stored.
|
||||
|
||||
Web push server transport already sends payload `{id, title, body, link, category}` with `Topic: "n"+base36(notificationID)` for collapse.
|
||||
|
||||
## Architecture
|
||||
|
||||
Six units (five web, one tiny Go endpoint):
|
||||
|
||||
1. **Service worker** — `web/public/sw.js` (static). `push` → `showNotification` from payload; `notificationclick` → focus an open app tab (postMessage the link) or `openWindow(link)`; `pushsubscriptionchange` → re-subscribe with the cached VAPID key and re-register (best-effort token refresh).
|
||||
2. **SW registration** — in `web/src/main.tsx`, registers `/sw.js` when `serviceWorker` + `PushManager` are supported; no-op otherwise.
|
||||
3. **`usePushDevice` hook** — `web/src/hooks/usePushDevice.ts`. Owns the browser-push lifecycle and exposes a status state machine + `enable()`/`disable()`.
|
||||
4. **Settings UI** — extend `web/src/pages/settings/NotificationsSettings.tsx`: "Push on this device" control + a device-management list (per-device enable/disable).
|
||||
5. **Admin push-config page** — `web/src/pages/admin-settings/PushSettings.tsx`: three credential cards + VAPID keygen + readiness badges.
|
||||
6. **VAPID keygen endpoint** — `POST /api/v1/admin/push/generate-vapid-keys` (RequireAdmin), ~10 lines in `push.go`. The only net-new server code.
|
||||
|
||||
## Push client data flow
|
||||
|
||||
`usePushDevice` reports `status`:
|
||||
- **unsupported** — no serviceWorker/PushManager, or iOS Safari not installed as a PWA. UI explains; no action.
|
||||
- **blocked** — `Notification.permission === "denied"`. UI explains how to re-enable in site settings (cannot un-deny programmatically).
|
||||
- **off** — supported, not subscribed. Toggle → `enable()`.
|
||||
- **on** — subscribed and registered. Toggle → `disable()`.
|
||||
- **pending** — transition in flight; control disabled.
|
||||
|
||||
`enable()`: `Notification.requestPermission()` → if granted, `navigator.serviceWorker.ready` → `GET /notifications/push/webpush-key` → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublic) })` → `PUT /notifications/push/device { transport: "webpush", token: JSON.stringify(subscription.toJSON()) }`. The api client supplies device + profile headers (satisfies the route's RequireProfile). Cache the VAPID public key (Cache Storage / IndexedDB) so the SW can re-subscribe on `pushsubscriptionchange` without the app open. Success → `on`, toast.
|
||||
|
||||
`disable()`: `pushManager.getSubscription()` → `unsubscribe()` (best-effort) → `DELETE /notifications/push/device` → `off`, toast.
|
||||
|
||||
Receiving (SW): payload `{id,title,body,link,category}` → `showNotification(title, { body, data:{link}, tag:"n"+id })`. The `tag` is an on-device display-dedup keyed on the notification id (a redelivery of the same notification replaces the visible banner rather than stacking); it is independent of the server's Web Push `Topic` header (which dedups in the push-service queue). `notificationclick` → focus matching-origin client + postMessage `{type:"notification-click", link}`, else `openWindow(link ?? "/notifications")`.
|
||||
|
||||
Idempotency / healing: `enable()` is safe to re-run (server upserts); a stale local subscription with no server row heals on re-enable. Permission-granted-but-subscribe-fails → toast, stay `off`.
|
||||
|
||||
## Admin config page
|
||||
|
||||
`PushSettings.tsx`, registered in `AdminSettingsLayout` `SETTINGS_GROUPS` (Connections group). Pattern copied from `IntegrationsSettings.tsx` (`SettingField` + `CredentialStatus` + `useUpdateServerSetting`). Cards:
|
||||
|
||||
- **Web Push** (leads — usable today): `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. "Generate keys" button → `POST /admin/push/generate-vapid-keys` → fills public/private fields (not auto-saved; admin reviews then saves each). Subject defaults to a `mailto:` hint.
|
||||
- **APNs**: `push.apns.p8_key` (textarea), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
|
||||
- **FCM**: `push.fcm.service_account_json` (textarea).
|
||||
|
||||
Readiness: card header badge from `GET /admin/push/status`; per-field "configured, leave blank to keep" from the sensitive-status hook (secrets never returned). Each save = `PUT /admin/settings/{key}` `{value}` via `useUpdateServerSetting`; encryption automatic; badges refetch after save.
|
||||
|
||||
VAPID keygen endpoint: generates and returns `{vapid_public, vapid_private}`; does not persist. Handler test: non-empty pair, admin-gated.
|
||||
|
||||
### Open decision — "Send test push" button
|
||||
|
||||
OPTION (admin's call at spec review): a "Send test push" button that creates a one-off system notification addressed to the admin themselves to verify config→subscribe→receive end to end from the UI. Requires one more net-new server endpoint (`POST /api/v1/admin/push/test` → `notifications.Create` a system notification for the caller). Recommended for operability (turns "did I configure this right?" into one click) but it is the only other net-new server surface. **Decision: include it** unless cut at review. (Implementation plan will gate this behind the decision.)
|
||||
|
||||
## Error handling
|
||||
|
||||
- Capability gating (unsupported, denied) are UI states, not errors; iOS Safari shows "Add to Home Screen to enable push."
|
||||
- Transient subscribe/register failures → toast, leave `off`; nothing half-committed (server PUT is source of truth, idempotent).
|
||||
- SW resilience: tolerate partial payload (generic title fallback); `notificationclick` falls back to `/notifications`; failed `pushsubscriptionchange` re-register logs and retries on next app load (browser stops receiving until reopened — never a crash).
|
||||
- Admin page: save failures toast; readiness badges always reflect server truth (refetch after save); partial config shows "Not configured" until complete; generate-keys failure leaves fields untouched.
|
||||
|
||||
## Testing
|
||||
|
||||
- `usePushDevice` — unit tests with mocked serviceWorker/PushManager/Notification/api client: status derivation, enable() happy path (permission→key→subscribe→PUT body), permission-denied short-circuit, disable() (unsubscribe + DELETE), idempotent re-enable.
|
||||
- Settings UI — component tests: each status renders the right control; device-list rows + per-device toggle mutation.
|
||||
- Service worker — vitest over the pure handlers: payload→showNotification-args mapping, click→target-URL logic. DOM wiring exercised in the manual smoke.
|
||||
- Admin page — component tests: three cards render, badges reflect `/admin/push/status`, save calls `PUT /admin/settings/{key}` with the right key, generate-keys fills fields.
|
||||
- Server — VAPID keygen handler test (non-empty pair, admin-gated); test-push endpoint handler test (if included).
|
||||
- Manual smoke (documented in plan): real browser — configure VAPID, enable push, trigger a notification, confirm OS banner with app closed, click → deep link.
|
||||
|
||||
## Implementation sequencing
|
||||
|
||||
1. Server: VAPID keygen endpoint (+ test-push endpoint if kept).
|
||||
2. Web: service worker + registration + VAPID-key caching.
|
||||
3. Web: `usePushDevice` hook + tests.
|
||||
4. Web: NotificationsSettings push control + device list.
|
||||
5. Web: admin PushSettings page + nav.
|
||||
6. Verification + manual browser smoke.
|
||||
|
||||
## Future work
|
||||
|
||||
- Mobile clients (APNs/FCM) against the frozen API.
|
||||
- Shared-device token dedup + active-profile child-suppression (recorded in the push server spec) — informs whether multiple browsers/profiles dedupe.
|
||||
- Per-category push targeting if category mutes prove too coarse.
|
||||
- PWA/offline (vite-plugin-pwa) if the app later wants installable offline behavior.
|
||||
@@ -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,65 +0,0 @@
|
||||
# Home Notification Bars — Design
|
||||
|
||||
Date: 2026-06-11
|
||||
Status: approved (design); implementation to follow
|
||||
|
||||
## Summary
|
||||
|
||||
Surface notifications at the top of the Home page so they're seen without opening the inbox tab, with system-level **announcements** visually distinguished from personal notifications. Two slim, dismissible bars stack at the top of Home: an **announcement bar** (distinct accent + 📢 + "Announcement" label) above a **notification strip** (the most recent unread personal notification). Both reuse existing hooks; no server changes. Web-only, additive.
|
||||
|
||||
## Goals
|
||||
|
||||
- Show the most recent unread notification high on the Home page, not only behind the bell/inbox.
|
||||
- Make admin **announcements** clearly distinct from request/content/system/admin notifications, via a separate styled bar.
|
||||
- Dismiss (✕) marks read and advances to the next unread; bars hide when nothing's unread.
|
||||
- Reuse the Phase-2 notifications hooks; inherit per-profile + child filtering and live WebSocket updates for free.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No server/API changes (the data already distinguishes `category: "announcement"`).
|
||||
- No change to the bell, inbox page, or toast (carrying the 📢 styling there is a possible follow-up, out of scope here).
|
||||
- Not app-wide — Home page only.
|
||||
|
||||
## Components
|
||||
|
||||
Two components in `web/src/components/home/` (or alongside Home), each small and independently testable, plus one mount edit.
|
||||
|
||||
### AnnouncementBar (`web/src/components/AnnouncementBar.tsx`)
|
||||
- Data: `useNotificationsList({ unread: true, category: "announcement" })` → the first (newest) item. Reliably finds the latest unread announcement regardless of how many other notifications are unread.
|
||||
- Render: a distinct bar using the app's accent tokens (reads as a system notice, not a personal row) — a 📢 icon, an "ANNOUNCEMENT" label, the title, truncated body, relative time, and a ✕.
|
||||
- Empty: renders nothing when there's no unread announcement.
|
||||
- ✕: `useMarkRead().mutate({ ids: [id] })` → the unread query updates → the next unread announcement shows, or the bar hides. Marking read also drops the bell badge (shared cache).
|
||||
- Click (anywhere but ✕): navigate to the notification's `link` (or `/notifications` when it has none) and mark it read.
|
||||
|
||||
### NotificationStrip (`web/src/components/NotificationStrip.tsx`)
|
||||
- Data: `useNotificationsList({ unread: true })` → the first item whose `category !== "announcement"` (so announcements live only in the bar above, never duplicated here).
|
||||
- Render: the slim strip — 🔔 icon, title, truncated body, relative time, ✕. Lighter styling than the announcement bar.
|
||||
- Empty: renders nothing when there's no unread non-announcement.
|
||||
- ✕ / click: same behavior as the bar (mark read + advance / navigate + mark read).
|
||||
|
||||
### Mount (`web/src/pages/Home.tsx`)
|
||||
At the top of the Home content region (near the existing `heroSlot` / `TasteSeedBanner`), render `<AnnouncementBar />` then `<NotificationStrip />` as the first elements, so they sit above the content rows. When both are empty, Home renders exactly as today.
|
||||
|
||||
## Data flow & shared behavior
|
||||
|
||||
- Both bars read through TanStack Query; the announcement bar's query (`{unread:true, category:"announcement"}`) and the strip's (`{unread:true}`) are distinct cache keys — two small fetches, both already used elsewhere.
|
||||
- Live updates: `useNotificationsLive` already invalidates the notifications list on `notification.created`, so both bars reactively show the newest unread without a reload.
|
||||
- Filtering: the list endpoint already scopes to the active profile and suppresses child-restricted categories server-side, so the bars inherit correct per-profile behavior.
|
||||
- The relative-time helper is the existing `timeAgo` (with the absolute-date fallback for old timestamps).
|
||||
|
||||
## Error handling
|
||||
|
||||
- While the query is loading, render nothing (no skeleton flash for a slim bar).
|
||||
- A mark-read failure surfaces the existing mutation `onError` toast; the bar stays until the next successful read.
|
||||
- A notification with no `link` navigates to `/notifications` rather than a dead link.
|
||||
|
||||
## Testing
|
||||
|
||||
- `AnnouncementBar.test.tsx`: renders the latest unread announcement with the 📢/label styling; ✕ calls `markRead({ids:[id]})`; clicking navigates (mocked `useNavigate`) to the link and marks read; renders nothing when no unread announcement.
|
||||
- `NotificationStrip.test.tsx`: shows the newest unread non-announcement (skips an announcement at the head of the list); ✕ marks read; click navigates + marks read; renders nothing when no unread non-announcement.
|
||||
- Existing suites stay green; tsc + lint clean.
|
||||
|
||||
## Future work
|
||||
|
||||
- Carry the 📢 announcement styling into the bell dropdown and inbox rows for a consistent identity.
|
||||
- Optional: a per-user "hide the home bars" preference if they prove noisy.
|
||||
@@ -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,13 +14,12 @@ 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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
@@ -31,30 +30,24 @@ require (
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/pgvector/pgvector-go v0.3.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/sideshow/apns2 v0.25.0
|
||||
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/oauth2 v0.36.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // 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
|
||||
@@ -62,10 +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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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
|
||||
@@ -76,11 +67,16 @@ require (
|
||||
github.com/zishang520/engine.io/v2 v2.5.0 // indirect
|
||||
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
|
||||
@@ -104,11 +100,9 @@ require (
|
||||
github.com/sony/sonyflake v1.3.0
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
golang.org/x/sync v0.20.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
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
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/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
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/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
@@ -50,20 +44,20 @@ 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=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
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=
|
||||
@@ -76,15 +70,10 @@ github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
|
||||
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
|
||||
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
|
||||
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -165,9 +154,8 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=
|
||||
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
@@ -178,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=
|
||||
@@ -198,13 +182,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=
|
||||
github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=
|
||||
github.com/sony/sonyflake v1.3.0 h1:tiB4Dlp0lnmKp/h6BLXA14P8Qi+LYS9+0QRpcrKHvg4=
|
||||
github.com/sony/sonyflake v1.3.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
@@ -231,7 +212,6 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHg
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
github.com/zishang520/engine.io-go-parser v1.3.2 h1:aEVrhQVhfk99Ct6htNffgHydUBC4dGclO/OXPz5CSy0=
|
||||
@@ -242,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=
|
||||
@@ -250,126 +232,64 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
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.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
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.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -73,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
|
||||
|
||||
|
||||
@@ -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,314 +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"
|
||||
"sync/atomic"
|
||||
"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).
|
||||
// The config is held behind an atomic pointer so admin settings changes apply
|
||||
// to subsequent requests without rebuilding the client; each request snapshots
|
||||
// the config once so its URL, key, and model stay consistent.
|
||||
type Client struct {
|
||||
cfg atomic.Pointer[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 {
|
||||
c := &Client{
|
||||
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
|
||||
asrHTTP: &http.Client{},
|
||||
}
|
||||
c.UpdateConfig(cfg)
|
||||
return c
|
||||
}
|
||||
|
||||
// UpdateConfig swaps the endpoint config. Safe for concurrent use; in-flight
|
||||
// requests finish with the config they started with.
|
||||
func (c *Client) UpdateConfig(cfg Config) {
|
||||
c.cfg.Store(&cfg)
|
||||
}
|
||||
|
||||
// Config returns a snapshot of the current endpoint config.
|
||||
func (c *Client) Config() Config {
|
||||
return *c.cfg.Load()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
cfg := c.Config()
|
||||
reqBody := chatCompletionRequest{
|
||||
Model: 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(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 cfg.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+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", 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,287 +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) {
|
||||
cfg := c.Config()
|
||||
if !cfg.ASRConfigured() {
|
||||
return nil, fmt.Errorf("transcription endpoint is not configured")
|
||||
}
|
||||
if IsChatOnlyGateway(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", 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(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", 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(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 := 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", 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,18 +21,14 @@ 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"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -531,15 +527,6 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish a domain event rather than creating the notification inline; the
|
||||
// notifications materializer's system matcher turns it into the user's
|
||||
// "Password changed" notice, keeping all notification creation event-driven.
|
||||
if req.Password != nil && h.RealtimeHub != nil {
|
||||
_ = h.RealtimeHub.EventsHub().PublishJSON(r.Context(),
|
||||
evt.ChannelSessions, notifications.EventUserPasswordChanged, nil,
|
||||
evt.PublishOptions{UserID: id})
|
||||
}
|
||||
|
||||
user, err := h.userRepo.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated user")
|
||||
@@ -1228,9 +1215,6 @@ func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.R
|
||||
type adminSettingResponse struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
// RestartRequired reports whether the saved value only takes effect
|
||||
// after a server restart (set on update responses only).
|
||||
RestartRequired bool `json:"restart_required,omitempty"`
|
||||
}
|
||||
|
||||
type adminSettingsListResponse struct {
|
||||
@@ -2117,33 +2101,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 {
|
||||
@@ -2159,10 +2116,9 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
h.OnServerSettingUpdated(r.Context(), key, req.Value)
|
||||
}
|
||||
|
||||
restartRequired := config.RestartRequired(key)
|
||||
if sensitiveSettingKeys[key] {
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired})
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired})
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal fake UserRepository for AdminHandler tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakeUserRepo struct {
|
||||
user *models.User
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (f *fakeUserRepo) List(_ context.Context) ([]*models.User, error) { return nil, nil }
|
||||
|
||||
func (f *fakeUserRepo) Create(_ context.Context, _ models.CreateUserInput) (*models.User, error) {
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserRepo) Update(_ context.Context, _ int, _ models.UpdateUserInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserRepo) Delete(_ context.Context, _ int) error { return nil }
|
||||
|
||||
func (f *fakeUserRepo) GetByID(_ context.Context, _ int) (*models.User, error) {
|
||||
return f.user, f.getErr
|
||||
}
|
||||
|
||||
// Compile-time check.
|
||||
var _ UserRepository = (*fakeUserRepo)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build AdminHandler with a realtime hub whose events the test can
|
||||
// observe. Password changes publish a domain event; the notifications
|
||||
// materializer (tested separately) turns it into the user's notification.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newAdminHandlerWithHub(userRepo UserRepository) (*AdminHandler, *evt.Hub) {
|
||||
rh := notifications.NewHub("test", nil)
|
||||
h := NewAdminHandler(userRepo, nil, nil)
|
||||
h.RealtimeHub = rh
|
||||
return h, rh.EventsHub()
|
||||
}
|
||||
|
||||
// adminUpdateRequest builds a PUT /admin/users/{id} HTTP request.
|
||||
func adminUpdateRequest(body []byte, userID string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/users/"+userID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", userID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleUpdateUser password change notification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestHandleUpdateUser_WithPassword_PublishesEvent asserts that including a
|
||||
// Password in the update payload publishes one user.password_changed event on
|
||||
// ChannelSessions targeted at the updated user.
|
||||
func TestHandleUpdateUser_WithPassword_PublishesEvent(t *testing.T) {
|
||||
targetUser := &models.User{
|
||||
ID: 99,
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Role: "user",
|
||||
}
|
||||
repo := &fakeUserRepo{user: targetUser}
|
||||
h, eventsHub := newAdminHandlerWithHub(repo)
|
||||
ch, unsub := eventsHub.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
body := []byte(`{"password":"newSecret123"}`)
|
||||
req := adminUpdateRequest(body, "99")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleUpdateUser(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case env := <-ch:
|
||||
if env.Channel != evt.ChannelSessions {
|
||||
t.Errorf("channel = %q, want %q", env.Channel, evt.ChannelSessions)
|
||||
}
|
||||
if env.Event != notifications.EventUserPasswordChanged {
|
||||
t.Errorf("event = %q, want %q", env.Event, notifications.EventUserPasswordChanged)
|
||||
}
|
||||
if env.UserID != 99 {
|
||||
t.Errorf("event user_id = %d, want 99", env.UserID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected a user.password_changed event, got none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleUpdateUser_WithoutPassword_NoEvent asserts that a non-password
|
||||
// update publishes no user.password_changed event.
|
||||
func TestHandleUpdateUser_WithoutPassword_NoEvent(t *testing.T) {
|
||||
targetUser := &models.User{
|
||||
ID: 99,
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Role: "user",
|
||||
}
|
||||
repo := &fakeUserRepo{user: targetUser}
|
||||
h, eventsHub := newAdminHandlerWithHub(repo)
|
||||
ch, unsub := eventsHub.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
body := []byte(`{"username":"alice-renamed"}`)
|
||||
req := adminUpdateRequest(body, "99")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleUpdateUser(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case env := <-ch:
|
||||
t.Errorf("expected no event, got %q on %q", env.Event, env.Channel)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// no event, as expected
|
||||
}
|
||||
}
|
||||
@@ -326,47 +326,6 @@ func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
value string
|
||||
restartRequired bool
|
||||
}{
|
||||
// Infrastructure settings are captured at startup.
|
||||
{key: "database.max_connections", value: "40", restartRequired: true},
|
||||
{key: "s3.public_bucket", value: "assets", restartRequired: true},
|
||||
// Branding is read live from the settings repo per request.
|
||||
{key: "branding.server_name", value: "Casa", restartRequired: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
settings := &fakeServerSettingsStore{}
|
||||
handler := &AdminHandler{SettingsRepo: settings}
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/admin/settings/"+tc.key,
|
||||
strings.NewReader(`{"value":"`+tc.value+`"}`),
|
||||
)
|
||||
req = withChiParam(req, "key", tc.key)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleUpdateSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp adminSettingResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.RestartRequired != tc.restartRequired {
|
||||
t.Fatalf("restart_required = %v, want %v", resp.RestartRequired, tc.restartRequired)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func performSettingsCheckRequest(
|
||||
t *testing.T,
|
||||
handler *AdminHandler,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
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/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
const audioPlaybackSessionTTL = 12 * time.Hour
|
||||
|
||||
type AudioDetailService interface {
|
||||
GetItemDetail(ctx context.Context, contentID string, filter catalog.AccessFilter) (*catalog.ItemDetail, error)
|
||||
}
|
||||
|
||||
type AudioFileResolver interface {
|
||||
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
|
||||
}
|
||||
|
||||
type AudioHandler struct {
|
||||
detailSvc AudioDetailService
|
||||
files AudioFileResolver
|
||||
storeProvider userstore.UserStoreProvider
|
||||
bookmarkStore *PGAudioBookmarkStore
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]audioPlaybackSession
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAudioHandler(
|
||||
detailSvc AudioDetailService,
|
||||
files AudioFileResolver,
|
||||
storeProvider userstore.UserStoreProvider,
|
||||
bookmarkStore *PGAudioBookmarkStore,
|
||||
) *AudioHandler {
|
||||
return &AudioHandler{
|
||||
detailSvc: detailSvc,
|
||||
files: files,
|
||||
storeProvider: storeProvider,
|
||||
bookmarkStore: bookmarkStore,
|
||||
sessions: make(map[string]audioPlaybackSession),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
type audioPlaybackSession struct {
|
||||
ID string
|
||||
UserID int
|
||||
ProfileID string
|
||||
ContentID string
|
||||
TotalSeconds float64
|
||||
Files []audioSessionFile
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type audioSessionFile struct {
|
||||
FileID int
|
||||
FilePath string
|
||||
Start float64
|
||||
Duration float64
|
||||
Mode string
|
||||
TrackIdx int
|
||||
}
|
||||
|
||||
type audioStartPlaybackRequest struct {
|
||||
ContentID string `json:"content_id"`
|
||||
StartPosition *float64 `json:"start_position,omitempty"`
|
||||
Restart bool `json:"restart,omitempty"`
|
||||
}
|
||||
|
||||
type audioProgressRequest struct {
|
||||
Position float64 `json:"position"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
}
|
||||
|
||||
type audioPlaybackResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ContentID string `json:"content_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
TotalDurationSeconds float64 `json:"total_duration_seconds"`
|
||||
ResumePositionSeconds float64 `json:"resume_position_seconds"`
|
||||
Tracks []audioTrackResponse `json:"tracks"`
|
||||
Chapters []audioChapter `json:"chapters"`
|
||||
Bookmarks []audioBookmark `json:"bookmarks"`
|
||||
}
|
||||
|
||||
type audioTrackResponse struct {
|
||||
Index int `json:"index"`
|
||||
FileID int `json:"file_id"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
StartOffsetSeconds float64 `json:"start_offset_seconds"`
|
||||
StreamURL string `json:"stream_url"`
|
||||
StreamType string `json:"stream_type"`
|
||||
PlayMethod string `json:"play_method"`
|
||||
Codec string `json:"codec,omitempty"`
|
||||
Container string `json:"container,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
Chapters []audioChapter `json:"chapters"`
|
||||
}
|
||||
|
||||
type audioChapter struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title,omitempty"`
|
||||
StartSeconds float64 `json:"start_seconds"`
|
||||
EndSeconds float64 `json:"end_seconds,omitempty"`
|
||||
TrackIndex int `json:"track_index,omitempty"`
|
||||
}
|
||||
|
||||
type audioBookmark struct {
|
||||
ID string `json:"id"`
|
||||
ContentID string `json:"content_id"`
|
||||
TimeSeconds float64 `json:"time_seconds"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type audioBookmarkRequest struct {
|
||||
TimeSeconds *float64 `json:"time_seconds"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.detailSvc == nil || h.files == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Audio playback is not configured")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if userID == 0 || profileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Profile is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req audioStartPlaybackRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
req.ContentID = strings.TrimSpace(req.ContentID)
|
||||
if req.ContentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
filter := requestAccessFilter(r)
|
||||
detail, err := h.detailSvc.GetItemDetail(r.Context(), req.ContentID, filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item detail")
|
||||
return
|
||||
}
|
||||
if detail == nil || detail.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audiobook not found")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.files.GetByContentID(r.Context(), req.ContentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load audio files")
|
||||
return
|
||||
}
|
||||
files = catalog.FilterMediaFilesByAccess(files, filter)
|
||||
sortAudioFiles(files)
|
||||
if len(files) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "No playable audio files found")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := ulid.Make().String()
|
||||
tracks, sessionFiles, chapters, totalDuration := buildAudioTrackResponses(sessionID, files)
|
||||
if detail.Audiobook != nil && detail.Audiobook.TotalDurationSeconds > 0 {
|
||||
totalDuration = float64(detail.Audiobook.TotalDurationSeconds)
|
||||
}
|
||||
|
||||
resume := resolveAudioResumePosition(r.Context(), h.storeProvider, userID, profileID, req.ContentID)
|
||||
if req.Restart {
|
||||
resume = 0
|
||||
} else if req.StartPosition != nil && isFiniteNonNegative(*req.StartPosition) {
|
||||
resume = clampFloat(*req.StartPosition, 0, totalDuration)
|
||||
}
|
||||
|
||||
bookmarks, _ := h.listBookmarks(r.Context(), userID, profileID, req.ContentID)
|
||||
session := audioPlaybackSession{
|
||||
ID: sessionID,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: req.ContentID,
|
||||
TotalSeconds: totalDuration,
|
||||
Files: sessionFiles,
|
||||
CreatedAt: h.now(),
|
||||
LastSeenAt: h.now(),
|
||||
ExpiresAt: h.now().Add(audioPlaybackSessionTTL),
|
||||
}
|
||||
h.putSession(session)
|
||||
|
||||
writeJSON(w, http.StatusOK, audioPlaybackResponse{
|
||||
SessionID: sessionID,
|
||||
ContentID: detail.ContentID,
|
||||
Type: detail.Type,
|
||||
Title: detail.Title,
|
||||
Subtitle: audioSubtitle(detail),
|
||||
PosterURL: detail.PosterURL,
|
||||
BackdropURL: detail.BackdropURL,
|
||||
TotalDurationSeconds: totalDuration,
|
||||
ResumePositionSeconds: resume,
|
||||
Tracks: tracks,
|
||||
Chapters: chapters,
|
||||
Bookmarks: bookmarks,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleSyncPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req audioProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
duration := req.Duration
|
||||
if duration <= 0 {
|
||||
duration = session.TotalSeconds
|
||||
}
|
||||
position := clampFloat(req.Position, 0, duration)
|
||||
if err := h.persistProgress(r.Context(), session.UserID, session.ProfileID, session.ContentID, position, duration); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync audio progress")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"session_id": session.ID,
|
||||
"position": position,
|
||||
"duration": duration,
|
||||
"is_paused": req.IsPaused,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleClosePlayback(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req audioProgressRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if isFiniteNonNegative(req.Position) {
|
||||
duration := req.Duration
|
||||
if duration <= 0 {
|
||||
duration = session.TotalSeconds
|
||||
}
|
||||
position := clampFloat(req.Position, 0, duration)
|
||||
_ = h.persistProgress(r.Context(), session.UserID, session.ProfileID, session.ContentID, position, duration)
|
||||
}
|
||||
h.deleteSession(session.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleStreamTrack(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.sessionForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rawIndex := chi.URLParam(r, "track_index")
|
||||
index, err := strconv.Atoi(rawIndex)
|
||||
if err != nil || index < 0 || index >= len(session.Files) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid track index")
|
||||
return
|
||||
}
|
||||
file := session.Files[index]
|
||||
mode := r.URL.Query().Get("mode")
|
||||
if mode == "" {
|
||||
mode = file.Mode
|
||||
}
|
||||
if mode == "aac" {
|
||||
seek := 0.0
|
||||
if raw := r.URL.Query().Get("seek"); raw != "" {
|
||||
if parsed, err := strconv.ParseFloat(raw, 64); err == nil && parsed >= 0 {
|
||||
seek = parsed
|
||||
}
|
||||
}
|
||||
if err := playback.ServeRemux(w, r, file.FilePath, "mp4", seek, true, 0); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = playback.ServeDirectPlay(w, r, file.FilePath)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleListBookmarks(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
bookmarks, err := h.listBookmarks(r.Context(), userID, profileID, contentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list bookmarks")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"bookmarks": bookmarks})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleCreateBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.bookmarkStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Bookmarks are not configured")
|
||||
return
|
||||
}
|
||||
var req audioBookmarkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TimeSeconds == nil || !isFiniteNonNegative(*req.TimeSeconds) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "time_seconds is required")
|
||||
return
|
||||
}
|
||||
filter := requestAccessFilter(r)
|
||||
detail, err := h.detailSvc.GetItemDetail(r.Context(), contentID, filter)
|
||||
if err != nil || detail == nil || detail.Type != "audiobook" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audiobook not found")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
bookmark, err := h.bookmarkStore.Upsert(r.Context(), userID, profileID, contentID, *req.TimeSeconds, req.Title)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save bookmark")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bookmark)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) HandleDeleteBookmark(w http.ResponseWriter, r *http.Request) {
|
||||
contentID, ok := h.contentIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.bookmarkStore == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Bookmarks are not configured")
|
||||
return
|
||||
}
|
||||
rawTime := chi.URLParam(r, "time_seconds")
|
||||
timeSeconds, err := strconv.ParseFloat(rawTime, 64)
|
||||
if err != nil || !isFiniteNonNegative(timeSeconds) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid bookmark time")
|
||||
return
|
||||
}
|
||||
if err := h.bookmarkStore.Delete(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), contentID, timeSeconds); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete bookmark")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) contentIDParam(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
|
||||
if contentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
|
||||
return "", false
|
||||
}
|
||||
return contentID, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) sessionForRequest(w http.ResponseWriter, r *http.Request) (audioPlaybackSession, bool) {
|
||||
sessionID := chi.URLParam(r, "session_id")
|
||||
if sessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
session, ok := h.getSession(sessionID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Audio playback session not found")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
if session.UserID != apimw.GetUserID(r.Context()) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
if profileID != "" && session.ProfileID != profileID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another profile")
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) putSession(session audioPlaybackSession) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.pruneExpiredLocked(h.now())
|
||||
h.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (h *AudioHandler) getSession(sessionID string) (audioPlaybackSession, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := h.now()
|
||||
h.pruneExpiredLocked(now)
|
||||
session, ok := h.sessions[sessionID]
|
||||
if !ok {
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(h.sessions, sessionID)
|
||||
return audioPlaybackSession{}, false
|
||||
}
|
||||
session.LastSeenAt = now
|
||||
h.sessions[sessionID] = session
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *AudioHandler) deleteSession(sessionID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.sessions, sessionID)
|
||||
}
|
||||
|
||||
func (h *AudioHandler) pruneExpiredLocked(now time.Time) {
|
||||
for id, session := range h.sessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(h.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AudioHandler) persistProgress(ctx context.Context, userID int, profileID, contentID string, position, duration float64) error {
|
||||
if h.storeProvider == nil {
|
||||
return nil
|
||||
}
|
||||
store, err := h.storeProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.SetProgress(ctx, profileID, contentID, position, duration, userstore.ProgressThresholds{})
|
||||
}
|
||||
|
||||
func (h *AudioHandler) listBookmarks(ctx context.Context, userID int, profileID, contentID string) ([]audioBookmark, error) {
|
||||
if h.bookmarkStore == nil {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
return h.bookmarkStore.List(ctx, userID, profileID, contentID)
|
||||
}
|
||||
|
||||
func buildAudioTrackResponses(sessionID string, files []*models.MediaFile) ([]audioTrackResponse, []audioSessionFile, []audioChapter, float64) {
|
||||
tracks := make([]audioTrackResponse, 0, len(files))
|
||||
sessionFiles := make([]audioSessionFile, 0, len(files))
|
||||
allChapters := []audioChapter{}
|
||||
offset := 0.0
|
||||
for index, file := range files {
|
||||
duration := float64(file.Duration)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
mode := audioDeliveryMode(file)
|
||||
streamURL := fmt.Sprintf("/api/v1/audio/playback/%s/tracks/%d", sessionID, index)
|
||||
if mode == "aac" {
|
||||
streamURL += "?mode=aac"
|
||||
}
|
||||
chapters := audioChaptersForTrack(index, offset, file.Chapters)
|
||||
allChapters = append(allChapters, chapters...)
|
||||
tracks = append(tracks, audioTrackResponse{
|
||||
Index: index,
|
||||
FileID: file.ID,
|
||||
FileName: filepath.Base(file.FilePath),
|
||||
DurationSeconds: duration,
|
||||
StartOffsetSeconds: offset,
|
||||
StreamURL: streamURL,
|
||||
StreamType: "progressive",
|
||||
PlayMethod: mode,
|
||||
Codec: file.CodecAudio,
|
||||
Container: file.Container,
|
||||
Bitrate: file.Bitrate,
|
||||
Chapters: chapters,
|
||||
})
|
||||
sessionFiles = append(sessionFiles, audioSessionFile{
|
||||
FileID: file.ID,
|
||||
FilePath: file.FilePath,
|
||||
Start: offset,
|
||||
Duration: duration,
|
||||
Mode: mode,
|
||||
TrackIdx: index,
|
||||
})
|
||||
offset += duration
|
||||
}
|
||||
return tracks, sessionFiles, allChapters, offset
|
||||
}
|
||||
|
||||
func audioChaptersForTrack(trackIndex int, offset float64, chapters []models.MediaChapter) []audioChapter {
|
||||
out := make([]audioChapter, 0, len(chapters))
|
||||
for _, chapter := range chapters {
|
||||
start := offset + maxFloat(0, chapter.StartSeconds)
|
||||
end := 0.0
|
||||
if chapter.EndSeconds > 0 {
|
||||
end = offset + chapter.EndSeconds
|
||||
}
|
||||
out = append(out, audioChapter{
|
||||
Index: len(out),
|
||||
Title: chapter.Title,
|
||||
StartSeconds: start,
|
||||
EndSeconds: end,
|
||||
TrackIndex: trackIndex,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortAudioFiles(files []*models.MediaFile) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
left, right := files[i], files[j]
|
||||
if left.PresentationPartIndex != right.PresentationPartIndex {
|
||||
if left.PresentationPartIndex == 0 {
|
||||
return false
|
||||
}
|
||||
if right.PresentationPartIndex == 0 {
|
||||
return true
|
||||
}
|
||||
return left.PresentationPartIndex < right.PresentationPartIndex
|
||||
}
|
||||
if left.FilePath != right.FilePath {
|
||||
return left.FilePath < right.FilePath
|
||||
}
|
||||
return left.ID < right.ID
|
||||
})
|
||||
}
|
||||
|
||||
func audioDeliveryMode(file *models.MediaFile) string {
|
||||
if file == nil {
|
||||
return "direct"
|
||||
}
|
||||
codec := strings.ToLower(strings.TrimSpace(file.CodecAudio))
|
||||
container := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(file.Container), "."))
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.FilePath), "."))
|
||||
if container == "" {
|
||||
container = ext
|
||||
}
|
||||
switch codec {
|
||||
case "aac", "mp3", "alac", "flac":
|
||||
return "direct"
|
||||
case "":
|
||||
switch container {
|
||||
case "mp3", "m4a", "m4b", "mp4", "aac", "flac", "wav", "aiff", "aif":
|
||||
return "direct"
|
||||
}
|
||||
}
|
||||
return "aac"
|
||||
}
|
||||
|
||||
func audioSubtitle(detail *catalog.ItemDetail) string {
|
||||
if detail == nil || detail.Audiobook == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
if len(detail.Audiobook.Authors) > 0 {
|
||||
names := make([]string, 0, len(detail.Audiobook.Authors))
|
||||
for _, author := range detail.Audiobook.Authors {
|
||||
if strings.TrimSpace(author.Name) != "" {
|
||||
names = append(names, author.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
parts = append(parts, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
if len(detail.Audiobook.Narrators) > 0 {
|
||||
names := make([]string, 0, len(detail.Audiobook.Narrators))
|
||||
for _, narrator := range detail.Audiobook.Narrators {
|
||||
if strings.TrimSpace(narrator.Name) != "" {
|
||||
names = append(names, narrator.Name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
parts = append(parts, "Narrated by "+strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " • ")
|
||||
}
|
||||
|
||||
func resolveAudioResumePosition(ctx context.Context, provider userstore.UserStoreProvider, userID int, profileID, contentID string) float64 {
|
||||
if provider == nil || userID == 0 || profileID == "" {
|
||||
return 0
|
||||
}
|
||||
store, err := provider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
progress, err := store.GetProgress(ctx, profileID, contentID)
|
||||
if err != nil || progress == nil || progress.Completed {
|
||||
return 0
|
||||
}
|
||||
if progress.PositionSeconds < 30 {
|
||||
return 0
|
||||
}
|
||||
if progress.DurationSeconds > 0 && progress.PositionSeconds >= progress.DurationSeconds-5 {
|
||||
return 0
|
||||
}
|
||||
return progress.PositionSeconds
|
||||
}
|
||||
|
||||
func isFiniteNonNegative(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0
|
||||
}
|
||||
|
||||
func clampFloat(value, lower, upper float64) float64 {
|
||||
if !isFiniteNonNegative(value) {
|
||||
return lower
|
||||
}
|
||||
if value < lower {
|
||||
return lower
|
||||
}
|
||||
if upper > lower && value > upper {
|
||||
return upper
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func maxFloat(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type PGAudioBookmarkStore struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPGAudioBookmarkStore(pool *pgxpool.Pool) *PGAudioBookmarkStore {
|
||||
if pool == nil {
|
||||
return nil
|
||||
}
|
||||
return &PGAudioBookmarkStore{Pool: pool}
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) List(ctx context.Context, userID int, profileID, contentID string) ([]audioBookmark, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, library_item_id, time_seconds, title, created_at, updated_at
|
||||
FROM abs_bookmarks
|
||||
WHERE user_id = $1
|
||||
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
AND library_item_id = $3
|
||||
ORDER BY time_seconds ASC`,
|
||||
userID, profileArg(profileID), contentID,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return []audioBookmark{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
bookmarks := []audioBookmark{}
|
||||
for rows.Next() {
|
||||
var createdAt, updatedAt time.Time
|
||||
var bookmark audioBookmark
|
||||
if err := rows.Scan(&bookmark.ID, &bookmark.ContentID, &bookmark.TimeSeconds, &bookmark.Title, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bookmark.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
bookmark.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
|
||||
bookmarks = append(bookmarks, bookmark)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bookmarks, nil
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) Upsert(ctx context.Context, userID int, profileID, contentID string, timeSeconds float64, title string) (audioBookmark, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return audioBookmark{}, fmt.Errorf("bookmark store unavailable")
|
||||
}
|
||||
id := ulid.Make().String()
|
||||
var createdAt, updatedAt time.Time
|
||||
var bookmark audioBookmark
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO abs_bookmarks
|
||||
(id, user_id, profile_id, library_item_id, time_seconds, title)
|
||||
VALUES ($1, $2, $3::uuid, $4, $5, $6)
|
||||
ON CONFLICT (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
library_item_id,
|
||||
time_seconds
|
||||
) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
updated_at = now()
|
||||
RETURNING id, library_item_id, time_seconds, title, created_at, updated_at`,
|
||||
id, userID, profileArg(profileID), contentID, timeSeconds, strings.TrimSpace(title),
|
||||
).Scan(&bookmark.ID, &bookmark.ContentID, &bookmark.TimeSeconds, &bookmark.Title, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return audioBookmark{}, err
|
||||
}
|
||||
bookmark.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
bookmark.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
|
||||
return bookmark, nil
|
||||
}
|
||||
|
||||
func (s *PGAudioBookmarkStore) Delete(ctx context.Context, userID int, profileID, contentID string, timeSeconds float64) error {
|
||||
if s == nil || s.Pool == nil {
|
||||
return fmt.Errorf("bookmark store unavailable")
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM abs_bookmarks
|
||||
WHERE user_id = $1
|
||||
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
AND library_item_id = $3
|
||||
AND time_seconds = $4`,
|
||||
userID, profileArg(profileID), contentID, timeSeconds,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func profileArg(profileID string) any {
|
||||
if strings.TrimSpace(profileID) == "" {
|
||||
return nil
|
||||
}
|
||||
return profileID
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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"
|
||||
)
|
||||
|
||||
type stubAudioDetailService struct {
|
||||
detail *catalog.ItemDetail
|
||||
}
|
||||
|
||||
func (s stubAudioDetailService) GetItemDetail(context.Context, string, catalog.AccessFilter) (*catalog.ItemDetail, error) {
|
||||
return s.detail, nil
|
||||
}
|
||||
|
||||
type stubAudioFiles struct {
|
||||
files []*models.MediaFile
|
||||
}
|
||||
|
||||
func (s stubAudioFiles) GetByContentID(context.Context, string) ([]*models.MediaFile, error) {
|
||||
return append([]*models.MediaFile(nil), s.files...), nil
|
||||
}
|
||||
|
||||
func (s stubAudioFiles) GetByID(_ context.Context, id int) (*models.MediaFile, error) {
|
||||
for _, file := range s.files {
|
||||
if file.ID == id {
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestAudioStartPlaybackOrdersTracksAndBuildsGlobalChapters(t *testing.T) {
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{
|
||||
testAudioFile(3, "part-03.mp3", 30, 30),
|
||||
testAudioFile(1, "part-01.mp3", 10, 10),
|
||||
testAudioFile(2, "part-02.mp3", 20, 20),
|
||||
}},
|
||||
testUserStoreProvider{store: newPlaybackTestStore(t)},
|
||||
nil,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleStartPlayback(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp audioPlaybackResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Tracks) != 3 {
|
||||
t.Fatalf("tracks = %d, want 3", len(resp.Tracks))
|
||||
}
|
||||
if resp.Tracks[0].FileID != 1 || resp.Tracks[1].FileID != 2 || resp.Tracks[2].FileID != 3 {
|
||||
t.Fatalf("track order = [%d %d %d], want [1 2 3]", resp.Tracks[0].FileID, resp.Tracks[1].FileID, resp.Tracks[2].FileID)
|
||||
}
|
||||
if resp.Tracks[1].StartOffsetSeconds != 10 || resp.Tracks[2].StartOffsetSeconds != 30 {
|
||||
t.Fatalf("offsets = %.0f %.0f, want 10 30", resp.Tracks[1].StartOffsetSeconds, resp.Tracks[2].StartOffsetSeconds)
|
||||
}
|
||||
if len(resp.Chapters) != 3 || resp.Chapters[2].StartSeconds != 30 {
|
||||
t.Fatalf("global chapters = %+v, want third chapter at 30s", resp.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioSyncPersistsCumulativeProgressAndEnforcesOwner(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{
|
||||
testAudioFile(1, "part-01.mp3", 10, 10),
|
||||
testAudioFile(2, "part-02.mp3", 20, 20),
|
||||
}},
|
||||
testUserStoreProvider{store: store},
|
||||
nil,
|
||||
)
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
startRec := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRec, startReq)
|
||||
if startRec.Code != http.StatusOK {
|
||||
t.Fatalf("start status = %d body=%s", startRec.Code, startRec.Body.String())
|
||||
}
|
||||
var start audioPlaybackResponse
|
||||
if err := json.Unmarshal(startRec.Body.Bytes(), &start); err != nil {
|
||||
t.Fatalf("decode start: %v", err)
|
||||
}
|
||||
|
||||
otherCtx := apimw.SetClaims(context.Background(), &auth.Claims{UserID: 2, Role: "user", TokenType: auth.TokenTypeAccess})
|
||||
otherCtx = apimw.SetProfileID(otherCtx, "profile-1")
|
||||
denied := httptest.NewRequest(http.MethodPatch, "/api/v1/audio/playback/"+start.SessionID+"/sync", strings.NewReader(`{"position":12,"duration":30}`))
|
||||
denied = withAudioRouteParam(denied.WithContext(otherCtx), "session_id", start.SessionID)
|
||||
deniedRec := httptest.NewRecorder()
|
||||
handler.HandleSyncPlayback(deniedRec, denied)
|
||||
if deniedRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("foreign sync status = %d, want 403", deniedRec.Code)
|
||||
}
|
||||
|
||||
syncReq := httptest.NewRequest(http.MethodPatch, "/api/v1/audio/playback/"+start.SessionID+"/sync", strings.NewReader(`{"position":18,"duration":30}`))
|
||||
syncReq = withAudioRouteParam(syncReq.WithContext(newAuthorizedPlaybackContext()), "session_id", start.SessionID)
|
||||
syncRec := httptest.NewRecorder()
|
||||
handler.HandleSyncPlayback(syncRec, syncReq)
|
||||
if syncRec.Code != http.StatusOK {
|
||||
t.Fatalf("sync status = %d body=%s", syncRec.Code, syncRec.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 != 18 || progress.DurationSeconds != 30 {
|
||||
t.Fatalf("progress = %+v, want 18/30", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioSessionExpires(t *testing.T) {
|
||||
handler := NewAudioHandler(
|
||||
stubAudioDetailService{detail: testAudiobookDetail()},
|
||||
stubAudioFiles{files: []*models.MediaFile{testAudioFile(1, "part-01.mp3", 10, 10)}},
|
||||
testUserStoreProvider{store: newPlaybackTestStore(t)},
|
||||
nil,
|
||||
)
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler.now = func() time.Time { return now }
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/audio/playback/start", strings.NewReader(`{"content_id":"book-1"}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
startRec := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRec, startReq)
|
||||
var start audioPlaybackResponse
|
||||
if err := json.Unmarshal(startRec.Body.Bytes(), &start); err != nil {
|
||||
t.Fatalf("decode start: %v", err)
|
||||
}
|
||||
|
||||
now = now.Add(audioPlaybackSessionTTL + time.Second)
|
||||
streamReq := httptest.NewRequest(http.MethodHead, "/api/v1/audio/playback/"+start.SessionID+"/tracks/0", nil)
|
||||
streamReq = withAudioRouteParam(streamReq.WithContext(newAuthorizedPlaybackContext()), "session_id", start.SessionID)
|
||||
streamReq = withAudioRouteParam(streamReq, "track_index", "0")
|
||||
streamRec := httptest.NewRecorder()
|
||||
handler.HandleStreamTrack(streamRec, streamReq)
|
||||
if streamRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expired stream status = %d, want 404", streamRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func testAudiobookDetail() *catalog.ItemDetail {
|
||||
return &catalog.ItemDetail{
|
||||
ContentID: "book-1",
|
||||
Type: "audiobook",
|
||||
Title: "Book One",
|
||||
Audiobook: &catalog.AudiobookDetailExtension{
|
||||
Authors: []catalog.AudiobookPerson{{Name: "Author One"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testAudioFile(id int, name string, partIndex int, duration int) *models.MediaFile {
|
||||
return &models.MediaFile{
|
||||
ID: id,
|
||||
ContentID: "book-1",
|
||||
FilePath: "/media/" + name,
|
||||
CodecAudio: "mp3",
|
||||
Container: "mp3",
|
||||
Duration: duration,
|
||||
PresentationPartIndex: partIndex,
|
||||
Chapters: []models.MediaChapter{{
|
||||
Index: 0,
|
||||
Title: name,
|
||||
StartSeconds: 0,
|
||||
EndSeconds: float64(duration),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func withAudioRouteParam(req *http.Request, key, value string) *http.Request {
|
||||
routeCtx := chi.RouteContext(req.Context())
|
||||
if routeCtx == nil {
|
||||
routeCtx = chi.NewRouteContext()
|
||||
}
|
||||
routeCtx.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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", "audiobook":
|
||||
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,901 +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,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
ctx = apimw.SetProfileID(ctx, "profile-1")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withEbookReaderRouteParams(req *http.Request, contentID, fileID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
routeCtx.URLParams.Add("file_id", fileID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func withEbookReaderContentRouteParam(req *http.Request, contentID string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("content_id", contentID)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
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,
|
||||
Role: "user",
|
||||
TokenType: auth.TokenTypeAccess,
|
||||
})
|
||||
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSaveProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if store.upserted == nil {
|
||||
t.Fatal("expected progress to be saved")
|
||||
}
|
||||
if store.upserted.UserID != 1 || store.upserted.ProfileID != "profile-9" {
|
||||
t.Fatalf("scope = user %d profile %q", store.upserted.UserID, store.upserted.ProfileID)
|
||||
}
|
||||
if store.upserted.ContentID != "ebook-1" || store.upserted.FileID != 42 {
|
||||
t.Fatalf("target = content %q file %d", store.upserted.ContentID, store.upserted.FileID)
|
||||
}
|
||||
if store.upserted.Location != "epubcfi(/6/4)" || store.upserted.Progress != 0.42 {
|
||||
t.Fatalf("progress = %+v", store.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookReaderReturnsSavedProgress(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
|
||||
FileResolver: stubMediaFileResolver{},
|
||||
ItemAccess: stubItemAccessChecker{},
|
||||
})
|
||||
handler.ProgressStore = &fakeEbookProgressStore{
|
||||
progress: &EbookReaderProgress{
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-1",
|
||||
FileID: 42,
|
||||
Location: "epubcfi(/6/8)",
|
||||
Progress: 0.75,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/progress")
|
||||
req = withEbookReaderContentRouteParam(req, "ebook-1")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleGetProgress(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
`"content_id":"ebook-1"`,
|
||||
`"file_id":42`,
|
||||
`"location":"epubcfi(/6/8)"`,
|
||||
`"progress":0.75`,
|
||||
`"updated_at":"2026-06-08T12:00:00Z"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response %s missing %s", body, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
Role: "user",
|
||||
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,
|
||||
Role: "user",
|
||||
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,8 +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/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/presence"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -33,11 +31,6 @@ type activeScanLister interface {
|
||||
ListActive(ctx context.Context) ([]evt.ScanRun, error)
|
||||
}
|
||||
|
||||
// notificationUnreadCounter is the subset of notifications.Service used by EventsHandler.
|
||||
type notificationUnreadCounter interface {
|
||||
UnreadCount(ctx context.Context, userID int, profileID string, childSafe bool) (int, error)
|
||||
}
|
||||
|
||||
type EventsHandler struct {
|
||||
hub *evt.Hub
|
||||
jobs *AdminJobsHandler
|
||||
@@ -46,8 +39,6 @@ type EventsHandler struct {
|
||||
scans *evt.ScanRegistry
|
||||
persistedScans activeScanLister
|
||||
historyImports historyImportActiveLister
|
||||
notifications notificationUnreadCounter
|
||||
presence presence.Registry
|
||||
}
|
||||
|
||||
func NewEventsHandler(
|
||||
@@ -58,8 +49,6 @@ func NewEventsHandler(
|
||||
scans *evt.ScanRegistry,
|
||||
persistedScans *scanqueue.Service,
|
||||
historyImports historyImportActiveLister,
|
||||
notificationsSvc *notifications.Service,
|
||||
presenceReg presence.Registry,
|
||||
) *EventsHandler {
|
||||
return &EventsHandler{
|
||||
hub: hub,
|
||||
@@ -69,8 +58,6 @@ func NewEventsHandler(
|
||||
scans: scans,
|
||||
persistedScans: persistedScans,
|
||||
historyImports: historyImports,
|
||||
notifications: notificationsSvc,
|
||||
presence: presenceReg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,16 +85,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
eventsCh, unsubscribe := h.hub.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
if h.presence != nil {
|
||||
release := h.presence.Add(ctx, claims.UserID)
|
||||
defer release()
|
||||
}
|
||||
|
||||
startWebSocketPingLoop(ctx, func() error {
|
||||
if h.presence != nil {
|
||||
h.presence.Refresh(ctx, claims.UserID)
|
||||
}
|
||||
return writeWebSocketControl(conn, websocket.PingMessage, nil)
|
||||
})
|
||||
|
||||
@@ -296,8 +274,6 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
|
||||
channels := []evt.EventChannel{
|
||||
evt.ChannelCatalog,
|
||||
evt.ChannelHistoryImport,
|
||||
evt.ChannelNotifications,
|
||||
evt.ChannelRequests,
|
||||
evt.ChannelUserState,
|
||||
}
|
||||
if role == "admin" {
|
||||
@@ -318,24 +294,12 @@ func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
|
||||
if env.AdminOnly && claims.Role != "admin" {
|
||||
return false
|
||||
}
|
||||
if env.UserID > 0 && env.UserID != claims.UserID {
|
||||
// The notifications and requests channels carry per-user private content
|
||||
// (inbox titles/bodies, requested media names). Nobody — including
|
||||
// admins — may receive another user's events on them. Other channels
|
||||
// keep the admin cross-user monitoring bypass.
|
||||
if claims.Role != "admin" || isPersonalChannel(env.Channel) {
|
||||
return false
|
||||
}
|
||||
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isPersonalChannel reports whether a channel carries per-user private content
|
||||
// that must never be delivered across user boundaries, even to admins.
|
||||
func isPersonalChannel(c evt.EventChannel) bool {
|
||||
return c == evt.ChannelNotifications || c == evt.ChannelRequests
|
||||
}
|
||||
|
||||
func marshalJSON(value any) json.RawMessage {
|
||||
if value == nil {
|
||||
return json.RawMessage("null")
|
||||
@@ -355,28 +319,6 @@ func (h *EventsHandler) snapshotForChannel(
|
||||
switch channel {
|
||||
case evt.ChannelCatalog, evt.ChannelUserState:
|
||||
return json.RawMessage("null"), nil
|
||||
case evt.ChannelNotifications:
|
||||
if h == nil || h.notifications == nil {
|
||||
return json.RawMessage(`{"unread_count":0}`), nil
|
||||
}
|
||||
// Profile ID is read directly from the WS upgrade request header because
|
||||
// the events/ws route does not go through the RequireProfile middleware.
|
||||
// childSafe is intentionally false here: the EventsHandler has no access
|
||||
// to a profile store, so the WS snapshot is a conservative approximation.
|
||||
// The REST GET /notifications/unread-count endpoint is authoritative for
|
||||
// child-safe profile filtering.
|
||||
profileID := r.Header.Get("X-Profile-Id")
|
||||
count, err := h.notifications.UnreadCount(r.Context(), claims.UserID, profileID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalJSON(struct {
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}{UnreadCount: count}), nil
|
||||
case evt.ChannelRequests:
|
||||
// Request state is fetched via REST; the WS channel only carries
|
||||
// lifecycle events. Return an empty object as the initial snapshot.
|
||||
return json.RawMessage(`{}`), nil
|
||||
case evt.ChannelJobs:
|
||||
if h == nil || h.jobs == nil || h.jobs.repo == nil {
|
||||
return json.RawMessage("[]"), nil
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake notificationUnreadCounter for snapshot tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakeUnreadCounter struct {
|
||||
count int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeUnreadCounter) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
|
||||
return f.count, f.err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// allowedChannelsForRole tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAllowedChannelsForRole_UserContainsNotificationsAndRequests(t *testing.T) {
|
||||
channels := allowedChannelsForRole("user")
|
||||
if !slices.Contains(channels, evt.ChannelNotifications) {
|
||||
t.Errorf("allowedChannelsForRole(user) missing ChannelNotifications; got %v", channels)
|
||||
}
|
||||
if !slices.Contains(channels, evt.ChannelRequests) {
|
||||
t.Errorf("allowedChannelsForRole(user) missing ChannelRequests; got %v", channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedChannelsForRole_AdminContainsAllUserChannelsPlusExtras(t *testing.T) {
|
||||
userChannels := allowedChannelsForRole("user")
|
||||
adminChannels := allowedChannelsForRole("admin")
|
||||
|
||||
for _, ch := range userChannels {
|
||||
if !slices.Contains(adminChannels, ch) {
|
||||
t.Errorf("allowedChannelsForRole(admin) missing user channel %q", ch)
|
||||
}
|
||||
}
|
||||
|
||||
adminExtras := []evt.EventChannel{
|
||||
evt.ChannelJobs,
|
||||
evt.ChannelSessions,
|
||||
evt.ChannelTasks,
|
||||
evt.ChannelScans,
|
||||
}
|
||||
for _, ch := range adminExtras {
|
||||
if !slices.Contains(adminChannels, ch) {
|
||||
t.Errorf("allowedChannelsForRole(admin) missing admin-only channel %q", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// snapshotForChannel tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSnapshotForChannel_NotificationsNilService(t *testing.T) {
|
||||
h := &EventsHandler{notifications: nil}
|
||||
r := httptest.NewRequest("GET", "/events/ws", nil)
|
||||
claims := &auth.Claims{UserID: 42, Role: "user"}
|
||||
|
||||
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v, raw=%s", err, data)
|
||||
}
|
||||
if got.UnreadCount != 0 {
|
||||
t.Errorf("nil service: want unread_count=0, got %d", got.UnreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotForChannel_NotificationsWithService(t *testing.T) {
|
||||
h := &EventsHandler{notifications: &fakeUnreadCounter{count: 7}}
|
||||
r := httptest.NewRequest("GET", "/events/ws", nil)
|
||||
r.Header.Set("X-Profile-Id", "profile-abc")
|
||||
claims := &auth.Claims{UserID: 5, Role: "user"}
|
||||
|
||||
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v, raw=%s", err, data)
|
||||
}
|
||||
if got.UnreadCount != 7 {
|
||||
t.Errorf("want unread_count=7, got %d", got.UnreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotForChannel_RequestsReturnsEmptyObject(t *testing.T) {
|
||||
h := &EventsHandler{}
|
||||
r := httptest.NewRequest("GET", "/events/ws", nil)
|
||||
claims := &auth.Claims{UserID: 1, Role: "user"}
|
||||
|
||||
data, err := h.snapshotForChannel(r, claims, evt.ChannelRequests)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v, raw=%s", err, data)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("ChannelRequests snapshot should be empty object, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+27
-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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,18 @@ 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 "audiobooks", "audiobook":
|
||||
levels = []string{"audiobook"}
|
||||
case "mixed":
|
||||
levels = []string{"movie", "series", "season", "episode", "audiobook"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2054,7 +2055,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 +2093,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// profileResolver is the minimal interface NotificationsHandler needs from the
|
||||
// userstore provider. Defined on the handler side so tests can stub it without
|
||||
// importing the full provider.
|
||||
type profileResolver interface {
|
||||
ForUser(ctx context.Context, userID int) (userstore.UserStore, error)
|
||||
}
|
||||
|
||||
// NotificationsHandler handles inbox, preferences, and announcement endpoints.
|
||||
type NotificationsHandler struct {
|
||||
svc *notifications.Service
|
||||
profiles profileResolver // nil-tolerant: ChildSafe=false when absent
|
||||
}
|
||||
|
||||
// NewNotificationsHandler creates a new NotificationsHandler.
|
||||
// provider may be nil; when nil, ChildSafe defaults to false on all requests.
|
||||
func NewNotificationsHandler(svc *notifications.Service, provider ...profileResolver) *NotificationsHandler {
|
||||
h := &NotificationsHandler{svc: svc}
|
||||
if len(provider) > 0 {
|
||||
h.profiles = provider[0]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// childSafe resolves the active profile and returns Profile.IsChild.
|
||||
// Fails closed: an unresolvable profile is treated as a child so restricted
|
||||
// categories are never leaked on errors. Logs at debug level only — never fails
|
||||
// the request. Nil resolver / missing ids keep returning false (unauthenticated
|
||||
// paths never reach here; that's the wiring-absent case).
|
||||
func (h *NotificationsHandler) childSafe(r *http.Request, userID int, profileID string) bool {
|
||||
if h.profiles == nil || userID == 0 || profileID == "" {
|
||||
return false
|
||||
}
|
||||
store, err := h.profiles.ForUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Debug("notifications: childSafe: ForUser failed", "user_id", userID, "error", err)
|
||||
return true // fail closed: treat unknown as child
|
||||
}
|
||||
p, err := store.GetProfile(r.Context(), profileID)
|
||||
if err != nil || p == nil {
|
||||
slog.Debug("notifications: childSafe: GetProfile failed", "profile_id", profileID, "error", err)
|
||||
return true // fail closed: treat unknown as child
|
||||
}
|
||||
return p.IsChild
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User-scoped inbox endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// HandleList handles GET /notifications.
|
||||
// Query params: unread=1, category, cursor (int64), limit (int, default 50, max 100).
|
||||
// Response: {"items":[...],"next_cursor":<int64|null>}
|
||||
func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
limit := 50
|
||||
if v := q.Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
// keep in sync with the clamp in internal/notifications/store.go List
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
f := notifications.ListFilter{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
Limit: limit,
|
||||
ChildSafe: h.childSafe(r, userID, profileID),
|
||||
}
|
||||
if q.Get("unread") == "1" {
|
||||
f.UnreadOnly = true
|
||||
}
|
||||
if cat := q.Get("category"); cat != "" {
|
||||
f.Category = notifications.Category(cat)
|
||||
}
|
||||
if v := q.Get("cursor"); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
f.Cursor = n
|
||||
}
|
||||
}
|
||||
|
||||
items, err := h.svc.List(r.Context(), f)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications")
|
||||
return
|
||||
}
|
||||
|
||||
var nextCursor *int64
|
||||
if len(items) == limit && limit > 0 {
|
||||
last := items[len(items)-1].ID
|
||||
nextCursor = &last
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Items []*notifications.Notification `json:"items"`
|
||||
NextCursor *int64 `json:"next_cursor"`
|
||||
}{
|
||||
Items: items,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleUnreadCount handles GET /notifications/unread-count.
|
||||
// Response: {"count": n}
|
||||
func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
count, err := h.svc.UnreadCount(r.Context(), userID, profileID, h.childSafe(r, userID, profileID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get unread count")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Count int `json:"count"`
|
||||
}{Count: count})
|
||||
}
|
||||
|
||||
// markReadRequest is the request body for POST /notifications/read.
|
||||
type markReadRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
|
||||
// HandleMarkRead handles POST /notifications/read.
|
||||
// Body: {"ids":[...]} OR {"all":true}. 400 when neither present.
|
||||
func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
var req markReadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if !req.All && len(req.IDs) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Provide ids or all:true")
|
||||
return
|
||||
}
|
||||
|
||||
if req.All {
|
||||
if err := h.svc.MarkAllRead(r.Context(), userID, profileID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark all read")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.svc.MarkRead(r.Context(), userID, profileID, req.IDs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read")
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleDismiss handles POST /notifications/{id}/dismiss.
|
||||
// 400 for invalid id; 404 on ErrNotFound; 204 on success.
|
||||
func (h *NotificationsHandler) HandleDismiss(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
|
||||
rawID := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(rawID, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid notification id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Dismiss(r.Context(), userID, profileID, id); err != nil {
|
||||
if errors.Is(err, notifications.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to dismiss notification")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleGetPreferences handles GET /notifications/preferences.
|
||||
// Response: {"preferences":[{category,enabled},...]}
|
||||
func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
prefs, err := h.svc.GetPreferences(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get preferences")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Preferences []notifications.Preference `json:"preferences"`
|
||||
}{Preferences: prefs})
|
||||
}
|
||||
|
||||
// putPreferencesRequest is the request body for PUT /notifications/preferences.
|
||||
type putPreferencesRequest struct {
|
||||
Preferences []notifications.Preference `json:"preferences"`
|
||||
}
|
||||
|
||||
// HandlePutPreferences handles PUT /notifications/preferences.
|
||||
// 400 on empty or invalid category; 204 on success.
|
||||
func (h *NotificationsHandler) HandlePutPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
var req putPreferencesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if len(req.Preferences) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "preferences must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SetPreferences(r.Context(), userID, req.Preferences); err != nil {
|
||||
if errors.Is(err, notifications.ErrInvalidCategory) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save preferences")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin announcement endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// HandleListAnnouncements handles GET /admin/announcements.
|
||||
// Response: {"items":[...]}
|
||||
func (h *NotificationsHandler) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.svc.ListAnnouncements(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list announcements")
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []*notifications.Announcement{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Items []*notifications.Announcement `json:"items"`
|
||||
}{Items: items})
|
||||
}
|
||||
|
||||
// HandleCreateAnnouncement handles POST /admin/announcements.
|
||||
// Decodes an Announcement, sets CreatedBy from claims, publishes; 400 on
|
||||
// validation error; 201 with the announcement on success.
|
||||
func (h *NotificationsHandler) HandleCreateAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
|
||||
// Announcements fan out one row per (user, profile); cap the request body so
|
||||
// a large title/body cannot be amplified into the whole server's inboxes.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
|
||||
var a notifications.Announcement
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if claims != nil {
|
||||
userID := claims.UserID
|
||||
a.CreatedBy = &userID
|
||||
}
|
||||
|
||||
if err := h.svc.PublishAnnouncement(r.Context(), &a); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, &a)
|
||||
}
|
||||
|
||||
// HandleDeleteAnnouncement handles DELETE /admin/announcements/{id}.
|
||||
// 404 on ErrNotFound; 204 on success.
|
||||
func (h *NotificationsHandler) HandleDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
rawID := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(rawID, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid announcement id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAnnouncement(r.Context(), id); err != nil {
|
||||
if errors.Is(err, notifications.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Announcement not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete announcement")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,673 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake profile resolver for child-safe tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fakeProfileStore satisfies userstore.UserStore minimally.
|
||||
// Only GetProfile is exercised by childSafe.
|
||||
type fakeProfileStore struct {
|
||||
userstore.UserStore // embed for unimplemented methods
|
||||
isChild bool
|
||||
}
|
||||
|
||||
func (s *fakeProfileStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
|
||||
return &userstore.Profile{ID: id, IsChild: s.isChild}, nil
|
||||
}
|
||||
|
||||
// fakeResolverAsProfileResolver implements the profileResolver interface used
|
||||
// by NotificationsHandler.
|
||||
type fakeResolverAsProfileResolver struct {
|
||||
isChild bool
|
||||
}
|
||||
|
||||
func (f *fakeResolverAsProfileResolver) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
|
||||
return &fakeProfileStore{isChild: f.isChild}, nil
|
||||
}
|
||||
|
||||
// fakeResolverWithError returns an error from ForUser.
|
||||
type fakeResolverWithError struct{}
|
||||
|
||||
func (f *fakeResolverWithError) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
|
||||
return nil, errors.New("resolver failed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake store implementing notifications.Store for handler tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakeNotificationsStore struct {
|
||||
notifications.Store // embed for unimplemented panics
|
||||
|
||||
list []*notifications.Notification
|
||||
announcements []*notifications.Announcement
|
||||
unreadCount int
|
||||
prefs map[notifications.Category]bool
|
||||
markReadCalled bool
|
||||
markAllReadCalled bool
|
||||
dismissErr error
|
||||
setPrefErr error
|
||||
deleteAnnouncementErr error
|
||||
insertCalled bool
|
||||
|
||||
// capturedFilter records the most recent ListFilter passed to List.
|
||||
capturedFilter *notifications.ListFilter
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) Insert(_ context.Context, n *notifications.Notification) (bool, error) {
|
||||
f.insertCalled = true
|
||||
f.list = append(f.list, n)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) List(_ context.Context, filter notifications.ListFilter) ([]*notifications.Notification, error) {
|
||||
f.capturedFilter = &filter
|
||||
return f.list, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
|
||||
return f.unreadCount, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) MarkRead(_ context.Context, _ int, _ string, _ []int64) error {
|
||||
f.markReadCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) MarkAllRead(_ context.Context, _ int, _ string) error {
|
||||
f.markAllReadCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) Dismiss(_ context.Context, _ int, _ string, _ int64) error {
|
||||
return f.dismissErr
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) Preferences(_ context.Context, _ int) (map[notifications.Category]bool, error) {
|
||||
if f.prefs != nil {
|
||||
return f.prefs, nil
|
||||
}
|
||||
return map[notifications.Category]bool{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) SetPreferences(_ context.Context, _ int, _ []notifications.Preference) error {
|
||||
return f.setPrefErr
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) InsertAnnouncement(_ context.Context, a *notifications.Announcement) error {
|
||||
a.ID = int64(len(f.announcements) + 1)
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
f.announcements = append(f.announcements, a)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) ListAnnouncements(_ context.Context) ([]*notifications.Announcement, error) {
|
||||
return f.announcements, nil
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) DeleteAnnouncement(_ context.Context, id int64) error {
|
||||
if f.deleteAnnouncementErr != nil {
|
||||
return f.deleteAnnouncementErr
|
||||
}
|
||||
for i, a := range f.announcements {
|
||||
if a.ID == id {
|
||||
f.announcements = append(f.announcements[:i], f.announcements[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return notifications.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeNotificationsStore) DismissAnnouncementNotifications(_ context.Context, _ int64) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeNotificationsStore) ProfileIDsForUsers(_ context.Context, _ []int) (map[int][]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeNotificationsStore) PurgeOld(_ context.Context, _, _ time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeNotificationsStore) AdminUserIDs(_ context.Context) ([]int, error) { return nil, nil }
|
||||
func (f *fakeNotificationsStore) UserIDsWithLibraryAccess(_ context.Context, _ int) ([]int, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeNotificationsStore) AllEnabledUserIDs(_ context.Context) ([]int, error) { return nil, nil }
|
||||
|
||||
// Compile-time check.
|
||||
var _ notifications.Store = (*fakeNotificationsStore)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build a handler over the fake store.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newTestNotificationsHandler(store *fakeNotificationsStore) *NotificationsHandler {
|
||||
hub := evt.NewHub("test", nil)
|
||||
svc := notifications.NewService(store, hub)
|
||||
return NewNotificationsHandler(svc)
|
||||
}
|
||||
|
||||
func newTestNotificationsHandlerWithResolver(store *fakeNotificationsStore, resolver profileResolver) *NotificationsHandler {
|
||||
hub := evt.NewHub("test", nil)
|
||||
svc := notifications.NewService(store, hub)
|
||||
return NewNotificationsHandler(svc, resolver)
|
||||
}
|
||||
|
||||
// notifUserRequest creates an authenticated request with a profile ID.
|
||||
func notifUserRequest(method, target string, body []byte, userID int, profileID string) *http.Request {
|
||||
var req *http.Request
|
||||
if len(body) > 0 {
|
||||
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "user"})
|
||||
ctx = apimw.SetProfileID(ctx, profileID)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// notifAdminRequest creates an admin-authenticated request.
|
||||
func notifAdminRequest(method, target string, body []byte, userID int) *http.Request {
|
||||
var req *http.Request
|
||||
if len(body) > 0 {
|
||||
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "admin"})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// notifChiRequest adds a chi URL param to a request context.
|
||||
func notifChiRequest(r *http.Request, key, value string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add(key, value)
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleList
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleList_HappyPath_TwoRowsNoCursor(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := &fakeNotificationsStore{
|
||||
list: []*notifications.Notification{
|
||||
{ID: 2, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
|
||||
{ID: 1, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
|
||||
},
|
||||
}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []*notifications.Notification `json:"items"`
|
||||
NextCursor *int64 `json:"next_cursor"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 2 {
|
||||
t.Fatalf("items count = %d, want 2", len(resp.Items))
|
||||
}
|
||||
// len < limit (default 50), so next_cursor must be null.
|
||||
if resp.NextCursor != nil {
|
||||
t.Fatalf("next_cursor = %v, want nil (items < limit)", resp.NextCursor)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleUnreadCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleUnreadCount_ReturnsCount(t *testing.T) {
|
||||
store := &fakeNotificationsStore{unreadCount: 7}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications/unread-count", nil, 1, "prof-1")
|
||||
h.HandleUnreadCount(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Count != 7 {
|
||||
t.Fatalf("count = %d, want 7", resp.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleMarkRead
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleMarkRead_EmptyBody_Returns400(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{}`), 1, "prof-1")
|
||||
h.HandleMarkRead(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleMarkRead_AllTrue_Returns204(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"all":true}`), 1, "prof-1")
|
||||
h.HandleMarkRead(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !store.markAllReadCalled {
|
||||
t.Fatal("MarkAllRead was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleMarkRead_IDs_Returns204(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"ids":[1,2,3]}`), 1, "prof-1")
|
||||
h.HandleMarkRead(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !store.markReadCalled {
|
||||
t.Fatal("MarkRead was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleDismiss
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleDismiss_NotFound_Returns404(t *testing.T) {
|
||||
store := &fakeNotificationsStore{dismissErr: notifications.ErrNotFound}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/999/dismiss", nil, 1, "prof-1")
|
||||
req = notifChiRequest(req, "id", "999")
|
||||
h.HandleDismiss(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleDismiss_BadID_Returns400(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPost, "/notifications/abc/dismiss", nil, 1, "prof-1")
|
||||
req = notifChiRequest(req, "id", "abc")
|
||||
h.HandleDismiss(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandleGetPreferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleGetPreferences_DefaultsReturned(t *testing.T) {
|
||||
// No stored prefs → service returns all MutableCategories with defaults.
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications/preferences", nil, 1, "prof-1")
|
||||
h.HandleGetPreferences(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Preferences []notifications.Preference `json:"preferences"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Preferences) != len(notifications.MutableCategories) {
|
||||
t.Fatalf("prefs count = %d, want %d", len(resp.Preferences), len(notifications.MutableCategories))
|
||||
}
|
||||
|
||||
prefMap := make(map[notifications.Category]bool)
|
||||
for _, p := range resp.Preferences {
|
||||
prefMap[p.Category] = p.Enabled
|
||||
}
|
||||
|
||||
// content_digest defaults to false (opt-in); all others default to true.
|
||||
for _, cat := range notifications.MutableCategories {
|
||||
enabled, ok := prefMap[cat]
|
||||
if !ok {
|
||||
t.Errorf("category %q missing from preferences", cat)
|
||||
continue
|
||||
}
|
||||
if cat == notifications.CategoryContentDigest {
|
||||
if enabled {
|
||||
t.Errorf("content_digest default should be false (opt-in), got true")
|
||||
}
|
||||
} else {
|
||||
if !enabled {
|
||||
t.Errorf("category %q default should be true, got false", cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: HandlePutPreferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandlePutPreferences_RejectsAnnouncementCategory_400(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
// "announcement" is not in MutableCategories.
|
||||
body := `{"preferences":[{"category":"announcement","enabled":false}]}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
|
||||
h.HandlePutPreferences(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandlePutPreferences_ValidPrefs_204(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
body := `{"preferences":[{"category":"request","enabled":false},{"category":"content","enabled":true}]}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
|
||||
h.HandlePutPreferences(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Admin announcements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNotificationsHandleCreateAnnouncement_201(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
body := `{"title":"Test Announcement","body":"Hello","audience":{"all":true}}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
|
||||
h.HandleCreateAnnouncement(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp notifications.Announcement
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Title != "Test Announcement" {
|
||||
t.Fatalf("title = %q, want %q", resp.Title, "Test Announcement")
|
||||
}
|
||||
if resp.CreatedBy == nil || *resp.CreatedBy != 1 {
|
||||
t.Fatalf("created_by = %v, want 1", resp.CreatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleCreateAnnouncement_ValidationError_400(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
// Empty title triggers service validation error.
|
||||
body := `{"title":"","body":"Hello","audience":{"all":true}}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
|
||||
h.HandleCreateAnnouncement(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleDeleteAnnouncement_204(t *testing.T) {
|
||||
store := &fakeNotificationsStore{
|
||||
announcements: []*notifications.Announcement{
|
||||
{ID: 5, Title: "To Delete", Audience: notifications.Audience{All: true}},
|
||||
},
|
||||
}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/5", nil, 1)
|
||||
req = notifChiRequest(req, "id", "5")
|
||||
h.HandleDeleteAnnouncement(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleDeleteAnnouncement_NotFound_404(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/999", nil, 1)
|
||||
req = notifChiRequest(req, "id", "999")
|
||||
h.HandleDeleteAnnouncement(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsHandleListAnnouncements_ReturnsItems(t *testing.T) {
|
||||
store := &fakeNotificationsStore{
|
||||
announcements: []*notifications.Announcement{
|
||||
{ID: 1, Title: "Hello", Audience: notifications.Audience{All: true}},
|
||||
},
|
||||
}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifAdminRequest(http.MethodGet, "/admin/announcements", nil, 1)
|
||||
h.HandleListAnnouncements(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []*notifications.Announcement `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 1 {
|
||||
t.Fatalf("items count = %d, want 1", len(resp.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: ChildSafe profile filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestNotificationsList_ChildProfileFiltered asserts that when the profile
|
||||
// resolver reports IsChild=true, the ListFilter passed to the store has
|
||||
// ChildSafe=true.
|
||||
func TestNotificationsList_ChildProfileFiltered(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
resolver := &fakeResolverAsProfileResolver{isChild: true}
|
||||
h := newTestNotificationsHandlerWithResolver(store, resolver)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-child")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.capturedFilter == nil {
|
||||
t.Fatal("List was not called; capturedFilter is nil")
|
||||
}
|
||||
if !store.capturedFilter.ChildSafe {
|
||||
t.Fatalf("capturedFilter.ChildSafe = false, want true for child profile")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsList_AdultProfileNotFiltered asserts that a non-child
|
||||
// profile leaves ChildSafe=false in the ListFilter.
|
||||
func TestNotificationsList_AdultProfileNotFiltered(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
resolver := &fakeResolverAsProfileResolver{isChild: false}
|
||||
h := newTestNotificationsHandlerWithResolver(store, resolver)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-adult")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.capturedFilter == nil {
|
||||
t.Fatal("List was not called; capturedFilter is nil")
|
||||
}
|
||||
if store.capturedFilter.ChildSafe {
|
||||
t.Fatalf("capturedFilter.ChildSafe = true, want false for non-child profile")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsList_NilResolverDefaultsToFalse asserts that when no
|
||||
// resolver is wired (nil), ChildSafe remains false.
|
||||
func TestNotificationsList_NilResolverDefaultsToFalse(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
h := newTestNotificationsHandler(store) // no resolver
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.capturedFilter == nil {
|
||||
t.Fatal("List was not called; capturedFilter is nil")
|
||||
}
|
||||
if store.capturedFilter.ChildSafe {
|
||||
t.Fatalf("capturedFilter.ChildSafe = true, want false when no resolver is wired")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsList_ResolverErrorFailsClosed asserts that when the resolver
|
||||
// returns an error, ChildSafe=true (fail closed).
|
||||
func TestNotificationsList_ResolverErrorFailsClosed(t *testing.T) {
|
||||
store := &fakeNotificationsStore{}
|
||||
resolver := &fakeResolverWithError{}
|
||||
h := newTestNotificationsHandlerWithResolver(store, resolver)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.capturedFilter == nil {
|
||||
t.Fatal("List was not called; capturedFilter is nil")
|
||||
}
|
||||
if !store.capturedFilter.ChildSafe {
|
||||
t.Fatalf("capturedFilter.ChildSafe = false, want true (fail closed on resolver error)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsList_NextCursorSet asserts that when limit=2 and the store
|
||||
// returns exactly 2 notifications, next_cursor is set to the last item's ID.
|
||||
func TestNotificationsList_NextCursorSet(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := &fakeNotificationsStore{
|
||||
list: []*notifications.Notification{
|
||||
{ID: 12, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
|
||||
{ID: 11, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
|
||||
},
|
||||
}
|
||||
h := newTestNotificationsHandler(store)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := notifUserRequest(http.MethodGet, "/notifications?limit=2", nil, 1, "prof-1")
|
||||
h.HandleList(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Items []*notifications.Notification `json:"items"`
|
||||
NextCursor *int64 `json:"next_cursor"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 2 {
|
||||
t.Fatalf("items count = %d, want 2", len(resp.Items))
|
||||
}
|
||||
// With limit=2 and 2 items returned, next_cursor should be set to the last item's ID (11).
|
||||
if resp.NextCursor == nil {
|
||||
t.Fatal("next_cursor is nil, want 11")
|
||||
}
|
||||
if *resp.NextCursor != 11 {
|
||||
t.Fatalf("next_cursor = %d, want 11", *resp.NextCursor)
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused import.
|
||||
var _ = strings.Contains
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
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/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -50,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)
|
||||
}
|
||||
@@ -60,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
|
||||
}
|
||||
@@ -109,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
|
||||
@@ -130,12 +139,11 @@ type PlaybackHandler struct {
|
||||
RealtimeHub *playback.RealtimeHub
|
||||
CommandTracker *playback.CommandTracker
|
||||
CommandDispatcher *playback.CommandDispatcher
|
||||
// PlaybackConfig returns the current playback config (ffmpeg path,
|
||||
// hwaccel, transcode dir). Wired to the live config in integrated mode
|
||||
// so admin changes apply to newly started transcodes. Read it through
|
||||
// playbackConfig(), which falls back to defaults when unset.
|
||||
PlaybackConfig func() config.PlaybackConfig
|
||||
FFmpegLogSink playback.FFmpegLogSink
|
||||
FFmpegPath string
|
||||
HWAccel string
|
||||
HWDevice string
|
||||
TranscodeDir string
|
||||
FFmpegLogSink playback.FFmpegLogSink
|
||||
transcodeMu sync.RWMutex
|
||||
transcodes map[string]*playback.TranscodeSession
|
||||
realtimeCommandMu sync.Mutex
|
||||
@@ -158,6 +166,7 @@ type sessionExpirationHookSetter interface {
|
||||
func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathResolver) *PlaybackHandler {
|
||||
h := &PlaybackHandler{
|
||||
sessionMgr: sessionMgr,
|
||||
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
|
||||
transcodes: make(map[string]*playback.TranscodeSession),
|
||||
realtimeCommands: make(map[string]playbackCommandRecord),
|
||||
}
|
||||
@@ -180,19 +189,6 @@ func (h *PlaybackHandler) SetProfileRefreshRequester(requester ProfileRefreshReq
|
||||
h.profileRefreshRequester = requester
|
||||
}
|
||||
|
||||
// playbackConfig returns the current playback config, falling back to the
|
||||
// same defaults as config loading (transcode enabled, temp transcode dir)
|
||||
// when no provider is wired (tests, minimal setups).
|
||||
func (h *PlaybackHandler) playbackConfig() config.PlaybackConfig {
|
||||
if h.PlaybackConfig != nil {
|
||||
return h.PlaybackConfig()
|
||||
}
|
||||
return config.PlaybackConfig{
|
||||
TranscodeEnabled: true,
|
||||
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupOrphanedTranscodes removes stale per-session temp directories for
|
||||
// transcodes that are no longer tracked in memory.
|
||||
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||||
@@ -203,7 +199,7 @@ func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||||
}
|
||||
h.transcodeMu.RUnlock()
|
||||
|
||||
return playback.CleanupOrphanedTranscodeDirs(h.playbackConfig().TranscodeDir, active)
|
||||
return playback.CleanupOrphanedTranscodeDirs(h.TranscodeDir, active)
|
||||
}
|
||||
|
||||
// playbackThresholds reads the playback.watched_threshold and
|
||||
@@ -261,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"`
|
||||
@@ -447,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
|
||||
@@ -628,9 +616,9 @@ func normalizeAudioTrackIndex(file *models.MediaFile, audioTrackIndex int) int {
|
||||
return directPlayAudioTrackIndex(file)
|
||||
}
|
||||
|
||||
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader, transcodeEnabled bool) playback.AdminSettings {
|
||||
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader) playback.AdminSettings {
|
||||
settings := playback.AdminSettings{
|
||||
TranscodeEnabled: transcodeEnabled,
|
||||
TranscodeEnabled: true,
|
||||
}
|
||||
if repo != nil {
|
||||
if v, _ := repo.Get(ctx, "allow_4k_transcode"); v == "true" {
|
||||
@@ -675,7 +663,7 @@ func (h *PlaybackHandler) resolveCapabilityPlaybackSelection(
|
||||
}
|
||||
|
||||
audioTrackIndex = normalizeAudioTrackIndex(requestedFile, audioTrackIndex)
|
||||
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo, h.playbackConfig().TranscodeEnabled)
|
||||
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo)
|
||||
method, transcodeAudio := resolvePlaybackMethodForFile(requestedFile, req, audioTrackIndex, adminSettings)
|
||||
|
||||
if requestedFile.Resolution == "2160p" &&
|
||||
@@ -773,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)
|
||||
@@ -814,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{}
|
||||
}
|
||||
|
||||
@@ -1296,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)
|
||||
@@ -1342,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)
|
||||
@@ -1381,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),
|
||||
@@ -1402,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)
|
||||
@@ -1444,14 +1421,10 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
|
||||
|
||||
// subtitleURLExt returns the URL file extension for a subtitle codec.
|
||||
// ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for
|
||||
// client-side rendering (JASSUB); PGS tracks get ".sup" for client-side
|
||||
// bitmap rendering (libpgs); all other text formats get ".vtt".
|
||||
// client-side rendering (JASSUB); all other text formats get ".vtt".
|
||||
func subtitleURLExt(codec string) string {
|
||||
switch {
|
||||
case playback.IsASS(codec):
|
||||
if playback.IsASS(codec) {
|
||||
return ".ass"
|
||||
case playback.IsPGS(codec):
|
||||
return ".sup"
|
||||
}
|
||||
return ".vtt"
|
||||
}
|
||||
@@ -1478,10 +1451,7 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su
|
||||
|
||||
embeddedOffset := len(file.ExternalSubtitles)
|
||||
for i, track := range file.SubtitleTracks {
|
||||
// PGS bitmap tracks are deliverable as .sup streams for
|
||||
// client-side rendering; DVD/DVB bitmap tracks still have no
|
||||
// non-burn-in delivery path, so they stay hidden.
|
||||
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) {
|
||||
if playback.NeedsBurnIn(track.Codec) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1579,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 != "" {
|
||||
@@ -1788,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),
|
||||
@@ -1803,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 {
|
||||
@@ -2058,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.
|
||||
@@ -2085,7 +2043,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
TargetCodecAudio: req.TargetCodecAudio,
|
||||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||||
SegmentDuration: req.SegmentDuration,
|
||||
HWAccel: h.playbackConfig().HWAccel,
|
||||
HWAccel: h.HWAccel,
|
||||
AudioTrackIndex: session.AudioTrackIndex,
|
||||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||||
@@ -2131,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,
|
||||
@@ -2144,24 +2102,14 @@ 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
|
||||
}
|
||||
// Snapshot once so the directory, ffmpeg path, and hwaccel of this
|
||||
// session stay consistent even if the config reloads mid-start.
|
||||
playbackCfg := h.playbackConfig()
|
||||
if err := os.MkdirAll(playbackCfg.TranscodeDir, 0o755); err != nil {
|
||||
if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory")
|
||||
return
|
||||
}
|
||||
|
||||
transcodeSession, err := playback.StartTranscode(context.WithoutCancel(r.Context()), playback.TranscodeOpts{
|
||||
InputPath: file.FilePath,
|
||||
OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID),
|
||||
OutputDir: filepath.Join(h.TranscodeDir, req.SessionID),
|
||||
SessionID: req.SessionID,
|
||||
SourceVideoCodec: file.CodecVideo,
|
||||
SeekSeconds: req.SeekSeconds,
|
||||
@@ -2171,9 +2119,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
TargetCodecAudio: req.TargetCodecAudio,
|
||||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||||
SegmentDuration: req.SegmentDuration,
|
||||
FFmpegPath: playbackCfg.FFmpegPath,
|
||||
HWAccel: playbackCfg.HWAccel,
|
||||
HWDevice: playbackCfg.HWDevice,
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
HWAccel: h.HWAccel,
|
||||
HWDevice: h.HWDevice,
|
||||
AudioTrackIndex: session.AudioTrackIndex,
|
||||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||||
@@ -2495,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestSubtitleURLExt(t *testing.T) {
|
||||
cases := []struct {
|
||||
codec string
|
||||
want string
|
||||
}{
|
||||
{"ass", ".ass"},
|
||||
{"ssa", ".ass"},
|
||||
{"pgs", ".sup"},
|
||||
{"hdmv_pgs_subtitle", ".sup"},
|
||||
{"subrip", ".vtt"},
|
||||
{"srt", ".vtt"},
|
||||
{"", ".vtt"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := subtitleURLExt(tc.codec); got != tc.want {
|
||||
t.Errorf("subtitleURLExt(%q) = %q, want %q", tc.codec, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_IncludesPGSButNotOtherBitmaps(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "subrip"},
|
||||
{Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"},
|
||||
{Index: 2, Language: "fr", Codec: "dvd_subtitle"},
|
||||
{Index: 3, Language: "de", Codec: "dvb_subtitle"},
|
||||
},
|
||||
}
|
||||
|
||||
urls := buildSubtitleURLs("sess-1", file, nil)
|
||||
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 subtitle URLs (text + PGS), got %d: %+v", len(urls), urls)
|
||||
}
|
||||
|
||||
srt := urls[0]
|
||||
if srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt" {
|
||||
t.Errorf("unexpected text track entry: %+v", srt)
|
||||
}
|
||||
|
||||
pgs := urls[1]
|
||||
if pgs.Codec != "hdmv_pgs_subtitle" {
|
||||
t.Errorf("expected PGS track to be included, got %+v", pgs)
|
||||
}
|
||||
if pgs.URL != "/stream/sess-1/subtitles/1.sup" {
|
||||
t.Errorf("PGS track should get a .sup URL, got %q", pgs.URL)
|
||||
}
|
||||
if pgs.FontBundleURL != "" {
|
||||
t.Errorf("PGS track must not advertise a font bundle, got %q", pgs.FontBundleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ExternalSubtitles: []models.ExternalSubtitle{
|
||||
{Path: "/media/movie.en.srt", Language: "en", Format: "srt"},
|
||||
},
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "pgs"},
|
||||
},
|
||||
}
|
||||
|
||||
urls := buildSubtitleURLs("sess-2", file, nil)
|
||||
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 subtitle URLs, got %d: %+v", len(urls), urls)
|
||||
}
|
||||
pgs := urls[1]
|
||||
if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup" {
|
||||
t.Errorf("PGS track index should include the external offset, got %+v", pgs)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
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/config"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
@@ -130,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 }
|
||||
@@ -183,16 +178,6 @@ func writePlaybackTestFFmpeg(t *testing.T) string {
|
||||
return path
|
||||
}
|
||||
|
||||
func playbackTestConfig(ffmpegPath, transcodeDir string) func() config.PlaybackConfig {
|
||||
return func() config.PlaybackConfig {
|
||||
return config.PlaybackConfig{
|
||||
FFmpegPath: ffmpegPath,
|
||||
TranscodeDir: transcodeDir,
|
||||
TranscodeEnabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writePlaybackTestMediaFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -298,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}
|
||||
|
||||
@@ -1128,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",
|
||||
@@ -1184,7 +1111,8 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
|
||||
handler.TranscodeDir = t.TempDir()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","audio_track_index":1,"codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`))
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
@@ -1249,7 +1177,8 @@ func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) {
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
|
||||
handler.TranscodeDir = t.TempDir()
|
||||
|
||||
transcodeReq := httptest.NewRequest(
|
||||
"POST",
|
||||
@@ -1412,7 +1341,7 @@ func TestHandleStartTranscode_KeepsSessionWhenStartupFailsForNonMissingReason(t
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.SessionSyncer = syncer
|
||||
handler.AdminStore = adminStore
|
||||
handler.PlaybackConfig = playbackTestConfig("", writePlaybackTestMediaFile(t, "occupied-transcode-dir"))
|
||||
handler.TranscodeDir = writePlaybackTestMediaFile(t, "occupied-transcode-dir")
|
||||
|
||||
startReq := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`))
|
||||
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
|
||||
|
||||
@@ -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,6 +121,11 @@ type verifyPINResponse struct {
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
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
|
||||
// profiles belonging to their user. Server admins always can. Otherwise the
|
||||
// caller's active profile must be the primary profile for the household.
|
||||
@@ -133,7 +135,7 @@ type verifyPINResponse struct {
|
||||
// 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 apimw.IsAdmin(ctx) {
|
||||
if isAdminProfileRequest(ctx) {
|
||||
return true, nil
|
||||
}
|
||||
activeProfileID := apimw.GetProfileID(ctx)
|
||||
@@ -311,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 && !apimw.IsAdmin(r.Context()) &&
|
||||
if isBootstrap && !isAdminProfileRequest(r.Context()) &&
|
||||
(req.IsChild || req.MaxContentRating != "" ||
|
||||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
|
||||
req.MaxPlaybackQuality != "") {
|
||||
@@ -354,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,
|
||||
@@ -504,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,
|
||||
@@ -684,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,
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/push"
|
||||
)
|
||||
|
||||
// maxPushTokenLen bounds a registered push token. APNs/FCM device tokens are
|
||||
// short; a Web Push subscription JSON (endpoint + keys) is ~1 KB.
|
||||
const maxPushTokenLen = 8192
|
||||
|
||||
// pushRegistry is the minimal interface PushHandler needs from *push.Store.
|
||||
type pushRegistry interface {
|
||||
RegisterToken(ctx context.Context, userID int, profileID, deviceID, transport, token string) error
|
||||
RevokeToken(ctx context.Context, userID int, profileID, deviceID string) error
|
||||
SetDeviceEnabled(ctx context.Context, userID int, deviceID string, enabled bool) error
|
||||
ListDevices(ctx context.Context, userID int) ([]push.DeviceInfo, error)
|
||||
}
|
||||
|
||||
// pushConfigReader is the minimal interface PushHandler needs from *push.Config.
|
||||
type pushConfigReader interface {
|
||||
WebPush(ctx context.Context) push.WebPushConfig
|
||||
Status(ctx context.Context) push.Status
|
||||
}
|
||||
|
||||
// pushNotifier is the minimal interface PushHandler needs to send system notifications.
|
||||
type pushNotifier interface {
|
||||
CreateSystem(ctx context.Context, userID int, typ, title, body string)
|
||||
}
|
||||
|
||||
// PushHandler handles push notification registration, management, and status endpoints.
|
||||
type PushHandler struct {
|
||||
reg pushRegistry
|
||||
config pushConfigReader
|
||||
notifier pushNotifier
|
||||
}
|
||||
|
||||
// NewPushHandler creates a new PushHandler. notifier may be nil (test-push endpoint
|
||||
// returns 503 when nil).
|
||||
func NewPushHandler(reg pushRegistry, config pushConfigReader, notifier pushNotifier) *PushHandler {
|
||||
return &PushHandler{reg: reg, config: config, notifier: notifier}
|
||||
}
|
||||
|
||||
// registerRequest is the request body for HandleRegister.
|
||||
type registerRequest struct {
|
||||
Transport string `json:"transport"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// deviceContext extracts the authenticated user, device id, and active profile
|
||||
// required by the profile-scoped push device endpoints. It writes a 400 and
|
||||
// returns ok=false when any of the three is missing.
|
||||
func (h *PushHandler) deviceContext(w http.ResponseWriter, r *http.Request) (userID int, deviceID, profileID string, ok bool) {
|
||||
userID = apimw.GetUserID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Authentication required")
|
||||
return 0, "", "", false
|
||||
}
|
||||
deviceID = strings.TrimSpace(r.Header.Get(deviceIDHeader))
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "X-Silo-Device-Id header is required")
|
||||
return 0, "", "", false
|
||||
}
|
||||
profileID = strings.TrimSpace(apimw.GetProfileID(r.Context()))
|
||||
if profileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
|
||||
return 0, "", "", false
|
||||
}
|
||||
return userID, deviceID, profileID, true
|
||||
}
|
||||
|
||||
// HandleRegister handles PUT /notifications/push/device.
|
||||
// Requires userID (from JWT claims), X-Silo-Device-Id, and X-Profile-Id headers.
|
||||
// Body: {"transport": "apns"|"fcm"|"webpush", "token": "..."}
|
||||
// Returns 204 on success.
|
||||
func (h *PushHandler) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
userID, deviceID, profileID, ok := h.deviceContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Transport {
|
||||
case push.TransportAPNs, push.TransportFCM, push.TransportWebPush:
|
||||
// valid
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "transport must be one of apns, fcm, webpush")
|
||||
return
|
||||
}
|
||||
if req.Token == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "token must not be empty")
|
||||
return
|
||||
}
|
||||
// Cap token length: APNs/FCM device tokens are short; a Web Push subscription
|
||||
// JSON (endpoint + keys) is ~1 KB. 8 KB is generous and bounds the stored row.
|
||||
if len(req.Token) > maxPushTokenLen {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "token too large")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.reg.RegisterToken(r.Context(), userID, profileID, deviceID, req.Transport, req.Token); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register push token")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleRevoke handles DELETE /notifications/push/device.
|
||||
// Requires userID, X-Silo-Device-Id, and X-Profile-Id headers.
|
||||
// Returns 204 on success.
|
||||
func (h *PushHandler) HandleRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
userID, deviceID, profileID, ok := h.deviceContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.reg.RevokeToken(r.Context(), userID, profileID, deviceID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke push token")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleListDevices handles GET /notifications/push/devices.
|
||||
// Response: {"devices": [...]}
|
||||
func (h *PushHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
|
||||
devices, err := h.reg.ListDevices(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
|
||||
return
|
||||
}
|
||||
if devices == nil {
|
||||
devices = []push.DeviceInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Devices []push.DeviceInfo `json:"devices"`
|
||||
}{Devices: devices})
|
||||
}
|
||||
|
||||
// toggleDeviceRequest is the request body for HandleToggleDevice.
|
||||
type toggleDeviceRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// HandleToggleDevice handles PUT /notifications/push/devices/{device_id}.
|
||||
// Body: {"enabled": true|false}
|
||||
// Returns 204 on success, 404 if device not found.
|
||||
func (h *PushHandler) HandleToggleDevice(w http.ResponseWriter, r *http.Request) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
deviceID := chi.URLParam(r, "device_id")
|
||||
|
||||
var req toggleDeviceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "enabled is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.reg.SetDeviceEnabled(r.Context(), userID, deviceID, *req.Enabled); err != nil {
|
||||
if errors.Is(err, push.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Device not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleWebPushKey handles GET /notifications/push/webpush-key.
|
||||
// Response: {"vapid_public_key": "..."} — empty string when not configured.
|
||||
func (h *PushHandler) HandleWebPushKey(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := h.config.WebPush(r.Context())
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
VAPIDPublicKey string `json:"vapid_public_key"`
|
||||
}{VAPIDPublicKey: cfg.VAPIDPublic})
|
||||
}
|
||||
|
||||
// HandleAdminStatus handles GET /admin/push/status.
|
||||
// Response: {"apns": bool, "fcm": bool, "webpush": bool}
|
||||
func (h *PushHandler) HandleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status := h.config.Status(r.Context())
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// HandleGenerateVAPIDKeys handles POST /admin/push/generate-vapid-keys.
|
||||
// Generates a fresh VAPID key pair and returns it as JSON.
|
||||
// Response: {"vapid_public": "...", "vapid_private": "..."}
|
||||
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
|
||||
// GenerateVAPIDKeys returns (privateKey, publicKey string, err error).
|
||||
priv, pub, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
|
||||
}
|
||||
|
||||
// HandleSendTestPush handles POST /admin/push/test.
|
||||
// Sends a system test push notification to the authenticated admin user.
|
||||
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
|
||||
if h.notifier == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
|
||||
return
|
||||
}
|
||||
h.notifier.CreateSystem(r.Context(), userID, "system.test_push", "Test push",
|
||||
"If you can see this on a device, push delivery is working.")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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/push"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakePushRegistry struct {
|
||||
registerErr error
|
||||
revokeErr error
|
||||
enableErr error
|
||||
devices []push.DeviceInfo
|
||||
|
||||
// capture last RegisterToken call
|
||||
lastUserID int
|
||||
lastProfileID string
|
||||
lastDeviceID string
|
||||
lastTransport string
|
||||
lastToken string
|
||||
}
|
||||
|
||||
func (f *fakePushRegistry) RegisterToken(_ context.Context, userID int, profileID, deviceID, transport, token string) error {
|
||||
f.lastUserID = userID
|
||||
f.lastProfileID = profileID
|
||||
f.lastDeviceID = deviceID
|
||||
f.lastTransport = transport
|
||||
f.lastToken = token
|
||||
return f.registerErr
|
||||
}
|
||||
|
||||
func (f *fakePushRegistry) RevokeToken(_ context.Context, _ int, _, _ string) error {
|
||||
return f.revokeErr
|
||||
}
|
||||
|
||||
func (f *fakePushRegistry) SetDeviceEnabled(_ context.Context, _ int, _ string, _ bool) error {
|
||||
return f.enableErr
|
||||
}
|
||||
|
||||
func (f *fakePushRegistry) ListDevices(_ context.Context, _ int) ([]push.DeviceInfo, error) {
|
||||
return f.devices, nil
|
||||
}
|
||||
|
||||
type fakePushConfig struct {
|
||||
webPush push.WebPushConfig
|
||||
status push.Status
|
||||
}
|
||||
|
||||
func (f *fakePushConfig) WebPush(_ context.Context) push.WebPushConfig { return f.webPush }
|
||||
func (f *fakePushConfig) Status(_ context.Context) push.Status { return f.status }
|
||||
|
||||
type fakeNotifier struct {
|
||||
calls []fakeNotifierCall
|
||||
}
|
||||
|
||||
type fakeNotifierCall struct {
|
||||
userID int
|
||||
typ string
|
||||
title string
|
||||
body string
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) CreateSystem(_ context.Context, userID int, typ, title, body string) {
|
||||
f.calls = append(f.calls, fakeNotifierCall{userID: userID, typ: typ, title: title, body: body})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// pushUserRequest builds a request with JWT claims (userID) and optionally
|
||||
// sets the device-id and profile-id headers.
|
||||
func pushUserRequest(method, target string, body []byte, userID int, profileID, deviceID string) *http.Request {
|
||||
var req *http.Request
|
||||
if len(body) > 0 {
|
||||
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "user"})
|
||||
if profileID != "" {
|
||||
ctx = apimw.SetProfileID(ctx, profileID)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if deviceID != "" {
|
||||
req.Header.Set(deviceIDHeader, deviceID)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// pushChiRequest injects a chi URL parameter into the request context.
|
||||
func pushChiRequest(r *http.Request, key, value string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add(key, value)
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// newPushHandler builds a PushHandler backed by the given fakes.
|
||||
func newPushHandler(reg *fakePushRegistry, cfg *fakePushConfig) *PushHandler {
|
||||
return NewPushHandler(reg, cfg, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HandleRegister
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPushHandleRegister_MissingDeviceID_Returns400(t *testing.T) {
|
||||
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
body := mustMarshal(t, map[string]string{"transport": "apns", "token": "tok"})
|
||||
// no device header
|
||||
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "")
|
||||
h.HandleRegister(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleRegister_MissingProfileID_Returns400(t *testing.T) {
|
||||
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
body := mustMarshal(t, map[string]string{"transport": "apns", "token": "tok"})
|
||||
// device header set but no profile
|
||||
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "", "dev-1")
|
||||
h.HandleRegister(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleRegister_BadTransport_Returns400(t *testing.T) {
|
||||
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
body := mustMarshal(t, map[string]string{"transport": "smoke-signals", "token": "tok"})
|
||||
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
|
||||
h.HandleRegister(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleRegister_EmptyToken_Returns400(t *testing.T) {
|
||||
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
body := mustMarshal(t, map[string]string{"transport": "apns", "token": ""})
|
||||
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
|
||||
h.HandleRegister(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleRegister_HappyPath_Returns204(t *testing.T) {
|
||||
reg := &fakePushRegistry{}
|
||||
h := newPushHandler(reg, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
body := mustMarshal(t, map[string]string{"transport": "fcm", "token": "my-fcm-token"})
|
||||
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 42, "prof-99", "dev-abc")
|
||||
h.HandleRegister(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if reg.lastUserID != 42 {
|
||||
t.Errorf("userID = %d, want 42", reg.lastUserID)
|
||||
}
|
||||
if reg.lastProfileID != "prof-99" {
|
||||
t.Errorf("profileID = %q, want prof-99", reg.lastProfileID)
|
||||
}
|
||||
if reg.lastDeviceID != "dev-abc" {
|
||||
t.Errorf("deviceID = %q, want dev-abc", reg.lastDeviceID)
|
||||
}
|
||||
if reg.lastTransport != "fcm" {
|
||||
t.Errorf("transport = %q, want fcm", reg.lastTransport)
|
||||
}
|
||||
if reg.lastToken != "my-fcm-token" {
|
||||
t.Errorf("token = %q, want my-fcm-token", reg.lastToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleRegister_WebPushTransport_Returns204(t *testing.T) {
|
||||
reg := &fakePushRegistry{}
|
||||
h := newPushHandler(reg, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
body := mustMarshal(t, map[string]string{"transport": "webpush", "token": "{}"})
|
||||
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
|
||||
h.HandleRegister(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HandleRevoke
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPushHandleRevoke_HappyPath_Returns204(t *testing.T) {
|
||||
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
req := pushUserRequest(http.MethodDelete, "/notifications/push/device", nil, 1, "prof-1", "dev-1")
|
||||
h.HandleRevoke(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleRevoke_MissingDevice_Returns400(t *testing.T) {
|
||||
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
req := pushUserRequest(http.MethodDelete, "/notifications/push/device", nil, 1, "prof-1", "")
|
||||
h.HandleRevoke(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HandleListDevices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPushHandleListDevices_ReturnsDevicesJSON(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
reg := &fakePushRegistry{
|
||||
devices: []push.DeviceInfo{
|
||||
{DeviceID: "dev-1", Name: "iPhone", Platform: "ios", Transport: push.TransportAPNs, PushEnabled: true, RegisteredAt: &now},
|
||||
{DeviceID: "dev-2", Name: "Chrome", Platform: "web", Transport: push.TransportWebPush, PushEnabled: false},
|
||||
},
|
||||
}
|
||||
h := newPushHandler(reg, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
req := pushUserRequest(http.MethodGet, "/notifications/push/devices", nil, 5, "prof-1", "")
|
||||
h.HandleListDevices(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Devices []push.DeviceInfo `json:"devices"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Devices) != 2 {
|
||||
t.Fatalf("devices count = %d, want 2", len(resp.Devices))
|
||||
}
|
||||
if resp.Devices[0].DeviceID != "dev-1" {
|
||||
t.Errorf("devices[0].DeviceID = %q, want dev-1", resp.Devices[0].DeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleListDevices_EmptySlice_ReturnsEmptyArray(t *testing.T) {
|
||||
reg := &fakePushRegistry{devices: nil}
|
||||
h := newPushHandler(reg, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
req := pushUserRequest(http.MethodGet, "/notifications/push/devices", nil, 1, "", "")
|
||||
h.HandleListDevices(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
// Must produce {"devices":[]} not {"devices":null}
|
||||
if !containsJSON(body, `"devices":[]`) {
|
||||
t.Errorf("expected empty array in response, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HandleToggleDevice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPushHandleToggleDevice_HappyPath_Returns204(t *testing.T) {
|
||||
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
body := mustMarshal(t, map[string]bool{"enabled": true})
|
||||
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-1", body, 1, "", "")
|
||||
req = pushChiRequest(req, "device_id", "dev-1")
|
||||
h.HandleToggleDevice(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleToggleDevice_NotFound_Returns404(t *testing.T) {
|
||||
reg := &fakePushRegistry{enableErr: push.ErrNotFound}
|
||||
h := newPushHandler(reg, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
body := mustMarshal(t, map[string]bool{"enabled": false})
|
||||
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-x", body, 1, "", "")
|
||||
req = pushChiRequest(req, "device_id", "dev-x")
|
||||
h.HandleToggleDevice(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleToggleDevice_WrappedNotFound_Returns404(t *testing.T) {
|
||||
reg := &fakePushRegistry{enableErr: errors.Join(errors.New("outer"), push.ErrNotFound)}
|
||||
h := newPushHandler(reg, &fakePushConfig{})
|
||||
rec := httptest.NewRecorder()
|
||||
body := mustMarshal(t, map[string]bool{"enabled": false})
|
||||
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-x", body, 1, "", "")
|
||||
req = pushChiRequest(req, "device_id", "dev-x")
|
||||
h.HandleToggleDevice(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HandleWebPushKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPushHandleWebPushKey_ReturnsConfiguredKey(t *testing.T) {
|
||||
cfg := &fakePushConfig{webPush: push.WebPushConfig{VAPIDPublic: "BPublicKey123", VAPIDPrivate: "priv", Subject: "mailto:a@b.com"}}
|
||||
h := newPushHandler(&fakePushRegistry{}, cfg)
|
||||
rec := httptest.NewRecorder()
|
||||
req := pushUserRequest(http.MethodGet, "/notifications/push/webpush-key", nil, 1, "", "")
|
||||
h.HandleWebPushKey(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
VAPIDPublicKey string `json:"vapid_public_key"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.VAPIDPublicKey != "BPublicKey123" {
|
||||
t.Errorf("vapid_public_key = %q, want BPublicKey123", resp.VAPIDPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleWebPushKey_Unconfigured_ReturnsEmptyString(t *testing.T) {
|
||||
cfg := &fakePushConfig{webPush: push.WebPushConfig{}}
|
||||
h := newPushHandler(&fakePushRegistry{}, cfg)
|
||||
rec := httptest.NewRecorder()
|
||||
req := pushUserRequest(http.MethodGet, "/notifications/push/webpush-key", nil, 0, "", "")
|
||||
h.HandleWebPushKey(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
VAPIDPublicKey string `json:"vapid_public_key"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.VAPIDPublicKey != "" {
|
||||
t.Errorf("vapid_public_key = %q, want empty string", resp.VAPIDPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HandleAdminStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPushHandleAdminStatus_ReturnsBooleans(t *testing.T) {
|
||||
cfg := &fakePushConfig{status: push.Status{APNs: true, FCM: false, WebPush: true}}
|
||||
h := newPushHandler(&fakePushRegistry{}, cfg)
|
||||
rec := httptest.NewRecorder()
|
||||
req := pushUserRequest(http.MethodGet, "/admin/push/status", nil, 1, "", "")
|
||||
h.HandleAdminStatus(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var resp push.Status
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.APNs {
|
||||
t.Error("apns = false, want true")
|
||||
}
|
||||
if resp.FCM {
|
||||
t.Error("fcm = true, want false")
|
||||
}
|
||||
if !resp.WebPush {
|
||||
t.Error("webpush = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HandleGenerateVAPIDKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPushHandleGenerateVAPIDKeys_ReturnsPair(t *testing.T) {
|
||||
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/push/generate-vapid-keys", nil)
|
||||
h.HandleGenerateVAPIDKeys(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
VAPIDPublic string `json:"vapid_public"`
|
||||
VAPIDPrivate string `json:"vapid_private"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.VAPIDPublic == "" {
|
||||
t.Error("vapid_public is empty")
|
||||
}
|
||||
if resp.VAPIDPrivate == "" {
|
||||
t.Error("vapid_private is empty")
|
||||
}
|
||||
if resp.VAPIDPublic == resp.VAPIDPrivate {
|
||||
t.Errorf("vapid_public and vapid_private are identical: %q", resp.VAPIDPublic)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HandleSendTestPush
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPushHandleSendTestPush_CreatesForCaller(t *testing.T) {
|
||||
n := &fakeNotifier{}
|
||||
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, n)
|
||||
rec := httptest.NewRecorder()
|
||||
req := pushUserRequest(http.MethodPost, "/admin/push/test", nil, 7, "", "")
|
||||
h.HandleSendTestPush(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(n.calls) != 1 {
|
||||
t.Fatalf("CreateSystem called %d times, want 1", len(n.calls))
|
||||
}
|
||||
call := n.calls[0]
|
||||
if call.userID != 7 {
|
||||
t.Errorf("userID = %d, want 7", call.userID)
|
||||
}
|
||||
if call.typ != "system.test_push" {
|
||||
t.Errorf("typ = %q, want system.test_push", call.typ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushHandleSendTestPush_NilNotifier503(t *testing.T) {
|
||||
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
req := pushUserRequest(http.MethodPost, "/admin/push/test", nil, 1, "", "")
|
||||
h.HandleSendTestPush(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503; body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// containsJSON checks that s contains the substring (after stripping whitespace
|
||||
// equivalents that json.Marshal doesn't emit — good enough for our payloads).
|
||||
func containsJSON(s, sub string) bool {
|
||||
return len(s) > 0 && (len(sub) == 0 || len(s) >= len(sub) && func() bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -38,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)
|
||||
@@ -588,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,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
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ type SectionHandler struct {
|
||||
DetailSvc *catalog.DetailService
|
||||
Settings catalog.SettingsStore
|
||||
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 {
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,7 +12,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/config"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
@@ -32,20 +31,10 @@ type StreamHandler struct {
|
||||
EventsHub *evt.Hub
|
||||
AdminStore PlaybackAdminStore
|
||||
SessionSyncer PlaybackSessionSyncer
|
||||
// PlaybackConfig returns the current playback config; read it through
|
||||
// ffmpegPath(). May be nil (tests).
|
||||
PlaybackConfig func() config.PlaybackConfig
|
||||
SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles
|
||||
S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles
|
||||
S3Bucket string // bucket for subtitle storage
|
||||
}
|
||||
|
||||
// ffmpegPath returns the currently configured ffmpeg binary path.
|
||||
func (h *StreamHandler) ffmpegPath() string {
|
||||
if h.PlaybackConfig != nil {
|
||||
return h.PlaybackConfig().FFmpegPath
|
||||
}
|
||||
return ""
|
||||
FFmpegPath string
|
||||
SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles
|
||||
S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles
|
||||
S3Bucket string // bucket for subtitle storage
|
||||
}
|
||||
|
||||
// NewStreamHandler creates a new StreamHandler backed by the given session
|
||||
@@ -204,7 +193,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
vttData, err := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format, h.ffmpegPath())
|
||||
vttData, err := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format, h.FFmpegPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error",
|
||||
"Failed to load external subtitle")
|
||||
@@ -219,10 +208,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
// Check embedded tracks.
|
||||
if embeddedIndex < len(file.SubtitleTracks) {
|
||||
track := file.SubtitleTracks[embeddedIndex]
|
||||
// PGS is the one bitmap codec we can deliver without burn-in: the
|
||||
// track is copied losslessly into a .sup stream and rendered
|
||||
// client-side. DVD/DVB bitmap subs still require burn-in.
|
||||
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) {
|
||||
if playback.NeedsBurnIn(track.Codec) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"Bitmap subtitle tracks cannot be extracted as text")
|
||||
return
|
||||
@@ -338,7 +324,7 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.ffmpegPath())
|
||||
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.FFmpegPath)
|
||||
if err != nil {
|
||||
slog.WarnContext(r.Context(), "subtitle font extraction failed",
|
||||
"file_id", file.ID,
|
||||
@@ -420,24 +406,12 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session
|
||||
func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session) {
|
||||
track := file.SubtitleTracks[embeddedIndex]
|
||||
outFormat := "vtt"
|
||||
switch {
|
||||
case playback.IsASS(track.Codec):
|
||||
if playback.IsASS(track.Codec) {
|
||||
outFormat = "ass"
|
||||
case playback.IsPGS(track.Codec):
|
||||
outFormat = "sup"
|
||||
}
|
||||
|
||||
// ASS and PGS are fetched exactly once and consumed whole by their
|
||||
// client-side renderers (JASSUB / libpgs), so they must never be
|
||||
// windowed. Note subtitleSeekPosition falls back to the session's
|
||||
// last reported position even without a ?position= query — relying
|
||||
// on StreamExtractSubtitle's codec guard alone would still log a
|
||||
// misleading nonzero seek here.
|
||||
var seek, duration float64
|
||||
if outFormat == "vtt" {
|
||||
seek = subtitleSeekPosition(r, session)
|
||||
duration = subtitleWindowDuration(r)
|
||||
}
|
||||
seek := subtitleSeekPosition(r, session)
|
||||
duration := subtitleWindowDuration(r)
|
||||
slog.InfoContext(r.Context(), "subtitle stream requested",
|
||||
"file_id", file.ID,
|
||||
"embedded_index", embeddedIndex,
|
||||
@@ -448,12 +422,9 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
|
||||
"duration_seconds", duration,
|
||||
)
|
||||
|
||||
switch outFormat {
|
||||
case "ass":
|
||||
if outFormat == "ass" {
|
||||
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
|
||||
case "sup":
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
default:
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
@@ -466,7 +437,7 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
|
||||
SourceCodec: track.Codec,
|
||||
SeekSeconds: seek,
|
||||
DurationSeconds: duration,
|
||||
FFmpegPath: h.ffmpegPath(),
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
Writer: w,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
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/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// SubtitleAIHandler exposes on-demand AI subtitle translation backed by the
|
||||
@@ -23,9 +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
|
||||
}
|
||||
|
||||
// NewSubtitleAIHandler creates a handler backed by the given service.
|
||||
@@ -53,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"`
|
||||
@@ -66,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
|
||||
@@ -81,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
|
||||
@@ -95,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
|
||||
}
|
||||
@@ -117,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,
|
||||
})
|
||||
@@ -131,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:
|
||||
@@ -146,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 !apimw.IsAdmin(ctx) {
|
||||
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)
|
||||
|
||||
@@ -181,10 +181,7 @@ func (h *RecommendationsHandler) resolveTasteSeedUserStates(ctx context.Context,
|
||||
if err != nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
|
||||
UserID: userID,
|
||||
EbookProgressStore: h.EbookProgress,
|
||||
})
|
||||
states, err := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// decodedURLParam returns the named chi route parameter with percent-encoding
|
||||
// removed. chi matches routes against the raw (escaped) request path, so
|
||||
// parameters arrive still encoded when clients escape reserved characters —
|
||||
// e.g. encodeURIComponent("plugin:6:introdb") arrives as
|
||||
// "plugin%3A6%3Aintrodb". Returns an error for malformed escape sequences.
|
||||
func decodedURLParam(r *http.Request, name string) (string, error) {
|
||||
return url.PathUnescape(chi.URLParam(r, name))
|
||||
}
|
||||
@@ -14,28 +14,12 @@ type itemUserStateResponse struct {
|
||||
InWatchlist bool `json:"in_watchlist"`
|
||||
}
|
||||
|
||||
type itemUserStateOptions struct {
|
||||
UserID int
|
||||
EbookProgressStore EbookReaderProgressLister
|
||||
}
|
||||
|
||||
func resolveItemUserStates(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
episodeRepo *catalog.EpisodeRepository,
|
||||
items []*models.MediaItem,
|
||||
) (map[string]*itemUserStateResponse, error) {
|
||||
return resolveItemUserStatesWithOptions(ctx, store, profileID, episodeRepo, items, itemUserStateOptions{})
|
||||
}
|
||||
|
||||
func resolveItemUserStatesWithOptions(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
episodeRepo *catalog.EpisodeRepository,
|
||||
items []*models.MediaItem,
|
||||
options itemUserStateOptions,
|
||||
) (map[string]*itemUserStateResponse, error) {
|
||||
result := make(map[string]*itemUserStateResponse, len(items))
|
||||
if store == nil || profileID == "" || len(items) == 0 {
|
||||
@@ -127,10 +111,6 @@ func resolveItemUserStatesWithOptions(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ebookProgressMap, err := resolveEbookProgressForUserStates(ctx, options, profileID, contentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item == nil || item.ContentID == "" {
|
||||
@@ -145,8 +125,6 @@ func resolveItemUserStatesWithOptions(
|
||||
state.Played = allEpisodesCompleted(seriesEpisodes[item.ContentID], progressMap)
|
||||
case "season":
|
||||
state.Played = allEpisodesCompleted(seasonEpisodes[item.ContentID], progressMap)
|
||||
case "ebook":
|
||||
state.Played = ebookProgressMap[item.ContentID].Progress >= models.EbookFinishedProgressThreshold
|
||||
default:
|
||||
state.Played = progressMap[item.ContentID].Completed
|
||||
}
|
||||
@@ -156,18 +134,6 @@ func resolveItemUserStatesWithOptions(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveEbookProgressForUserStates(
|
||||
ctx context.Context,
|
||||
options itemUserStateOptions,
|
||||
profileID string,
|
||||
contentIDs []string,
|
||||
) (map[string]EbookReaderProgress, error) {
|
||||
if options.EbookProgressStore == nil || options.UserID <= 0 || profileID == "" || len(contentIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return options.EbookProgressStore.ListByContentIDs(ctx, options.UserID, profileID, contentIDs)
|
||||
}
|
||||
|
||||
func allEpisodesCompleted(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) bool {
|
||||
if len(episodes) == 0 {
|
||||
return false
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
func TestResolveItemUserStatesIncludesCompletedEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete Ebook"},
|
||||
{ContentID: "ebook-progress", Type: "ebook", Title: "Partial Ebook"},
|
||||
}
|
||||
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
|
||||
UserID: 42,
|
||||
EbookProgressStore: &fakeEbookReaderProgressLister{
|
||||
progress: map[string]EbookReaderProgress{
|
||||
"ebook-complete": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-complete",
|
||||
Progress: 0.95,
|
||||
UpdatedAt: time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
"ebook-progress": {
|
||||
UserID: 42,
|
||||
ProfileID: "profile-1",
|
||||
ContentID: "ebook-progress",
|
||||
Progress: 0.45,
|
||||
UpdatedAt: time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
|
||||
}
|
||||
|
||||
if states["ebook-complete"] == nil || !states["ebook-complete"].Played {
|
||||
t.Fatalf("completed ebook state = %+v, want played", states["ebook-complete"])
|
||||
}
|
||||
if states["ebook-progress"] == nil || states["ebook-progress"].Played {
|
||||
t.Fatalf("partial ebook state = %+v, want not played", states["ebook-progress"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveItemUserStatesExcludesHiddenEbookReaderProgress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newProfileTestStore(t)
|
||||
updatedAt := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC)
|
||||
items := []*models.MediaItem{
|
||||
{ContentID: "ebook-hidden", Type: "ebook", Title: "Hidden Ebook"},
|
||||
{ContentID: "ebook-reread", Type: "ebook", Title: "Reread Ebook"},
|
||||
}
|
||||
|
||||
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
|
||||
UserID: 42,
|
||||
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),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
|
||||
}
|
||||
|
||||
if states["ebook-hidden"] == nil || states["ebook-hidden"].Played {
|
||||
t.Fatalf("hidden ebook state = %+v, want not played", states["ebook-hidden"])
|
||||
}
|
||||
if states["ebook-reread"] == nil || !states["ebook-reread"].Played {
|
||||
t.Fatalf("ebook updated after hidden_before = %+v, want played again", states["ebook-reread"])
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||||
)
|
||||
@@ -218,11 +220,7 @@ func (h *WatchProviderHandler) connectionStatus(w http.ResponseWriter, r *http.R
|
||||
func watchProviderRequestScope(w http.ResponseWriter, r *http.Request) (int, string, string, bool) {
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
profileID := apimw.GetProfileID(r.Context())
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return 0, "", "", false
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
if userID == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return 0, "", "", false
|
||||
|
||||
@@ -362,10 +362,7 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil
|
||||
if h.storeProvider != nil {
|
||||
store, storeErr := h.storeProvider.ForUser(ctx, userID)
|
||||
if storeErr == nil && store != nil {
|
||||
states, stateErr := 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 {
|
||||
stateMap = states
|
||||
}
|
||||
|
||||
@@ -181,14 +181,6 @@ func GetClaims(ctx context.Context) *auth.Claims {
|
||||
return claims
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the context's authenticated user account has the
|
||||
// admin role. Returns false when no claims are present. Note this is the
|
||||
// account-level role; it says nothing about which household profile is active.
|
||||
func IsAdmin(ctx context.Context) bool {
|
||||
claims := GetClaims(ctx)
|
||||
return claims != nil && claims.Role == "admin"
|
||||
}
|
||||
|
||||
// GetUserID retrieves the user ID from the JWT claims in the context.
|
||||
// Returns 0 if no claims are present.
|
||||
func GetUserID(ctx context.Context) int {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
|
||||
// PluginRequestRouterAdapter adapts plugins.Service to
|
||||
// mediarequests.RouterClientResolver. The concrete *pluginhost.RequestRouterClient
|
||||
// satisfies mediarequests.RouterClient, so the adapter names the interface as its
|
||||
// return type (Go has no return-type covariance).
|
||||
type PluginRequestRouterAdapter struct {
|
||||
Svc *plugins.Service
|
||||
}
|
||||
|
||||
func (a PluginRequestRouterAdapter) RequestRouterClient(ctx context.Context, installationID int, capabilityID string) (mediarequests.RouterClient, error) {
|
||||
if a.Svc == nil {
|
||||
return nil, errors.New("request router plugin service is not configured")
|
||||
}
|
||||
return a.Svc.RequestRouterClient(ctx, installationID, capabilityID)
|
||||
}
|
||||
|
||||
// AttachRequestRouter wires the plugin-backed router provider onto a requests
|
||||
// service. Both the HTTP handler and the reconcile task call this so the wiring
|
||||
// lives in one place. With either dependency absent (e.g. a build without the
|
||||
// plugin service) it leaves the requests service router-less, so fulfillment
|
||||
// degrades to the existing "no backend configured" failure instead of panicking.
|
||||
func AttachRequestRouter(svc *mediarequests.Service, pluginService *plugins.Service) {
|
||||
if svc == nil || pluginService == nil {
|
||||
return
|
||||
}
|
||||
svc.SetRouterProvider(mediarequests.NewPluginRouterProvider(PluginRequestRouterAdapter{pluginService}))
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// AttachRequestRouter must tolerate absent dependencies (e.g. a build with the
|
||||
// plugin service disabled) without panicking — fulfillment then degrades to the
|
||||
// existing "no backend configured" failure rather than crashing the wiring.
|
||||
func TestAttachRequestRouterNilDependenciesDoesNotPanic(t *testing.T) {
|
||||
AttachRequestRouter(nil, nil)
|
||||
}
|
||||
|
||||
// A router adapter installed without a plugin service must return a controlled
|
||||
// error rather than panic on the request path.
|
||||
func TestPluginRequestRouterAdapterNilServiceReturnsError(t *testing.T) {
|
||||
adapter := PluginRequestRouterAdapter{}
|
||||
if _, err := adapter.RequestRouterClient(context.Background(), 1, "request_router.v1"); err == nil {
|
||||
t.Fatal("RequestRouterClient with nil Svc = nil error, want a controlled error")
|
||||
}
|
||||
}
|
||||
+52
-327
@@ -20,8 +20,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/activitylog"
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
|
||||
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
||||
"github.com/Silo-Server/silo-server/internal/api/handlers"
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
@@ -42,17 +40,16 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/metadata"
|
||||
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
|
||||
metatrakt "github.com/Silo-Server/silo-server/internal/metadata/trakt"
|
||||
metadatatranslation "github.com/Silo-Server/silo-server/internal/metadata/translation"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/opslog"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/presence"
|
||||
"github.com/Silo-Server/silo-server/internal/push"
|
||||
"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"
|
||||
@@ -75,13 +72,7 @@ import (
|
||||
|
||||
// Dependencies holds all shared dependencies that handlers need.
|
||||
type Dependencies struct {
|
||||
Config *config.Config
|
||||
// LiveConfig returns the current hot-reloaded config. May be nil (tests,
|
||||
// worker modes); read through CurrentConfig(), which falls back to Config.
|
||||
LiveConfig func() *config.Config
|
||||
// OnConfigChange registers a callback fired after a live config reload
|
||||
// actually changes the config. May be nil when hot reload is not wired.
|
||||
OnConfigChange func(fn func(old, updated *config.Config))
|
||||
Config *config.Config
|
||||
BootstrapSensitiveConfigured map[string]bool
|
||||
BootstrapSensitiveValues map[string]string
|
||||
AppContext context.Context
|
||||
@@ -106,7 +97,6 @@ type Dependencies struct {
|
||||
NodeRepo *nodepool.Repository // stream node repository (may be nil)
|
||||
ProxyPool *nodepool.ProxyPool // proxy node pool (may be nil)
|
||||
TranscodePool *nodepool.TranscodePool // transcode node pool (may be nil)
|
||||
NodePlanner *nodepool.Planner // group/cap-aware node selection (may be nil)
|
||||
SessionSyncer handlers.PlaybackSessionSyncer // optional; immediate playback session sync trigger
|
||||
EventBus cache.EventBus
|
||||
AdminStatsProvider handlers.AdminStatsSource
|
||||
@@ -177,23 +167,6 @@ type Dependencies struct {
|
||||
// clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all
|
||||
// resolve correctly. May be nil; no ABS routes are registered in that case.
|
||||
ABSHandler absHandler
|
||||
|
||||
// NotificationsService is the notifications domain service used to power
|
||||
// the inbox, preferences, and announcement endpoints. May be nil; routes
|
||||
// are not registered in that case.
|
||||
NotificationsService *notifications.Service
|
||||
|
||||
// PushStore is the push token/device registry. May be nil; push routes
|
||||
// are not registered in that case.
|
||||
PushStore *push.Store
|
||||
|
||||
// PushConfig reads push provider configuration from the settings repo.
|
||||
// May be nil; push routes are not registered when PushStore is nil.
|
||||
PushConfig *push.Config
|
||||
|
||||
// PresenceRegistry tracks live WebSocket connections for presence-aware
|
||||
// push suppression. May be nil; a no-op fallback is used in that case.
|
||||
PresenceRegistry presence.Registry
|
||||
}
|
||||
|
||||
// absHandler is the narrow interface the router needs from the ABS handler.
|
||||
@@ -202,17 +175,6 @@ type absHandler interface {
|
||||
Mount(r chi.Router)
|
||||
}
|
||||
|
||||
// CurrentConfig returns the live config when hot reload is wired, falling
|
||||
// back to the startup snapshot otherwise.
|
||||
func (d *Dependencies) CurrentConfig() *config.Config {
|
||||
if d.LiveConfig != nil {
|
||||
if cfg := d.LiveConfig(); cfg != nil {
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
return d.Config
|
||||
}
|
||||
|
||||
// NewRouter creates a chi.Router with all middleware and routes mounted
|
||||
// under /api/v1/. ABS-compat routes (/abs/*, /login, /socket.io/*) are
|
||||
// mounted at the root level when deps.ABSHandler is non-nil.
|
||||
@@ -231,10 +193,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(apimw.Metrics)
|
||||
|
||||
// Compress text-like responses (JSON, SVG, …); media content types are
|
||||
// not in the middleware's allowlist and stream through untouched.
|
||||
r.Use(middleware.Compress(5))
|
||||
|
||||
// Activity logging (before auth — captures all requests including failed auth).
|
||||
if deps.ActivityLogWriter != nil {
|
||||
r.Use(activitylog.NewMiddleware(deps.ActivityLogWriter, deps.NodeID))
|
||||
@@ -298,12 +256,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
deps.Config.Auth.AccessTokenExpiry,
|
||||
deps.Config.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
if deps.OnConfigChange != nil {
|
||||
jwtForReload := jwtService
|
||||
deps.OnConfigChange(func(_, updated *config.Config) {
|
||||
jwtForReload.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
}
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
authService = auth.NewService(
|
||||
provider,
|
||||
@@ -420,14 +372,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
var webhookSyncHandler *handlers.WebhookSyncHandler
|
||||
var requestHandler *handlers.RequestsHandler
|
||||
var autoscanHandler *handlers.AutoscanHandler
|
||||
var ebookReaderHandler *handlers.EbookReaderHandler
|
||||
var ebookProgressStore *handlers.PGEbookReaderProgressStore
|
||||
var ebookConfigStore *handlers.PGEbookReaderConfigStore
|
||||
var ebookAnnotationStore *handlers.PGEbookReaderAnnotationStore
|
||||
if deps.DB != nil {
|
||||
ebookProgressStore = handlers.NewPGEbookReaderProgressStore(deps.DB)
|
||||
ebookConfigStore = handlers.NewPGEbookReaderConfigStore(deps.DB)
|
||||
ebookAnnotationStore = handlers.NewPGEbookReaderAnnotationStore(deps.DB)
|
||||
browseRepo := catalog.NewBrowseRepository(deps.DB)
|
||||
itemRepo = catalog.NewItemRepository(deps.DB)
|
||||
episodeRepo = catalog.NewEpisodeRepository(deps.DB)
|
||||
@@ -478,25 +423,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalWatchEventDispatcher); ok {
|
||||
itemsHandler.SetLocalWatchEventDispatcher(dispatcher)
|
||||
}
|
||||
if ebookProgressStore != nil {
|
||||
itemsHandler.SetEbookReaderProgressStore(ebookProgressStore)
|
||||
}
|
||||
if deps.FileRepo != nil {
|
||||
ebookReaderHandler = handlers.NewEbookReaderHandler(&handlers.MediaFileAuthorizer{
|
||||
FileResolver: deps.FileRepo,
|
||||
ItemAccess: itemRepo,
|
||||
EpisodeLookup: episodeRepo,
|
||||
})
|
||||
if ebookProgressStore != nil {
|
||||
ebookReaderHandler.ProgressStore = ebookProgressStore
|
||||
}
|
||||
if ebookConfigStore != nil {
|
||||
ebookReaderHandler.ConfigStore = ebookConfigStore
|
||||
}
|
||||
if ebookAnnotationStore != nil {
|
||||
ebookReaderHandler.AnnotationStore = ebookAnnotationStore
|
||||
}
|
||||
}
|
||||
catalogResourceHandler = handlers.NewCatalogResourceHandler(itemsHandler)
|
||||
catalogHandler = handlers.NewCatalogHandler(
|
||||
catalog.NewCatalogResolver(browseRepo, itemRepo).
|
||||
@@ -515,9 +441,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
tmdb.NewClient(tmdbAPIKey, 40),
|
||||
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
|
||||
)
|
||||
AttachRequestRouter(requestSvc, deps.PluginService)
|
||||
requestSvc.SetEventsHub(deps.EventsHub)
|
||||
requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
if viewerResolver != nil {
|
||||
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
|
||||
}
|
||||
@@ -576,9 +500,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
if detailSvc != nil {
|
||||
personalDataHandler.SetDetailService(detailSvc)
|
||||
}
|
||||
if ebookProgressStore != nil {
|
||||
personalDataHandler.SetEbookReaderProgressStore(ebookProgressStore)
|
||||
}
|
||||
personalDataHandler.SetEpisodeRepo(episodeRepo)
|
||||
personalDataHandler.SetSeasonRepo(seasonRepo)
|
||||
personalDataHandler.EventsHub = deps.EventsHub
|
||||
@@ -661,6 +582,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
var playbackCommandDispatcher *playback.CommandDispatcher
|
||||
var streamHandler *handlers.StreamHandler
|
||||
var watchTogetherHandler *handlers.WatchTogetherHandler
|
||||
var audioHandler *handlers.AudioHandler
|
||||
if deps.SessionMgr != nil {
|
||||
var playbackAdminStore handlers.PlaybackAdminStore
|
||||
if deps.DB != nil {
|
||||
@@ -698,21 +620,24 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
}
|
||||
|
||||
// Wire the optional node planner and JWT secret for node-aware stream URLs.
|
||||
if deps.NodePlanner != nil {
|
||||
playbackHandler.NodePlanner = deps.NodePlanner
|
||||
// Wire optional proxy/transcode pools and JWT secret for node-aware stream URLs.
|
||||
if deps.ProxyPool != nil {
|
||||
playbackHandler.ProxyPool = deps.ProxyPool
|
||||
}
|
||||
if deps.TranscodePool != nil {
|
||||
playbackHandler.TranscodePool = deps.TranscodePool
|
||||
}
|
||||
if deps.Config != nil && deps.Config.Auth.JWTSecret != "" {
|
||||
playbackHandler.JWTSecret = deps.Config.Auth.JWTSecret
|
||||
}
|
||||
if deps.Config != nil {
|
||||
playbackHandler.PlaybackConfig = func() config.PlaybackConfig {
|
||||
return deps.CurrentConfig().Playback
|
||||
}
|
||||
playbackHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
|
||||
playbackHandler.HWAccel = deps.Config.Playback.HWAccel
|
||||
playbackHandler.TranscodeDir = deps.Config.Playback.TranscodeDir
|
||||
if cleaned, err := playbackHandler.CleanupOrphanedTranscodes(); err != nil {
|
||||
slog.Warn("playback transcode cleanup failed", "dir", deps.Config.Playback.TranscodeDir, "error", err)
|
||||
slog.Warn("playback transcode cleanup failed", "dir", playbackHandler.TranscodeDir, "error", err)
|
||||
} else if cleaned > 0 {
|
||||
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", deps.Config.Playback.TranscodeDir, "count", cleaned)
|
||||
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", playbackHandler.TranscodeDir, "count", cleaned)
|
||||
}
|
||||
}
|
||||
playbackHandler.ProbeEnsurer = deps.ProbeEnsurer
|
||||
@@ -767,6 +692,14 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
)
|
||||
}
|
||||
}
|
||||
if detailSvc != nil && deps.FileRepo != nil && deps.UserStoreProvider != nil {
|
||||
audioHandler = handlers.NewAudioHandler(
|
||||
detailSvc,
|
||||
deps.FileRepo,
|
||||
deps.UserStoreProvider,
|
||||
handlers.NewPGAudioBookmarkStore(deps.DB),
|
||||
)
|
||||
}
|
||||
|
||||
// Wire subtitle repo and S3 client onto streamHandler for S3-stored subtitle serving.
|
||||
if streamHandler != nil && subtitleRepo != nil && deps.S3Public != nil {
|
||||
@@ -775,9 +708,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
streamHandler.S3Bucket = deps.S3Public.Bucket()
|
||||
}
|
||||
if streamHandler != nil && deps.Config != nil {
|
||||
streamHandler.PlaybackConfig = func() config.PlaybackConfig {
|
||||
return deps.CurrentConfig().Playback
|
||||
}
|
||||
streamHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
|
||||
}
|
||||
|
||||
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher)
|
||||
@@ -957,102 +888,35 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
// Build the AI subtitle handler (on-demand translation). Generated tracks are
|
||||
// stored as ordinary downloaded subtitles, so they reach every client through
|
||||
// the existing subtitle pipeline with no client changes.
|
||||
// Shared AI endpoint client + dispatch semaphore: subtitle translation/ASR
|
||||
// and metadata translation draw from one client and one concurrency bound.
|
||||
// Connection settings, models, toggles, and quotas hot-reload through
|
||||
// OnConfigChange; only the semaphore size (ai.max_concurrent_jobs) is
|
||||
// fixed at construction.
|
||||
var aiClient *llm.Client
|
||||
var aiSem chan struct{}
|
||||
if deps.Config != nil {
|
||||
aiClient = llm.NewClient(llmConfigFromServer(deps.Config))
|
||||
aiSem = jobrunner.NewSemaphore(deps.Config.AI.MaxConcurrentJobs)
|
||||
if deps.OnConfigChange != nil {
|
||||
clientForReload := aiClient
|
||||
deps.OnConfigChange(func(_, updated *config.Config) {
|
||||
clientForReload.UpdateConfig(llmConfigFromServer(updated))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var subtitleAIHandler *handlers.SubtitleAIHandler
|
||||
if subtitleManager != nil && subtitleRepo != nil && deps.FileRepo != nil && deps.DB != nil && deps.Config != nil {
|
||||
aiCfg, disabledGateway := effectiveSubtitleAIConfig(deps.Config)
|
||||
if disabledGateway != "" {
|
||||
warnChatOnlyGateway(disabledGateway)
|
||||
aiCfg := subtitleai.Config{
|
||||
Enabled: deps.Config.SubtitleAI.Enabled,
|
||||
BaseURL: deps.Config.SubtitleAI.BaseURL,
|
||||
APIKey: deps.Config.SubtitleAI.APIKey,
|
||||
ChatModel: deps.Config.SubtitleAI.ChatModel,
|
||||
MaxConcurrentJobs: deps.Config.SubtitleAI.MaxConcurrentJobs,
|
||||
BatchSize: deps.Config.SubtitleAI.BatchSize,
|
||||
ContextNeighbors: deps.Config.SubtitleAI.ContextNeighbors,
|
||||
}
|
||||
var aiNotifier subtitleai.Notifier
|
||||
if subtitleAINotifier != nil {
|
||||
aiNotifier = subtitleAINotifier
|
||||
}
|
||||
aiTranslator := subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors)
|
||||
aiTranscriber := subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds)
|
||||
aiService := subtitleai.NewService(
|
||||
deps.AppContext,
|
||||
aiCfg,
|
||||
subtitleai.NewPgJobRepository(deps.DB),
|
||||
aiTranslator,
|
||||
aiTranscriber,
|
||||
subtitleai.NewLLMTranslator(subtitleai.NewClient(aiCfg), aiCfg.BatchSize, aiCfg.ContextNeighbors),
|
||||
subtitleManager,
|
||||
subtitleRepo,
|
||||
deps.FileRepo,
|
||||
aiNotifier,
|
||||
deps.Config.Playback.FFmpegPath,
|
||||
slog.Default(),
|
||||
aiSem,
|
||||
)
|
||||
aiService.Recover()
|
||||
if deps.OnConfigChange != nil {
|
||||
deps.OnConfigChange(func(old, updated *config.Config) {
|
||||
newCfg, newDisabled := effectiveSubtitleAIConfig(updated)
|
||||
aiService.UpdateConfig(newCfg)
|
||||
aiTranslator.SetBatching(updated.SubtitleAI.BatchSize, updated.SubtitleAI.ContextNeighbors)
|
||||
aiTranscriber.SetExtraction(updated.Playback.FFmpegPath, updated.SubtitleAI.ASRChunkSeconds)
|
||||
// Warn only when the gateway-disable condition newly appears,
|
||||
// not on every unrelated settings change.
|
||||
if newDisabled != "" && old != nil {
|
||||
if _, oldDisabled := effectiveSubtitleAIConfig(old); oldDisabled == "" {
|
||||
warnChatOnlyGateway(newDisabled)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
|
||||
subtitleAIHandler.StoreProvider = deps.UserStoreProvider
|
||||
}
|
||||
|
||||
// Metadata AI translation (descriptions into the localization tables).
|
||||
var metadataAIHandler *handlers.MetadataAIHandler
|
||||
if deps.DB != nil && deps.Config != nil && aiClient != nil {
|
||||
mtRepo := metadatatranslation.NewPgRepository(deps.DB)
|
||||
mtService := metadatatranslation.NewService(
|
||||
deps.AppContext,
|
||||
metadataAIConfigFromServer(deps.Config),
|
||||
mtRepo,
|
||||
mtRepo,
|
||||
&metadatatranslation.CatalogLocalizationStore{
|
||||
Items: catalog.NewMediaItemLocalizationRepository(deps.DB),
|
||||
Seasons: catalog.NewSeasonLocalizationRepository(deps.DB),
|
||||
Episodes: catalog.NewEpisodeLocalizationRepository(deps.DB),
|
||||
},
|
||||
aiClient.SystemUserChat,
|
||||
aiSem,
|
||||
slog.Default(),
|
||||
)
|
||||
mtService.Recover()
|
||||
if deps.OnConfigChange != nil {
|
||||
deps.OnConfigChange(func(_, updated *config.Config) {
|
||||
mtService.UpdateConfig(metadataAIConfigFromServer(updated))
|
||||
})
|
||||
}
|
||||
metadataAIHandler = handlers.NewMetadataAIHandler(mtService)
|
||||
// Wire the refresh fallback: libraries with auto_translate_metadata get
|
||||
// missing localizations filled after each metadata refresh.
|
||||
if mt, ok := deps.MetadataService.(interface {
|
||||
SetAutoTranslator(metadata.AutoTranslator)
|
||||
}); ok {
|
||||
mt.SetAutoTranslator(mtService)
|
||||
}
|
||||
}
|
||||
|
||||
// Build section handler if DB is available.
|
||||
@@ -1069,7 +933,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
sectionFetcher.StoreProvider = deps.UserStoreProvider
|
||||
sectionFetcher.CollectionRepo = catalog.NewLibraryCollectionRepository(deps.DB)
|
||||
sectionFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
|
||||
sectionFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB)
|
||||
if deps.DB != nil {
|
||||
sectionFetcher.RecommendationRepo = recommendations.NewRepo(deps.DB)
|
||||
if ratingsRepo != nil {
|
||||
@@ -1085,9 +948,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
sectionHandler.EpisodeRepo = episodeRepo
|
||||
sectionHandler.DetailSvc = detailSvc
|
||||
if ebookProgressStore != nil {
|
||||
sectionHandler.EbookProgress = ebookProgressStore
|
||||
}
|
||||
if userRepo != nil {
|
||||
sectionHandler.UserRepo = userRepo
|
||||
}
|
||||
@@ -1261,7 +1121,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
recsFetcher := sections.NewFetcher(deps.DB)
|
||||
recsFetcher.StoreProvider = deps.UserStoreProvider
|
||||
recsFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
|
||||
recsFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB)
|
||||
recsHandler.Fetcher = recsFetcher
|
||||
recsHandler.WatchTonightFetcher = recsFetcher
|
||||
}
|
||||
@@ -1270,9 +1129,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
recsHandler.CalendarRepo = calendarRepo
|
||||
recsHandler.EpisodeRepo = episodeRepo
|
||||
if ebookProgressStore != nil {
|
||||
recsHandler.EbookProgress = ebookProgressStore
|
||||
}
|
||||
if deps.PersonRepo != nil {
|
||||
recsHandler.CastFetcher = deps.PersonRepo
|
||||
}
|
||||
@@ -1513,8 +1369,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
deps.ScanRegistry,
|
||||
deps.LibraryScanQueue,
|
||||
historyImportSvc,
|
||||
deps.NotificationsService,
|
||||
deps.PresenceRegistry,
|
||||
)
|
||||
r.Get("/events/ws", eventsHandler.HandleWebSocket)
|
||||
}
|
||||
@@ -1575,7 +1429,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/catalog", catalogHandler.HandleGetCatalog)
|
||||
r.Get("/catalog/filters", catalogHandler.HandleGetCatalogFilters)
|
||||
r.Get("/catalog/filters/search", catalogHandler.HandleGetCatalogFacetSearch)
|
||||
r.Get("/catalog/audiobook-groups", catalogHandler.HandleGetAudiobookGroups)
|
||||
r.Post("/catalog/query", catalogHandler.HandlePostCatalogQuery)
|
||||
if catalogResourceHandler != nil {
|
||||
r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail)
|
||||
@@ -1663,36 +1516,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications inbox and preferences (profile-scoped).
|
||||
if deps.NotificationsService != nil {
|
||||
notificationsHandler := handlers.NewNotificationsHandler(deps.NotificationsService, deps.UserStoreProvider)
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Use(apimw.RequireProfile)
|
||||
r.Get("/", notificationsHandler.HandleList)
|
||||
r.Get("/unread-count", notificationsHandler.HandleUnreadCount)
|
||||
r.Post("/read", notificationsHandler.HandleMarkRead)
|
||||
r.Post("/{id}/dismiss", notificationsHandler.HandleDismiss)
|
||||
r.Get("/preferences", notificationsHandler.HandleGetPreferences)
|
||||
r.Put("/preferences", notificationsHandler.HandlePutPreferences)
|
||||
})
|
||||
}
|
||||
|
||||
// Push registration, device management, and VAPID key.
|
||||
// Register and revoke are profile-scoped: they store a profileID alongside the
|
||||
// push token so that notifications are delivered to the correct profile.
|
||||
// RequireProfile is applied per-route (not via r.Use) to leave the three
|
||||
// user-scoped endpoints (devices list, toggle, webpush-key) without it.
|
||||
if deps.PushStore != nil && deps.PushConfig != nil {
|
||||
pushHandler := handlers.NewPushHandler(deps.PushStore, deps.PushConfig, deps.NotificationsService)
|
||||
r.Route("/notifications/push", func(r chi.Router) {
|
||||
r.With(apimw.RequireProfile).Put("/device", pushHandler.HandleRegister)
|
||||
r.With(apimw.RequireProfile).Delete("/device", pushHandler.HandleRevoke)
|
||||
r.Get("/devices", pushHandler.HandleListDevices)
|
||||
r.Put("/devices/{device_id}", pushHandler.HandleToggleDevice)
|
||||
r.Get("/webpush-key", pushHandler.HandleWebPushKey)
|
||||
})
|
||||
}
|
||||
|
||||
// Progress and sync routes (profile-scoped).
|
||||
if progressHandler != nil {
|
||||
r.Route("/progress", func(r chi.Router) {
|
||||
@@ -1892,35 +1715,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
if ebookReaderHandler != nil {
|
||||
r.Route("/ebooks", func(r chi.Router) {
|
||||
r.Use(apimw.RequireProfile)
|
||||
r.Get("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
|
||||
r.Head("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
|
||||
r.Get("/{content_id}/progress", ebookReaderHandler.HandleGetProgress)
|
||||
r.Put("/{content_id}/progress", ebookReaderHandler.HandleSaveProgress)
|
||||
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
|
||||
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
|
||||
r.Get("/{content_id}/annotations", ebookReaderHandler.HandleListAnnotations)
|
||||
r.Post("/{content_id}/annotations", ebookReaderHandler.HandleCreateAnnotation)
|
||||
r.Patch("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleUpdateAnnotation)
|
||||
r.Delete("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleDeleteAnnotation)
|
||||
})
|
||||
}
|
||||
|
||||
// Metadata AI translation availability probe (the metadata editor
|
||||
// and detail pages show or hide their translate actions based on
|
||||
// this) plus the viewer-facing on-view translation trigger.
|
||||
if metadataAIHandler != nil {
|
||||
r.Get("/metadata/ai/status", metadataAIHandler.HandleStatus)
|
||||
if itemRepo != nil {
|
||||
metadataAIHandler.ItemAccess = itemRepo
|
||||
r.Post("/items/{id}/translate-description", metadataAIHandler.HandleTranslateOnView)
|
||||
}
|
||||
} else {
|
||||
r.Get("/metadata/ai/status", handlers.WriteMetadataAIDisabledStatus)
|
||||
}
|
||||
|
||||
// Subtitle search + AI translation routes.
|
||||
if subtitleSearchHandler != nil {
|
||||
if deps.FileRepo != nil && itemRepo != nil {
|
||||
@@ -1941,7 +1735,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage)
|
||||
if subtitleAIHandler != nil {
|
||||
r.Get("/ai/status", subtitleAIHandler.HandleStatus)
|
||||
r.Get("/ai/quota", subtitleAIHandler.HandleQuota)
|
||||
r.Post("/ai/translate", subtitleAIHandler.HandleTranslate)
|
||||
r.Get("/ai/jobs", subtitleAIHandler.HandleListJobs)
|
||||
r.Get("/ai/jobs/{job_id}", subtitleAIHandler.HandleGetJob)
|
||||
@@ -1986,6 +1779,22 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
if audioHandler != nil {
|
||||
r.Route("/audio", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apimw.RequireProfile)
|
||||
r.Post("/playback/start", audioHandler.HandleStartPlayback)
|
||||
r.Patch("/playback/{session_id}/sync", audioHandler.HandleSyncPlayback)
|
||||
r.Post("/playback/{session_id}/close", audioHandler.HandleClosePlayback)
|
||||
r.Get("/items/{content_id}/bookmarks", audioHandler.HandleListBookmarks)
|
||||
r.Post("/items/{content_id}/bookmarks", audioHandler.HandleCreateBookmark)
|
||||
r.Delete("/items/{content_id}/bookmarks/{time_seconds}", audioHandler.HandleDeleteBookmark)
|
||||
})
|
||||
r.Get("/playback/{session_id}/tracks/{track_index}", audioHandler.HandleStreamTrack)
|
||||
r.Head("/playback/{session_id}/tracks/{track_index}", audioHandler.HandleStreamTrack)
|
||||
})
|
||||
}
|
||||
|
||||
if watchTogetherHandler != nil {
|
||||
r.Route("/watch-together", func(r chi.Router) {
|
||||
r.Get("/rooms/{room_id}/ws", watchTogetherHandler.HandleRoomWebSocket)
|
||||
@@ -2092,11 +1901,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates)
|
||||
r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch)
|
||||
}
|
||||
if metadataAIHandler != nil {
|
||||
r.Post("/items/{id}/metadata-translation", metadataAIHandler.HandleTranslate)
|
||||
r.Get("/items/{id}/metadata-translation/jobs", metadataAIHandler.HandleListJobs)
|
||||
r.Post("/items/{id}/metadata-translation/jobs/{job_id}/cancel", metadataAIHandler.HandleCancelJob)
|
||||
}
|
||||
})
|
||||
|
||||
if adminJobsHandler != nil {
|
||||
@@ -2378,11 +2182,8 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
})
|
||||
}
|
||||
|
||||
// Rate limit admin routes. Mounted even when the limiter is not
|
||||
// running (deps.RateLimitMW == nil) so admins can always reach the
|
||||
// config — otherwise disabling rate limiting and restarting would
|
||||
// permanently lock the settings page out of re-enabling it.
|
||||
if settingsRepo != nil {
|
||||
// Rate limit admin routes
|
||||
if deps.RateLimitMW != nil && settingsRepo != nil {
|
||||
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus)
|
||||
r.Route("/rate-limits", func(r chi.Router) {
|
||||
r.Get("/config", rateLimitHandler.HandleGetConfig)
|
||||
@@ -2447,22 +2248,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
|
||||
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
|
||||
}
|
||||
if deps.NotificationsService != nil {
|
||||
notificationsHandler := handlers.NewNotificationsHandler(deps.NotificationsService, deps.UserStoreProvider)
|
||||
r.Route("/announcements", func(r chi.Router) {
|
||||
r.Get("/", notificationsHandler.HandleListAnnouncements)
|
||||
r.Post("/", notificationsHandler.HandleCreateAnnouncement)
|
||||
r.Delete("/{id}", notificationsHandler.HandleDeleteAnnouncement)
|
||||
})
|
||||
}
|
||||
|
||||
if deps.PushStore != nil && deps.PushConfig != nil {
|
||||
pushHandler := handlers.NewPushHandler(deps.PushStore, deps.PushConfig, deps.NotificationsService)
|
||||
r.Get("/push/status", pushHandler.HandleAdminStatus)
|
||||
r.Post("/push/generate-vapid-keys", pushHandler.HandleGenerateVAPIDKeys)
|
||||
r.Post("/push/test", pushHandler.HandleSendTestPush)
|
||||
}
|
||||
|
||||
if adminPlaybackControlHandler != nil {
|
||||
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
|
||||
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
|
||||
@@ -2751,63 +2536,3 @@ func (a *traktCollectionAdapter) GetCollectionPreset(ctx context.Context, preset
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// llmConfigFromServer derives the shared AI client config from the server
|
||||
// config. Used at construction and again on every config reload.
|
||||
func llmConfigFromServer(cfg *config.Config) llm.Config {
|
||||
return llm.Config{
|
||||
BaseURL: cfg.AI.BaseURL,
|
||||
APIKey: cfg.AI.APIKey,
|
||||
ChatModel: cfg.AI.ChatModel,
|
||||
ASRBaseURL: cfg.AI.ASRBaseURL,
|
||||
ASRAPIKey: cfg.AI.ASRAPIKey,
|
||||
ASRModel: cfg.AI.ASRModel,
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveSubtitleAIConfig derives the subtitle AI service config from the
|
||||
// server config. A chat-only gateway (e.g. OpenRouter) cannot produce
|
||||
// timestamped transcriptions, so transcription is disabled rather than
|
||||
// letting every job fail; the settings API rejects such values for the ASR
|
||||
// URL, but the chat base URL legitimately may be one — this catches the
|
||||
// blank-ASR-URL fallback case. The second return is the offending endpoint
|
||||
// when that guard fired, empty otherwise.
|
||||
func effectiveSubtitleAIConfig(cfg *config.Config) (subtitleai.Config, string) {
|
||||
transcribeEnabled := cfg.SubtitleAI.TranscribeEnabled
|
||||
effectiveASRBase := cfg.AI.ASRBaseURL
|
||||
if effectiveASRBase == "" {
|
||||
effectiveASRBase = cfg.AI.BaseURL
|
||||
}
|
||||
disabledGateway := ""
|
||||
if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) {
|
||||
transcribeEnabled = false
|
||||
disabledGateway = effectiveASRBase
|
||||
}
|
||||
return subtitleai.Config{
|
||||
Configured: cfg.AI.BaseURL != "",
|
||||
TranslateEnabled: cfg.SubtitleAI.Enabled,
|
||||
TranscribeEnabled: transcribeEnabled,
|
||||
ChatModel: cfg.AI.ChatModel,
|
||||
ASRModel: cfg.AI.ASRModel,
|
||||
BatchSize: cfg.SubtitleAI.BatchSize,
|
||||
ContextNeighbors: cfg.SubtitleAI.ContextNeighbors,
|
||||
TranscribeQuotaJobs: cfg.SubtitleAI.TranscribeQuotaJobs,
|
||||
TranscribeQuotaPeriod: cfg.SubtitleAI.TranscribeQuotaPeriod,
|
||||
}, disabledGateway
|
||||
}
|
||||
|
||||
func warnChatOnlyGateway(endpoint string) {
|
||||
slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+
|
||||
"set a Whisper-compatible Transcription base URL in AI Services", "endpoint", endpoint)
|
||||
}
|
||||
|
||||
// metadataAIConfigFromServer derives the metadata translation service config
|
||||
// from the server config. Used at construction and on every config reload.
|
||||
func metadataAIConfigFromServer(cfg *config.Config) metadatatranslation.Config {
|
||||
return metadatatranslation.Config{
|
||||
Enabled: cfg.MetadataAI.Enabled,
|
||||
Configured: cfg.AI.BaseURL != "",
|
||||
ChatModel: cfg.AI.ChatModel,
|
||||
OnView: cfg.MetadataAI.OnView,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,9 @@ func (s *ABSProgressStore) GetProgress(ctx context.Context, userID, profileID, c
|
||||
var completed bool
|
||||
var progressPct *float64
|
||||
|
||||
// Completed rows store position_seconds = 0 (no resume point), so the
|
||||
// percentage must come from the completed flag, not the position.
|
||||
dbRow := s.Pool.QueryRow(ctx, `
|
||||
SELECT media_item_id, position_seconds, duration_seconds, completed,
|
||||
CASE WHEN completed THEN 1.0
|
||||
WHEN duration_seconds > 0 THEN position_seconds / duration_seconds
|
||||
ELSE 0 END AS progress_pct,
|
||||
CASE WHEN duration_seconds > 0 THEN position_seconds / duration_seconds ELSE 0 END AS progress_pct,
|
||||
updated_at
|
||||
FROM user_watch_progress
|
||||
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
|
||||
@@ -89,9 +85,7 @@ func (s *ABSProgressStore) ListProgressForAudiobooks(ctx context.Context, userID
|
||||
wp.position_seconds,
|
||||
wp.duration_seconds,
|
||||
wp.completed,
|
||||
CASE WHEN wp.completed THEN 1.0
|
||||
WHEN wp.duration_seconds > 0 THEN wp.position_seconds / wp.duration_seconds
|
||||
ELSE 0 END,
|
||||
CASE WHEN wp.duration_seconds > 0 THEN wp.position_seconds / wp.duration_seconds ELSE 0 END,
|
||||
wp.updated_at
|
||||
FROM user_watch_progress wp
|
||||
JOIN media_items mi ON mi.content_id = wp.media_item_id
|
||||
@@ -144,27 +138,17 @@ func (s *ABSProgressStore) UpsertProgress(ctx context.Context, row abs.ProgressR
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = time.Now().UTC()
|
||||
}
|
||||
// Completed rows hold no resume point (position_seconds = 0) — the same
|
||||
// invariant as the userstore write paths — so finished books can't leak
|
||||
// into position-based continue-watching/listening queries. A later
|
||||
// non-finished report (re-listen) moves position forward from 0 while
|
||||
// the completed latch stays.
|
||||
position := row.CurrentSeconds
|
||||
if row.IsFinished {
|
||||
position = 0
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO user_watch_progress
|
||||
(user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
|
||||
position_seconds = CASE WHEN EXCLUDED.completed THEN 0
|
||||
ELSE GREATEST(user_watch_progress.position_seconds, EXCLUDED.position_seconds) END,
|
||||
position_seconds = GREATEST(user_watch_progress.position_seconds, EXCLUDED.position_seconds),
|
||||
duration_seconds = GREATEST(user_watch_progress.duration_seconds, EXCLUDED.duration_seconds),
|
||||
completed = user_watch_progress.completed OR EXCLUDED.completed,
|
||||
updated_at = GREATEST(user_watch_progress.updated_at, EXCLUDED.updated_at)`,
|
||||
uid, row.ProfileID, row.ContentID,
|
||||
position, row.DurationSeconds, row.IsFinished, updatedAt,
|
||||
row.CurrentSeconds, row.DurationSeconds, row.IsFinished, updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("abs_progress_store: upsert progress: %w", err)
|
||||
|
||||
@@ -44,8 +44,7 @@ const (
|
||||
|
||||
// defaultEnrichBatchSize is the maximum number of audiobook items processed
|
||||
// per sweep invocation. Keeps latency bounded for large libraries.
|
||||
// Override with SILO_AUDIOBOOK_ENRICH_BATCH_SIZE.
|
||||
defaultEnrichBatchSize = 250
|
||||
defaultEnrichBatchSize = 50
|
||||
// defaultEnrichWorkers is the default fan-out used by Enricher.Run.
|
||||
// Network-bound: each worker holds one provider HTTP call at a time, so
|
||||
// 4 is enough to mask single-request latency without hammering plugins.
|
||||
@@ -53,28 +52,18 @@ const (
|
||||
defaultEnrichWorkers = 4
|
||||
)
|
||||
|
||||
// audiobookEnrichBatchSize returns the configured maximum sweep size.
|
||||
func audiobookEnrichBatchSize() int {
|
||||
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultEnrichBatchSize
|
||||
}
|
||||
|
||||
// audiobookEnrichWorkers returns the configured number of parallel enrichment
|
||||
// workers, capped to the active batch size so workers never outnumber the batch
|
||||
// they drain.
|
||||
func audiobookEnrichWorkers(batchSize int) int {
|
||||
// workers, capped to defaultEnrichBatchSize so workers never outnumber the
|
||||
// batch they drain.
|
||||
func audiobookEnrichWorkers() int {
|
||||
n := defaultEnrichWorkers
|
||||
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_WORKERS"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
if batchSize > 0 && n > batchSize {
|
||||
n = batchSize
|
||||
if n > defaultEnrichBatchSize {
|
||||
n = defaultEnrichBatchSize
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -116,7 +105,6 @@ func NewEnricher(
|
||||
personRepo *catalog.PersonRepository,
|
||||
providerIDs *catalog.ProviderIDRepository,
|
||||
) *Enricher {
|
||||
batchSize := audiobookEnrichBatchSize()
|
||||
return &Enricher{
|
||||
pool: pool,
|
||||
chainRepo: chainRepo,
|
||||
@@ -124,8 +112,8 @@ func NewEnricher(
|
||||
itemRepo: itemRepo,
|
||||
personRepo: personRepo,
|
||||
providerIDs: providerIDs,
|
||||
batchSize: batchSize,
|
||||
workers: audiobookEnrichWorkers(batchSize),
|
||||
batchSize: defaultEnrichBatchSize,
|
||||
workers: audiobookEnrichWorkers(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,32 +166,6 @@ func (e *Enricher) Run(ctx context.Context) (int, error) {
|
||||
return enriched, nil
|
||||
}
|
||||
|
||||
// HasPendingItems reports whether a scheduled sweep has any audiobook rows to
|
||||
// process. It mirrors claimBatch's eligibility predicate without loading rows
|
||||
// or provider IDs, so the scheduler can skip no-op executions without adding
|
||||
// task-history noise.
|
||||
func (e *Enricher) HasPendingItems(ctx context.Context) (bool, error) {
|
||||
if e == nil || e.pool == nil || e.chainRepo == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := e.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM media_items mi
|
||||
WHERE mi.type = 'audiobook'
|
||||
AND (mi.poster_path IS NULL OR mi.poster_path = '')
|
||||
AND mi.last_refreshed IS NULL
|
||||
LIMIT 1
|
||||
)
|
||||
`).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking pending audiobook enrichment: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// runBatch processes items in parallel using e.workers goroutines. The
|
||||
// enrichFn parameter exists for testing: production calls pass e.enrichItem.
|
||||
// Returns the count of items where enrichFn returned nil.
|
||||
|
||||
@@ -63,50 +63,6 @@ func TestEnricherRunFansOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEnricherUsesConfiguredBatchSize(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "123")
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
|
||||
|
||||
e := NewEnricher(nil, nil, nil, nil, nil, nil)
|
||||
|
||||
if e.batchSize != 123 {
|
||||
t.Fatalf("batchSize = %d, want 123", e.batchSize)
|
||||
}
|
||||
if e.workers != 8 {
|
||||
t.Fatalf("workers = %d, want 8", e.workers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEnricherCapsWorkersToConfiguredBatchSize(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "50")
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "100")
|
||||
|
||||
e := NewEnricher(nil, nil, nil, nil, nil, nil)
|
||||
|
||||
if e.batchSize != 50 {
|
||||
t.Fatalf("batchSize = %d, want 50", e.batchSize)
|
||||
}
|
||||
if e.workers != e.batchSize {
|
||||
t.Fatalf("workers = %d, want capped batchSize %d", e.workers, e.batchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookEnrichWorkersCapsAtBatchSize(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
|
||||
|
||||
if got := audiobookEnrichWorkers(3); got != 3 {
|
||||
t.Fatalf("audiobookEnrichWorkers(3) = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookEnrichBatchSizeIgnoresInvalidEnv(t *testing.T) {
|
||||
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "nope")
|
||||
|
||||
if got := audiobookEnrichBatchSize(); got != defaultEnrichBatchSize {
|
||||
t.Fatalf("audiobookEnrichBatchSize() = %d, want %d", got, defaultEnrichBatchSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
|
||||
cacher := &fakeAudiobookImageCacher{}
|
||||
e := &Enricher{imageCacher: cacher}
|
||||
|
||||
@@ -106,23 +106,6 @@ func (r *Refresher) WithHTTPClient(hc *http.Client) *Refresher {
|
||||
return r
|
||||
}
|
||||
|
||||
// HasDue reports whether RefreshDue would attempt at least one feed. The task
|
||||
// scheduler uses this to avoid recording empty maintenance runs when no podcast
|
||||
// feeds exist or every feed is still inside its refresh window.
|
||||
func (r *Refresher) HasDue(ctx context.Context, s Store) (bool, error) {
|
||||
feeds, err := s.ListPodcastFeeds(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("list podcast feeds: %w", err)
|
||||
}
|
||||
now := time.Now()
|
||||
for _, f := range feeds {
|
||||
if shouldRefreshFeed(f, now) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// RefreshDue walks every podcast_feeds row and refreshes those whose
|
||||
// last_refreshed_at + refresh_interval has elapsed. Per-feed failures are
|
||||
// logged at Warn and do not abort the walk. Returns the count of feeds
|
||||
@@ -135,7 +118,10 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
|
||||
now := time.Now()
|
||||
attempted := 0
|
||||
for _, f := range feeds {
|
||||
if !shouldRefreshFeed(f, now) {
|
||||
if f.FeedURL == "" {
|
||||
continue
|
||||
}
|
||||
if !isDue(f, now) {
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
@@ -150,10 +136,6 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
|
||||
return attempted, nil
|
||||
}
|
||||
|
||||
func shouldRefreshFeed(f PodcastFeed, now time.Time) bool {
|
||||
return f.FeedURL != "" && isDue(f, now)
|
||||
}
|
||||
|
||||
// RefreshOne refreshes a single podcast feed. Public so the admin
|
||||
// force-refresh endpoint can call it directly. Records success or failure
|
||||
// in podcast_feeds.last_refresh_error regardless of outcome.
|
||||
|
||||
@@ -162,7 +162,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
|
||||
if deps.Detail == nil {
|
||||
return ""
|
||||
}
|
||||
return deps.Detail.PresignImageURL(ctx, path, "poster", variant)
|
||||
return deps.Detail.PresignURL(ctx, path, variant)
|
||||
},
|
||||
})
|
||||
s.ABSHandler = h
|
||||
|
||||
@@ -3,14 +3,7 @@ package audiobooks
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
)
|
||||
|
||||
type fakeSettingsReader struct {
|
||||
@@ -81,50 +74,3 @@ func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
|
||||
t.Fatal("ABSCompatEnabled = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildABSHandlerCoverResolverUsesPosterVariant(t *testing.T) {
|
||||
resolver := &recordingImageResolver{}
|
||||
detail := &catalog.DetailService{}
|
||||
detail.SetImageResolver(resolver)
|
||||
|
||||
handler := New(nil).BuildABSHandler(ABSHandlerDeps{
|
||||
Items: &catalog.ItemRepository{},
|
||||
Files: &scanner.FileRepository{},
|
||||
Detail: detail,
|
||||
})
|
||||
|
||||
coverResolver := absCoverResolverForTest(t, handler)
|
||||
got := coverResolver(context.Background(), "local/audiobooks/book-1/poster/original.webp", "card")
|
||||
|
||||
if !strings.Contains(got, "/w500.webp") {
|
||||
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
|
||||
}
|
||||
if resolver.variant != "featured" {
|
||||
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
|
||||
}
|
||||
}
|
||||
|
||||
func absCoverResolverForTest(t *testing.T, handler *abs.Handler) func(context.Context, string, string) string {
|
||||
t.Helper()
|
||||
field := reflect.ValueOf(handler).Elem().FieldByName("deps").FieldByName("CoverResolver")
|
||||
return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface().(func(context.Context, string, string) string)
|
||||
}
|
||||
|
||||
type recordingImageResolver struct {
|
||||
path string
|
||||
variant string
|
||||
}
|
||||
|
||||
func (r *recordingImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
|
||||
r.path = path
|
||||
r.variant = variant
|
||||
return "resolved://" + path
|
||||
}
|
||||
|
||||
func (r *recordingImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
|
||||
out := make(map[string]string, len(paths))
|
||||
for _, path := range paths {
|
||||
out[path] = r.ResolveImageURL(context.Background(), path, variant)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+11
-20
@@ -3,7 +3,6 @@ package auth
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -37,37 +36,29 @@ const (
|
||||
const PluginAccessCookieName = "silo_plugin_access"
|
||||
|
||||
// JWTService handles JWT token generation and validation using HMAC-SHA256.
|
||||
// The expiry durations are atomics so they can be hot-reloaded from admin
|
||||
// settings while tokens are being generated; the secret is fixed for the
|
||||
// service lifetime (changing it would invalidate every outstanding token).
|
||||
type JWTService struct {
|
||||
secret []byte
|
||||
accessExpiry atomic.Int64 // nanoseconds
|
||||
refreshExpiry atomic.Int64 // nanoseconds
|
||||
accessExpiry time.Duration
|
||||
refreshExpiry time.Duration
|
||||
}
|
||||
|
||||
// NewJWTService creates a new JWTService with the given secret and expiry durations.
|
||||
func NewJWTService(secret string, accessExpiry, refreshExpiry time.Duration) *JWTService {
|
||||
j := &JWTService{secret: []byte(secret)}
|
||||
j.SetExpiries(accessExpiry, refreshExpiry)
|
||||
return j
|
||||
}
|
||||
|
||||
// SetExpiries updates the token expiry durations. Safe for concurrent use;
|
||||
// applies to tokens generated afterwards.
|
||||
func (j *JWTService) SetExpiries(access, refresh time.Duration) {
|
||||
j.accessExpiry.Store(int64(access))
|
||||
j.refreshExpiry.Store(int64(refresh))
|
||||
return &JWTService{
|
||||
secret: []byte(secret),
|
||||
accessExpiry: accessExpiry,
|
||||
refreshExpiry: refreshExpiry,
|
||||
}
|
||||
}
|
||||
|
||||
// AccessExpiry returns the configured access token expiry duration.
|
||||
func (j *JWTService) AccessExpiry() time.Duration {
|
||||
return time.Duration(j.accessExpiry.Load())
|
||||
return j.accessExpiry
|
||||
}
|
||||
|
||||
// RefreshExpiry returns the configured refresh token expiry duration.
|
||||
func (j *JWTService) RefreshExpiry() time.Duration {
|
||||
return time.Duration(j.refreshExpiry.Load())
|
||||
return j.refreshExpiry
|
||||
}
|
||||
|
||||
// GenerateAccessToken creates a signed JWT access token with the configured
|
||||
@@ -102,11 +93,11 @@ func (j *JWTService) GeneratePluginAccessToken(userID int, role, sessionID strin
|
||||
}
|
||||
|
||||
func (j *JWTService) generateAccessToken(claims Claims) (string, error) {
|
||||
return j.generateToken(claims, TokenTypeAccess, j.AccessExpiry())
|
||||
return j.generateToken(claims, TokenTypeAccess, j.accessExpiry)
|
||||
}
|
||||
|
||||
func (j *JWTService) generateRefreshToken(claims Claims) (string, error) {
|
||||
return j.generateToken(claims, TokenTypeRefresh, j.RefreshExpiry())
|
||||
return j.generateToken(claims, TokenTypeRefresh, j.refreshExpiry)
|
||||
}
|
||||
|
||||
// generateToken creates a signed JWT with the given claims and expiry duration.
|
||||
|
||||
@@ -15,11 +15,7 @@ type AccessFilter struct {
|
||||
DisabledLibraryIDs []int // user-disabled libraries (only set when AllowedLibraryIDs is nil)
|
||||
PresentationLibraryID *int
|
||||
PresentationLanguage string
|
||||
// ProfilePreferredLanguage is the viewer profile's preferred metadata
|
||||
// language. Presentation language resolves: explicit PresentationLanguage
|
||||
// → ProfilePreferredLanguage → the library's metadata_language.
|
||||
ProfilePreferredLanguage string
|
||||
MaxContentRating string
|
||||
MaxContentRating string
|
||||
MaxPlaybackQuality string
|
||||
SelectedFileID int
|
||||
UserID int
|
||||
@@ -29,11 +25,6 @@ type AccessFilter struct {
|
||||
// given (case-insensitive) prefix. Pushed into the SQL WHERE clause so
|
||||
// the predicate can use idx_media_items_sort_key.
|
||||
NamePrefix string
|
||||
// ExcludedMediaTypes lists media_items.type values the viewer's surface
|
||||
// never exposes (e.g. the Jellyfin compat layer excludes "audiobook" and
|
||||
// "podcast" — they're served by the ABS-compat API instead). Applied by
|
||||
// every query builder that consumes an AccessFilter.
|
||||
ExcludedMediaTypes []string
|
||||
}
|
||||
|
||||
func applyAccessFilter(alias string, filter AccessFilter, conditions *[]string, args *[]any, argIdx *int) {
|
||||
@@ -47,11 +38,6 @@ func applyAccessFilter(alias string, filter AccessFilter, conditions *[]string,
|
||||
*argIdx = *argIdx + 1
|
||||
}
|
||||
}
|
||||
if len(filter.ExcludedMediaTypes) > 0 {
|
||||
*conditions = append(*conditions, fmt.Sprintf("NOT (%s.type = ANY($%d))", alias, *argIdx))
|
||||
*args = append(*args, filter.ExcludedMediaTypes)
|
||||
*argIdx = *argIdx + 1
|
||||
}
|
||||
}
|
||||
|
||||
// ApplySectionAccessFilter applies non-library access constraints to section queries.
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// AudiobookGroupBy enumerates the supported audiobook grouping axes.
|
||||
type AudiobookGroupBy string
|
||||
|
||||
const (
|
||||
AudiobookGroupByAuthor AudiobookGroupBy = "author"
|
||||
AudiobookGroupByNarrator AudiobookGroupBy = "narrator"
|
||||
AudiobookGroupBySeries AudiobookGroupBy = "series"
|
||||
)
|
||||
|
||||
// ParseAudiobookGroupBy validates a group_by request parameter.
|
||||
func ParseAudiobookGroupBy(raw string) (AudiobookGroupBy, bool) {
|
||||
switch AudiobookGroupBy(strings.ToLower(strings.TrimSpace(raw))) {
|
||||
case AudiobookGroupByAuthor:
|
||||
return AudiobookGroupByAuthor, true
|
||||
case AudiobookGroupByNarrator:
|
||||
return AudiobookGroupByNarrator, true
|
||||
case AudiobookGroupBySeries:
|
||||
return AudiobookGroupBySeries, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// AudiobookGroupsQuery controls the grouped audiobook browse lookup.
|
||||
type AudiobookGroupsQuery struct {
|
||||
LibraryID int
|
||||
GroupBy AudiobookGroupBy
|
||||
// Sort is one of "name" (default), "count", "duration".
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// AudiobookGroup is one grouped browse row: an author, narrator, or series
|
||||
// with aggregate stats over the audiobooks visible to the viewer.
|
||||
type AudiobookGroup struct {
|
||||
Name string
|
||||
ItemCount int
|
||||
TotalDurationSeconds int64
|
||||
InProgressCount int
|
||||
FinishedCount int
|
||||
// PosterPaths holds up to four raw poster paths for cover stacks; the
|
||||
// API layer presigns them.
|
||||
PosterPaths []string
|
||||
}
|
||||
|
||||
// ListAudiobookGroups returns grouped browse rows for an audiobook library.
|
||||
// Groups are aggregated per person name (authors/narrators) or per series
|
||||
// name, matching the case-insensitive semantics of the corresponding catalog
|
||||
// filters, so a group name can be fed straight back into an author/narrator/
|
||||
// series filter rule. Progress counts are scoped to the requesting profile via
|
||||
// filter.UserID / filter.ProfileID.
|
||||
func ListAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGroupsQuery, filter AccessFilter) ([]AudiobookGroup, int, error) {
|
||||
if pool == nil {
|
||||
return nil, 0, fmt.Errorf("audiobook groups: no database pool")
|
||||
}
|
||||
if q.LibraryID <= 0 {
|
||||
return nil, 0, fmt.Errorf("audiobook groups: library id is required")
|
||||
}
|
||||
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
offset := max(q.Offset, 0)
|
||||
|
||||
args := []any{q.LibraryID}
|
||||
argIdx := 2
|
||||
|
||||
conditions := []string{
|
||||
"mi.type = 'audiobook'",
|
||||
"mil.media_folder_id = $1",
|
||||
}
|
||||
if !appendAudiobookItemAccessConditions("mi", filter, &conditions, &args, &argIdx) {
|
||||
return []AudiobookGroup{}, 0, nil
|
||||
}
|
||||
|
||||
var joinClause, nameExpr, groupExpr, posterOrder string
|
||||
switch q.GroupBy {
|
||||
case AudiobookGroupByAuthor, AudiobookGroupByNarrator:
|
||||
kind := models.PersonKindAuthor
|
||||
if q.GroupBy == AudiobookGroupByNarrator {
|
||||
kind = models.PersonKindNarrator
|
||||
}
|
||||
joinClause = fmt.Sprintf(`JOIN item_people ip ON ip.content_id = b.content_id AND ip.kind = $%d
|
||||
JOIN people p ON p.id = ip.person_id`, argIdx)
|
||||
args = append(args, int(kind))
|
||||
argIdx++
|
||||
nameExpr = "MIN(BTRIM(p.name))"
|
||||
groupExpr = "LOWER(BTRIM(p.name))"
|
||||
posterOrder = "b.sort_title"
|
||||
case AudiobookGroupBySeries:
|
||||
joinClause = "JOIN audiobook_series s ON s.content_id = b.content_id"
|
||||
nameExpr = "MIN(BTRIM(s.series_name))"
|
||||
groupExpr = "LOWER(BTRIM(s.series_name))"
|
||||
posterOrder = "s.series_index NULLS LAST, b.sort_title"
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("audiobook groups: unsupported group_by %q", q.GroupBy)
|
||||
}
|
||||
|
||||
userArg := fmt.Sprintf("$%d", argIdx)
|
||||
args = append(args, filter.UserID)
|
||||
argIdx++
|
||||
profileArg := fmt.Sprintf("$%d", argIdx)
|
||||
args = append(args, filter.ProfileID)
|
||||
argIdx++
|
||||
|
||||
var orderClause string
|
||||
switch strings.ToLower(strings.TrimSpace(q.Sort)) {
|
||||
case "", "name":
|
||||
orderClause = "LOWER(name)"
|
||||
case "count":
|
||||
orderClause = "item_count DESC, LOWER(name)"
|
||||
case "duration":
|
||||
orderClause = "total_duration_seconds DESC, LOWER(name)"
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("audiobook groups: unsupported sort %q", q.Sort)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH books AS (
|
||||
SELECT
|
||||
mi.content_id,
|
||||
mi.poster_path,
|
||||
mi.sort_title,
|
||||
COALESCE((
|
||||
SELECT SUM(mf.duration)
|
||||
FROM media_files mf
|
||||
WHERE mf.content_id = mi.content_id AND mf.missing_since IS NULL
|
||||
), 0) AS duration_seconds
|
||||
FROM media_items mi
|
||||
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
|
||||
WHERE %s
|
||||
),
|
||||
grouped AS (
|
||||
SELECT
|
||||
%s AS name,
|
||||
COUNT(*)::int AS item_count,
|
||||
COALESCE(SUM(b.duration_seconds), 0)::bigint AS total_duration_seconds,
|
||||
COUNT(*) FILTER (WHERE uwp.media_item_id IS NOT NULL AND NOT uwp.completed)::int AS in_progress_count,
|
||||
COUNT(*) FILTER (WHERE uwp.completed)::int AS finished_count,
|
||||
(ARRAY_AGG(b.poster_path ORDER BY %s) FILTER (WHERE NULLIF(b.poster_path, '') IS NOT NULL))[1:4] AS poster_paths
|
||||
FROM books b
|
||||
%s
|
||||
LEFT JOIN user_watch_progress uwp
|
||||
ON uwp.media_item_id = b.content_id
|
||||
AND uwp.user_id = %s
|
||||
AND uwp.profile_id = %s
|
||||
GROUP BY %s
|
||||
)
|
||||
SELECT name, item_count, total_duration_seconds, in_progress_count, finished_count, poster_paths,
|
||||
COUNT(*) OVER ()::int AS total_groups
|
||||
FROM grouped
|
||||
ORDER BY %s
|
||||
LIMIT $%d OFFSET $%d`,
|
||||
strings.Join(conditions, " AND "),
|
||||
nameExpr, posterOrder, joinClause, userArg, profileArg, groupExpr, orderClause,
|
||||
argIdx, argIdx+1,
|
||||
)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("querying audiobook groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
total := 0
|
||||
groups := make([]AudiobookGroup, 0, limit)
|
||||
for rows.Next() {
|
||||
var g AudiobookGroup
|
||||
var posterPaths []string
|
||||
if err := rows.Scan(&g.Name, &g.ItemCount, &g.TotalDurationSeconds, &g.InProgressCount, &g.FinishedCount, &posterPaths, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scanning audiobook group: %w", err)
|
||||
}
|
||||
if posterPaths == nil {
|
||||
posterPaths = []string{}
|
||||
}
|
||||
g.PosterPaths = posterPaths
|
||||
groups = append(groups, g)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("iterating audiobook groups: %w", err)
|
||||
}
|
||||
return groups, total, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user