diff --git a/CLAUDE.md b/CLAUDE.md index a020d81..7f9a7ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,7 @@ internal/catalog/ libraries, access grants, books, FTS search, listening sta internal/library/ filesystem view (fsview.go) + background scanner (scanner.go) internal/metadata/ dhowden/tag + ffprobe extraction; DeriveFromPath (structural path parsing) internal/media/ Range streaming, download, embedded cover extraction +internal/meta/ Phase 1.5 community metadata lookup: HTTP client + Service (asin/isbn -> composed enrichment envelope) with a bounded TTL cache internal/toolfetch/ on-demand ffmpeg/ffprobe download+cache (/tools) when none is local internal/api/ HTTP transport: routing (api.go), middleware, rate limiting, handlers_*.go internal/server/ HTTP(S) server, TLS modes (off/selfsigned/autocert), graceful shutdown @@ -242,6 +243,24 @@ future metadata site can attach enrichment without reshaping the schema. react-native-web's runtime styles). Admin/connect pages keep the stricter site-wide CSP. Compatibility is by construction (the image pins a matching web build); native apps negotiate via `GET /server` capability flags. +- **Community metadata lookup (Phase 1.5, `internal/meta`)**: `GET + /api/v1/libraries/{id}/meta?path=` (authed, scope-checked via `authorizedPath` + + `bookForPath`, exactly like `item`) resolves the book's `asin`/`isbn` + (backfilled via `book_enrichment`) against the community metadata API + (`metaserve`, meta.audiosilo.app) and returns a composed enrichment envelope + (work + matched recording + series rails, each carrying its own `web_url`). + Config is `metadata.{enabled,base_url}` (env `AUDIOSILO_METADATA_ENABLED` / + `AUDIOSILO_METADATA_BASE_URL`; `base_url` must be an absolute http(s) URL when + enabled) - one key disables ALL outbound calls. `meta.Service` (constructed in + `api.New` only when configured; `a.meta == nil` = off) owns the compose logic + (lookup -> works/{id} -> pick the recording by `recording_id`, first as + fallback -> up to 3 series rails) behind a bounded in-memory TTL cache (24h + positive / 1h not-found / 2min transport-error, ~2048-entry cap) so a hot path + or a down upstream isn't hammered; the api handler (`handlers_meta.go`) is + transport-only. Degradation: disabled -> 404 (and the `metadata` capability is + false, so clients hide the UI); no asin/isbn or no upstream match -> `200 + {"matched": false}`; upstream unreachable -> 502. Out of scope for now: no cover + remote-fallback, no persisting meta into the DB, no tag-based ASIN extraction. - **Native deep-link association**: `GET /.well-known/apple-app-site-association` and `/assetlinks.json` are served from `config.AppLinkConfig` (`app_links` in YAML) and 404 when unset. They only enable auto-app-launch for domains the @@ -375,13 +394,17 @@ future metadata site can attach enrichment without reshaping the schema. See the plan file. `GET /api/v1/server` advertises capability flags (`admin_ui`, `web_player`, -`upload`, `transcode`, `websocket`, `api_keys`); flip them on as phases land. -`transcode` already reflects whether ffmpeg is configured; `api_keys` is true -(user-minted personal access tokens are supported). +`upload`, `transcode`, `websocket`, `api_keys`, `metadata`); flip them on as +phases land. `transcode` already reflects whether ffmpeg is configured; +`api_keys` is true (user-minted personal access tokens are supported); +`metadata` reflects whether the Phase 1.5 metadata lookup is configured +(`metadata.enabled && metadata.base_url != ""`). ## API surface See `internal/api/api.go` for the full route table. Public: `/server`, `/auth/redeem`, `/auth/exchange`, `/auth/login`, the well-known association files, and the static UI (`/`, `/connect`, `/admin`, `/web/...`). Everything else needs a -session bearer token; `/admin/*` additionally requires the admin role. +session bearer token; `/admin/*` additionally requires the admin role. The +metadata lookup is `GET /libraries/{id}/meta?path=` (authed, scope-checked like +the other `?path=` content endpoints; 404 when metadata is disabled). diff --git a/config.example.yaml b/config.example.yaml index 5511225..9d267a0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -71,3 +71,13 @@ demo: library: "" # name of a library above to grant demo users [AUDIOSILO_DEMO_LIBRARY] # max_users: 200 # cap on live demo users; omit for the safe default of 200, explicit 0 = unlimited (opt-in risk) [AUDIOSILO_DEMO_MAX_USERS] idle_ttl: "24h" # reap demo users idle longer than this [AUDIOSILO_DEMO_IDLE_TTL] + +# Community metadata lookup (Phase 1.5). When enabled, the server resolves a +# book's ASIN/ISBN against the community metadata API and exposes a composed +# enrichment envelope at GET /libraries/{id}/meta (author/narrator, description, +# series rail); results are cached in memory. Disabling it stops all outbound +# calls with one key, and the `metadata` capability then reports false so clients +# hide the UI. base_url must be an absolute http(s) URL when enabled. +metadata: + enabled: true # [AUDIOSILO_METADATA_ENABLED] + base_url: "https://meta.audiosilo.app" # metaserve base URL [AUDIOSILO_METADATA_BASE_URL] diff --git a/internal/api/api.go b/internal/api/api.go index 1318927..df55eed 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -13,6 +13,7 @@ import ( "github.com/kodestar/audiosilo-server/internal/catalog" "github.com/kodestar/audiosilo-server/internal/config" "github.com/kodestar/audiosilo-server/internal/library" + "github.com/kodestar/audiosilo-server/internal/meta" "github.com/kodestar/audiosilo-server/internal/web" ) @@ -35,7 +36,11 @@ type API struct { cat *catalog.Catalog scanner *library.Scanner ffmpeg string // path to ffmpeg for on-the-fly transcoding; "" disables it - log *slog.Logger + // meta resolves book asin/isbn against the community metadata API (Phase 1.5). + // nil when metadata lookup is disabled/unconfigured; the handler and the + // `metadata` capability flag both gate on it being non-nil. + meta *meta.Service + log *slog.Logger // baseCtx is the server lifecycle context; background work detached from a // request (e.g. backgroundScan) derives from it so it's cancelled on shutdown @@ -74,12 +79,20 @@ func New(cfg *config.Config, authSvc *auth.Service, cat *catalog.Catalog, scanne if log == nil { log = slog.Default() } + // Construct the metadata lookup service from config; nil when disabled or + // unconfigured so the feature (endpoint + capability) is off. The config is + // validated upstream (base_url is an absolute http(s) URL when enabled). + var metaSvc *meta.Service + if cfg.Metadata.Enabled && cfg.Metadata.BaseURL != "" { + metaSvc = meta.NewService(cfg.Metadata.BaseURL, nil) + } return &API{ cfg: cfg, auth: authSvc, cat: cat, scanner: scanner, ffmpeg: ffmpeg, + meta: metaSvc, log: log, baseCtx: context.Background(), timeoutDur: requestTimeout, @@ -150,6 +163,7 @@ func (a *API) Handler() http.Handler { mux.Handle("GET /api/v1/libraries/{id}/books", a.requireAuth(http.HandlerFunc(a.handleListBooks))) mux.Handle("GET /api/v1/libraries/{id}/item", a.requireAuth(http.HandlerFunc(a.handleItem))) mux.Handle("GET /api/v1/libraries/{id}/chapters", a.requireAuth(http.HandlerFunc(a.handleChapters))) + mux.Handle("GET /api/v1/libraries/{id}/meta", a.requireAuth(http.HandlerFunc(a.handleMeta))) // Media GETs accept the session token as a ?token= query param (browser // /