Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<data>/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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
10 changes: 10 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
16 changes: 15 additions & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
// <img>/<audio> can't set headers); other routes do not (see requireMediaAuth).
mux.Handle("GET /api/v1/libraries/{id}/cover", a.requireMediaAuth(http.HandlerFunc(a.handleCover)))
Expand Down
1 change: 1 addition & 0 deletions internal/api/handlers_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func (a *API) handleServerInfo(w http.ResponseWriter, r *http.Request) {
"upload": false, // Phase B
"websocket": false, // Phase C
"api_keys": true, // user-minted personal access tokens (POST /auth/tokens)
"metadata": a.meta != nil, // community metadata lookup (GET /libraries/{id}/meta)
},
"auth": map[string]any{
"methods": []string{"auth_code", "password"},
Expand Down
56 changes: 56 additions & 0 deletions internal/api/handlers_meta.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package api

import (
"errors"
"net/http"

"github.com/kodestar/audiosilo-server/internal/library"
"github.com/kodestar/audiosilo-server/internal/meta"
)

// handleMeta resolves a book's asin/isbn against the community metadata API and
// returns a composed enrichment envelope. Transport-only: scope + path resolution
// reuse authorizedPath/bookForPath (exactly like item), the composition and cache
// live in internal/meta.
//
// Responses:
// - metadata disabled: 404 (clients gate on the `metadata` capability, so they
// never request this).
// - book has neither asin nor isbn, or the lookup found no match: 200 {"matched": false}.
// - upstream unreachable/error: 502.
// - match: 200 {"matched": true, ...} (see internal/meta.Enrichment).
func (a *API) handleMeta(w http.ResponseWriter, r *http.Request) {
if a.meta == nil {
writeError(w, http.StatusNotFound, "metadata lookup not enabled")
return
}
lib, path, status, msg := a.authorizedPath(r)
if status != 0 {
writeError(w, status, msg)
return
}
book, err := a.bookForPath(r.Context(), lib, path)
switch {
case errors.Is(err, library.ErrNotIndexable):
writeError(w, http.StatusNotFound, "no book at that path")
return
case err != nil:
a.writeCatalogError(w, err, "load book for meta failed", "could not load book", "library", lib.ID, "path", path)
return
}
if book.ASIN == "" && book.ISBN == "" {
writeJSON(w, http.StatusOK, map[string]bool{"matched": false})
return
}

env, err := a.meta.Enrich(r.Context(), book.ASIN, book.ISBN)
switch {
case errors.Is(err, meta.ErrNotFound):
writeJSON(w, http.StatusOK, map[string]bool{"matched": false})
case err != nil:
a.log.Warn("meta lookup failed", "err", err, "library", lib.ID, "path", path)
writeError(w, http.StatusBadGateway, "metadata service unavailable")
default:
writeJSON(w, http.StatusOK, env)
}
}
181 changes: 181 additions & 0 deletions internal/api/meta_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package api

import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"

"github.com/kodestar/audiosilo-server/internal/auth"
"github.com/kodestar/audiosilo-server/internal/catalog"
"github.com/kodestar/audiosilo-server/internal/config"
)

// escape url-escapes a ?path= value (a query param, matching the other tests).
func escape(s string) string { return url.QueryEscape(s) }

// mockMetaserve is a minimal metaserve stand-in for the /meta handler tests.
type mockMetaserve struct {
lookupCode int // non-zero overrides the lookup response status
}

