Skip to content

Admin library export for meta.audiosilo.app - #42

Merged
KodeStar merged 2 commits into
mainfrom
feat/library-export
Sep 21, 2026
Merged

KodeStar merged 2 commits into
mainfrom
feat/library-export

Conversation

@KodeStar

Copy link
Copy Markdown
Owner

What

  • GET /api/v1/admin/libraries/{id}/export (admin only): downloads a library's book list as the audiosilo-books v1 envelope that meta.audiosilo.app's import parser already reads - title, authors, narrators, series and position, ASIN, ISBN, runtime, chapter count. Never paths, sizes, codecs or roots (an allowlist test on the marshalled JSON keys pins that).
  • export capability flag on GET /server (additive; mirrored in audiosilo-frontend's types in a sibling PR).
  • Admin console: an Export button in each library's actions row (fetch with the bearer token, blob download; CSP unchanged). i18n keys in all six locales, with a key-parity test.
  • Composition lives in internal/catalog (keyset paging, one grouped chapter query, duplicate copies merged field-by-field so the tagged copy is never dropped for the sparse one).

Why

The meta site's new Watching page lets a user mark which volumes of a watched series they own; this export is how they get their library in without typing it.

Checks

Full gate green (build, vet, test -race, golangci-lint). Simplify and code-review passes applied. Docs: audiosilo-docs server-export branch (API reference, web UI page, user guide); the libraries screenshot is regenerated separately.

An admin can now download a library's book list as a JSON file and import it
on meta.audiosilo.app's Watching page to mark which entries of a series they
own.

GET /api/v1/admin/libraries/{id}/export (requireAdmin) serves the
{"format":"audiosilo-books","version":1,...} envelope the metadata site
already parses, as an attachment named
audiosilo-<library-slug>-<YYYY-MM-DD>.json.

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.

- internal/catalog/export.go composes the envelope (unit-testable, no HTTP):
  keyset paging over ListBooks (never OFFSET), copies of the same book within
  the library collapsed on exposedDedupKey, one chapter-count query per page.
  The single author/narrator string splits into a list only where it clearly
  holds several names: on ";", " & " and " and " always, and on a comma only
  when every part still has two words, so "Alexandre Dumas, pere" stays whole.
  series_position is the reader's form of the float index ("2", "2.5").
- internal/api/handlers_export.go is transport-only and streams the envelope
  with a json.Encoder; 404 for an unknown library, 400 for a bad id.
- The "export" capability is added to GET /server (additive; old clients
  ignore it).
- The admin console gains an Export button per library. It cannot be a plain
  <a href> because the console authenticates with a bearer token, so it
  fetches the endpoint, reads the Content-Disposition filename and saves via
  a blob object URL - no CSP change needed. Strings added to all six locales,
  and internal/web now has a key-parity test so a locale cannot fall behind.

Note: books rows are books, including folder-per-book ones (is_folder), so
nothing is skipped on is_folder - a multi-file book is already one row and
therefore one export entry.
Pre-merge pass over the export branch.

Simplification/reuse:
- splitNames folds its three separator passes into one regexp split, so the
  cleaning no longer has to happen between passes (which left a stranded
  joiner on "X; and Y"); splitOnCommas no longer re-cleans an already-clean
  chunk, and slugify is strings.FieldsFunc instead of a hand-rolled
  builder + dash flag.
- chapterCounts is one grouped query for the whole library instead of one
  per export page, dropping the dynamic IN (?,?,...) placeholder building.
- Filename reads the date with strings.Cut; the always-empty Subtitle field
  is gone (the envelope is additive, so it returns when the index has one).
- admin.js: apiFetch now owns the API prefix, bearer auth, the 401 re-login
  and the {error} envelope, and api() is JSON decoding over it - the export
  download shares that one contract instead of a second copy of it.

Correctness:
- Duplicate copies of a book merge rather than keeping whichever sorted
  first: an untagged rip indexed before the tagged copy used to win the
  (title, id) keyset and take the narrator/series/isbn/chapters with it.
  mergeExportBook fills only fields the kept entry lacks.
- cleanNameParts trims a dangling comma from both ends, so a half-empty
  "Last, First" tag no longer exports as ", Jane Doe".

Tests:
- The leak guard now asserts on the marshalled key sets as an allowlist, so
  a leaked field fails even when its name is invisible to a substring scan
  ("format" is the envelope's own marker; "files" was unchecked). Verified
  by mutation: adding rel_path/size/format/files to ExportBook fails it.
- The i18n parity check no longer compares en against itself, and drops an
  unreachable block/name count guard.
@KodeStar
KodeStar merged commit f740fd7 into main Sep 21, 2026
2 checks passed
@KodeStar
KodeStar deleted the feat/library-export branch September 21, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant