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
25 changes: 23 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,25 @@ future metadata site can attach enrichment without reshaping the schema.
progress/bookmarks). `PUT/DELETE /admin/libraries/{id}/folder-override?path=`
sets/clears it and rescans; the admin console's per-library **Detection** browser
drives it. `GET /fs` annotates each entry's effective `override`.
- **Library export** (`internal/catalog/export.go` + `api/handlers_export.go`):
`GET /admin/libraries/{id}/export` (admin only) downloads a library's book list
as `audiosilo-<library-slug>-<YYYY-MM-DD>.json` - the `{"format":"audiosilo-books",
"version":1,…}` envelope the community metadata site imports on its Watching page
so a user can mark which entries of a series they own. **The file leaves the
server**, so it carries bibliographic facts only (title, authors, narrators,
series + position, asin/isbn, runtime_min, chapters) and **never** a path, size,
codec, format or anything else about the filesystem - a regression test asserts
that. `catalog.ExportLibraryBooks` composes the envelope (keyset paging over
`ListBooks`, never OFFSET; copies of the same book within the library collapse on
`exposedDedupKey`, the kept entry taking each fact from whichever copy has it -
`mergeExportBook`, since the copy that sorts first may be the untagged rip); the
handler is transport-only and writes it with a `json.Encoder`. The leak guard
asserts on the MARSHALLED key sets as an allowlist, so a field added to
`ExportBook` fails the test even when its name (`format`, `files`) is invisible
to a substring scan. The single `author`/`narrator` string is split into a list only
where it clearly holds several names (`;`, ` & `, ` and `, and a comma **only**
when every part still has two words, so "Alexandre Dumas, pere" stays whole).
Advertised by the additive `export` capability.
- **Library admin**: `PATCH /admin/libraries/{id}` edits name/root/default_view and
triggers a background rescan; `DELETE /admin/libraries/{id}` removes the library
+ its index (files on disk untouched). Both are surfaced in the admin console.
Expand Down Expand Up @@ -434,8 +453,8 @@ 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`, `metadata`); flip them on as
phases land. `transcode` already reflects whether ffmpeg is configured;
`upload`, `transcode`, `websocket`, `api_keys`, `metadata`, `export`); 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 live
(`metadataOn()`: a valid `metadata.base_url` AND the runtime enabled flag, which
Expand All @@ -451,6 +470,8 @@ metadata lookup is `GET /libraries/{id}/meta?path=` (authed, scope-checked like
the other `?path=` content endpoints; 404 when metadata is disabled), plus
`GET /meta/work?id=<work id>` (authed, no library scope - global community data;
404 when metadata is disabled or the work id is unknown).
The library export is `GET /admin/libraries/{id}/export` (admin only; returns a
JSON attachment, not the usual envelope - see Library export above).
Runtime-toggleable settings are `GET`/`PATCH /admin/settings` (admin only): a
feature-keyed envelope (`{"metadata":{"enabled","base_url","available"}}`) whose
`PATCH {"metadata":{"enabled":bool}}` flips the metadata lookup and persists it.
1 change: 1 addition & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ func (a *API) Handler() http.Handler {
mux.Handle("PUT /api/v1/admin/libraries/{id}/folder-override", a.requireAdmin(http.HandlerFunc(a.handleSetFolderOverride)))
mux.Handle("DELETE /api/v1/admin/libraries/{id}/folder-override", a.requireAdmin(http.HandlerFunc(a.handleDeleteFolderOverride)))
mux.Handle("PUT /api/v1/admin/libraries/{id}/enrichment", a.requireAdmin(http.HandlerFunc(a.handleSetEnrichment)))
mux.Handle("GET /api/v1/admin/libraries/{id}/export", a.requireAdmin(http.HandlerFunc(a.handleExportLibrary)))
mux.Handle("POST /api/v1/admin/libraries/{id}/scan", a.requireAdmin(http.HandlerFunc(a.handleScanLibrary)))
mux.Handle("GET /api/v1/admin/libraries/{id}/scan", a.requireAdmin(http.HandlerFunc(a.handleScanStatus)))

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)
"export": true, // admin library export (GET /admin/libraries/{id}/export)
"metadata": a.metadataOn(), // community metadata lookup (GET /libraries/{id}/meta); runtime-toggleable
},
"auth": map[string]any{
Expand Down
48 changes: 48 additions & 0 deletions internal/api/handlers_export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package api

import (
"encoding/json"
"errors"
"net/http"
"strconv"

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

// handleExportLibrary serves a library's book list as a downloadable JSON file
// that the community metadata site (meta.audiosilo.app) can import, so a user can
// mark which entries of a series they own.
//
// Admin-only: it is a whole-library dump, not a scoped view, so it is not gated
// on share path rules - requireAdmin is the gate. The envelope is composed in
// internal/catalog (export.go); this handler is transport-only and writes it
// straight to the response with an encoder rather than buffering a string of
// its own.
//
// The file carries bibliographic facts only - no paths, sizes, codecs or anything
// else describing the filesystem - because it is meant to leave the server.
func (a *API) handleExportLibrary(w http.ResponseWriter, r *http.Request) {
id, ok := pathInt(r, "id")
if !ok {
writeError(w, http.StatusBadRequest, "invalid library id")
return
}
exp, err := a.cat.ExportLibraryBooks(r.Context(), id, Version)
if errors.Is(err, catalog.ErrNotFound) {
writeError(w, http.StatusNotFound, "library not found")
return
}
if err != nil {
a.writeCatalogError(w, err, "export library failed", "could not export library", "library", id)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// strconv.Quote gives the quoted-string form browsers expect (same convention
// as media.ServeFile's download header).
w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(exp.Filename()))
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(exp); err != nil {
// The status and headers are already out; all that is left is a log line.
a.log.Warn("export library: write failed", "err", err, "library", id)
}
}
155 changes: 155 additions & 0 deletions internal/api/handlers_export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package api

import (
"context"
"encoding/json"
"mime"
"net/http"
"strconv"
"strings"
"testing"

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

// TestExportLibrary covers the allowed+denied pair for
// GET /admin/libraries/{id}/export: an admin gets the importable envelope with
// download headers; a non-admin and an anonymous caller are refused; an unknown
// library 404s with the usual {error} envelope.
func TestExportLibrary(t *testing.T) {
e := newTestEnv(t)
ctx := context.Background()
lib, _ := e.cat.CreateLibrary(ctx, catalog.Library{Name: "My Books", Root: t.TempDir()})
if _, err := e.cat.UpsertBook(ctx, &catalog.Book{
LibraryID: lib.ID, RelPath: "Lee Child/Die Trying", IsFolder: true,
Title: "Die Trying", Author: "Lee Child", Narrator: "Dick Hill",
Series: "Jack Reacher", SeriesIndex: 2, Duration: 36720, ASIN: "B0TESTASIN",
Format: "mp3", Size: 4242,
}); err != nil {
t.Fatal(err)
}
adminTok, _ := e.auth.IssueToken(ctx, e.adminID, auth.KindSession, "t", 0)
path := "/api/v1/admin/libraries/" + strconv.FormatInt(lib.ID, 10) + "/export"

// Allowed: the admin downloads the file.
resp, body := e.do(t, "GET", path, adminTok, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("export = %d %s, want 200", resp.StatusCode, body)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
disp, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
if err != nil {
t.Fatalf("Content-Disposition %q: %v", resp.Header.Get("Content-Disposition"), err)
}
if disp != "attachment" {
t.Errorf("disposition = %q, want attachment", disp)
}
if !strings.HasPrefix(params["filename"], "audiosilo-my-books-") ||
!strings.HasSuffix(params["filename"], ".json") {
t.Errorf("filename = %q, want audiosilo-my-books-<date>.json", params["filename"])
}

// Round-trip the envelope: exactly the shape the metadata site imports.
var out struct {
Format string `json:"format"`
Version int `json:"version"`
Source string `json:"source"`
ServerVersion string `json:"server_version"`
Library struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"library"`
ExportedAt string `json:"exported_at"`
Books []struct {
Title string `json:"title"`
Authors []string `json:"authors"`
Narrators []string `json:"narrators"`
Series string `json:"series"`
SeriesPosition string `json:"series_position"`
ASIN string `json:"asin"`
RuntimeMin int `json:"runtime_min"`
} `json:"books"`
}
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode: %v (%s)", err, body)
}
if out.Format != "audiosilo-books" || out.Version != 1 || out.Source != "audiosilo-server" {
t.Fatalf("envelope = %+v", out)
}
if out.ServerVersion != Version {
t.Errorf("server_version = %q, want %q", out.ServerVersion, Version)
}
if out.Library.ID != lib.ID || out.Library.Name != "My Books" || out.ExportedAt == "" {
t.Errorf("library/exported_at = %+v %q", out.Library, out.ExportedAt)
}
if len(out.Books) != 1 {
t.Fatalf("books = %d, want 1: %s", len(out.Books), body)
}
b := out.Books[0]
if b.Title != "Die Trying" || b.Series != "Jack Reacher" || b.SeriesPosition != "2" ||
b.ASIN != "B0TESTASIN" || b.RuntimeMin != 612 {
t.Errorf("book = %+v", b)
}
if len(b.Authors) != 1 || b.Authors[0] != "Lee Child" ||
len(b.Narrators) != 1 || b.Narrators[0] != "Dick Hill" {
t.Errorf("contributors = %v / %v", b.Authors, b.Narrators)
}
// Nothing about the filesystem leaves the server.
for _, leak := range []string{"Lee Child/Die Trying", "rel_path", "4242", `"size"`} {
if strings.Contains(body, leak) {
t.Errorf("export leaks %q: %s", leak, body)
}
}

// Denied: a non-admin session is refused by requireAdmin.
member, _ := e.auth.CreateUser(ctx, "member", "member-password", auth.RoleUser)
memberTok, _ := e.auth.IssueToken(ctx, member.ID, auth.KindSession, "t", 0)
if resp, body := e.do(t, "GET", path, memberTok, ""); resp.StatusCode != http.StatusForbidden {
t.Fatalf("non-admin export = %d %s, want 403", resp.StatusCode, body)
}
// Denied: no credential at all.
if resp, body := e.do(t, "GET", path, "", ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous export = %d %s, want 401", resp.StatusCode, body)
}
// Denied: a token in the query string is not accepted (export is not a media
// route, so the ?token= fallback must not apply).
if resp, _ := e.do(t, "GET", path+"?token="+adminTok, "", ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("query-token export = %d, want 401", resp.StatusCode)
}

// Unknown library: the usual {error} envelope.
resp, body = e.do(t, "GET", "/api/v1/admin/libraries/9999/export", adminTok, "")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown library = %d %s, want 404", resp.StatusCode, body)
}
var errEnv struct {
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(body), &errEnv); err != nil || errEnv.Error == "" {
t.Errorf("404 body = %s, want an {error} envelope", body)
}

// A non-numeric id is a 400, not a 404.
if resp, _ := e.do(t, "GET", "/api/v1/admin/libraries/abc/export", adminTok, ""); resp.StatusCode != http.StatusBadRequest {
t.Fatalf("bad id = %d, want 400", resp.StatusCode)
}
}

// TestServerInfoExportCapability checks the additive capability flag clients gate
// the Export affordance on.
func TestServerInfoExportCapability(t *testing.T) {
e := newTestEnv(t)
_, body := e.do(t, "GET", "/api/v1/server", "", "")
var out struct {
Capabilities map[string]bool `json:"capabilities"`
}
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode: %v (%s)", err, body)
}
if !out.Capabilities["export"] {
t.Fatalf("capabilities.export = false, want true: %s", body)
}
}
Loading
Loading