func (m *mockMetaserve) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/lookup", func(w http.ResponseWriter, _ *http.Request) {
if m.lookupCode != 0 {
w.WriteHeader(m.lookupCode)
return
}
_, _ = w.Write([]byte(`{"work":{"id":"the-martian","title":"The Martian","authors":[{"id":"andy-weir","name":"Andy Weir"}],"series":null,"cover_url":null,"added_at":null},"recording_id":"rec1"}`))
})
mux.HandleFunc("GET /api/v1/works/{id}", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"id":"the-martian","title":"The Martian","subtitle":"","authors":[{"id":"andy-weir","name":"Andy Weir"}],"language":"en","first_published":"2011","description":"Stranded.","series":[{"id":"mars","name":"Mars","position":"1"}],"recordings":[{"id":"rec1","narrators":[{"id":"r-c-bray","name":"R. C. Bray"}],"abridged":false,"runtime_min":634,"release_date":"2013-03-22","publisher":"Podium Audio","cover_url":"https://c/1.jpg","chapter_count":12}]}`))
})
mux.HandleFunc("GET /api/v1/series/{id}", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"id":"mars","name":"Mars","authors":[{"id":"andy-weir","name":"Andy Weir"}],"works":[{"position":"1","work":{"id":"the-martian","title":"The Martian","authors":[{"id":"andy-weir","name":"Andy Weir"}],"series":null,"cover_url":null,"added_at":null}},{"position":"2","work":{"id":"artemis","title":"Artemis","authors":[{"id":"andy-weir","name":"Andy Weir"}],"series":null,"cover_url":null,"added_at":null}}]}`))
})
return mux
}

// newMetaEnv builds a test env whose metadata service points at a fresh mock
// metaserve (torn down with the test). enabled=false disables the feature.
func newMetaEnv(t *testing.T, enabled bool, lookupCode int) *testEnv {
t.Helper()
mock := httptest.NewServer((&mockMetaserve{lookupCode: lookupCode}).handler())
t.Cleanup(mock.Close)
return newTestEnvWith(t, func(c *config.Config) {
c.Metadata.Enabled = enabled
c.Metadata.BaseURL = mock.URL
})
}

// seedBook upserts a book at path with the given asin, returning the library id.
func seedBook(t *testing.T, e *testEnv, path, asin string) int64 {
t.Helper()
lib, err := e.cat.CreateLibrary(context.Background(), catalog.Library{Name: "Main", Root: t.TempDir()})
if err != nil {
t.Fatal(err)
}
book := &catalog.Book{LibraryID: lib.ID, RelPath: path, Title: "Book", Author: "Author", ASIN: asin, AddedAt: "2020-01-01"}
if _, err := e.cat.UpsertBook(context.Background(), book); err != nil {
t.Fatal(err)
}
return lib.ID
}

func TestMetaMatch(t *testing.T) {
e := newMetaEnv(t, true, 0)
libID := seedBook(t, e, "Andy Weir/The Martian", "B00FLIJJSY")
adminTok, _ := e.auth.IssueToken(context.Background(), e.adminID, auth.KindSession, "t", 0)

path := "/api/v1/libraries/" + strconv.FormatInt(libID, 10) + "/meta?path=" + escape("Andy Weir/The Martian")
resp, body := e.do(t, "GET", path, adminTok, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("meta match = %d %s, want 200", resp.StatusCode, body)
}
for _, want := range []string{`"matched":true`, `"the-martian"`, `"R. C. Bray"`, `"Podium Audio"`, `/work?id=the-martian`, `"artemis"`} {
if !strings.Contains(body, want) {
t.Fatalf("meta envelope missing %q: %s", want, body)
}
}

// The capability is advertised when the service is configured.
_, si := e.do(t, "GET", "/api/v1/server", "", "")
if !strings.Contains(si, `"metadata":true`) {
t.Fatalf("expected metadata capability true: %s", si)
}
}

func TestMetaNoIDs(t *testing.T) {
e := newMetaEnv(t, true, 0)
libID := seedBook(t, e, "Author/No IDs", "") // neither asin nor isbn
adminTok, _ := e.auth.IssueToken(context.Background(), e.adminID, auth.KindSession, "t", 0)

path := "/api/v1/libraries/" + strconv.FormatInt(libID, 10) + "/meta?path=" + escape("Author/No IDs")
resp, body := e.do(t, "GET", path, adminTok, "")
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `"matched":false`) {
t.Fatalf("no-ids meta = %d %s, want 200 matched:false", resp.StatusCode, body)
}
}

func TestMetaUpstreamNotFound(t *testing.T) {
e := newMetaEnv(t, true, http.StatusNotFound)
libID := seedBook(t, e, "Author/Unknown", "B0UNKNOWN")
adminTok, _ := e.auth.IssueToken(context.Background(), e.adminID, auth.KindSession, "t", 0)

path := "/api/v1/libraries/" + strconv.FormatInt(libID, 10) + "/meta?path=" + escape("Author/Unknown")
resp, body := e.do(t, "GET", path, adminTok, "")
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `"matched":false`) {
t.Fatalf("upstream 404 meta = %d %s, want 200 matched:false", resp.StatusCode, body)
}
}

