diff --git a/Dockerfile b/Dockerfile index bdeee00c..acfac7ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,7 @@ RUN apt-get update && \ RUN mkdir -p /tmp/silo-transcode COPY --from=build /silo /usr/local/bin/silo COPY third_party/jellyfin-web/ /srv/jellyfin-web/ -EXPOSE 8080 8096 +EXPOSE 8080 8096 13378 HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1 ENTRYPOINT ["silo"] diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 00430c35..93d52829 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -18,9 +18,12 @@ import ( "sort" "strconv" "strings" + "sync/atomic" "syscall" "time" + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" "github.com/hashicorp/go-hclog" "github.com/jackc/pgx/v5/pgxpool" @@ -34,6 +37,8 @@ import ( "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/api" "github.com/Silo-Server/silo-server/internal/api/handlers" + "github.com/Silo-Server/silo-server/internal/audiobooks" + "github.com/Silo-Server/silo-server/internal/audiobooks/podcastfeed" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/autoscan" "github.com/Silo-Server/silo-server/internal/cache" @@ -421,6 +426,8 @@ func main() { appCtx, appCancel := context.WithCancel(ctx) defer appCancel() + restartReqCh := make(chan struct{}, 1) + var restartRequested atomic.Bool eventBus := cache.NewEventBus(cfg.Redis.URL) logStreamHub := logstream.NewHub(nodeID, eventBus) @@ -521,6 +528,19 @@ func main() { OpsLogRepo: opsRepo, FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID), PublicURL: os.Getenv("SILO_PUBLIC_URL"), + RequestServerRestart: func(context.Context) error { + if !restartRequested.CompareAndSwap(false, true) { + return handlers.ErrServerRestartAlreadyRequested + } + restartReqCh <- struct{}{} + return nil + }, + } + audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo}) + absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx) + if err != nil { + slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err) + absCompatEnabled = false } adminJobCancelRegistry := adminjob.NewCancelRegistry() deps.AdminJobCancelRegistry = adminJobCancelRegistry @@ -868,6 +888,7 @@ func main() { var groupClaimRepo *catalog.GroupClaimRepository var seasonRepo *catalog.SeasonRepository var episodeRepo *catalog.EpisodeRepository + var audiobookEnricher *audiobooks.Enricher if needsWorkers && deps.DB != nil && deps.FileRepo != nil { chainRepo := metadata.NewChainRepository(deps.DB) skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB) @@ -938,6 +959,18 @@ func main() { personRefreshService = metadata.NewPersonRefreshService(deps.DB, pluginResolver, personRepo) personRefreshService.SetImageResolver(imageResolver) + // Wire the audiobook enricher. It uses the same plugin resolver and chain + // repo as the movie/TV pipeline, but resolves providers at + // content_level='audiobook' and sweeps items directly rather than via a queue. + audiobookEnricher = audiobooks.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. metadataService.SetImageResolver(imageResolver) @@ -948,10 +981,17 @@ func main() { imageCacher := imagecache.New(deps.S3Public) metadataService.SetImageCacher(imageCacher) metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages) + if deps.Scanner != nil { + deps.Scanner.SetImageCacher(imageCacher) + } if cfg.Metadata.CacheImages { personRefreshService.SetImageCacher(imageCacher) slog.Info("metadata image caching enabled") } + if audiobookEnricher != nil { + audiobookEnricher.SetImageCacher(imageCacher) + audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath))) + } } matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second) @@ -1487,6 +1527,10 @@ func main() { historyReconciler := watchstate.NewHistoryReconciler(deps.DB, historyResolver) taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler)) taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler)) + taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB))) + if audiobookEnricher != nil { + taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher)) + } if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil { pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx) if err != nil { @@ -1503,6 +1547,57 @@ func main() { slog.Info("task manager started") } + // Build the ABS-compatible REST + Socket.io handler when a DB pool is + // available. Routes are mounted at the root level by NewRouter (not under + // /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and + // /abs/socket.io/* without path prefix hacks. + if absCompatEnabled && deps.DB != nil { + absUserRepo := auth.NewUserRepository(deps.DB) + absSessionRepo := auth.NewSessionRepository(deps.DB) + absJWTService := auth.NewJWTService( + cfg.Auth.JWTSecret, + cfg.Auth.AccessTokenExpiry, + cfg.Auth.RefreshTokenExpiry, + ) + absAuthSvc := auth.NewService( + auth.NewLocalProvider(absUserRepo, absSessionRepo), + absJWTService, + absSessionRepo, + absUserRepo, + nil, // invite codes: not needed for ABS compat + nil, // settings: not needed here + nil, // user store: not needed here + ) + absItemRepo := catalog.NewItemRepository(deps.DB) + absEpisodeRepo := catalog.NewEpisodeRepository(deps.DB) + absSeasonRepo := catalog.NewSeasonRepository(deps.DB) + absPersonRepo := catalog.NewPersonRepository(deps.DB) + var absFileFetcher catalog.FileVersionFetcher + if deps.FileRepo != nil { + absFileFetcher = deps.FileRepo + } + absDetailSvc := catalog.NewDetailService(absItemRepo, absEpisodeRepo, absSeasonRepo, absPersonRepo, absFileFetcher) + if deps.ImageResolver != nil { + absDetailSvc.SetImageResolver(deps.ImageResolver) + } + absHDeps := audiobooks.ABSHandlerDeps{ + Pool: deps.DB, + Items: absItemRepo, + Files: deps.FileRepo, + Settings: settingsRepo, + Auth: &audiobooks.SiloCredValidator{ + Auth: absAuthSvc, + Pool: deps.DB, + }, + AccessResolver: audiobooks.NewABSAccessResolver(absUserRepo, userStoreProvider), + Recs: recommendations.NewRepo(deps.DB), + Detail: absDetailSvc, + } + absH := audiobooksService.BuildABSHandler(absHDeps) + deps.ABSHandler = absH + } + _ = audiobooksService + if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil { userRepo := auth.NewUserRepository(deps.DB) sessionRepo := auth.NewSessionRepository(deps.DB) @@ -1624,6 +1719,11 @@ func main() { metricsMux := http.NewServeMux() metricsMux.Handle("/metrics", promhttp.Handler()) metricsMux.Handle("/api/", router) + // ABS-compat is NOT mounted on the main listener — see the "ABS compat + // listener" block below. It binds its own port so the discovery probes + // (/ping, /healthcheck, /status, /init, /login, /socket.io) own the URL + // space without collision with silo's SPA fallback. Mirrors how the + // Jellyfin compat server is set up at :8096. metricsMux.Handle("/", server.FrontendHandler()) // Step 9: Start background workers (if needed). @@ -1846,6 +1946,28 @@ func main() { compatSrv.IdleTimeout = 120 * time.Second } + // ABS-compat listener — dedicated http.Server bound to its own port + // (default :13378) that hosts the Audiobookshelf-compatible API. + // Mirrors the Jellyfin compat layout above. The ABS handler mounts + // onto a fresh chi router here so /ping, /healthcheck, /status, /login, + // /socket.io, etc. own the URL space at the root — no SPA fallback, + // no collision with silo's /api/v1. + var absSrv *http.Server + if (mode == "integrated" || mode == "api") && deps.ABSHandler != nil && cfg.AudiobookshelfCompat.Listen != "" { + absRouter := chi.NewRouter() + absRouter.Use(chimiddleware.Recoverer) + absRouter.Use(chimiddleware.Compress(5)) + deps.ABSHandler.Mount(absRouter) + absSrv = &http.Server{ + Addr: cfg.AudiobookshelfCompat.Listen, + Handler: absRouter, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 60 * time.Second, + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + } + } + // Run non-critical startup work in the background so it doesn't delay the // HTTP listener from accepting connections. Steps run sequentially and stop // early if the app context is cancelled (shutdown). @@ -1870,7 +1992,7 @@ func main() { }() } - errCh := make(chan error, 2) + errCh := make(chan error, 3) go func() { slog.Info("HTTP server listening", "addr", cfg.Server.Listen) if listenErr := srv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed { @@ -1885,6 +2007,14 @@ func main() { } }() } + if absSrv != nil { + go func() { + slog.Info("ABS compat server listening", "addr", absSrv.Addr) + if listenErr := absSrv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed { + errCh <- fmt.Errorf("abs compat server error: %w", listenErr) + } + }() + } // Step 11: Wait for termination signal. sigCh := make(chan os.Signal, 1) @@ -1895,6 +2025,9 @@ func main() { case sig := <-sigCh: appCancel() slog.Info("received signal, shutting down", "signal", sig) + case <-restartReqCh: + appCancel() + slog.Info("server restart requested, shutting down") case serverErr := <-errCh: appCancel() slog.Error("server error, shutting down", "error", serverErr) @@ -1914,6 +2047,11 @@ func main() { slog.Error("jellyfin compat shutdown error", "error", shutdownErr) } } + if absSrv != nil { + if shutdownErr := absSrv.Shutdown(shutdownCtx); shutdownErr != nil { + slog.Error("abs compat shutdown error", "error", shutdownErr) + } + } // 2. Clean up stale sessions. if sessionCleaner != nil { @@ -2264,3 +2402,14 @@ func mapFolderTypeToMediaType(t string) string { return "mixed" } } + +// audiobooksSettingsAdapter bridges catalog.ServerSettingsRepo (which +// exposes Get) to the audiobooks.SettingsReader interface (which +// requires GetString). The two signatures are identical modulo name. +type audiobooksSettingsAdapter struct { + repo *catalog.ServerSettingsRepo +} + +func (a *audiobooksSettingsAdapter) GetString(ctx context.Context, key string) (string, error) { + return a.repo.Get(ctx, key) +} diff --git a/docker-compose.yml b/docker-compose.yml index 8eab93af..9b4c83c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,11 +43,14 @@ services: ports: - "${PORT:-8090}:8080" - "${JF_PORT:-8096}:8096" + - "${ABS_PORT:-13378}:13378" volumes: - ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro + - ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro - ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins - ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode - ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro + - ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers - /proc/meminfo:/host/proc/meminfo:ro depends_on: postgres: diff --git a/docs/superpowers/notes/2026-05-27-abs-wire-shape-verification.md b/docs/superpowers/notes/2026-05-27-abs-wire-shape-verification.md new file mode 100644 index 00000000..173e3a0a --- /dev/null +++ b/docs/superpowers/notes/2026-05-27-abs-wire-shape-verification.md @@ -0,0 +1,138 @@ +# ABS Wire-Shape Verification (post collections-unify cutover) + +Breadcrumbs for the next person debugging an ABS endpoint wire-shape issue +after the canonical-tables cutover (migration 156 + commits `0dc830e`, +`8c7fe1b`, `b64ce17`). + +The Go in-memory structs (`abs.Collection`, `abs.Playlist`, +`abs.SmartCollection`, `abs.CollectionItem`, `abs.PlaylistItem`) have **no +`json:"..."` struct tags**. The JSON wire contract is defined entirely by +the `*ToABS()` map-builder helpers in `internal/audiobooks/abs/`. As long as +the store layer populates the struct fields with the same values, the wire +shape is preserved. The rewrites in `0dc830e`, `8c7fe1b`, `b64ce17` did NOT +modify the emitters — only the SQL-backed store implementations. + +## Envelope tests — which one guards which endpoint + +Run with `go test ./internal/audiobooks/abs/ -run Envelope -v -count=1`. + +| Test file | Functions | Guards | +|---|---|---| +| `collections_envelope_test.go` | `TestCollectionEnvelope_HasRequiredKeys`, `TestCollectionListShape_OmitsBooks` | `collectionToABS` keys; list-shape (no `books`) vs detail-shape (with `books`) for `GET /api/collections`, `GET /api/collections/{id}`, `GET /api/libraries/{id}/collections`, and all POST/PATCH/DELETE collection endpoints | +| `playlists_envelope_test.go` | `TestPlaylistEnvelope_HasRequiredKeys`, `TestPlaylistEnvelope_OmitsCoverPathWhenEmpty`, `TestPlaylistListShape_OmitsItems` | `playlistToABS` keys; list-shape (no `items`) vs detail-shape (with `items`); `coverPath` is omitted when `CoverItem == ""` — covers `GET /api/playlists`, `GET /api/playlists/{id}`, `GET /api/libraries/{id}/playlists`, batch and item-add/remove endpoints | +| `smart_collections_envelope_test.go` | `TestSmartCollectionEnvelope_HasRequiredKeys`, `TestSmartCollectionEnvelope_EmptyQueryDef` | `smartCollectionToABS` keys; `queryDef` decoded from raw JSONB bytes into nested object, empty bytes → `{}` — covers `GET /api/me/smart-collections`, `GET /api/me/smart-collections/{id}`, POST/PATCH equivalents | +| `bookmarks_envelope_test.go` | `TestBookmarkEnvelope_HasRequiredKeys` | Bookmarks emitter (separate from this cutover, not affected by migration 156) | +| `login_envelope_test.go` | `TestLoginEnvelope_HasRequiredKeys` and three xReturnTokens / displayName variants | Login envelope (not affected by migration 156) | + +In addition, handler-level round-trip tests live in +`playlists_handler_test.go` and `bookmarks_handler_test.go`. There is NO +snapshot/goldenfile harness in the repo today — these envelope tests are +the primary regression guard. + +## Manual live-DB diff procedure + +For a pre/post-deploy wire-shape verification against a live silo, see the +plan's Task 5 "manual verification" section at +`docs/superpowers/plans/2026-05-27-collections-unify-3-abs-adapters.md` +(§ `Task 5: Wire-shape regression test`). Summary: + +1. Pre-cutover, seed one of each (collection, playlist with item, + smart collection) via the old `abs_*` tables, then capture each list + endpoint's response to `/tmp/wire_before_.json` using a curl + against the running silo with a valid ABS bearer token (HS256 JWT — + minted by the login flow, NOT the raw `abs_sessions.token` value). +2. Apply migration 156. Seed equivalent rows in `user_personal_collections` + with the same IDs and content. Capture again to + `/tmp/wire_after_.json`. +3. `diff /tmp/wire_before_.json /tmp/wire_after_.json` for each + `kind in {collections,playlists,smart_collections}`. Expected: empty + diff. + +This is an MR-description-level manual step, not a committed test. + +## Intentionally-zero fields after the rewrite + +These wire keys are still emitted, but the store always populates the +in-memory field with the zero value because the canonical +`user_personal_collections` schema has no analog column (per spec §6 of the +collections-unify plan). They are NOT bugs — do not "fix" them by reaching +for some other column. + +| In-memory field | Wire key | Zero value | Spec ref | Disposition | +|---|---|---|---|---| +| `abs.Playlist.CoverItem` | `coverPath` | `""` (key omitted entirely when empty — see `playlistToABS`) | spec §6.1 | Dropped. PATCH `coverPath` body field is silently ignored by the store. Cover regeneration from first-item poster is the chosen long-term path. | +| `abs.SmartCollection.Color` | `color` | `""` (key always emitted as empty string) | spec §6.3 | Deferred. No column on `user_personal_collections`. Wire key stays present for client compatibility. | +| `abs.SmartCollection.IsPinned` | `isPinned` | `false` (key always emitted) | spec §6.2 | Deferred. Same rationale. | + +If you're adding a "Pin this smart collection" feature later, the column +needs to land in a new migration on `user_personal_collections` first; +don't try to thread it through some adjacent column. + +## Canonical mapping — struct field → source column + +The full pre-cutover wire contract was captured in a working note that does +not persist (`/tmp/abs_wire_contract.md`). The essentials are reproduced +here so the next maintainer doesn't have to re-derive them. + +All three struct families now read from `user_personal_collections` +(and `user_personal_collection_items` for collections + playlists), +discriminated by `collection_type IN ('manual','playlist','smart')`. + +### `abs.Collection` (`collection_type = 'manual'`) + +| Field | Source column | +|---|---| +| ID | `user_personal_collections.id` | +| UserID | `user_personal_collections.user_id::text` (column is `integer`) | +| ProfileID | `user_personal_collections.profile_id` | +| Name | `user_personal_collections.name` | +| Description | `user_personal_collections.description` | +| IsPublic | `user_personal_collections.is_shared` | +| CreatedAt | `user_personal_collections.created_at` | +| UpdatedAt | `user_personal_collections.updated_at` | + +`abs.CollectionItem` reads `user_personal_collection_items` with +`sub_item_id = ''` filter (the manual-collection sentinel established in +migration 156 step 1). LibraryItemID ← `media_item_id`. ORDER BY +`added_at ASC`. + +### `abs.Playlist` (`collection_type = 'playlist'`) + +Same column mapping as `abs.Collection` (modulo `collection_type` filter) +EXCEPT `CoverItem` which is always `""` — see "Intentionally-zero fields" +above. + +`abs.PlaylistItem` reads `user_personal_collection_items` with NO +`sub_item_id` filter (playlists can carry episode entries). Mapping: +LibraryItemID ← `media_item_id`, EpisodeID ← `sub_item_id`, +Position ← `position`. ORDER BY `position ASC, added_at ASC`. + +### `abs.SmartCollection` (`collection_type = 'smart'`) + +Same column mapping as `abs.Collection` EXCEPT: + +- `Color`, `IsPinned` → always zero (see above). +- `QueryDef` ← `user_personal_collections.query_definition` (JSONB → `[]byte` + round-trip; column is `NOT NULL DEFAULT '{}'::jsonb` per migration 016). + +No items table — smart-collection membership is evaluated at request time +via the `smartcoll` package. + +### Wire-shape quirks worth remembering + +- Collection/Playlist emit `lastUpdate` (NOT `updatedAt`). SmartCollection + emits `updatedAt`. Cross-struct inconsistency, carry forward verbatim. +- All timestamps are `UnixMilli()` int64, NOT RFC3339 strings. +- `ProfileID` is carried in memory but NEVER emitted on the wire — it's + scope/auth only. +- The list vs detail shape distinction is implicit: list responses pass + `nil` for the items/books slice; the emitter then omits the key + entirely. Clients differentiate on key presence. + +## Verification status (2026-05-27) + +- All 13 envelope tests pass (run: `go test ./internal/audiobooks/abs/ + -run Envelope -v -count=1`). +- Full audiobooks test suite passes (`go test ./internal/audiobooks/... + -short -count=1 -timeout 120s`). +- Live-DB diff was NOT executed in CI — see manual procedure above. diff --git a/docs/superpowers/plans/2026-05-24-audiobook-ui-redesign.md b/docs/superpowers/plans/2026-05-24-audiobook-ui-redesign.md new file mode 100644 index 00000000..c00d6b81 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-audiobook-ui-redesign.md @@ -0,0 +1,3072 @@ +# Audiobook UI Redesign 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 audiobook detail page and player to visual + interaction parity with Silo's video player, translated for audio, per the [design spec](../specs/2026-05-24-audiobook-ui-redesign-design.md). + +**Architecture:** Extract `CircleButton` and add new menu primitives (`SpeedMenu`, `SleepTimerMenu`) into `web/src/player/components/` so both players consume the same source of truth. Split today's monolithic `AudiobookPlayer.tsx` into a state hook (`useAudiobookPlayback`) plus two chrome components (`MiniBar`, `NowListening`) under `web/src/pages/audiobooks/player/`. The same `