func TestMetaUpstreamDown(t *testing.T) {
e := newMetaEnv(t, true, http.StatusInternalServerError)
libID := seedBook(t, e, "Author/Book", "B0DOWN")
adminTok, _ := e.auth.IssueToken(context.Background(), e.adminID, auth.KindSession, "t", 0)

path := "/api/v1/libraries/" + strconv.FormatInt(libID, 10) + "/meta?path=" + escape("Author/Book")
resp, body := e.do(t, "GET", path, adminTok, "")
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("upstream down meta = %d %s, want 502", resp.StatusCode, body)
}
}

func TestMetaDisabled(t *testing.T) {
e := newMetaEnv(t, false, 0)
libID := seedBook(t, e, "Author/Book", "B0OFF")
adminTok, _ := e.auth.IssueToken(context.Background(), e.adminID, auth.KindSession, "t", 0)

path := "/api/v1/libraries/" + strconv.FormatInt(libID, 10) + "/meta?path=" + escape("Author/Book")
if resp, body := e.do(t, "GET", path, adminTok, ""); resp.StatusCode != http.StatusNotFound {
t.Fatalf("disabled meta = %d %s, want 404", resp.StatusCode, body)
}
// The capability reflects the disabled service.
if _, si := e.do(t, "GET", "/api/v1/server", "", ""); !strings.Contains(si, `"metadata":false`) {
t.Fatalf("expected metadata capability false: %s", si)
}
}

// TestMetaScopeSecurity is the required allowed+denied pair: a scoped non-admin
// may probe a book inside their grant but must be refused (403) for a path
// outside it, exactly like the other content handlers.
func TestMetaScopeSecurity(t *testing.T) {
e := newMetaEnv(t, true, 0)
// Two books under distinct top-level folders in one library.
lib, err := e.cat.CreateLibrary(context.Background(), catalog.Library{Name: "Main", Root: t.TempDir()})
if err != nil {
t.Fatal(err)
}
for _, b := range []*catalog.Book{
{LibraryID: lib.ID, RelPath: "Andy Weir/The Martian", Title: "Book", Author: "Author", ASIN: "B00FLIJJSY", AddedAt: "2020-01-01"},
{LibraryID: lib.ID, RelPath: "Other Author/Secret", Title: "Secret", Author: "Other", ASIN: "B0SECRET", AddedAt: "2020-01-01"},
} {
if _, err := e.cat.UpsertBook(context.Background(), b); err != nil {
t.Fatal(err)
}
}

// A non-admin granted only the "Andy Weir" subtree.
kid, _ := e.auth.CreateUser(context.Background(), "kid", "kid-password", auth.RoleUser)
share, _ := e.cat.CreateShare(context.Background(), catalog.Share{Name: "Weir only"})
e.cat.AddSharePath(context.Background(), share.ID, catalog.PathRule{LibraryID: lib.ID, Path: "Andy Weir"})
e.cat.GrantShare(context.Background(), kid.ID, share.ID)
token, _ := e.auth.IssueToken(context.Background(), kid.ID, auth.KindSession, "t", 0)
libPath := "/api/v1/libraries/" + strconv.FormatInt(lib.ID, 10)

// Allowed: a book inside the grant resolves against the mock.
in := libPath + "/meta?path=" + escape("Andy Weir/The Martian")
if resp, body := e.do(t, "GET", in, token, ""); resp.StatusCode != http.StatusOK || !strings.Contains(body, `"matched":true`) {
t.Fatalf("in-scope meta = %d %s, want 200 matched", resp.StatusCode, body)
}

// Denied: a path outside the grant must be refused (403), never probed.
out := libPath + "/meta?path=" + escape("Other Author/Secret")
if resp, body := e.do(t, "GET", out, token, ""); resp.StatusCode != http.StatusForbidden {
t.Fatalf("out-of-scope meta = %d %s, want 403", resp.StatusCode, body)
}
}
Loading
Loading