Compare commits

...

10 commits

Author SHA1 Message Date
mithral
051169a18a Documentation, and make a fresh clone actually work
Docs
- docs/ARCHITECTURE.md: how the pieces fit and why. The request path, the six
  enforced layers, the data model, the scanner, the job queue, direct-play
  playback, and the three protocol surfaces over one application.
- docs/decisions/: seven ADRs with the alternatives that were rejected —
  SQLite-with-a-seam, direct-play-only, mark-missing-never-delete, the modular
  monolith, the one item-access chokepoint, the table-backed queue, and
  fixed-width timestamps.
- README: the layout section was stale (it predated catalog, home, scanner,
  providers, engine, probe, playback, apitoken, arch and the dto packages) and
  now lists what exists, plus links to the new docs.

Two real bugs, both found by verifying the docs instead of trusting them

1. The CLI could not run on a fresh install. `Config.Validate` created the data
   and config directories, but the CLI never called it, so `vellum user add` —
   the recovery path when you are locked out — failed with "unable to open
   database file" until the server had booted at least once. Split the directory
   creation into `Config.EnsureDirs`, call it from the CLI, and keep the media
   root check out of it: an admin command must not fail because a library is
   unmounted. The CLI tests now point DBPath inside DataDir, exactly as
   LoadConfig derives it, so they exercise the fresh-install path; removing
   EnsureDirs fails three of them.

2. A fresh clone could not build at all. `//go:embed all:dist` fails to compile
   when web/dist is absent, and web/.gitignore expected a `dist/.gitkeep` that
   was never created. Added it, so a clone builds and the server logs
   "web UI not embedded; run the frontend build to include it" — the intended
   behaviour for someone who only wants the API.

Also: the httpapi tests depended on a built frontend and FAILED rather than
skipping, so the suite was red on any fresh clone. findAsset and readAsset now
skip with a message naming the missing step. They still RUN when a bundle is
present (verified: zero skips here), so they have not been quietly disabled.

Verified from a simulated clone of tracked files only: go build exit 0, 25
packages pass, the server boots and applies all 12 migrations, and the CLI
creates a user and takes a backup. gofmt clean, vet exit 0, svelte-check 0/0.
2026-10-12 00:18:11 -04:00
mithral
129ad80f14 Guard the storage seam against erosion
Phase 3 built the seam; nothing stopped it being undone one convenient import at
a time. Two rules, both proven able to fail.

TestDatabaseSQLImportersAreDeclared freezes the blast radius of database/sql.
Twenty packages import it today and that set is now explicit. A new package that
needs its own SQL must be added deliberately, with a reason. The rule also fails
in the other direction — if a listed package stops importing database/sql, the
list must shrink, so it cannot rot into a meaningless allowlist.

TestThreadedConsumersStayOffSQL is the concrete regression guard: internal/
playback was moved onto catalog.Store, and this fails if it reaches for *sql.DB
again.

Both checks read non-test files only, so a package that needs database/sql
purely to build a test database is not forced onto the list.

Proven by breaking each on purpose:
  - dropping internal/media from the list  -> "imports database/sql but is not
    in sqlImporters"
  - adding internal/natsort (no SQL)       -> "is listed ... but no longer
    imports database/sql"
  - re-adding database/sql to playback     -> "undoes the seam"
All restored afterwards and the files verified unchanged.

Seven arch rules now, each able to fail. gofmt clean, build 0, vet 0, 25
packages pass.
2026-10-12 00:06:01 -04:00
mithral
8bd6b8a417 Phase 3: a storage seam behind catalog, and one consumer threaded onto it
EXPAND ONLY, per the migration order (expand, migrate, verify, contract). No
Postgres implementation, no behaviour change.

internal/catalog now exposes:

  type Store interface {
      Item / ItemFor / Files / FilesFor / FileByID / FirstFile / FileCount
  }
  type SQL struct{ db *sql.DB }; func New(db *sql.DB) *SQL

The seven bodies are the old functions moved verbatim. ItemFor still returns the
same ErrNotFound for absent and forbidden, so the security property is
unchanged. A compile-time `var _ Store = (*SQL)(nil)` keeps the implementation
honest.

Deliberately NOT moved: browse lists, home shelves and subsonic album queries.
Those are each package's own presentation concern, and pulling all of them into
the seam is exactly the 186-statement rewrite the seam exists to avoid. The
package doc says so.

Proven substitutable, not just declared: store_test.go implements a fake Store
in memory with no database at all and drives a caller through the interface. A
seam nobody can substitute proves nothing.

The agent's own risk note was right and I acted on it: the seam only pays off
once something actually threads it, and initially nothing did. So internal/
playback — the smallest consumer — now takes a catalog.Store instead of *sql.DB
and is wired with catalog.New(db) at the composition point. It has no database
reference left outside a comment. The remaining consumers (media, jellyfin,
subsonic, metadata) still call the package-level wrappers; threading them is
mechanical, and I would rather do it deliberately than in one sweep.

Verified: gofmt clean, build exit 0, vet exit 0, 25 packages pass, arch rules
pass. End to end against a running server: /playback/resolve still returns
vcodec:hevc for html5 and engine mpv when mpv is offered, an unknown item 404s,
/home and Jellyfin /Items still answer.

Known gap, unchanged by this commit: internal/arch does not enforce "new code
must not take *sql.DB", so the seam can erode by drift. That rule would be the
natural next step if this is to hold.
2026-10-11 16:16:07 -04:00
mithral
608ac5c6d6 Close the Jellyfin test gap
internal/jellyfin is the most client-visible surface in the tree — real Jellyfin
apps parse its JSON — and it had zero test files. Phase 2 had just moved its
DTOs, so this closes the gap before the next refactor.

Coverage: internal/jellyfin 0.0% -> 35.7%, internal/jellyfin/dto -> 68.9%.

The tests, and why each exists:

- Credential forms. X-Emby-Token, X-MediaBrowser-Token, ?api_key= and
  Authorization: MediaBrowser Token="...". The query form matters most: <video>
  and <audio> elements cannot set headers, so a client that can only use
  ?api_key= must still authenticate.
- Grant scoping. A user with no grants sees zero items, and an item they cannot
  read returns a response BYTE-IDENTICAL to one that does not exist. A 403 would
  confirm the library is there and leak its existence. The test asserts the two
  responses are indistinguishable, not merely both non-200.
- The transcode refusal. A client that disables both direct play and direct
  stream is demanding a transcode, and gets ErrorCode NoCompatibleStream rather
  than a TranscodingUrl that would never resolve. The normal path is asserted to
  advertise SupportsTranscoding FALSE, because a client told otherwise waits
  forever for a stream that never starts.
- Streaming with ONLY ?api_key= plus a Range header returns 206 with a correct
  Content-Range and the right bytes, and a token cannot stream a file it cannot
  read.
- AuthenticateByName mints a token that then authenticates, and returns no token
  for bad credentials.
- DTO mapping: exact JSON key set, RunTimeTicks as ms x 10000, id round-trip,
  ImageTags only with a cover, and golden JSON for both the item and the media
  source.

Both halves are proven able to FAIL, because a test that cannot fail is
decoration:
  - flipping SupportsTranscoding to true fails TestMediaSourceNeverTranscodes
  - disabling the grant check in catalog.LoadFor fails
    TestGrantScopingIsIndistinguishableFromAbsent AND TestStreamRefusesUnreadableItem
Both were restored afterwards and the non-test files verified byte-unchanged.

Process note: the agent for this task failed twice (HTTP 400) and left a test
file that did not compile — a bad slices.Delete call and a helper it never
wrote. I fixed those, corrected one wrong expectation (a video item legitimately
carries MediaType; the test's want-list omitted it), and wrote the handler tests
myself since that was the half that never got reached.

gofmt clean, build exit 0, vet exit 0, 25 packages pass, svelte-check 0/0.
2026-10-11 16:10:58 -04:00
mithral
35442e4aff Phase 2: split the fat packages along real boundaries
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.

internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
  AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
  the package that authorizes the item; it imports home.RecordView for that one
  thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
  return different explore ids, which is the non-determinism the Explore rewrite
  exists for.

internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
  surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
  are not the scanner (modules register types, handlers serve the list, metadata
  reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
  not of the scan. The one genuinely scanner-owned helper became
  library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
  the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
  detection still short-circuits.

internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
  the database writes. The split's whole point is that a provider is testable
  WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
  BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
  because real false matches were found.

dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
  the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
  grep. That is what makes them reviewable.
- No field name, tag or error code changed.

internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.

Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.

Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.

Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
2026-10-11 16:03:10 -04:00
mithral
ac1bd04df3 Phase 1: one item-access seam, and architecture rules that fail the build
Motivation, from the evidence rather than from taste. Five packages each
implemented "load an item, then check the caller may read it": media, comics,
games, jellyfin (with its own dbItem type and its own SQL), and subsonic. Five
copies of one authorization rule means four can drift, and the failure mode is
not a crash — it is silently serving somebody a library they were never granted.
One implementation is one thing to review.

internal/catalog now owns item and file access, and the access check.

  LoadUnchecked(ctx, db, id)        -- no access check; jobs and tests only
  LoadFor(ctx, db, u, id)           -- load AND enforce the grant, in one call
  Files / FilesFor / FileByID / FirstFile / FileCount

The unchecked variant is deliberately named so that reaching for it by accident
is hard: it is the dangerous call, so it should not have the friendlier name.
This came from the interface-design rule that a good interface makes the right
thing easy and the wrong thing hard.

The security property is tested, not asserted: a forbidden item and an absent
item return byte-identical errors, so no handler can leak the existence of
another user's library through a 403. That is now enforced in one place.

Migrated all five surfaces onto it. media, comics, games and ebooks went from
four duplicate loaders to zero. jellyfin's dbItem and loadDBItem are gone.
subsonic's per-item authorization moved while its album/artist aggregate
projections stayed put, because grouping items into albums is a presentation
concern, not a data-access one. The remaining raw SQL everywhere is browse and
aggregate queries; no per-item load decides access any more.

internal/arch enforces the layering instead of documenting it. Five rules, each
proven able to fail by deliberately breaking it:
  - no upward imports across six explicit layers
  - internal/core may depend only on migrations
  - internal/catalog must not know about HTTP, or it stops being reusable
  - modules must not import each other
  - every package must be placed in a layer, so the map cannot go stale

A test that only ever passes is decoration, so each rule was verified by
introducing the violation and confirming the failure.

Verified: gofmt clean, go build ./... exit 0, go vet exit 0, 20 packages pass,
svelte-check 0/0. End to end against a running server: the vellum API serves
item detail with codec facts, an unknown item 404s, Jellyfin AuthenticateByName
mints a token and /Items browses, and Subsonic ping returns status ok. The
subsonic migration was additionally A/B'd byte-identical against the previous
implementation across ~15 endpoints, and Jellyfin/Subsonic both still return the
same error for a forbidden item as for an absent one.

Known follow-ups, recorded rather than silently skipped:
  - internal/jellyfin has no test files at all. Its wire contract is the most
    client-visible surface in the tree and is now verified only by hand.
  - catalog is item-centric, but Subsonic addresses songs file-first (mf-<id>),
    so it resolves file to item through its own projection. A catalog lookup
    that takes a file id would close that.
  - subsonic loadAlbum now scopes the projection to the item's own library
    rather than all visible audio libraries. Equivalent by construction, since
    an item's kind tracks its library's kind, but a hand-edited mismatch would
    differ.
2026-10-11 15:47:18 -04:00
mithral
6da194045b Waves 9-10: Jellyfin and Subsonic clients can browse and play
Vellum now speaks two foreign protocols, so the clients people already own can
use it. Both are translation layers over queries that already exist, and both go
through the same grant chokepoint as the web app — a token is a second way to be
a user, never a second set of permissions.

Shared foundation (coordinator, written before dispatch)
- migrations/0012_api_tokens.sql + internal/apitoken. Both protocols need a
  credential that is not the session cookie, and letting two agents each invent
  a token table would have produced two incompatible ones.
- The table stores two shapes for a real reason: a Jellyfin token is a bearer
  value so only its hash is kept, while a Subsonic key must be recoverable
  because verifying `t` means computing md5(key + salt) server-side. token_plain
  is therefore the one column in the schema holding a usable credential. It is a
  generated random secret, never a user password, and it is revocable.

Wave 9 - Jellyfin
- /System/Info/Public, /Users/AuthenticateByName, /Users/Me, /Users/{id},
  /Items, /Users/{id}/Items, /Items/{id}, /Items/{id}/Images/Primary,
  /Items/{id}/PlaybackInfo, /Videos/{id}/stream, /Audio/{id}/stream,
  /Sessions/Playing{,/Progress,/Stopped}.
- Auth accepts X-Emby-Token, X-MediaBrowser-Token, ?api_key=, and
  Authorization: MediaBrowser Token="...". The ?api_key= form is the one that
  matters: <video> elements cannot set headers.
- Honest about the one thing Vellum cannot do. PlaybackInfo reports
  SupportsDirectPlay/SupportsDirectStream true and SupportsTranscoding FALSE,
  with a DirectStreamUrl carrying the api_key. A client that disables both
  direct modes is demanding a transcode and is refused with ErrorCode
  "NoCompatibleStream" — rather than handed a TranscodingUrl that would never
  resolve, which is how a client hangs forever.

Wave 10 - Subsonic
- /rest/{method} dispatch with ping, getLicense, getMusicFolders, getIndexes,
  getArtists/getArtist/getAlbum, getMusicDirectory, getSong, search3, stream,
  getCoverArt, getAlbumList2, getRandomSongs, scrobble, getUser, getPlaylists,
  plus GET /api/v1/subsonic/credentials to hand a user their key.
- Auth via t=md5(key+salt), p=key, constant-time comparison. A bad credential
  returns HTTP 200 with the envelope status "failed" and error code 40 — NOT an
  HTTP 401. Subsonic clients read the envelope, and getting this wrong is the
  single most common integration failure in that protocol.
- stream supports format=raw and never invokes ffmpeg.

Coordinator wired both into main.go (the line agents were told to leave alone)
and verified against real requests.

Verified end to end, 18 packages pass, gofmt/vet clean, svelte-check 0/0:
- Jellyfin: discovery, AuthenticateByName minting a token, /Items reporting 18,
  PlaybackInfo showing SupportsTranscoding=false, and a Range request to
  /Videos/{id}/stream using ONLY ?api_key= returning 206 with Content-Range.
- Subsonic: ping as JSON and XML, md5(key+salt) auth succeeding, and a bad token
  returning the failed envelope with code 40.
- Scoping, both protocols, with a genuinely non-admin user: 0 items, 404 on an
  admin-only item, 404 on its stream, 0 music folders; admin still sees 18.
- The SPA is not shadowed by the root-mounted compat paths: /, /healthz and
  /api/v1/version all still answer correctly.

Both agents failed with an HTTP 400 at the REPORT stage, not during the work;
their files were complete and compile, so the work was salvaged and verified
here rather than redone.
2026-10-11 15:05:43 -04:00
mithral
67f145a9b1 Waves 7-8: playback engine selection, and missing files you can see
Wave 7 - engines, chosen per file by the client's own capabilities
- internal/engine: a registry. html5 is built in and declares a CONSERVATIVE
  browser baseline (h264/vp8/vp9/av1, deliberately no hevc and no mpeg4, because
  that is what was measured). mpv, vlc and mdk are registered as native engines
  declaring the broad support those libraries really have.
- The server never declares a file playable. It reports what is in the file and
  matches it against the engines the CLIENT declares. Proven:
      engines=["html5"]       -> playable=false engine=null reason=vcodec:hevc
      engines=["html5","mpv"] -> playable=true  engine=mpv
  Same file, different answer, because the client offered a different engine.
  That is what makes adding a native client a registration rather than a rewrite.
- internal/playback: GET /playback/engines and POST /playback/resolve, behind
  auth and the same grant chokepoint. An unreadable item is 404, not 403.
- web/src/lib/capabilities.ts: uses navigator.mediaCapabilities.decodingInfo(),
  which returns {supported, smooth, powerEfficient}. That is real client-hardware
  detection and it distinguishes "cannot decode" from "can decode but it will
  stutter and drain the battery". Feature-detected; degrades to the server
  baseline rather than throwing.
- VideoPlayer consults the server as an ADVISORY only. The element remains the
  source of truth: the server's note appears first, then the element's own
  confirmation replaces it once videoWidth proves the answer. Verified on HEVC —
  "Playback may not work here" at 0-906ms, "Video cannot play here" from ~1208ms.

Wave 8 - missing files: visibility and an explicit purge
- migrations/0011 adds missing_since to media_items and media_files.
- It is written only on the TRANSITION into missing, so the age says when the
  file first vanished rather than the last rescan. Proven byte-identical across a
  rescan, and cleared when a file is restored.
- GET /api/v1/missing (grant-scoped) and POST /api/v1/libraries/{id}/purge
  (admin only). The purge deletes only items that are missing AND have no
  present file, and its cascade into progress/item_people/item_views/game_saves
  is enumerated in a comment — that cascade is exactly why deletion is never
  automatic. Proven: 19 items -> 18, only the missing one, the non-missing item
  untouched and its files intact, second purge returns 0.
- MissingFiles.svelte in the Manage nav: title, kind, library, path, human age,
  and a per-library Purge behind a confirmation that names the count and says
  progress cannot be regenerated.

Coordinator wired playback.NewHandlers into main.go (the one line agents were
told to leave alone).

Verified end to end: 17 packages pass, gofmt/vet clean, svelte-check 0/0, and
every claim above checked against the running server.
2026-10-11 14:36:55 -04:00
mithral
915f390156 Video player: load the file, don't let the browser skip it
Follow-up to 785d3ed. The committed version put the codec `type` on a <source>
child. That is the obvious thing to do and it is wrong: an explicit type lets
Chrome SKIP the resource during selection whenever canPlayType disagrees with
reality, and canPlayType is wrong in both directions.

The cost was measurable. mov+h264 has canPlayType('video/quicktime') === "no",
so the browser refused to try it — a file that PLAYS would have been reported as
unplayable. Verified now at 640x360, readyState 4.

Setting src on the element instead makes the browser actually load the file and
report videoWidth / error honestly, which is what playback.ts's measured notes
describe:
  mov+h264  -> loads, videoWidth 640   -> plays
  mp4+hevc  -> loads, videoWidth 0     -> detected by the decode check
  avi+mpeg4 -> error code 4            -> detected by the error path
The `type` is kept only as an ignored hint, and the load watchdog still covers
the case where the element says nothing at all. All three detection paths are
now reachable, which is the point: for HEVC, Chrome plays audio over a blank
frame with no error, and that must never be mistaken for working playback.

Process note, against the coordinator: I edited this file while its agent still
owned it, and the edit introduced the mov+h264 regression above. The agent
reverted it and kept the watchdog, which was the useful half. Do not edit a file
an agent owns mid-task; hand the finding over instead.
2026-10-11 14:20:15 -04:00
mithral
785d3ed5ee Waves 4-6: audio depth, ffprobe facts, and direct-play video
Direct play only. The server is a headless GPU-less Xeon: nothing is ever
transcoded or remuxed. A file either plays in the client or it does not.

Measured, not assumed (ffmpeg-generated files with known codecs, played through
Vellum's own stream endpoint in Chrome 155 / Linux):
  mp4  h264+aac   plays        mkv h264+aac  PLAYS   <- container is fine
  mov  h264+aac   plays        webm vp9/opus plays
  mp4  av1+opus   plays        webm vp8/vorbis plays
  mp4  hevc+aac   AUDIO ONLY, videoWidth stays 0, no error
  mkv  hevc+flac  AUDIO ONLY
  avi  mpeg4+mp3  fails outright
All seven audio formats play. Two conclusions drive the design: codec matters
and container barely does (mkv+h264 plays, mp4+HEVC does not), and canPlayType
is unreliable in both directions — it says "no" for mov+h264 (which plays) and
"maybe" for mkv+hevc (whose video never decodes).

Therefore playability is a property of the CLIENT, not the file. The server
never declares a file playable; it reports codec facts and each client decides.
That is what makes adding mpv/libvlc/MDK later a matter of registering an engine
rather than re-architecting.

Wave 4 - audio depth
- New MusicPlayer.svelte: real queue, shuffle that walks a fixed permutation
  (so "previous" means something and nothing repeats early), repeat off/all/one,
  per-track rows, album art, next-track preload.
- AudiobookPlayer: MediaSession (OS media keys + lock screen), playback speed
  that SURVIVES a chapter change and persists, sleep timer with countdown that a
  manual pause cancels, keyboard shortcuts, next-chapter preload.
- Shared mediaSession.ts written by the coordinator before dispatch so both
  players consume one implementation instead of inventing two.

Wave 5 - ffprobe facts
- internal/probe (ffprobe wrapper, optional dependency), migration 0010 adding
  vcodec/acodec/width/height/probed_at to media_files, scanner hook, API fields.
- Probing runs only for NEW or CHANGED files and BEFORE the write transaction
  opens, so it can never hold SQLite's write lock and a rescan never becomes a
  full-library ffprobe sweep. Proven: 797ms probing scan, then 1ms rescan with
  probed_at byte-identical.
- ffprobe skips attached_pic so an mp3 with cover art reports no video codec.
- Fixed a real hole: .avi is in the video registry but had no MIME entry, so it
  was served as application/octet-stream and could never play.

Wave 6 - the video player
- VideoPlayer.svelte with resume, progress + keepalive final flush, MediaSession,
  keyboard shortcuts, multi-file picker, and codec facts shown.
- The honest path is the feature: an HEVC file makes Chrome play audio over a
  blank frame with no error, which is the worst possible outcome. The player
  detects it and says so, naming the codec and pointing at clients that can.

Coordinator fix to Wave 6: the agent's detection hung off `loadedmetadata`, but
an explicit `type` the browser cannot satisfy makes it reject the source during
selection, so NEITHER loadedmetadata NOR error fires — and the player showed a
working-looking 0:00 with a Play button, exactly the silently-broken state the
component exists to prevent. Added a load watchdog: if the element says nothing
within 2.5s, the source was rejected and the honest notice is shown. Verified
per file afterwards (h264/mkv play at 640 wide; hevc and mpeg4 show their
notices).

Verified end to end, not just compiled: 15 packages pass (including the new
internal/probe), gofmt/vet clean, svelte-check 0/0, and every claim above checked
against a running server. Note the demo's own mp3s are 100 KB of random bytes and
cannot decode; the audio players were proven against generated real audio instead.
2026-10-11 14:14:32 -04:00
93 changed files with 12211 additions and 513 deletions

1
.gitignore vendored
View file

@ -18,3 +18,4 @@
# local preview instance
demo/
.archify/

View file

@ -83,35 +83,58 @@ the server logs a warning and refuses to invent a default password.
## Layout
```
cmd/vellum/ main — wiring and lifecycle only
cmd/vellum/ main — wiring and lifecycle only, the one place packages meet
internal/
core/ config, logging, database, migration runner
core/ config, logging, database, migrations, timestamps
httpx/ shared JSON helpers
httpapi/ router, middleware, static SPA serving
httpapi/ router, middleware, security headers, static SPA serving
auth/ argon2id, sessions, grants, bootstrap
library/ library CRUD, media-type registry, scanner
apitoken/ credentials for non-browser clients (Jellyfin, Subsonic)
catalog/ THE data seam: read an item, and check you may
library/ library CRUD and the media-type registry
scanner/ walk the filesystem and reconcile with the database
probe/ ffprobe wrapper — codec facts (optional dependency)
media/ generic item/file/cover/progress endpoints
home/ the five home shelves
search/ FTS5 queries
metadata/ the enrichment job and its matching policy
providers/ Open Library, Google Books, MusicBrainz, ComicVine, IGDB
playback/ playback engine selection endpoint
engine/ playback engine registry (html5, mpv, vlc, mdk)
progress/ the polymorphic per-user progress store
stream/ range-capable file serving
natsort/ natural ordering for pages and tracks
jobs/ table-backed job queue
metadata/ provider interface, Open Library, Google Books, enricher
search/ FTS5 queries
natsort/ natural ordering for pages and tracks
jellyfin/ Jellyfin protocol adapter (+ dto/)
subsonic/ Subsonic protocol adapter (+ dto/)
arch/ import-rule tests; holds no code
modules/
ebooks/ EPUB parsing and cover extraction
comics/ CBZ page listing and extraction
games/ platforms, IGDB, cloud saves, EmulatorJS
photos/ thumbnail generation
web/ Svelte app; dist/ is embedded via web/embed.go
migrations/ embedded SQL, applied in order at boot
docs/ architecture guide and decision records
```
**Module rule:** the core never imports a feature module; modules import the
core and mount their own routes, tables, and scanner rules. Adding a media type
is a new package plus a migration, never a core edit.
**Layer rule:** packages are arranged in six layers and a package may never
import from a higher one. This is enforced by tests in `internal/arch`, not by
convention — see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
**Schema rule:** media items are global; everything user-specific is a join.
Nothing may hold per-user state on a media row.
## Documentation
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the pieces fit and why.
- [docs/decisions/](docs/decisions/) — the decisions that were expensive to
reverse, with the alternatives that were rejected.
## Database
SQLite in WAL mode, opened through `modernc.org/sqlite` — a pure-Go driver, so

View file

@ -15,9 +15,12 @@ import (
"time"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/cli"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/home"
"github.com/mithral/vellum/internal/httpapi"
"github.com/mithral/vellum/internal/jellyfin"
"github.com/mithral/vellum/internal/jobs"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/media"
@ -26,7 +29,11 @@ import (
"github.com/mithral/vellum/internal/modules/ebooks"
"github.com/mithral/vellum/internal/modules/games"
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
"github.com/mithral/vellum/internal/playback"
"github.com/mithral/vellum/internal/providers"
"github.com/mithral/vellum/internal/scanner"
"github.com/mithral/vellum/internal/search"
"github.com/mithral/vellum/internal/subsonic"
"github.com/mithral/vellum/migrations"
)
@ -100,10 +107,26 @@ func run(cfg core.Config, log *slog.Logger) error {
library.NewHandlers(db, log, authenticator, queue).Register(api.Mux())
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
// The landing screen is an aggregate over items, not an item resource, so it
// is mounted by its own package.
home.NewHandlers(db, log, authenticator).Register(api.Mux())
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
games.NewHandlers(db, log, authenticator).Register(api.Mux())
search.NewHandlers(db, log, authenticator).Register(api.Mux())
// Playback engine registry + selection. Vellum never transcodes, so the
// server reports what is inside a file and matches it against the engines the
// CLIENT declares — a browser offers "html5", a native client can offer mpv.
playback.NewHandlers(catalog.New(db), log, authenticator).Register(api.Mux())
// Client compatibility layers. Neither speaks the session cookie; both
// authenticate with an api_tokens credential and then go through the same
// grant chokepoint as the web app. They mount at the root because that is
// where the real clients look, and both report SupportsTranscoding=false:
// this server has no CPU for it, so a client that demands a transcode is
// refused rather than left waiting for one that will never start.
jellyfin.NewHandlers(db, log, authenticator).Register(api.Mux())
subsonic.NewHandlers(db, log, authenticator).Register(api.Mux())
// Scans run on the queue rather than inside the request: a real library
// takes longer than any sensible timeout, and a hanging request is
@ -118,7 +141,7 @@ func run(cfg core.Config, log *slog.Logger) error {
if err != nil {
return nil // deleted since the job was queued
}
res, err := library.Scan(ctx, db, lib, log)
res, err := scanner.Scan(ctx, db, lib, log)
if jobID, ok := jobs.JobID(ctx); ok {
if serr := jobs.SetResult(ctx, db, jobID, res); serr != nil {
log.Warn("record scan result", "err", serr)
@ -129,16 +152,16 @@ func run(cfg core.Config, log *slog.Logger) error {
// Providers are registered primary-first; a fallback is only consulted
// when the primary returns nothing.
metadata.Register(metadata.NewOpenLibrary())
metadata.Register(metadata.NewGoogleBooks())
metadata.Register(metadata.NewMusicBrainz())
providers.Register(providers.NewOpenLibrary())
providers.Register(providers.NewGoogleBooks())
providers.Register(providers.NewMusicBrainz())
if cfg.ComicVineAPIKey != "" {
metadata.Register(metadata.NewComicVine(cfg.ComicVineAPIKey))
providers.Register(providers.NewComicVine(cfg.ComicVineAPIKey))
} else {
log.Info("comic metadata disabled: set VELLUM_COMICVINE_API_KEY to enable ComicVine lookups")
}
if cfg.IGDBClientID != "" && cfg.IGDBClientSecret != "" {
metadata.Register(metadata.NewIGDB(cfg.IGDBClientID, cfg.IGDBClientSecret))
providers.Register(providers.NewIGDB(cfg.IGDBClientID, cfg.IGDBClientSecret))
} else {
log.Info("game metadata disabled: set VELLUM_IGDB_CLIENT_ID and VELLUM_IGDB_CLIENT_SECRET to enable IGDB lookups")
}

316
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,316 @@
# Vellum — how it works
Vellum is a self-hosted media server. It replaces Jellyfin, Navidrome,
Audiobookshelf, Stump and Kavita with one program: one Go binary, one SQLite
file, one web UI. It handles ebooks, comics, audiobooks, music, photos, games
and video.
This document explains how the pieces fit together and, more importantly, *why*
they fit that way. Where a decision had a real trade-off, there is an ADR in
[`decisions/`](decisions/) with the alternatives that were rejected.
**Size:** ~11,500 lines of Go (plus ~7,000 lines of tests), ~7,100 lines of
frontend, 33 packages, 12 migrations, 64 routes.
---
## 1. The shape
```
one binary one file one UI
┌──────────────────┐ ┌───────────────┐ ┌──────────────┐
│ vellum (Go) │─────▶│ vellum.db │ │ Svelte app │
│ serves API+UI │ │ SQLite + WAL │ │ embedded in │
│ runs the jobs │ └───────────────┘ │ the binary │
└──────────────────┘ └──────────────┘
```
There is no Redis, no message broker, no second process and no CGO. The SQLite
driver is pure Go (`modernc.org/sqlite`), which is why `CGO_ENABLED=0` works and
the container is a bare Alpine image. The job queue is a *table*.
**"One binary" is a deployment choice, not an architecture.** The code is a
modular monolith: 33 packages with enforced boundaries. Splitting it into
services would add network failure modes and make debugging harder, not easier.
See [ADR-004](decisions/ADR-004-modular-monolith.md).
---
## 2. What happens when you open the app
Every request goes through the same chain, in this order:
```
browser
│
├─ withSecurityHeaders outermost, so headers survive a panic
├─ withRecovery panic → 500, never a dead connection
├─ withRequestID X-Request-ID, echoed into every log line
├─ withRequestLogging method, path, status, duration, request_id
└─ auth.Middleware cookie → session → User in the request context
│
└─ handler → auth.VisibleLibraryIDs / CanAccessLibrary → SQL → JSON
```
The **grant check is the security chokepoint**. Every read path goes through it,
including the Jellyfin and Subsonic adapters, which is why an API token can never
see more than the web app can. An item you cannot read returns **404, not 403** —
a 403 would confirm the item exists and leak the shape of someone else's library.
Media bytes take a different path on purpose: `internal/stream` hands off to
`http.ServeContent`, which implements HTTP Range correctly. That is why seeking
works in every player, and it is why compression is scoped to the static UI only
(compressing a Range response would break seeking).
---
## 3. The layers, and the rules that hold them
The code is arranged in six layers. A package may import from a lower layer or
its own, never from a higher one.
| Layer | Packages | What lives here |
|---|---|---|
| 0 | `core`, `httpx`, `natsort`, `arch`, `migrations` | foundation; knows nothing about the app |
| 1 | `auth`, `jobs`, `stream`, `progress`, `probe`, `engine`, `apitoken` | primitives everything is built from |
| 2 | `catalog`, `providers` | the data seam, and third-party API clients |
| 3 | `library`, `scanner`, `media`, `home`, `search`, `metadata`, `playback`, `modules/*`, `*/dto` | the application |
| 4 | `httpapi`, `jellyfin`, `subsonic`, `cli` | adapters: HTTP and foreign protocols |
| 5 | `cmd/vellum` | the only place everything is wired together |
**These rules are enforced by tests, not by documentation.** `internal/arch`
parses every package's imports and fails the build on a violation. There are
seven rules:
1. No upward imports across the layers.
2. `internal/core` may depend only on `migrations`.
3. `internal/catalog` must not know about HTTP — or it stops being reusable by a
job or a test.
4. Modules must not import each other.
5. **Every package must be placed in a layer**, so the map cannot go stale.
6. The set of packages allowed to import `database/sql` is explicit.
7. Packages already moved onto the `catalog.Store` seam must not reach for
`*sql.DB` again.
Each of the seven has been deliberately broken and confirmed to fail. A test that
cannot fail is decoration.
---
## 4. The data model — the decision everything follows from
```
users ──< library_grants >── libraries
│ │
│ └──< media_items ──< media_files
│ │
└──< progress (user, item, file) ───────┘
└──< item_views, favorites, api_tokens
```
**Media items are global. Every per-user thing is a join.**
The `progress` primary key is `(user_id, item_id, file_id)`. That one choice is
why audiobooks needed no new Go module: a twelve-chapter book is one item with
twelve files, and "chapter 7, 4 minutes 32 seconds in" is a single row. It is
also why the Continue shelf is one query, and why per-user state can never
contaminate the shared catalogue.
**Deletion is deliberately not deletion.** A file that vanishes is marked
`missing = 1` with a `missing_since` timestamp, never removed. Deleting cascades
into `progress`, the one thing in the schema that cannot be regenerated — so an
unmounted drive would silently wipe your reading history. There is an explicit
admin purge for when you really mean it. See
[ADR-003](decisions/ADR-003-mark-missing-never-delete.md).
---
## 5. How the library gets in
`internal/scanner` walks the library root and reconciles it with the database.
```
walkRoot → files on disk, keyed by relative path
buildGroups → one item per file, OR one item per directory
batched upsert → 500 groups per transaction
missing pass → mark vanished files, never delete
```
Three things worth knowing:
- **Transactions are chunked.** It used to hold SQLite's single write lock for
the whole library. Now each transaction covers 500 groups, so a progress save
from a playing client succeeds *during* a scan. Measured at 20,000 files with
concurrent writes all returning 200.
- **Change detection is `size` + `mtime`.** Matching files are skipped entirely.
`ffprobe` runs only for changed files, and before the write transaction opens,
so a slow probe can never hold the write lock.
- **Two scans can run at once.** The job queue has four workers, so scanning two
libraries concurrently works — the *writes* interleave at batch granularity
rather than one library blocking the other.
---
## 6. The job queue
`internal/jobs` is a queue implemented as a database table. Claiming a job is a
single atomic statement, so several workers are safe with no extra locking:
```sql
UPDATE jobs SET state = 'running', attempts = attempts + 1
WHERE id = (SELECT id FROM jobs WHERE state = 'queued' AND run_after <= ?
ORDER BY run_after, id LIMIT 1)
RETURNING id, kind, payload, attempts
```
Four workers with jittered idle waits. A panicking handler is recovered and the
job marked failed, so one bad handler cannot kill a worker or strand a row in
`running`. `RecoverStale()` runs at boot and requeues anything a dead process
left behind — without it, a crash mid-scan left a job `running` forever while the
UI polled it forever.
Two job kinds: `scan_library` and `enrich_item`.
---
## 7. Playback — direct play only
**Vellum never transcodes.** The media server is a headless, GPU-less Xeon with
no spare CPU for it. A file either plays in the client or it does not.
This is the single most important thing to understand about the playback code,
and it came from measurement rather than assumption:
| container | video | result |
|---|---|---|
| mp4 | h264 | plays |
| **mkv** | h264 | **plays** — the container is not the problem |
| mov | h264 | plays, even though `canPlayType` says "no" |
| mp4 | **hevc** | **audio only** — a black picture, no error |
| avi | mpeg4 | fails outright |
Two conclusions drive the design:
1. **Codec matters, container barely does.** mkv+h264 plays; mp4+HEVC does not.
Judging a file by its extension is wrong.
2. **Playability is a property of the CLIENT, not the file.** Chrome cannot
decode HEVC; Safari can; Jellyfin's native apps handle far more than either.
So the server **never** declares a file playable. It reports the codec facts
(from `ffprobe`, stored at scan time) and each client decides. See
[ADR-002](decisions/ADR-002-direct-play-only.md).
That is what makes the engine registry work. `internal/engine` holds engines that
declare what they can play — `html5` ships built in; `mpv`, `vlc` and `mdk` are
registered for native clients. Adding one is a declaration, not a rewrite:
```
POST /api/v1/playback/resolve {"item_id":16,"engines":["html5"]}
→ playable=false, reason="vcodec:hevc"
POST /api/v1/playback/resolve {"item_id":16,"engines":["html5","mpv"]}
→ playable=true, engine="mpv"
```
Same file, different answer, because the client offered a different engine.
**The element is always the final authority.** `canPlayType` lies in both
directions, so the player uses it only as a hint and confirms by actually loading
the file and checking `videoWidth`. For HEVC, Chrome plays audio over a blank
frame with no error — the worst possible outcome, and the player detects it and
says so rather than pretending.
---
## 8. Three protocol surfaces over one application
| surface | who uses it | how it authenticates |
|---|---|---|
| `/api/v1/*` | the web app | session cookie |
| Jellyfin (`/Items`, `/Users/…`) | Jellyfin native apps | `X-Emby-Token`, `?api_key=` |
| Subsonic (`/rest/*.view`) | Symfonium, Ultrasonic, play:Sub | `t=md5(key+salt)` |
All three resolve to the same `auth.User` and pass through the same grant check.
`internal/apitoken` is the shared credential layer, and its migration documents a
real asymmetry: Jellyfin tokens are stored **hashed** (like session tokens),
while a Subsonic key must be stored **in the clear**, because verifying `t` means
computing `md5(key + salt)` server-side.
Both adapters are honest about the one thing Vellum cannot do: `PlaybackInfo`
reports `SupportsTranscoding: false`, and a client that demands a transcode gets
`NoCompatibleStream` rather than a URL that would never resolve.
---
## 9. Where things live
```
cmd/vellum wiring and lifecycle — the only place packages meet
internal/
core config, database open, migrations, logger, timestamps
httpx small HTTP helpers (JSON, errors)
auth users, sessions, argon2id hashing, grants
apitoken credentials for non-browser clients
catalog THE data seam: read an item, and check you may
providers Open Library, Google Books, MusicBrainz, ComicVine, IGDB
library library definitions, media-type registry
scanner walk the filesystem and reconcile
probe ffprobe wrapper (optional dependency)
media item, file, cover, progress endpoints
home the five home shelves
search FTS5 search over titles, paths and authors
metadata the enrichment job and its matching policy
playback engine selection endpoint
engine playback engine registry
jobs table-backed job queue
progress per-user playback position
stream Range-capable file serving
modules/ comics, ebooks, games, photos
httpapi mux, middleware, security headers, embedded UI
jellyfin Jellyfin protocol adapter
subsonic Subsonic protocol adapter
arch import-rule tests (no code)
migrations/ numbered SQL, append-only, tracked by user_version
web/ Svelte 5 + Vite frontend
```
---
## 10. Things to know before changing anything
- **`cmd/vellum/main.go` is the only wiring point.** Nothing else should import a
module; modules register themselves.
- **Migrations are append-only.** Never edit an applied file. The FTS5 authors
migration is the cautionary tale: it worked on a fresh database and raised
*"database disk image is malformed"* on an upgrade.
- **All timestamps go through `core.Stamp`.** `RFC3339Nano` trims trailing zeros,
so its strings are not lexicographically sortable — `".5Z" > ".55Z"` while
`.500s < .550s`. That silently broke `ORDER BY` and `MAX()` on the Continue
shelf.
- **Progress is PUT-only**, so `sendBeacon` (POST-only) cannot be used; the
teardown flush uses `fetch` with `keepalive: true`.
- **`canPlayType` is not a source of truth.** Put `src` on the media element, not
`type` on a `<source>` — an explicit type makes Chrome *skip* files it would
otherwise play.
- **If you need an item, take a `catalog.Store`.** Not `*sql.DB`. The seam is
enforced by test rule 6/7.
- **Run `go test ./...` after touching a package's imports** — the arch rules
will tell you immediately if you crossed a layer.
---
## 11. Commands
| Command | What it does |
|---|---|
| `make dev` | run backend and Vite together |
| `go test ./...` | all Go tests, including the arch rules |
| `go vet ./...` | static checks |
| `cd web && pnpm check` | Svelte type-check |
| `vellum user add <name> <pass> [--admin]` | create a user |
| `vellum user passwd <name> <new>` | change a password |
| `vellum user list` | list users |
| `vellum backup <path>` | safe copy of the database (`VACUUM INTO`) |
See [DEPLOY.md](../DEPLOY.md) for deployment, and
[`decisions/`](decisions/) for why things are the way they are.

View file

@ -0,0 +1,67 @@
# ADR-001: SQLite, not PostgreSQL — with a seam for later
## Status
Accepted
## Date
2026-10-12
## Context
Vellum stores everything in one database: users, grants, the catalogue, per-user
progress, the job queue and API tokens. The choice of engine determines the
deployment story, the backup story, and how much concurrent writing is possible.
The original motivation for considering PostgreSQL was concurrency: SQLite allows
only one writer at a time, and a library scan holding the write lock would block
a playing client's progress save.
That problem turned out to be solvable without changing engines, and it was
measured rather than assumed.
## Decision
Use SQLite with WAL, and put a storage seam behind the one package that reads
items, so the engine is swappable later if a real need appears.
## Alternatives Considered
### PostgreSQL
- **Pros:** real concurrent writers; no single-writer bottleneck; mature.
- **Cons:** a second process to run and back up; the deployment story stops being
"one binary and one file"; the SQL is not portable — 186 statements, 193 `?`
placeholders, FTS5 full-text search, `strftime`, `group_concat`, `rowid`, and
`VACUUM INTO` for backups all need reworking.
- **Rejected for now.** The problem it solves was already fixed by other means
(see below), and there is no measured write contention left.
### Fixing the write-lock problem in code instead
Three changes removed the symptom without changing engines:
1. **Chunked scan transactions.** 500 groups per transaction instead of one
transaction over the whole library. Measured: a 20,000-file scan reports
partial progress and a concurrent progress save returns 200 throughout.
2. **Four job workers** with jittered idle waits, so a scan no longer stalls
every other job.
3. **Throttled session writes.** `last_seen` was written on *every* authenticated
request, including every audio Range request. Measured after the fix: 20
requests cause **0** session writes, down from 20.
Measured results after these changes: browse page 1 at **0.03 ms**, the home
screen at ~3 ms, and two concurrent 8,000-file scans interleaving successfully
with every concurrent write returning 200.
## Consequences
- Deployment stays one binary plus one file. Backup is `VACUUM INTO`.
- Writes serialise. This is fine at the measured scale and is the honest ceiling
of this design.
- `internal/catalog` exposes a `Store` interface with a SQLite implementation, so
a PostgreSQL implementation would be a second `Store` rather than a rewrite.
- The seam is guarded: `internal/arch` fails the build if a new package starts
importing `database/sql` without being declared, or if a package already on the
seam reaches for `*sql.DB` again.
- **When to revisit:** multiple Vellum instances sharing a database, or write
throughput beyond what chunked WAL handles. Neither is true today.

View file

@ -0,0 +1,102 @@
# ADR-002: Direct play only — no transcoding, ever
## Status
Accepted
## Date
2026-10-12
## Context
A media server normally transcodes: it re-encodes a file on the fly when the
client cannot play it. That is how Jellyfin and Plex make an arbitrary library
playable on an arbitrary device.
Vellum runs on a **headless, GPU-less Xeon**. It has no display and no spare CPU
for real-time encoding. A 1080p HEVC transcode on a CPU-only machine runs at a
few times realtime at best; a two-hour film would take tens of minutes, per
viewer, forever.
The question was what to do instead.
## Decision
**Never transcode.** A file either direct-plays in the client or it does not.
The server reports what is *in* the file and the **client** decides whether it
can play it.
## Alternatives Considered
### Transcode on demand, CPU only
- **Pros:** every file plays everywhere.
- **Cons:** on this hardware a single viewer would saturate the CPU for minutes
per film. Two viewers would make it unusable.
- **Rejected:** the hardware cannot support it.
### Remux on demand (`-c copy`), no re-encode
- **Pros:** cheap — no video re-encode, just a container change. Would fix
"mkv will not play".
- **Cons:** the premise turned out to be **false**. Measurement showed Chrome
plays `mkv + h264 + aac` perfectly. The container was never the problem.
- **Rejected:** it solves a problem that does not exist.
### Pre-transcode everything to a normalised format
- **Pros:** paid once, everyone direct-plays forever after.
- **Cons:** doubles disk usage for a whole library, and takes days of CPU for a
large collection. Also irreversible if the settings were wrong.
- **Rejected:** too much cost and risk for the benefit.
## The measurement that decided it
Files with known codecs, played through Vellum's own stream endpoint in Chrome
155 on Linux:
| container | video | audio | result |
|---|---|---|---|
| mp4 | h264 | aac | plays |
| **mkv** | h264 | aac | **plays** |
| mov | h264 | aac | plays |
| webm | vp9 / vp8 | opus / vorbis | plays |
| mp4 | av1 | opus | plays |
| mp4 | **hevc** | aac | **audio only**, `videoWidth` stays 0, no error |
| mkv | **hevc** | flac | **audio only** |
| avi | mpeg4 | mp3 | fails outright |
All seven audio formats play.
**Two conclusions:**
1. **Codec matters, container barely does.** mkv+h264 plays; mp4+HEVC does not.
Judging a file by its extension is wrong.
2. **`canPlayType` is unreliable in both directions.** It says "no" for
`mov+h264` (which plays) and "maybe" for `mkv+hevc` (whose video never
decodes). The only trustworthy test is loading the file and checking
`videoWidth`.
**And the conclusion that shapes the architecture:** playability is a property of
the **client**, not the file. Chrome cannot decode HEVC; Safari can; Jellyfin's
native apps handle far more than either. So a server-side `playable: true/false`
would be wrong for most clients.
## Consequences
- `internal/probe` records codec facts at scan time (optional dependency — the
server works without `ffprobe`, just with less information).
- `internal/engine` holds a registry of playback engines. `html5` ships built in;
`mpv`, `vlc` and `mdk` are registered for native clients. **Adding one is a
declaration, not a re-architecture.**
- `POST /api/v1/playback/resolve` matches a file against the engines the client
declares. The same HEVC file is unplayable with `["html5"]` and playable with
`["html5","mpv"]`.
- The player treats the server as an **advisory** and the media element as the
final authority, because `canPlayType` lies.
- Jellyfin's `PlaybackInfo` reports `SupportsTranscoding: false`, and a client
demanding a transcode gets `NoCompatibleStream` rather than a URL that would
never resolve.
- **The honest UX consequence:** files that cannot direct-play say so clearly,
the same way an unreadable `.mobi` does — rather than a player that loads and
shows nothing. For HEVC, Chrome plays audio over a blank frame with no error,
which is the worst possible outcome, so the player detects exactly that.

View file

@ -0,0 +1,65 @@
# ADR-003: A vanished file is marked missing, never deleted
## Status
Accepted
## Date
2026-10-12
## Context
A library root is not always available. Drives unmount, NFS has hiccups, and
people reorganise folders while a scan is running.
The obvious behaviour for a scanner is to delete rows whose files are gone. That
is also the most destructive thing it could do.
## Decision
A scan that cannot find a file marks it `missing = 1` and records
`missing_since`. It never deletes. Removal is a separate, explicit, admin-only
action.
## Alternatives Considered
### Delete on scan
- **Pros:** simple; the database always reflects the disk.
- **Cons:** deleting a `media_items` row cascades into `progress`. If a drive is
unmounted for an hour, every reading position, audiobook chapter and
watch-later marker for that drive is gone permanently.
- **Rejected:** it destroys the one thing in the schema that cannot be
regenerated. Everything else can be rebuilt by rescanning.
### Delete after a grace period, automatically
- **Pros:** reclaims space without anyone thinking about it.
- **Cons:** a drive left disconnected for longer than the period loses its
history silently. The user gets no warning and no undo.
- **Rejected:** the failure mode is silent and irreversible.
### Keep `missing` but never surface it
- **Pros:** none. This was the original state.
- **Cons:** the worst of both worlds — you cannot see what is missing, and it
never goes away. A deleted file was invisible *and* permanent.
- **Rejected:** this is the gap this ADR closes.
## Consequences
- Reads filter on `missing = 0`, so a missing item disappears from every shelf,
list and search result without the row being touched.
- `missing_since` is written **only on the transition** into missing, so the age
says when the file first vanished rather than when it was last rescanned. It is
cleared when a file is restored.
- `GET /api/v1/missing` lists what has vanished, scoped to the caller's visible
libraries.
- `POST /api/v1/libraries/{id}/purge` (admin only) deletes only items that are
missing **and** have no present file. It requires an explicit confirmation in
the UI that names the count and says progress cannot be regenerated.
- The purge cascade — `media_files`, `progress`, `item_people`, `provider_ids`,
`metadata_fetch`, `assets`, `collection_items`, `favorites`, `item_views`,
`game_saves`, child items via `parent_id` — is enumerated in the migration, so
the destructiveness is visible at the point of the decision.
- Verified: purging 19 items leaves 18, only the missing one goes, and a
non-missing item's files are untouched. A second purge returns 0.

View file

@ -0,0 +1,57 @@
# ADR-004: A modular monolith — one binary, not services
## Status
Accepted
## Date
2026-10-12
## Context
The goal was a codebase that is easy to contribute to and easy to debug. "Split
it into services" is the usual answer to that, and it was explicitly proposed.
## Decision
Keep one process. Get modularity from **enforced package boundaries**, not from
network boundaries.
## Alternatives Considered
### Split into microservices (a scanner service, a metadata service, an API service)
- **Pros:** independent deploys; each service small enough to read in one sitting.
- **Cons:** every call becomes a network call that can time out, retry, duplicate
or partially fail. Debugging a bug becomes "which service dropped this", and
the stack trace is replaced by distributed tracing.
- **Rejected:** it would make the stated goal — *easier to find and fix bugs* —
harder, not easier. Every bug found in this codebase so far has been a code
bug, and none would have been easier to locate across a network boundary.
### One package per concern, no rules
- **Pros:** simplest to start.
- **Cons:** without enforcement, boundaries erode. An upward import does not fail
to compile; it just quietly welds two layers together until nothing can be
moved.
- **Rejected:** documented rules that nobody enforces are rules that break within
a month.
## Consequences
- 33 packages in six layers, with the import rules **enforced by tests** in
`internal/arch`. A violation fails the build.
- Seven rules, each deliberately broken and confirmed to fail: no upward imports;
`core` depends only on `migrations`; `catalog` knows no HTTP; modules do not
import each other; every package must be in a layer; the `database/sql`
importers are an explicit list; packages on the storage seam must not drift
back.
- Rule 5 — *every package must be placed in a layer* — is the one that keeps the
others alive. Without it, a new package quietly has no rules at all. It caught
two unlayered packages during the Phase 2 splits.
- `cmd/vellum/main.go` is the single wiring point. Nothing else imports a module;
modules register themselves.
- Deployment stays one binary, which keeps the operational story simple: no
service discovery, no partial deploys, no version skew between services.
- **Cost accepted:** a change that spans layers needs a deliberate interface,
which is slower than reaching across. That is the point.

View file

@ -0,0 +1,77 @@
# ADR-005: One item-access chokepoint, `internal/catalog`
## Status
Accepted
## Date
2026-10-12
## Context
Five different packages needed the same thing: load a media item, then check the
caller is allowed to read it.
- `internal/media`
- `internal/modules/comics`
- `internal/modules/games`
- `internal/jellyfin` — with its own `dbItem` type and its own SQL
- `internal/subsonic` — with its own per-item queries
Each had its own implementation, and each decided for itself what to do when
access was denied.
## Decision
One package owns item access **and** the access check: `internal/catalog`.
## Alternatives Considered
### Leave the duplication
- **Pros:** no refactor; each package stays self-contained.
- **Cons:** five copies of one authorization rule means four can drift from the
fifth. The failure mode is not a crash — it is silently serving somebody a
library they were never granted. That is a data-leak bug, and it would be found
by accident, if at all.
- **Rejected:** this is the highest-value thing in the codebase to have exactly
one of.
### A shared helper that packages call with their own SQL
- **Pros:** smaller change.
- **Cons:** leaves five copies of the item-loading SQL, so a schema change still
has to be made in five places.
- **Rejected:** it halves the problem instead of removing it.
### A generic repository layer for everything
- **Pros:** one place for all SQL.
- **Cons:** pulls browse lists, home shelves and Subsonic album aggregates into a
generic layer where they do not belong. Those are each package's own
presentation concern, and moving them is a 186-statement rewrite.
- **Rejected:** the chokepoint is the security-critical, most-duplicated path —
not every query.
## The security property
`catalog.ItemFor` returns the **same** `ErrNotFound` for an item that does not
exist and an item the caller may not read.
This is deliberate. If the two were distinguishable, a 403 would confirm that
another user's library contains that item, leaking its shape. The test asserts
the two responses are **byte-identical**, not merely both non-200.
## Consequences
- Five duplicate implementations became one. `internal/jellyfin`'s `dbItem` type
and its own item SQL are gone.
- All four HTTP surfaces — web, modules, Jellyfin, Subsonic — go through the same
check, so an API token can never see more than the web app can.
- An unreadable item is **404, never 403**, everywhere.
- Aggregate and list queries deliberately stayed where they are. `catalog` is
item-centric; Subsonic is file-first (`mf-<id>`) and resolves file→item through
its own projection. A catalog lookup that takes a file id would close that gap.
- The seam is a `Store` interface with a SQLite implementation, so the storage
engine is swappable. One consumer (`internal/playback`) is threaded onto it;
the rest still use the package-level wrappers, and the remaining threading is
mechanical.

View file

@ -0,0 +1,62 @@
# ADR-006: The job queue is a table, not a broker
## Status
Accepted
## Date
2026-10-12
## Context
Vellum does work outside a request: scanning libraries and enriching metadata
from external providers. That work needs a queue — something has to survive a
restart, retry a failure, and be visible while it runs.
## Decision
Implement the queue as a table in the same SQLite database. No Redis, no broker,
no second process.
## Alternatives Considered
### Redis or a similar broker
- **Pros:** built for queues; fast; familiar.
- **Cons:** a second service to run, back up and monitor. The queue would then
live outside the database, so a backup would capture the catalogue but not the
work in flight.
- **Rejected:** it buys throughput this application does not need and costs the
"one binary, one file" deployment story.
### In-process goroutines with an in-memory queue
- **Pros:** no storage at all.
- **Cons:** a restart loses queued work silently. There is no way to see what is
pending, and no way to retry a failure after a crash.
- **Rejected:** the queue must survive a restart.
## Consequences
Claiming a job is a single atomic statement, so several workers are safe with no
extra locking:
```sql
UPDATE jobs SET state = 'running', attempts = attempts + 1
WHERE id = (SELECT id FROM jobs WHERE state = 'queued' AND run_after <= ?
ORDER BY run_after, id LIMIT 1)
RETURNING id, kind, payload, attempts
```
- Four workers with jittered idle waits. A panicking handler is recovered and the
job marked failed, so one bad handler cannot kill a worker or strand a row in
`running`.
- `RecoverStale()` runs at boot and requeues anything a dead process left behind.
Without it, a crash mid-scan left a job `running` forever while the UI polled it
forever.
- The queue is included in the same backup as everything else.
- Job state is queryable, which is how scan progress reaches the UI
(`GET /libraries/{id}/scan/status`).
- Two job kinds: `scan_library` and `enrich_item`.
- **Cost accepted:** the queue shares SQLite's single writer, so a very large
scan slows enqueueing. Chunked transactions keep each write short, and this is
a deliberate trade against running a second service.

View file

@ -0,0 +1,82 @@
# ADR-007: Timestamps are fixed-width, and written through one helper
## Status
Accepted
## Date
2026-10-12
## Context
Timestamps are stored as TEXT and compared as strings: `ORDER BY updated_at`,
`MAX(updated_at)`, `run_after <= ?`, `expires_at < ?`. String comparison is only
correct if the strings are the same width, which is easy to get wrong.
It was wrong. Two formats were in use:
- SQLite's `strftime('%Y-%m-%dT%H:%M:%fZ','now')` — exactly three fractional
digits.
- Go's `time.RFC3339Nano` — **variable** width, because it trims trailing zeros.
## The bug
`RFC3339Nano` produces `.5Z` for 500 milliseconds and `.55Z` for 550. Compared as
strings, `".5Z" > ".55Z"` — but `.500s < .550s`. The ordering is inverted.
This is **not** a cross-format problem. It is wrong even in a column written
100% by Go, because the fraction width varies within that column. Live data
showed 9-digit, 8-digit and 3-digit values in the same column:
```
jobs.run_after ...16:57:11.383879091Z ← 9 digits
jobs.run_after ...16:53:06.28925937Z ← 8 digits, trailing zero trimmed
media_items.added_at ...01:28:19.733Z ← 3 digits, SQLite default
```
It silently affected the **Continue shelf** (`MAX(updated_at)`) and **Most
Viewed** tiebreaks — both user-visible.
## Decision
All Go-side timestamp writes go through `core.Stamp`, which produces a
**fixed-width** format matching SQLite's own default exactly:
```go
t.UTC().Format("2006-01-02T15:04:05.000Z")
```
## Alternatives Considered
### Store Unix nanoseconds as INTEGER
- **Pros:** unambiguously sortable; no format question at all.
- **Cons:** every existing row and every query would need converting, and the
values stop being human-readable in the database — which matters when you are
debugging by hand.
- **Rejected:** too large a migration for a bug that a format change fixes.
### Store RFC3339 with a fixed 6 or 9 digits
- **Pros:** fixed width, more precision.
- **Cons:** would not match SQLite's default, so any row that ever took the
column default would still be a different width — the same bug in a new shape.
- **Rejected:** matching the database's own format is what makes both sources
comparable.
### Sort in Go instead of in SQL
- **Pros:** no format constraint.
- **Cons:** would require loading every row to sort, defeating the indexes.
- **Rejected:** the index does the sort; the format must cooperate.
## Consequences
- All 14 Go-side write sites use `core.Stamp` / `core.Now`. Verified: zero
variable-width writes remain.
- **Read paths were deliberately left alone.** `time.RFC3339Nano` parses both the
old and the new shapes, so existing rows keep working with no migration.
- A test proves the property directly: `Stamp` at .500s sorts **before** `Stamp`
at .550s as a string, and the output matches what SQLite's own default
produces, byte for byte.
- **The general rule:** if a value is ever compared in SQL, its textual form must
be fixed-width.

29
docs/decisions/README.md Normal file
View file

@ -0,0 +1,29 @@
# Decisions
Each record captures a decision that was expensive to reverse, the alternatives
that were rejected, and the reasoning. They exist so nobody has to re-litigate a
settled question — and so an agent does not "improve" something that was
deliberate.
Read the ones relevant to what you are changing.
| ADR | Decision | Rejected alternatives |
|---|---|---|
| [001](ADR-001-sqlite-with-a-seam.md) | SQLite, with a storage seam for later | PostgreSQL; fixing the write lock in code instead |
| [002](ADR-002-direct-play-only.md) | Direct play only, never transcode | On-demand CPU transcode; remux; pre-transcode |
| [003](ADR-003-mark-missing-never-delete.md) | A vanished file is marked, never deleted | Delete on scan; auto-purge; keep it invisible |
| [004](ADR-004-modular-monolith.md) | One binary with enforced package boundaries | Microservices; unenforced conventions |
| [005](ADR-005-one-item-access-chokepoint.md) | One item-access chokepoint, `internal/catalog` | Leave the duplication; a shared helper; a generic repository |
| [006](ADR-006-table-backed-job-queue.md) | The job queue is a table | Redis or a broker; in-memory goroutines |
| [007](ADR-007-fixed-width-timestamps.md) | Fixed-width timestamps via `core.Stamp` | Integer nanoseconds; six/nine digits; sorting in Go |
## Writing a new one
Add `ADR-00N-short-title.md` with **Status**, **Date**, **Context**, **Decision**,
**Alternatives Considered** (with the reason each was rejected) and
**Consequences**. Do not delete an ADR when a decision changes — write a new one
that supersedes it, so the history stays readable.
Two things make an ADR worth writing: the decision would be expensive to reverse,
and a reasonable person could have chosen differently. If neither is true, a
comment in the code is the right place.

View file

@ -0,0 +1,172 @@
// Package apitoken issues and verifies the tokens non-browser clients use.
//
// Jellyfin clients send `X-Emby-Token` (or `api_key=`); Subsonic clients send
// `t=md5(key+salt)`. Neither speaks the web app's session cookie, so these
// tokens are a second, revocable way to be a user. They do not weaken the cookie
// model: a token resolves to the same auth.User and every caller still goes
// through the same grant chokepoint.
//
// Two storage shapes, deliberately:
//
// - A Jellyfin token is a bearer value, so only its hash is stored — the same
// reasoning as session tokens. A stolen database yields nothing usable.
// - A Subsonic key must be recoverable, because verifying `t` means computing
// md5(key + salt) server-side. So Subsonic keys are stored in the clear.
// That is the Navidrome approach, and it is why the key is a generated
// random secret rather than anything derived from the user's password.
package apitoken
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"github.com/mithral/vellum/internal/core"
)
// Kinds of token. A client family, not a permission.
const (
KindJellyfin = "jellyfin"
KindSubsonic = "subsonic"
)
// ErrNotFound means no token matched.
var ErrNotFound = errors.New("apitoken: not found")
// Token is one issued credential. It never carries the secret itself.
type Token struct {
ID int64
UserID int64
Kind string
Label string
CreatedAt string
LastUsedAt string
ExpiresAt string
}
// Mint issues a new token for a user and returns the secret exactly once.
//
// The caller must show it to the user immediately; it is not recoverable for
// Jellyfin afterwards (only its hash is kept). For Subsonic it is recoverable,
// because that protocol requires it — see the package comment.
func Mint(ctx context.Context, db *sql.DB, userID int64, kind, label string) (string, error) {
if kind != KindJellyfin && kind != KindSubsonic {
return "", fmt.Errorf("apitoken: unknown kind %q", kind)
}
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("apitoken: read random: %w", err)
}
secret := hex.EncodeToString(raw[:])
plain := ""
if kind == KindSubsonic {
// Recoverable by necessity: verifying `t` needs the key itself.
plain = secret
}
_, err := db.ExecContext(ctx, `
INSERT INTO api_tokens (user_id, kind, label, token_hash, token_plain, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
userID, kind, label, Hash(secret), plain, core.Now())
if err != nil {
return "", fmt.Errorf("apitoken: insert: %w", err)
}
return secret, nil
}
// Hash is the lookup digest for a token. Exported so the Jellyfin layer can
// resolve a bearer value without duplicating the scheme.
func Hash(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
// Resolve finds the user a token belongs to, and records the use.
//
// An empty or unknown token is ErrNotFound; the caller turns that into a 401.
// An expired token is also ErrNotFound, so callers cannot accidentally treat an
// expired credential as valid by checking only the error's absence.
func Resolve(ctx context.Context, db *sql.DB, secret string) (int64, error) {
if secret == "" {
return 0, ErrNotFound
}
var (
id int64
userID int64
expires string
)
err := db.QueryRowContext(ctx,
`SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?`,
Hash(secret)).Scan(&id, &userID, &expires)
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrNotFound
}
if err != nil {
return 0, fmt.Errorf("apitoken: resolve: %w", err)
}
if expires != "" && expires < core.Now() {
return 0, ErrNotFound
}
// Best-effort: a failed touch must not fail the request.
_, _ = db.ExecContext(ctx, `UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, core.Now(), id)
return userID, nil
}
// Plain returns the recoverable secret for a Subsonic token. Jellyfin tokens
// have none by design, so this returns ErrNotFound for them.
func Plain(ctx context.Context, db *sql.DB, userID int64) (string, error) {
var plain string
err := db.QueryRowContext(ctx,
`SELECT token_plain FROM api_tokens
WHERE user_id = ? AND kind = ? AND token_plain != ''
ORDER BY id LIMIT 1`,
userID, KindSubsonic).Scan(&plain)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("apitoken: plain: %w", err)
}
return plain, nil
}
// ForUser lists a user's tokens, newest first.
func ForUser(ctx context.Context, db *sql.DB, userID int64) ([]Token, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, kind, label, created_at, last_used_at, expires_at
FROM api_tokens WHERE user_id = ? ORDER BY id DESC`, userID)
if err != nil {
return nil, fmt.Errorf("apitoken: list: %w", err)
}
defer rows.Close()
out := []Token{}
for rows.Next() {
var t Token
if err := rows.Scan(&t.ID, &t.UserID, &t.Kind, &t.Label,
&t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// Revoke deletes a token, scoped to its owner so one user cannot revoke
// another's. Reports whether anything was removed.
func Revoke(ctx context.Context, db *sql.DB, userID, tokenID int64) (bool, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, tokenID, userID)
if err != nil {
return false, fmt.Errorf("apitoken: revoke: %w", err)
}
n, err := res.RowsAffected()
return n > 0, err
}

7
internal/arch/arch.go Normal file
View file

@ -0,0 +1,7 @@
// Package arch holds no code. It exists so the import rules this project
// depends on are enforced by the test suite rather than by good intentions.
//
// See arch_test.go: it parses every package's imports and fails if a layer is
// violated, if core learns about a feature, if a module imports a sibling, or
// if a new package is added without being placed in a layer.
package arch

360
internal/arch/arch_test.go Normal file
View file

@ -0,0 +1,360 @@
// Package arch holds no code. It exists so that the import rules this project
// depends on are enforced by the test suite instead of by good intentions.
//
// A layering rule that is only written down in a comment is a rule that will be
// broken within a month, and the breakage is invisible: an upward import does
// not fail to compile, it just quietly welds two layers together until nothing
// can be moved. These tests fail loudly instead.
package arch
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
const modulePath = "github.com/mithral/vellum/"
// Layers, lowest first. A package may import from a LOWER layer or its own
// layer, never from a higher one. Peers are allowed because they genuinely
// collaborate (metadata enriches items that media serves); upward imports are
// not, because they invert the dependency and make the lower layer unusable on
// its own.
var layers = map[string]int{
// 0 — foundation. No knowledge of the application.
"internal/core": 0,
"internal/httpx": 0,
"internal/natsort": 0,
"internal/arch": 0,
"migrations": 0,
// 1 — primitives the application is built from.
"internal/auth": 1,
"internal/jobs": 1,
"internal/stream": 1,
"internal/progress": 1,
"internal/probe": 1,
"internal/engine": 1,
"internal/apitoken": 1,
// 2 — leaves that the application is built from: the data seam, and the
// external catalogues. providers sits here so it stays a leaf — a
// metadata client must be usable from a test with no database.
"internal/catalog": 2,
"internal/providers": 2,
// 3 — the application.
"internal/library": 3,
"internal/scanner": 3,
"internal/media": 3,
"internal/home": 3,
"internal/search": 3,
"internal/metadata": 3,
"internal/playback": 3,
"internal/modules/comics": 3,
"internal/modules/ebooks": 3,
"internal/modules/games": 3,
"internal/modules/photos": 3,
// 4 — adapters: HTTP surfaces and foreign protocols.
"internal/httpapi": 4,
"internal/jellyfin": 4,
"internal/jellyfin/dto": 3,
"internal/subsonic": 4,
"internal/subsonic/dto": 3,
"internal/cli": 4,
// 5 — the only place everything is wired together.
"cmd/vellum": 5,
}
// repoRoot walks up until it finds go.mod, so the test works regardless of
// where `go test` is invoked from.
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find go.mod above the test directory")
}
dir = parent
}
}
// importsOf parses a directory and returns the internal packages it imports.
func importsOf(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read %s: %v", dir, err)
}
seen := map[string]bool{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
continue
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filepath.Join(dir, e.Name()), nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", e.Name(), err)
}
for _, imp := range f.Imports {
p := strings.Trim(imp.Path.Value, `"`)
if strings.HasPrefix(p, modulePath) {
seen[strings.TrimPrefix(p, modulePath)] = true
}
}
}
out := make([]string, 0, len(seen))
for p := range seen {
out = append(out, p)
}
sort.Strings(out)
return out
}
// TestNoUpwardImports is the rule that keeps the layers separable.
func TestNoUpwardImports(t *testing.T) {
root := repoRoot(t)
for pkg, layer := range layers {
dir := filepath.Join(root, filepath.FromSlash(pkg))
if _, err := os.Stat(dir); err != nil {
continue // package not present (e.g. a module was removed)
}
for _, dep := range importsOf(t, dir) {
depLayer, known := layers[dep]
if !known {
continue // a package outside the map; TestEveryPackageIsLayered catches it
}
if depLayer > layer {
t.Errorf("%s (layer %d) imports %s (layer %d): upward dependency",
pkg, layer, dep, depLayer)
}
}
}
}
// TestCoreStaysPure is the module rule: core may not know about any feature, or
// it stops being the thing everything else can depend on.
func TestCoreStaysPure(t *testing.T) {
root := repoRoot(t)
for _, dep := range importsOf(t, filepath.Join(root, "internal", "core")) {
if dep != "migrations" {
t.Errorf("internal/core imports %s; core may depend only on migrations", dep)
}
}
}
// TestCatalogHasNoHTTP keeps the data seam a data seam. If it grows an
// http.ResponseWriter it can no longer be reused by a job, a CLI or a test
// without dragging a request in.
func TestCatalogHasNoHTTP(t *testing.T) {
root := repoRoot(t)
for _, dep := range importsOf(t, filepath.Join(root, "internal", "catalog")) {
if dep == "internal/httpx" || dep == "internal/httpapi" {
t.Errorf("internal/catalog imports %s; the data seam must not know about HTTP", dep)
}
}
}
// TestModulesDoNotImportEachOther keeps modules independently removable. A
// module that imports a sibling cannot be dropped without dropping both.
func TestModulesDoNotImportEachOther(t *testing.T) {
root := repoRoot(t)
modsDir := filepath.Join(root, "internal", "modules")
mods, err := os.ReadDir(modsDir)
if err != nil {
t.Skip("no modules directory")
}
for _, m := range mods {
if !m.IsDir() {
continue
}
self := "internal/modules/" + m.Name()
for _, dep := range importsOf(t, filepath.Join(modsDir, m.Name())) {
if strings.HasPrefix(dep, "internal/modules/") && dep != self {
t.Errorf("%s imports %s; modules must not depend on each other", self, dep)
}
}
}
}
// TestEveryPackageIsLayered stops the map above from silently going stale. A
// new package that nobody placed in a layer is a package with no rules, which
// is how the layering erodes.
func TestEveryPackageIsLayered(t *testing.T) {
root := repoRoot(t)
var found []string
var walk func(base string)
walk = func(base string) {
entries, err := os.ReadDir(base)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
full := filepath.Join(base, e.Name())
rel, _ := filepath.Rel(root, full)
rel = filepath.ToSlash(rel)
// Only directories that actually contain Go files are packages.
if hasGoFiles(full) {
found = append(found, rel)
}
walk(full)
}
}
walk(filepath.Join(root, "internal"))
found = append(found, "cmd/vellum")
for _, pkg := range found {
if _, ok := layers[pkg]; !ok {
t.Errorf("%s is not in the layers map; add it so the import rules apply to it", pkg)
}
}
}
func hasGoFiles(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
return true
}
}
return false
}
// allImportsOf returns every import of a package's non-test files, including
// standard library ones. importsOf only reports internal packages, which is
// right for the layering rules and wrong for asking "does this package know
// about SQL".
func allImportsOf(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read %s: %v", dir, err)
}
seen := map[string]bool{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
continue
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filepath.Join(dir, e.Name()), nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", e.Name(), err)
}
for _, imp := range f.Imports {
seen[strings.Trim(imp.Path.Value, `"`)] = true
}
}
out := make([]string, 0, len(seen))
for p := range seen {
out = append(out, p)
}
sort.Strings(out)
return out
}
// sqlImporters are the packages permitted to import database/sql in their
// non-test files.
//
// This list is the guard against the storage seam eroding by drift. The seam
// exists so the engine is swappable; it only holds if the set of packages that
// know about *sql.DB does not quietly grow. Adding a package here is a
// deliberate act with a reason, not a side effect of a feature.
//
// A package that only needs database/sql to build a TEST database does not
// belong here — the check ignores _test.go files on purpose.
var sqlImporters = map[string]bool{
"internal/apitoken": true,
"internal/auth": true,
"internal/catalog": true,
"internal/cli": true,
"internal/core": true,
"internal/home": true,
"internal/httpapi": true,
"internal/jellyfin": true,
"internal/jobs": true,
"internal/library": true,
"internal/media": true,
"internal/metadata": true,
"internal/modules/comics": true,
"internal/modules/ebooks": true,
"internal/modules/games": true,
"internal/modules/photos": true,
"internal/progress": true,
"internal/scanner": true,
"internal/search": true,
"internal/subsonic": true,
}
// threadedConsumers must NOT import database/sql. These are packages that have
// been moved onto the catalog.Store interface; if one of them reaches for
// *sql.DB again, the seam is being undone and this test says so.
var threadedConsumers = []string{
"internal/playback",
}
// TestDatabaseSQLImportersAreDeclared freezes the blast radius of database/sql.
// A new package that needs it must be added to the list deliberately, so the
// seam cannot erode one convenient import at a time.
func TestDatabaseSQLImportersAreDeclared(t *testing.T) {
root := repoRoot(t)
for pkg := range layers {
dir := filepath.Join(root, filepath.FromSlash(pkg))
if _, err := os.Stat(dir); err != nil {
continue
}
usesSQL := false
for _, imp := range allImportsOf(t, dir) {
if imp == "database/sql" {
usesSQL = true
break
}
}
switch {
case usesSQL && !sqlImporters[pkg]:
t.Errorf("%s imports database/sql but is not in sqlImporters.\n"+
"If it genuinely needs its own SQL, add it to the list with a reason.\n"+
"If it is reading items, take a catalog.Store instead.", pkg)
case !usesSQL && sqlImporters[pkg]:
t.Errorf("%s is listed in sqlImporters but no longer imports database/sql.\n"+
"Remove it from the list so the list keeps meaning something.", pkg)
}
}
}
// TestThreadedConsumersStayOffSQL is the concrete regression guard: a package
// already moved onto the seam must not drift back.
func TestThreadedConsumersStayOffSQL(t *testing.T) {
root := repoRoot(t)
for _, pkg := range threadedConsumers {
dir := filepath.Join(root, filepath.FromSlash(pkg))
if _, err := os.Stat(dir); err != nil {
continue
}
for _, imp := range allImportsOf(t, dir) {
if imp == "database/sql" {
t.Errorf("%s is on the catalog.Store seam but imports database/sql again.\n"+
"That undoes the seam: take a catalog.Store, do not reach for *sql.DB.", pkg)
}
}
}
}

308
internal/catalog/catalog.go Normal file
View file

@ -0,0 +1,308 @@
// Package catalog is the one place that reads items and files out of the
// database, and the one place that enforces "may this user read this item".
//
// It exists because that check had been reimplemented five times — in media,
// comics, games, jellyfin and subsonic — each with its own SQL and its own idea
// of what to do when access is denied. Five copies of one authorization rule
// means four of them can drift, and the failure mode is not a crash: it is
// silently serving somebody a library they were not granted. One implementation
// is one thing to review.
//
// Consumers depend on this package rather than on *sql.DB, which is also what
// makes the storage engine swappable: a Postgres implementation would satisfy
// this package's surface instead of editing 186 statements scattered across the
// tree.
//
// # What the Store covers, and what it deliberately does not
//
// Store is the seam, and it holds the per-item load path only: Item, ItemFor,
// Files, FilesFor, FileByID, FirstFile, FileCount. Those are the queries whose
// correctness is a security property (ItemFor is the authorization chokepoint)
// and whose shape is identical for every media kind, so one implementation is
// worth having.
//
// Aggregate and list queries — browse lists, home shelves, Subsonic albums —
// deliberately stay OUT of it. Each is a presentation concern of the package
// that owns the surface, with its own columns, ordering and JSON contract, and
// none of them is shared. Pulling them in would mean rewriting all 186
// statements to match one interface, which is the exact cost this seam exists to
// avoid, and the result would be an interface nobody could implement without
// writing the whole application's query layer twice. The seam buys a swappable
// per-item load; it is not a repository.
//
// This package holds no HTTP knowledge. It returns typed errors and lets the
// caller choose a status code.
package catalog
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"time"
"github.com/mithral/vellum/internal/auth"
)
// ErrNotFound means the item does not exist, OR exists but the caller may not
// read it.
//
// The two are deliberately indistinguishable. A caller that could tell them
// apart would leak the existence of other people's libraries through a 403, so
// every handler turns this into a plain 404.
var ErrNotFound = errors.New("catalog: item not found")
// Item is one media item plus the root of its library.
type Item struct {
ID int64
LibraryID int64
Kind string
Path string // path relative to the library root
Title string
Year int
Missing bool
// RootPath is the absolute library root, so AbsPath can be computed.
RootPath string
// ModTime is the backing file's modification time, used for conditional
// GETs. Zero when unknown.
ModTime time.Time
}
// AbsPath returns the absolute filesystem path of the item's file.
//
// item.path is stored relative to the library root and the root is stored
// resolved, so this join cannot escape the library.
func (i Item) AbsPath() string {
return filepath.Join(i.RootPath, filepath.FromSlash(i.Path))
}
// File is one physical file backing an item, including the ffprobe facts.
//
// The JSON tags are the frozen MediaFile contract in web/src/lib/api.ts. The
// codec fields are what let a CLIENT decide whether it can direct-play, since
// Vellum never transcodes and playability depends on the client, not the file.
type File struct {
ID int64 `json:"id"`
Path string `json:"path"`
Size int64 `json:"size"`
Container string `json:"container"`
Missing bool `json:"missing"`
DurationMS int64 `json:"duration_ms"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Width int `json:"width"`
Height int `json:"height"`
}
// Store is the storage seam. Implement it to run this package's per-item load
// path against something other than SQLite.
//
// The contract is the one the SQL implementation has always had: absent item and
// forbidden item both come back as ErrNotFound, and every other failure is a
// wrapped error. A caller that can distinguish the two has a bug.
type Store interface {
Item(ctx context.Context, id int64) (Item, error)
ItemFor(ctx context.Context, u auth.User, id int64) (Item, error)
Files(ctx context.Context, itemID int64) ([]File, error)
FilesFor(ctx context.Context, u auth.User, itemID int64) ([]File, error)
FileByID(ctx context.Context, itemID, fileID int64) (File, error)
FirstFile(ctx context.Context, itemID int64) (File, error)
FileCount(ctx context.Context, itemID int64) (int, error)
}
const itemSelect = `
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path,
COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), '')
FROM media_items i
JOIN libraries l ON l.id = i.library_id
WHERE i.id = ?`
// SQL is the SQLite implementation of Store, and the only thing in this package
// that knows the query text.
type SQL struct{ db *sql.DB }
var _ Store = (*SQL)(nil)
// New returns a Store backed by db. The db must already be migrated; this
// package never creates schema.
func New(db *sql.DB) *SQL { return &SQL{db: db} }
// Item reads an item WITHOUT checking access. Prefer ItemFor in any handler that
// serves a request: this exists for the scanner, jobs and tests, which have
// already established the right to touch the row.
func (s *SQL) Item(ctx context.Context, id int64) (Item, error) {
var (
it Item
mtime string
)
err := s.db.QueryRowContext(ctx, itemSelect, id).Scan(
&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title,
&it.Year, &it.Missing, &it.RootPath, &mtime)
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrNotFound
}
if err != nil {
return Item{}, fmt.Errorf("catalog: load item: %w", err)
}
if nanos := parseUnixNano(mtime); nanos > 0 {
it.ModTime = time.Unix(0, nanos).UTC()
}
return it, nil
}
// ItemFor reads an item and confirms the user may read its library.
//
// This is the authorization chokepoint. Every HTTP surface — the web app, the
// modules, Jellyfin, Subsonic — is expected to go through it, so there is one
// implementation to audit rather than five to keep in sync.
func (s *SQL) ItemFor(ctx context.Context, u auth.User, id int64) (Item, error) {
it, err := s.Item(ctx, id)
if err != nil {
return Item{}, err
}
allowed, err := auth.CanAccessLibrary(ctx, s.db, u, it.LibraryID)
if err != nil {
return Item{}, fmt.Errorf("catalog: check access: %w", err)
}
if !allowed {
// Deliberately the same error as a missing row: see ErrNotFound.
return Item{}, ErrNotFound
}
return it, nil
}
// Files returns an item's files in order, with codec facts.
func (s *SQL) Files(ctx context.Context, itemID int64) ([]File, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, path, size, container, missing,
duration_ms, vcodec, acodec, width, height
FROM media_files WHERE item_id = ? ORDER BY id`, itemID)
if err != nil {
return nil, fmt.Errorf("catalog: list files: %w", err)
}
defer rows.Close()
out := []File{}
for rows.Next() {
var f File
if err := rows.Scan(&f.ID, &f.Path, &f.Size, &f.Container, &f.Missing,
&f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height); err != nil {
return nil, fmt.Errorf("catalog: scan file: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// FilesFor returns an item's files after confirming access. Callers that
// already hold an authorized Item should use Files to avoid a second check.
func (s *SQL) FilesFor(ctx context.Context, u auth.User, itemID int64) ([]File, error) {
if _, err := s.ItemFor(ctx, u, itemID); err != nil {
return nil, err
}
return s.Files(ctx, itemID)
}
// FileByID returns one file of an item, scoped to that item so a file id from
// another item cannot be fetched through it.
func (s *SQL) FileByID(ctx context.Context, itemID, fileID int64) (File, error) {
var f File
err := s.db.QueryRowContext(ctx, `
SELECT id, path, size, container, missing,
duration_ms, vcodec, acodec, width, height
FROM media_files WHERE item_id = ? AND id = ?`, itemID, fileID).Scan(
&f.ID, &f.Path, &f.Size, &f.Container, &f.Missing,
&f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height)
if errors.Is(err, sql.ErrNoRows) {
return File{}, ErrNotFound
}
if err != nil {
return File{}, fmt.Errorf("catalog: load file: %w", err)
}
return f, nil
}
// FirstFile returns the item's first file, which is what single-file playback
// and cover extraction both want.
func (s *SQL) FirstFile(ctx context.Context, itemID int64) (File, error) {
var f File
err := s.db.QueryRowContext(ctx, `
SELECT id, path, size, container, missing,
duration_ms, vcodec, acodec, width, height
FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(
&f.ID, &f.Path, &f.Size, &f.Container, &f.Missing,
&f.DurationMS, &f.VCodec, &f.ACodec, &f.Width, &f.Height)
if errors.Is(err, sql.ErrNoRows) {
return File{}, ErrNotFound
}
if err != nil {
return File{}, fmt.Errorf("catalog: first file: %w", err)
}
return f, nil
}
// FileCount reports how many files back an item.
func (s *SQL) FileCount(ctx context.Context, itemID int64) (int, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM media_files WHERE item_id = ?`, itemID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("catalog: file count: %w", err)
}
return n, nil
}
// The functions below are the original package API, kept so that existing
// callers compile and behave exactly as before. Each one is a one-line
// delegation to the SQL implementation above.
//
// NEW CODE SHOULD TAKE A Store, NOT A *sql.DB. A caller that wants to be
// swappable off SQLite takes the Store interface and receives New(db) at the
// wiring point; reaching for these functions bakes the engine back in.
func LoadUnchecked(ctx context.Context, db *sql.DB, id int64) (Item, error) {
return New(db).Item(ctx, id)
}
func LoadFor(ctx context.Context, db *sql.DB, u auth.User, id int64) (Item, error) {
return New(db).ItemFor(ctx, u, id)
}
func Files(ctx context.Context, db *sql.DB, itemID int64) ([]File, error) {
return New(db).Files(ctx, itemID)
}
func FilesFor(ctx context.Context, db *sql.DB, u auth.User, itemID int64) ([]File, error) {
return New(db).FilesFor(ctx, u, itemID)
}
func FileByID(ctx context.Context, db *sql.DB, itemID, fileID int64) (File, error) {
return New(db).FileByID(ctx, itemID, fileID)
}
func FirstFile(ctx context.Context, db *sql.DB, itemID int64) (File, error) {
return New(db).FirstFile(ctx, itemID)
}
func FileCount(ctx context.Context, db *sql.DB, itemID int64) (int, error) {
return New(db).FileCount(ctx, itemID)
}
// parseUnixNano parses a base-10 nanosecond timestamp, returning 0 on anything
// that is not all digits.
func parseUnixNano(s string) int64 {
if s == "" {
return 0
}
var n int64
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0
}
n = n*10 + int64(s[i]-'0')
}
return n
}

View file

@ -0,0 +1,221 @@
package catalog
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
// testDB builds a migrated database in a temp directory.
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "catalog.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
// newLibrary inserts a library and returns its id.
func newLibrary(t *testing.T, db *sql.DB, name string) int64 {
t.Helper()
res, err := db.Exec(
`INSERT INTO libraries (kind, name, root_path) VALUES ('ebooks', ?, ?)`,
name, "/media/"+name)
if err != nil {
t.Fatalf("insert library: %v", err)
}
id, _ := res.LastInsertId()
return id
}
// newUser inserts a user. isAdmin decides whether grants matter.
func newUser(t *testing.T, db *sql.DB, name string, admin bool) auth.User {
t.Helper()
adminFlag := 0
if admin {
adminFlag = 1
}
res, err := db.Exec(
`INSERT INTO users (username, display_name, password_hash, is_admin) VALUES (?, ?, 'x', ?)`,
name, name, adminFlag)
if err != nil {
t.Fatalf("insert user: %v", err)
}
id, _ := res.LastInsertId()
return auth.User{ID: id, Username: name, IsAdmin: admin}
}
// newItem inserts an item with one file, including codec facts.
func newItem(t *testing.T, db *sql.DB, libID int64, title string) int64 {
t.Helper()
res, err := db.Exec(`
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
VALUES (?, 'ebooks', ?, ?, ?, 1999)`, libID, title+".epub", title, title)
if err != nil {
t.Fatalf("insert item: %v", err)
}
itemID, _ := res.LastInsertId()
if _, err := db.Exec(`
INSERT INTO media_files (item_id, path, size, mtime, container, duration_ms,
vcodec, acodec, width, height)
VALUES (?, ?, 100, '1700000000000000000', 'mp4', 5000, 'h264', 'aac', 640, 360)`,
itemID, title+".epub"); err != nil {
t.Fatalf("insert file: %v", err)
}
return itemID
}
func grant(t *testing.T, db *sql.DB, u auth.User, libID int64) {
t.Helper()
if _, err := db.Exec(
`INSERT INTO library_grants (user_id, library_id, can_read) VALUES (?, ?, 1)`,
u.ID, libID); err != nil {
t.Fatalf("grant: %v", err)
}
}
func TestLoadReturnsCodecFacts(t *testing.T) {
db := testDB(t)
lib := newLibrary(t, db, "A")
itemID := newItem(t, db, lib, "Thing")
it, err := LoadUnchecked(context.Background(), db, itemID)
if err != nil {
t.Fatal(err)
}
if it.Title != "Thing" || it.LibraryID != lib || it.Kind != "ebooks" {
t.Errorf("unexpected item: %+v", it)
}
if it.RootPath != "/media/A" {
t.Errorf("RootPath = %q", it.RootPath)
}
if it.AbsPath() != "/media/A/Thing.epub" {
t.Errorf("AbsPath = %q", it.AbsPath())
}
if it.ModTime.IsZero() {
t.Error("ModTime not parsed from the file's mtime")
}
files, err := Files(context.Background(), db, itemID)
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatalf("files = %d, want 1", len(files))
}
f := files[0]
if f.VCodec != "h264" || f.ACodec != "aac" || f.Width != 640 || f.DurationMS != 5000 {
t.Errorf("codec facts lost: %+v", f)
}
if f.Missing {
t.Error("a present file reported as missing")
}
}
// The security property: a missing item and a forbidden item must be
// INDISTINGUISHABLE, or a 403 would leak the existence of another user's
// library.
func TestLoadForForbiddenIsIndistinguishableFromAbsent(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := newLibrary(t, db, "Secret")
itemID := newItem(t, db, lib, "Hidden")
outsider := newUser(t, db, "outsider", false)
granted := newUser(t, db, "granted", false)
grant(t, db, granted, lib)
// Forbidden: the item exists but this user has no grant.
_, errForbidden := LoadFor(ctx, db, outsider, itemID)
// Absent: no such row at all.
_, errAbsent := LoadFor(ctx, db, outsider, itemID+9999)
if !errors.Is(errForbidden, ErrNotFound) {
t.Errorf("forbidden error = %v, want ErrNotFound", errForbidden)
}
if !errors.Is(errAbsent, ErrNotFound) {
t.Errorf("absent error = %v, want ErrNotFound", errAbsent)
}
if errForbidden.Error() != errAbsent.Error() {
t.Errorf("errors differ and would leak existence: %q vs %q",
errForbidden, errAbsent)
}
// Positive control: a granted user does get it.
if _, err := LoadFor(ctx, db, granted, itemID); err != nil {
t.Errorf("granted user denied: %v", err)
}
}
func TestAdminBypassesGrants(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := newLibrary(t, db, "Any")
itemID := newItem(t, db, lib, "Thing")
admin := newUser(t, db, "root", true)
if _, err := LoadFor(ctx, db, admin, itemID); err != nil {
t.Errorf("admin denied: %v", err)
}
}
func TestFileByIDIsScopedToItsItem(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := newLibrary(t, db, "A")
first := newItem(t, db, lib, "One")
second := newItem(t, db, lib, "Two")
other, err := FirstFile(ctx, db, second)
if err != nil {
t.Fatal(err)
}
// Asking for item Two's file through item One must not resolve.
if _, err := FileByID(ctx, db, first, other.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("file id leaked across items: err = %v, want ErrNotFound", err)
}
if _, err := FileByID(ctx, db, second, other.ID); err != nil {
t.Errorf("legitimate lookup failed: %v", err)
}
}
func TestFilesForRespectsGrants(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := newLibrary(t, db, "A")
itemID := newItem(t, db, lib, "Thing")
outsider := newUser(t, db, "outsider", false)
if _, err := FilesFor(ctx, db, outsider, itemID); !errors.Is(err, ErrNotFound) {
t.Errorf("FilesFor leaked to an ungranted user: %v", err)
}
}
func TestFileCount(t *testing.T) {
db := testDB(t)
lib := newLibrary(t, db, "A")
itemID := newItem(t, db, lib, "Thing")
n, err := FileCount(context.Background(), db, itemID)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("FileCount = %d, want 1", n)
}
}

View file

@ -0,0 +1,208 @@
package catalog
import (
"context"
"errors"
"testing"
"time"
"github.com/mithral/vellum/internal/auth"
)
// This file exists to keep the Store seam honest. An interface that no one can
// substitute is decoration, so fakeStore implements Store entirely in memory —
// no database, no SQL — and the test drives a caller through it. If a method is
// added to Store later, this file stops compiling, which is the intended alarm.
//
// What is NOT tested here: the query text. That is the SQL implementation's job
// and catalog_test.go covers it against a real database.
// fakeStore is an in-memory Store. Its authorization model mirrors auth's: an
// admin sees everything, everyone else sees only granted libraries.
type fakeStore struct {
items map[int64]Item
files map[int64][]File
granted map[int64][]int64 // user id -> library ids
admins map[int64]bool
// calls records which methods ran, so the test can prove the caller went
// through the interface rather than reaching for a database.
calls []string
}
var _ Store = (*fakeStore)(nil)
func newFakeStore() *fakeStore {
return &fakeStore{
items: map[int64]Item{},
files: map[int64][]File{},
granted: map[int64][]int64{},
admins: map[int64]bool{},
}
}
func (f *fakeStore) allowed(u auth.User, libID int64) bool {
if f.admins[u.ID] {
return true
}
for _, id := range f.granted[u.ID] {
if id == libID {
return true
}
}
return false
}
func (f *fakeStore) Item(_ context.Context, id int64) (Item, error) {
f.calls = append(f.calls, "Item")
it, ok := f.items[id]
if !ok {
return Item{}, ErrNotFound
}
return it, nil
}
func (f *fakeStore) ItemFor(_ context.Context, u auth.User, id int64) (Item, error) {
f.calls = append(f.calls, "ItemFor")
it, ok := f.items[id]
if !ok || !f.allowed(u, it.LibraryID) {
// The whole point: absent and forbidden are the same error.
return Item{}, ErrNotFound
}
return it, nil
}
func (f *fakeStore) Files(_ context.Context, itemID int64) ([]File, error) {
f.calls = append(f.calls, "Files")
return f.files[itemID], nil
}
func (f *fakeStore) FilesFor(ctx context.Context, u auth.User, itemID int64) ([]File, error) {
f.calls = append(f.calls, "FilesFor")
if _, err := f.ItemFor(ctx, u, itemID); err != nil {
return nil, err
}
return f.Files(ctx, itemID)
}
func (f *fakeStore) FileByID(_ context.Context, itemID, fileID int64) (File, error) {
f.calls = append(f.calls, "FileByID")
for _, fl := range f.files[itemID] { // scoped to the item, like the SQL
if fl.ID == fileID {
return fl, nil
}
}
return File{}, ErrNotFound
}
func (f *fakeStore) FirstFile(_ context.Context, itemID int64) (File, error) {
f.calls = append(f.calls, "FirstFile")
fs := f.files[itemID]
if len(fs) == 0 {
return File{}, ErrNotFound
}
return fs[0], nil
}
func (f *fakeStore) FileCount(_ context.Context, itemID int64) (int, error) {
f.calls = append(f.calls, "FileCount")
return len(f.files[itemID]), nil
}
// serveItem is a stand-in for a real caller (media.playback, jellyfin.playback)
// written against the Store interface instead of *sql.DB. It is here because
// that is what the seam is FOR: this function cannot be told it needs SQLite.
func serveItem(ctx context.Context, s Store, u auth.User, id int64) (Item, File, error) {
it, err := s.ItemFor(ctx, u, id)
if err != nil {
return Item{}, File{}, err
}
f, err := s.FirstFile(ctx, id)
if err != nil {
return Item{}, File{}, err
}
return it, f, nil
}
func fixture() (*fakeStore, auth.User, auth.User, int64) {
s := newFakeStore()
const libID = 42
s.items[7] = Item{ID: 7, LibraryID: libID, Kind: "ebooks", Path: "Thing.epub",
Title: "Thing", Year: 1999, RootPath: "/media/A",
ModTime: time.Unix(1700000000, 0).UTC()}
s.files[7] = []File{{ID: 700, Path: "Thing.epub", Size: 100, Container: "mp4"}}
s.files[8] = []File{{ID: 800, Path: "Other.epub", Size: 100}}
granted := auth.User{ID: 1, Username: "granted"}
outsider := auth.User{ID: 2, Username: "outsider"}
s.granted[granted.ID] = []int64{libID}
return s, granted, outsider, 7
}
func TestStoreIsSubstitutableWithoutADatabase(t *testing.T) {
s, granted, _, itemID := fixture()
it, f, err := serveItem(context.Background(), s, granted, itemID)
if err != nil {
t.Fatalf("in-memory Store rejected a granted user: %v", err)
}
if it.Title != "Thing" || it.AbsPath() != "/media/A/Thing.epub" {
t.Errorf("item lost in translation: %+v", it)
}
if f.ID != 700 {
t.Errorf("FirstFile = %d, want 700", f.ID)
}
if want := []string{"ItemFor", "FirstFile"}; len(s.calls) != 2 ||
s.calls[0] != want[0] || s.calls[1] != want[1] {
t.Errorf("calls = %v, want %v", s.calls, want)
}
}
// The security property has to survive the seam: a caller that could tell a
// forbidden item from an absent one would leak another user's library.
func TestStoreKeepsForbiddenIndistinguishableFromAbsent(t *testing.T) {
s, _, outsider, itemID := fixture()
ctx := context.Background()
_, _, ferr := serveItem(ctx, s, outsider, itemID)
_, _, aerr := serveItem(ctx, s, outsider, itemID+9999)
if !errors.Is(ferr, ErrNotFound) || !errors.Is(aerr, ErrNotFound) {
t.Fatalf("errors = %v / %v, want ErrNotFound for both", ferr, aerr)
}
if ferr.Error() != aerr.Error() {
t.Errorf("errors differ and would leak existence: %q vs %q", ferr, aerr)
}
}
func TestStoreAdminSeesEverythingWithoutAGrant(t *testing.T) {
s, _, _, itemID := fixture()
admin := auth.User{ID: 3, Username: "root", IsAdmin: true}
s.admins[admin.ID] = true
if _, err := s.ItemFor(context.Background(), admin, itemID); err != nil {
t.Errorf("admin denied: %v", err)
}
if _, err := s.FilesFor(context.Background(), admin, itemID); err != nil {
t.Errorf("admin FilesFor denied: %v", err)
}
if n, err := s.FileCount(context.Background(), itemID); err != nil || n != 1 {
t.Errorf("FileCount = %d, %v; want 1, nil", n, err)
}
}
// FileByID must stay scoped to its item through the interface too, or the
// in-memory Store would be laxer than the SQL one it stands in for.
func TestStoreFileByIDIsScopedToItsItem(t *testing.T) {
s, _, _, itemID := fixture()
ctx := context.Background()
other, err := s.FirstFile(ctx, itemID+1)
if err != nil {
t.Fatal(err)
}
if _, err := s.FileByID(ctx, itemID, other.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("file id leaked across items: %v", err)
}
if _, err := s.FileByID(ctx, itemID+1, other.ID); err != nil {
t.Errorf("legitimate lookup failed: %v", err)
}
}

View file

@ -42,6 +42,16 @@ func run(ctx context.Context, args []string, cfg core.Config, log *slog.Logger,
return false, nil
}
// Create the data and config directories before opening the database.
//
// The server does this in Config.Validate, but the CLI is the way back in
// when someone is locked out, so it must work on a fresh install too —
// before the server has ever run and created them. Only the directories:
// an admin command must not fail because a media library is unmounted.
if err := cfg.EnsureDirs(); err != nil {
return true, err
}
db, err := core.OpenDB(cfg.DBPath)
if err != nil {
return true, err

View file

@ -16,9 +16,22 @@ import (
// testConfig points a Config at a fresh database inside a temp dir. The CLI
// itself opens and migrates it, mirroring the auth_test.go setup pattern.
//
// DataDir and ConfigDir are set because the CLI creates them if missing — that
// is what lets `vellum user add` work on a fresh install, before the server has
// ever run. An empty dir here would be an unrealistic config.
func testConfig(t *testing.T) core.Config {
t.Helper()
return core.Config{DBPath: filepath.Join(t.TempDir(), "vellum.db")}
dir := t.TempDir()
data := filepath.Join(dir, "data")
return core.Config{
// DBPath lives INSIDE DataDir, exactly as LoadConfig derives it. That
// matters: it is why the CLI must create the directory, and it is what
// makes this config exercise the fresh-install path.
DBPath: filepath.Join(data, "vellum.db"),
DataDir: data,
ConfigDir: filepath.Join(dir, "config"),
}
}
func testLogger() *slog.Logger {

View file

@ -94,15 +94,27 @@ func LoadConfig() (Config, error) {
return c, nil
}
// Validate creates the data and config directories if missing and checks that
// the configured media roots exist. A missing media root is an error, not a
// warning: silently scanning nothing is worse than failing loudly at boot.
func (c Config) Validate() error {
// EnsureDirs creates the data and config directories if missing.
//
// Separate from Validate because the CLI needs the directories but has no
// interest in the media roots: `vellum user add` must work on a fresh install,
// and must not fail because a library happens to be unmounted.
func (c Config) EnsureDirs() error {
for _, dir := range []string{c.DataDir, c.ConfigDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
}
return nil
}
// Validate creates the data and config directories if missing and checks that
// the configured media roots exist. A missing media root is an error, not a
// warning: silently scanning nothing is worse than failing loudly at boot.
func (c Config) Validate() error {
if err := c.EnsureDirs(); err != nil {
return err
}
for _, dir := range c.MediaDirs {
info, err := os.Stat(dir)
if err != nil {

203
internal/engine/engine.go Normal file
View file

@ -0,0 +1,203 @@
// Package engine describes the playback engines a client can offer and decides
// whether a particular file can be direct-played by that client.
//
// Vellum never transcodes: the server is a headless, GPU-less Xeon, and measured
// against real files, playability turned out to be a property of the CLIENT, not
// the file. Chrome on Linux plays an MKV carrying H.264 perfectly well, yet
// cannot decode an MP4 carrying HEVC at all. So the server's job is only to
// report what is inside each file (see internal/probe) and to match it against
// what the client says it has.
//
// This package is the registry of those engines and the matcher. The web app's
// only engine is "html5"; native clients (mpv, libvlc, MDK) declare their own.
// Adding a native engine later is a registration, not a re-architecture — the
// native libraries themselves can only live inside a native client, never in a
// browser.
package engine
import (
"sort"
"strings"
)
// Engine kinds. A "browser" engine is an HTML5 media element and is bound by
// what the browser's decoders support; a "native" engine links a real media
// library (mpv/libmpv, libvlc, MDK) and can therefore live only inside a native
// client.
const (
KindBrowser = "browser"
KindNative = "native"
)
// HTML5ID is the built-in browser engine every client can be assumed to have.
const HTML5ID = "html5"
// Engine declares one playback backend and the media it can open.
//
// The JSON tags are the frozen EngineSupport contract in web/src/lib/api.ts and
// must not change without changing that file too.
type Engine struct {
// ID is the stable identifier clients pass in their declared engine list.
ID string `json:"id"`
// Name is the human label shown in the UI.
Name string `json:"name"`
// Kind is KindBrowser or KindNative.
Kind string `json:"kind"`
// Containers are lowercase extensions, without a leading dot ("mkv").
Containers []string `json:"containers"`
// VCodecs are ffprobe video codec names ("h264", "hevc", ...).
VCodecs []string `json:"vcodecs"`
// ACodecs are ffprobe audio codec names ("aac", "opus", ...).
ACodecs []string `json:"acodecs"`
}
// builtins are the engines Vellum knows out of the box. html5 is the browser
// baseline; the native engines are registered so a native client can name them,
// and are never selectable from a browser.
var builtins = []Engine{
{
ID: HTML5ID,
Name: "HTML5",
Kind: KindBrowser,
// A deliberately CONSERVATIVE browser baseline. The real answer comes
// from the client: MediaCapabilities (see web/src/lib/capabilities.ts)
// can distinguish "cannot decode at all" from "decodes but will
// stutter", and only the client's GPU knows that. This list is what the
// server can honestly assume of a browser with no other information.
//
// Note what is absent: hevc/h265 and mpeg4 are measured as undecodable
// in Chrome, so they must not be claimed here. Container support is
// broad because mkv/mov play fine when the video track is h264.
Containers: []string{
"mp4", "m4v", "mov", "mkv", "webm",
"mp3", "m4a", "m4b", "aac", "flac", "ogg", "opus", "wav",
},
VCodecs: []string{"h264", "vp8", "vp9", "av1"},
// pcm appears under several ffprobe names (pcm_s16le, ...); the aliases
// are listed so a WAV is not rejected over a naming detail.
ACodecs: []string{"aac", "mp3", "opus", "vorbis", "flac", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8"},
},
{
// mpv (libmpv) handles essentially every ffmpeg demuxer and decoder.
// Registered for NATIVE clients only: a browser cannot link libmpv.
ID: "mpv",
Name: "mpv",
Kind: KindNative,
Containers: []string{
"mkv", "mp4", "m4v", "mov", "webm", "avi", "wmv", "flv",
"ts", "m2ts", "mpg", "mpeg", "ogv", "ogm", "3gp", "vob",
},
VCodecs: []string{
"h264", "avc1", "hevc", "h265", "mpeg4", "mpeg2video", "mpeg1video",
"vc1", "wmv3", "av1", "vp8", "vp9", "theora", "prores", "dvvideo",
},
ACodecs: []string{
"aac", "mp3", "opus", "vorbis", "flac", "ac3", "eac3", "dts",
"truehd", "alac", "wma", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8",
},
},
{
// libvlc is similarly broad. Registered for NATIVE clients only.
ID: "vlc",
Name: "VLC",
Kind: KindNative,
Containers: []string{
"mkv", "mp4", "m4v", "mov", "webm", "avi", "wmv", "flv",
"ts", "m2ts", "mpg", "mpeg", "ogv", "ogm", "3gp", "vob", "asf", "rm",
},
VCodecs: []string{
"h264", "avc1", "hevc", "h265", "mpeg4", "mpeg2video", "mpeg1video",
"vc1", "wmv3", "av1", "vp8", "vp9", "theora", "prores", "dvvideo", "rv40",
},
ACodecs: []string{
"aac", "mp3", "opus", "vorbis", "flac", "ac3", "eac3", "dts",
"truehd", "alac", "wma", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8", "cook",
},
},
{
// MDK is a cross-platform multimedia SDK (ffmpeg-based). Registered for
// NATIVE clients only.
ID: "mdk",
Name: "MDK",
Kind: KindNative,
Containers: []string{
"mkv", "mp4", "m4v", "mov", "webm", "avi", "wmv", "flv",
"ts", "m2ts", "mpg", "mpeg", "ogv", "ogm", "3gp", "vob",
},
VCodecs: []string{
"h264", "avc1", "hevc", "h265", "mpeg4", "mpeg2video", "mpeg1video",
"vc1", "wmv3", "av1", "vp8", "vp9", "theora", "prores", "dvvideo",
},
ACodecs: []string{
"aac", "mp3", "opus", "vorbis", "flac", "ac3", "eac3", "dts",
"truehd", "alac", "wma", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8",
},
},
}
// registry is built once from builtins. Read-only after init, so All/Get need no
// lock; if runtime registration is ever added it must grow a mutex.
var registry = func() map[string]Engine {
m := make(map[string]Engine, len(builtins))
for _, e := range builtins {
m[e.ID] = e
}
return m
}()
// All returns every registered engine, sorted by ID so the response is stable.
func All() []Engine {
out := make([]Engine, 0, len(registry))
for _, e := range registry {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// Get looks up one engine by ID. The second result is false for an unknown ID,
// which callers treat as "this client has no such engine" rather than an error.
func Get(id string) (Engine, bool) {
e, ok := registry[strings.ToLower(strings.TrimSpace(id))]
return e, ok
}
// Facts packs a file's codec facts into the positional slice Resolve expects:
// container, video codec, audio codec. An empty video codec means there is no
// video track (audio, or a comic).
func Facts(container, vcodec, acodec string) []string {
return []string{container, vcodec, acodec}
}
// canPlay reports whether an engine can direct-play a file with these facts.
//
// A missing codec ("" for an audio-only file's video track) is not a blocker:
// there is simply no such track to decode. Container is always required.
func (e Engine) canPlay(container, vcodec, acodec string) bool {
if !contains(e.Containers, container) {
return false
}
if vcodec != "" && !contains(e.VCodecs, vcodec) {
return false
}
if acodec != "" && !contains(e.ACodecs, acodec) {
return false
}
return true
}
// contains reports membership, case-insensitively, so "H264" and "h264" match
// and a stray leading dot on a container extension does not.
func contains(list []string, want string) bool {
want = strings.ToLower(strings.TrimSpace(want))
want = strings.TrimPrefix(want, ".")
if want == "" {
return false
}
for _, v := range list {
if strings.ToLower(strings.TrimSpace(v)) == want {
return true
}
}
return false
}

View file

@ -0,0 +1,245 @@
package engine
import (
"encoding/json"
"strings"
"testing"
)
// TestRegistryHasBuiltins pins the engines a client can name out of the box.
func TestRegistryHasBuiltins(t *testing.T) {
for _, id := range []string{HTML5ID, "mpv", "vlc", "mdk"} {
if _, ok := Get(id); !ok {
t.Errorf("engine %q is not registered", id)
}
}
if _, ok := Get("nope"); ok {
t.Error("Get returned an unknown engine")
}
// Get is case/space tolerant so a client's casing never matters.
if _, ok := Get(" HTML5 "); !ok {
t.Error("Get should trim and lower-case the id")
}
}
// TestHTML5BaselineIsHonest guards the measured facts: the browser baseline must
// NOT claim HEVC or mpeg4, which Chrome cannot decode, and must not claim a
// container it does not handle.
func TestHTML5BaselineIsHonest(t *testing.T) {
html5, _ := Get(HTML5ID)
if contains(html5.VCodecs, "hevc") || contains(html5.VCodecs, "h265") {
t.Error("html5 baseline claims HEVC, which browsers cannot decode")
}
if contains(html5.VCodecs, "mpeg4") {
t.Error("html5 baseline claims mpeg4, which browsers cannot decode")
}
if !contains(html5.VCodecs, "h264") || !contains(html5.VCodecs, "vp9") || !contains(html5.VCodecs, "av1") {
t.Errorf("html5 baseline is missing a measured-playable codec: %v", html5.VCodecs)
}
if !contains(html5.Containers, "mkv") {
t.Error("html5 baseline must accept mkv (h264-in-mkv plays)")
}
if contains(html5.Containers, "avi") {
t.Error("html5 baseline must not claim avi")
}
}
// TestEngineJSONMatchesContract pins the frozen wire shape (web/src/lib/api.ts).
func TestEngineJSONMatchesContract(t *testing.T) {
e, _ := Get(HTML5ID)
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatal(err)
}
for _, key := range []string{"id", "name", "kind", "containers", "vcodecs", "acodecs"} {
if _, ok := raw[key]; !ok {
t.Errorf("engine JSON is missing %q", key)
}
}
if len(raw) != 6 {
t.Errorf("engine JSON has %d keys, want exactly 6: %s", len(raw), b)
}
}
func TestResolve(t *testing.T) {
tests := []struct {
name string
facts []string
engines []string
playable bool
engine string // expected engine id, or "" for nil
reason string
msgSubstr string // must appear in Message when not playable
}{
{
name: "mp4 h264 aac plays in html5",
facts: Facts("mp4", "h264", "aac"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "mkv h264 plays despite the container",
facts: Facts("mkv", "h264", "aac"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "mp4 hevc aac is not playable",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"html5"},
playable: false,
reason: "vcodec:hevc",
msgSubstr: "HEVC",
},
{
name: "avi mpeg4 is a container blocker",
facts: Facts("avi", "mpeg4", "mp3"),
engines: []string{"html5"},
playable: false,
reason: "container:avi",
msgSubstr: "AVI",
},
{
name: "unsupported audio codec",
facts: Facts("mp4", "h264", "dts"),
engines: []string{"html5"},
playable: false,
reason: "acodec:dts",
msgSubstr: "DTS",
},
{
name: "audio-only mp3 is playable",
facts: Facts("mp3", "", "mp3"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "audio-only flac is playable",
facts: Facts("flac", "", "flac"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "audio-only dts is not playable",
facts: Facts("m4a", "", "dts"),
engines: []string{"html5"},
playable: false,
reason: "acodec:dts",
msgSubstr: "DTS",
},
{
name: "empty engine list falls back to html5 baseline",
facts: Facts("mp4", "h264", "aac"),
engines: nil,
playable: true,
engine: "html5",
},
{
name: "empty engine list still rejects hevc",
facts: Facts("mp4", "hevc", "aac"),
engines: nil,
playable: false,
reason: "vcodec:hevc",
msgSubstr: "mpv",
},
{
name: "native engine changes the answer for hevc",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"html5", "mpv"},
playable: true,
engine: "mpv",
},
{
name: "native engine handles avi",
facts: Facts("avi", "mpeg4", "mp3"),
engines: []string{"html5", "vlc"},
playable: true,
engine: "vlc",
},
{
name: "client ordering wins among capable engines",
facts: Facts("mkv", "h264", "aac"),
engines: []string{"mpv", "html5"},
playable: true,
engine: "mpv",
},
{
name: "capable engine beats an earlier incapable one",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"html5", "vlc", "mpv"},
playable: true,
engine: "vlc",
},
{
name: "unknown engines are skipped, reason uses fallback",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"bogus"},
playable: false,
reason: "vcodec:hevc",
msgSubstr: "mpv",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Resolve(tt.facts, tt.engines)
if got.Playable != tt.playable {
t.Fatalf("playable = %v, want %v (reason %q)", got.Playable, tt.playable, got.Reason)
}
if tt.playable {
if got.Engine == nil || *got.Engine != tt.engine {
t.Errorf("engine = %v, want %q", got.Engine, tt.engine)
}
if got.Reason != "" || got.Message != "" {
t.Errorf("playable result carried reason=%q message=%q", got.Reason, got.Message)
}
return
}
if got.Engine != nil {
t.Errorf("engine = %q, want null", *got.Engine)
}
if got.Reason != tt.reason {
t.Errorf("reason = %q, want %q", got.Reason, tt.reason)
}
if tt.msgSubstr != "" && !strings.Contains(got.Message, tt.msgSubstr) {
t.Errorf("message = %q, want it to mention %q", got.Message, tt.msgSubstr)
}
if strings.Contains(strings.ToLower(got.Message), "you ") {
t.Errorf("message blames the user: %q", got.Message)
}
})
}
}
// TestResolutionJSONEngineNull pins that a non-playable answer serialises
// engine as JSON null, exactly as PlaybackResolution declares.
func TestResolutionJSONEngineNull(t *testing.T) {
got := Resolve(Facts("mp4", "hevc", "aac"), []string{"html5"})
b, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
var raw struct {
Playable bool `json:"playable"`
Engine json.RawMessage `json:"engine"`
Reason string `json:"reason"`
Message string `json:"message"`
}
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatal(err)
}
if string(raw.Engine) != "null" {
t.Errorf("engine = %s, want null", raw.Engine)
}
if raw.Playable || raw.Reason != "vcodec:hevc" || raw.Message == "" {
t.Errorf("unexpected resolution JSON: %s", b)
}
}

156
internal/engine/resolve.go Normal file
View file

@ -0,0 +1,156 @@
package engine
import (
"fmt"
"strings"
)
// Resolution is the answer to "can this client play this file, and with what?".
//
// The JSON shape is frozen by PlaybackResolution in web/src/lib/api.ts:
// playable, engine (null when nothing can), reason, message. Reason is
// machine-readable ("vcodec:hevc"); message is the human text the UI shows.
type Resolution struct {
Playable bool `json:"playable"`
// Engine is the ID of the engine that should play the file, or nil when
// nothing the client declared can. It is a pointer so it serialises to JSON
// null, matching the frozen contract.
Engine *string `json:"engine"`
// Reason is empty when playable; otherwise a machine-readable cause such as
// "vcodec:hevc", "container:avi" or "acodec:dts".
Reason string `json:"reason"`
// Message is human text for the UI. Empty when playable.
Message string `json:"message"`
}
// Resolve decides whether a client can direct-play a file.
//
// fileFacts is the positional slice produced by Facts: container, video codec,
// audio codec. clientEngineIDs is the list of engines the CLIENT declared, in
// the client's own preference order.
//
// Semantics:
// - Only engines the client declared are considered. If the client declared
// none (an older client, or one that has not implemented capability
// detection), we fall back to the html5 baseline rather than refusing to
// answer.
// - An engine that can play the file always beats one that cannot; among
// capable engines the client's ordering wins, so a native client can say
// "try mpv first".
// - A file with no video codec (audio-only) is playable as soon as its audio
// codec is supported.
func Resolve(fileFacts, clientEngineIDs []string) Resolution {
container, vcodec, acodec := splitFacts(fileFacts)
ids := clientEngineIDs
if len(ids) == 0 {
// No declared engines: fall back to the conservative html5 baseline so
// the caller still gets a useful verdict. The client is always free to
// refine it (see web/src/lib/capabilities.ts).
ids = []string{HTML5ID}
}
// First declared capable engine wins.
for _, id := range ids {
e, ok := Get(id)
if !ok {
continue
}
if e.canPlay(container, vcodec, acodec) {
engineID := e.ID
return Resolution{Playable: true, Engine: &engineID}
}
}
// Nothing the client declared can play it. Explain the blocker using the
// client's first *known* engine — its stated preference — falling back to
// html5 when the client named only engines we do not know.
ref, ok := Get(firstKnown(ids))
if !ok {
ref, _ = Get(HTML5ID)
}
reason := blocker(ref, container, vcodec, acodec)
return Resolution{
Playable: false,
Reason: reason,
Message: message(reason, container, vcodec, acodec),
}
}
// splitFacts unpacks the positional fileFacts slice, tolerating a short slice.
func splitFacts(fileFacts []string) (container, vcodec, acodec string) {
get := func(i int) string {
if i < len(fileFacts) {
return fileFacts[i]
}
return ""
}
return get(0), get(1), get(2)
}
// firstKnown returns the first ID the registry knows, or "" if none.
func firstKnown(ids []string) string {
for _, id := range ids {
if _, ok := Get(id); ok {
return id
}
}
return ""
}
// blocker names the first fact the reference engine cannot handle, in the order
// container, video codec, audio codec. The reason is machine-readable; the
// caller turns it into a human message.
func blocker(e Engine, container, vcodec, acodec string) string {
switch {
case container != "" && !contains(e.Containers, container):
return "container:" + strings.ToLower(strings.TrimPrefix(container, "."))
case vcodec != "" && !contains(e.VCodecs, vcodec):
return "vcodec:" + strings.ToLower(vcodec)
case acodec != "" && !contains(e.ACodecs, acodec):
return "acodec:" + strings.ToLower(acodec)
default:
// canPlay was false, so one of the checks above should have matched.
return "unsupported"
}
}
// message renders the human explanation for a non-playable file. It names the
// offending codec, never blames the user, and — when a registered native engine
// could play the file — points at it, because the browser is not the only way
// in and a native client may well be on hand.
func message(reason, container, vcodec, acodec string) string {
var lead string
switch {
case strings.HasPrefix(reason, "container:"):
lead = fmt.Sprintf("This file is a %s, which this browser cannot open directly.",
strings.ToUpper(strings.TrimPrefix(container, ".")))
case strings.HasPrefix(reason, "vcodec:"):
lead = fmt.Sprintf("This file uses %s video, which this browser cannot decode.",
strings.ToUpper(vcodec))
case strings.HasPrefix(reason, "acodec:"):
lead = fmt.Sprintf("This file uses %s audio, which this browser cannot decode.",
strings.ToUpper(acodec))
default:
lead = "This browser cannot play this file directly."
}
if native := nativeEngineFor(container, vcodec, acodec); native != "" {
lead += fmt.Sprintf(" A native player such as %s can play this.", native)
}
lead += " Vellum does not transcode."
return lead
}
// nativeEngineFor returns the ID of a registered native engine that can play
// the file, or "" if none can. It is used only to make the message honest about
// the ways the file could still be played. mpv is named first because it is the
// engine Vellum's native client reaches for; the others follow.
func nativeEngineFor(container, vcodec, acodec string) string {
for _, id := range []string{"mpv", "vlc", "mdk"} {
if e, ok := Get(id); ok && e.Kind == KindNative && e.canPlay(container, vcodec, acodec) {
return e.ID
}
}
return ""
}

View file

@ -1,4 +1,4 @@
package media
package home
import (
"context"

View file

@ -1,31 +1,11 @@
package media
package home
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
type fixture struct {
db *sql.DB
libA int64

View file

@ -1,7 +1,21 @@
package media
// Package home computes what the landing screen shows: the five shelves and the
// counts the sidebar needs.
//
// It is separated from internal/media because the two answer different
// questions. Media serves item RESOURCES — one item, its file, its cover, its
// progress. Home computes AGGREGATES over many items: what you were in the
// middle of, what you keep opening, what to try next. Neither can be built from
// the other, and a change to a shelf query has no business touching the endpoint
// that streams a file.
//
// It depends on internal/catalog only for the shape of an item; the SQL is its
// own because the shelves are the only queries in the server that aggregate
// across libraries, kinds and users at once.
package home
import (
"database/sql"
"log/slog"
"net/http"
"strings"
@ -10,6 +24,23 @@ import (
"github.com/mithral/vellum/internal/httpx"
)
// Handlers serves the home screen.
type Handlers struct {
db *sql.DB
log *slog.Logger
auth *auth.Authenticator
}
// NewHandlers builds the home-screen HTTP layer.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{db: db, log: log, auth: a}
}
// Register mounts the routes.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome))
}
// shelfItem is one card on the home screen.
type shelfItem struct {
ID int64 `json:"id"`
@ -159,32 +190,9 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.Use
httpx.WriteJSON(w, http.StatusOK, resp)
}
// handleRecordView increments the user's view count for an item.
func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
views, err := RecordView(r.Context(), h.db, u.ID, it.ID)
if err != nil {
h.fail(w, "record view", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"item_id": it.ID, "views": views})
}
// handleAuthors returns the people credited on an item.
func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
authors, err := h.authors(r, it.ID)
if err != nil {
h.fail(w, "authors", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors})
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
h.log.Error(what, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
}
func scanShelf(rows *sql.Rows) ([]shelfItem, error) {

View file

@ -1,4 +1,4 @@
package media
package home
import (
"context"
@ -9,20 +9,37 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
// ── shared helpers ─────────────────────────────────────────────────────
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
func homeHandlers(t *testing.T, db *sql.DB) *Handlers {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewHandlers(db, log, nil, t.TempDir())
return NewHandlers(db, log, nil)
}
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "home.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
@ -205,7 +222,7 @@ func getHome(t *testing.T, h *Handlers, u auth.User) (homeResponse, []byte) {
func TestHandleHomeShelvesAndCounts(t *testing.T) {
f := setupHome(t)
h := mediaHandlers(t, f.db)
h := homeHandlers(t, f.db)
resp, body := getHome(t, h, auth.User{ID: f.userA})
// The landing screen contract: all five shelves plus the sidebar counts.
@ -266,7 +283,7 @@ func TestHandleHomeShelvesAndCounts(t *testing.T) {
func TestHandleHomeGrantScoping(t *testing.T) {
f := setupHome(t)
h := mediaHandlers(t, f.db)
h := homeHandlers(t, f.db)
// userA holds a grant to libA only. Nothing from libB may surface anywhere.
resp, _ := getHome(t, h, auth.User{ID: f.userA})
@ -304,7 +321,7 @@ func TestHandleHomeGrantScoping(t *testing.T) {
func TestHandleHomeIsolatesUsers(t *testing.T) {
f := setupHome(t)
h := mediaHandlers(t, f.db)
h := homeHandlers(t, f.db)
resp, _ := getHome(t, h, auth.User{ID: f.userB})
if got := ids(resp.Continue); len(got) != 1 || got[0] != f.b1 {

View file

@ -41,7 +41,12 @@ func findAsset(t *testing.T, suffix string) string {
t.Fatalf("walk dist: %v", err)
}
if found == "" {
t.Fatalf("no embedded asset ending in %q", suffix)
// A fresh clone has no built frontend: web/dist holds only .gitkeep, so
// the Go embed compiles and the server logs "web UI not embedded". The
// gzip behaviour cannot be exercised without a real asset, so skip
// rather than fail — the suite must pass on a clone that has not run
// `pnpm build`.
t.Skipf("no embedded asset ending in %q; run `pnpm build` in web/ to exercise this test", suffix)
}
return found
}
@ -54,7 +59,10 @@ func readAsset(t *testing.T, name string) []byte {
}
b, err := fs.ReadFile(sub, name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
// Same reason as findAsset: without `pnpm build` the only thing in dist
// is .gitkeep, so a test that reads a real asset cannot run. Skip so the
// suite is green on a fresh clone.
t.Skipf("embedded %s not present; run `pnpm build` in web/ to exercise this test", name)
}
return b
}

89
internal/jellyfin/auth.go Normal file
View file

@ -0,0 +1,89 @@
package jellyfin
import (
"net/http"
"strings"
"github.com/mithral/vellum/internal/apitoken"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
)
// require resolves the caller to an auth.User from a Jellyfin credential and
// hands them to fn. It is the Jellyfin twin of auth.Authenticator.Require:
// the same shape, but token-based.
//
// On any failure it has already written a 401. Note the deliberate difference
// from the web layer: a Jellyfin token resolves to the same auth.User, and
// every handler below still filters through VisibleLibraryIDs /
// CanAccessLibrary, so a token can never see another user's libraries.
func (h *Handlers) require(fn func(http.ResponseWriter, *http.Request, auth.User)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := h.currentUser(r)
if !ok {
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
return
}
fn(w, r, u)
})
}
// currentUser resolves the request's Jellyfin credential through
// apitoken.Resolve and loads the owning user.
func (h *Handlers) currentUser(r *http.Request) (auth.User, bool) {
secret := tokenFromRequest(r)
if secret == "" {
return auth.User{}, false
}
userID, err := apitoken.Resolve(r.Context(), h.db, secret)
if err != nil {
// ErrNotFound and any storage error both mean "not authenticated".
return auth.User{}, false
}
u, err := auth.GetUserByID(r.Context(), h.db, userID)
if err != nil {
return auth.User{}, false
}
return u, true
}
// tokenFromRequest extracts the credential from the three places Jellyfin
// clients put it, in order of reliability:
//
// - X-Emby-Token header (modern clients)
// - api_key query parameter (media elements cannot set headers)
// - Authorization: MediaBrowser Token="..." (older clients)
//
// X-MediaBrowser-Token is also accepted; some clients send it.
func tokenFromRequest(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get("X-Emby-Token")); v != "" {
return v
}
if v := strings.TrimSpace(r.Header.Get("X-MediaBrowser-Token")); v != "" {
return v
}
if v := strings.TrimSpace(r.URL.Query().Get("api_key")); v != "" {
return v
}
return mediaBrowserToken(r.Header.Get("Authorization"))
}
// mediaBrowserToken parses an Authorization header of the form
//
// MediaBrowser Token="abc", UserId="...", Client="...", Device="..."
//
// The token may be quoted or bare, and the parameter list may be comma- or
// space-separated in the wild.
func mediaBrowserToken(header string) string {
if header == "" {
return ""
}
// Normalise commas to spaces so both separators split the same way.
fields := strings.Fields(strings.ReplaceAll(header, ",", " "))
for _, f := range fields {
if v, ok := strings.CutPrefix(f, "Token="); ok {
return strings.Trim(v, `"`)
}
}
return ""
}

View file

@ -0,0 +1,405 @@
// Package dto is the Jellyfin wire contract: the request and response shapes
// real clients (the web app, iOS, Android, Roku, TV) send and expect, plus the
// pure functions that build them from Vellum's own types.
//
// It is deliberately separate from internal/jellyfin, which owns the HTTP
// handlers and the queries. A DTO here knows nothing about a request, a
// response writer or a database row: it maps data structures. That is what
// makes the contract reviewable on its own — the field names below are the ones
// clients are coded against, and a rename in here silently breaks an app that
// nobody in this repository can see failing.
//
// Every JSON tag below is load-bearing. Do not rename one without reading the
// Jellyfin OpenAPI first.
package dto
import (
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/library"
)
// This file defines a documented SUBSET of the Jellyfin DTOs. Jellyfin's
// BaseItemDto has roughly a hundred fields; real clients tolerate the absence
// of most of them, and the ones below are what browse and playback actually
// consume. Every struct is PascalCase on the wire because that is the Jellyfin
// convention (the web app's own API is snake_case and is untouched).
// UserDto is the subset of the Jellyfin user shape clients read.
type UserDto struct {
Name string `json:"Name"`
ServerId string `json:"ServerId,omitempty"`
Id string `json:"Id"`
HasPassword bool `json:"HasPassword"`
HasConfiguredPassword bool `json:"HasConfiguredPassword"`
Policy *UserPolicy `json:"Policy,omitempty"`
Configuration *UserConfig `json:"Configuration,omitempty"`
}
// UserPolicy carries the two things a client needs from a user's policy: the
// admin flag and which library folders the account can see. EnabledFolders is
// the user's visible library ids rendered as Jellyfin ids, so a client can hide
// views it is not allowed to open.
type UserPolicy struct {
IsAdministrator bool `json:"IsAdministrator"`
IsDisabled bool `json:"IsDisabled"`
EnableAllFolders bool `json:"EnableAllFolders"`
EnabledFolders []string `json:"EnabledFolders"`
EnableMediaPlayback bool `json:"EnableMediaPlayback"`
EnableContentDownloading bool `json:"EnableContentDownloading"`
}
// UserConfig is a near-empty subset; clients only require the key to exist.
type UserConfig struct {
EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
}
// UserItemDataDto is per-user state for one item.
//
// Ticks are 100ns units. Vellum stores milliseconds, so the conversion is
// x10000 (ms -> ticks) and /10000 (ticks -> ms).
type UserItemDataDto struct {
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
PlayedPercentage float64 `json:"PlayedPercentage,omitempty"`
PlayCount int `json:"PlayCount"`
IsFavorite bool `json:"IsFavorite"`
Played bool `json:"Played"`
}
// BaseItemDto is the browse shape. Type is the Jellyfin item type
// ("Movie"/"Audio"/"Book"/"Photo"/"CollectionFolder"/"Folder"), mapped from
// Vellum's media kind.
type BaseItemDto struct {
Id string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
MediaType string `json:"MediaType,omitempty"`
CollectionType string `json:"CollectionType,omitempty"`
Container string `json:"Container,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
ProductionYear int `json:"ProductionYear,omitempty"`
ParentId string `json:"ParentId,omitempty"`
IsFolder bool `json:"IsFolder"`
SortName string `json:"SortName,omitempty"`
Path string `json:"Path,omitempty"`
ImageTags map[string]string `json:"ImageTags,omitempty"`
UserData *UserItemDataDto `json:"UserData,omitempty"`
MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"`
}
// MediaSourceInfo is one playable source. Vellum never transcodes, so
// SupportsTranscoding is always false and TranscodingUrl is never set.
type MediaSourceInfo struct {
Id string `json:"Id"`
Name string `json:"Name"`
Path string `json:"Path,omitempty"`
Container string `json:"Container,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
Size int64 `json:"Size,omitempty"`
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
SupportsDirectStream bool `json:"SupportsDirectStream"`
SupportsTranscoding bool `json:"SupportsTranscoding"`
DirectStreamUrl string `json:"DirectStreamUrl,omitempty"`
Protocol string `json:"Protocol"`
Type string `json:"Type"`
IsRemote bool `json:"IsRemote"`
MediaStreams []MediaStream `json:"MediaStreams,omitempty"`
}
// MediaStream describes one track inside a file, using the codec facts the
// scanner wrote from ffprobe.
type MediaStream struct {
Type string `json:"Type"` // "Video" | "Audio"
Codec string `json:"Codec,omitempty"`
Index int `json:"Index"`
IsDefault bool `json:"IsDefault"`
Width int `json:"Width,omitempty"`
Height int `json:"Height,omitempty"`
}
// PlaybackInfoResponse is the response to PlaybackInfo. ErrorCode is set only
// when the client demanded a transcode Vellum will not perform.
type PlaybackInfoResponse struct {
MediaSources []MediaSourceInfo `json:"MediaSources"`
PlaySessionId string `json:"PlaySessionId,omitempty"`
ErrorCode string `json:"ErrorCode,omitempty"`
}
// ItemQueryResult is the browse envelope.
type ItemQueryResult struct {
Items []BaseItemDto `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
StartIndex int `json:"StartIndex"`
}
// SystemInfoPublic is the discovery document clients fetch before they have a
// token. Only the four fields the task requires (plus a couple clients read).
type SystemInfoPublic struct {
Id string `json:"Id"`
ServerName string `json:"ServerName"`
Version string `json:"Version"`
ProductName string `json:"ProductName"`
OperatingSystem string `json:"OperatingSystem,omitempty"`
}
// Version is the Jellyfin API version string clients compare against. Vellum
// reports a conservative, widely-supported version rather than its own build
// number, because clients gate features on this value.
const Version = "10.8.10"
// AuthByNameRequest is the AuthenticateByName body. Jellyfin spells the
// password "Pw".
type AuthByNameRequest struct {
Username string `json:"Username"`
Pw string `json:"Pw"`
}
// PlayReport is the body of the three playback-reporting endpoints. Jellyfin
// spells these fields in PascalCase and sends PositionTicks in 100ns units.
type PlayReport struct {
ItemId string `json:"ItemId"`
PositionTicks int64 `json:"PositionTicks"`
PlaySessionId string `json:"PlaySessionId"`
MediaSourceId string `json:"MediaSourceId"`
IsPaused bool `json:"IsPaused"`
}
// ItemView is one browsable item assembled for a Jellyfin DTO.
//
// It composes the two types internal/catalog owns — the item row and its first
// file — with the per-user overlay (progress, view count, favourite) and the
// two browse-only columns catalog.Item does not carry (sort title and parent).
// Nothing here reads the database or checks a grant: catalog.LoadFor loads the
// item and enforces the grant, and catalog.FirstFile reads the file, so there
// is exactly one implementation of "may this user read this item" and exactly
// one place item and file rows are read.
type ItemView struct {
Item catalog.Item
File catalog.File
// SortTitle is a browse-only column; the DTO maps it to SortName.
SortTitle string
// ParentID is a browse-only column mapped to ParentId. Zero means "no
// parent": item ids are positive, so zero is unambiguous.
ParentID int64
// HasCover is true when the scanner extracted a primary image.
HasCover bool
// Per-user state, zero when the user has never touched the item.
PositionMS int64
Percent float64
Finished bool
PlayCount int
Favorite bool
}
// ItemType maps a Vellum media kind to the Jellyfin item type. The four types
// the task requires are Movie, Audio, Book and Photo; anything Vellum adds
// later (games) degrades to Folder so a client can still show it.
func ItemType(kind string) string {
switch kind {
case "video":
return "Movie"
case "audiobooks", "music":
return "Audio"
case "photos":
return "Photo"
case "ebooks", "comics":
return "Book"
default:
return "Folder"
}
}
// MediaType maps a Vellum media kind to the Jellyfin MediaType. It is the
// coarser classification ("Video"/"Audio"/"Book"/"Photo").
func MediaType(kind string) string {
switch kind {
case "video":
return "Video"
case "audiobooks", "music":
return "Audio"
case "photos":
return "Photo"
case "ebooks", "comics":
return "Book"
default:
return ""
}
}
// CollectionType maps a Vellum kind to the Jellyfin collection type, which
// tells a client how to render the folder.
func CollectionType(kind string) string {
switch kind {
case "video":
return "movies"
case "audiobooks", "music":
return "music"
case "photos":
return "homevideos"
case "ebooks", "comics":
return "books"
default:
return ""
}
}
// KindsForTypes maps requested Jellyfin IncludeItemTypes back to Vellum kinds,
// so a client asking for Movies does not receive ebooks. An unknown type maps
// to nothing rather than to everything.
func KindsForTypes(types []string) []string {
var out []string
for _, t := range types {
switch t {
case "Movie":
out = append(out, "video")
case "Audio":
out = append(out, "audiobooks", "music")
case "Book":
out = append(out, "ebooks", "comics")
case "Photo":
out = append(out, "photos")
}
}
return out
}
// ToBaseItemDto renders one ItemView. includeMediaSources is set from the
// Fields query parameter; lists normally omit sources to keep responses small.
func ToBaseItemDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto {
dto := BaseItemDto{
Id: ItemID(v.Item.ID),
Name: v.Item.Title,
Type: ItemType(v.Item.Kind),
MediaType: MediaType(v.Item.Kind),
Container: v.File.Container,
RunTimeTicks: MsToTicks(v.File.DurationMS),
ProductionYear: v.Item.Year,
IsFolder: false,
SortName: v.SortTitle,
Path: v.File.Path,
}
if v.ParentID > 0 {
dto.ParentId = ItemID(v.ParentID)
}
if v.HasCover {
// The tag is the item id: stable, and it changes when the item changes.
dto.ImageTags = map[string]string{"Primary": ItemID(v.Item.ID)}
}
dto.UserData = &UserItemDataDto{
PlaybackPositionTicks: MsToTicks(v.PositionMS),
PlayedPercentage: v.Percent * 100,
PlayCount: v.PlayCount,
IsFavorite: v.Favorite,
Played: v.Finished,
}
if includeMediaSources {
dto.MediaSources = []MediaSourceInfo{BuildMediaSource(v.Item, v.File, apiKey)}
}
return dto
}
// BuildMediaSource renders the single direct-play source for an item. It is
// shared by PlaybackInfo and by list responses that requested the MediaSources
// field. Both inputs come from internal/catalog; a zero File is an item with no
// file, and produces the same defaults a missing file always did.
func BuildMediaSource(it catalog.Item, f catalog.File, apiKey string) MediaSourceInfo {
ms := MediaSourceInfo{
Id: ItemID(it.ID),
Name: it.Title,
Path: f.Path,
Container: f.Container,
RunTimeTicks: MsToTicks(f.DurationMS),
Size: f.Size,
SupportsDirectPlay: true,
SupportsDirectStream: true,
// The hard constraint: no transcoding, ever.
SupportsTranscoding: false,
Protocol: "File",
Type: "Default",
IsRemote: false,
}
ms.DirectStreamUrl = directStreamURL(it, apiKey)
if f.VCodec != "" {
ms.MediaStreams = append(ms.MediaStreams, MediaStream{
Type: "Video", Codec: f.VCodec, Index: 0, IsDefault: true,
Width: f.Width, Height: f.Height,
})
}
if f.ACodec != "" {
ms.MediaStreams = append(ms.MediaStreams, MediaStream{
Type: "Audio", Codec: f.ACodec, Index: 1, IsDefault: true,
})
}
return ms
}
// LibraryDto renders a library as a Jellyfin folder.
func LibraryDto(lib library.Library) BaseItemDto {
return BaseItemDto{
Id: LibraryID(lib.ID),
Name: lib.Name,
Type: "CollectionFolder",
CollectionType: CollectionType(lib.Kind),
IsFolder: true,
SortName: lib.Name,
}
}
// User renders the Jellyfin user shape, including the visible library folders
// so a client can scope its views. visibleLibIDs are the caller's granted
// library ids; this function only renders them, it does not look them up.
func User(u auth.User, serverID string, visibleLibIDs []int64) UserDto {
folders := make([]string, 0, len(visibleLibIDs))
for _, id := range visibleLibIDs {
folders = append(folders, LibraryID(id))
}
return UserDto{
Name: displayName(u),
ServerId: serverID,
Id: ItemID(u.ID),
HasPassword: u.PasswordHash != "",
HasConfiguredPassword: u.PasswordHash != "",
Policy: &UserPolicy{
IsAdministrator: u.IsAdmin,
IsDisabled: false,
EnableAllFolders: u.IsAdmin,
EnabledFolders: folders,
EnableMediaPlayback: true,
EnableContentDownloading: true,
},
Configuration: &UserConfig{},
}
}
// displayName falls back to the username when no display name is set.
func displayName(u auth.User) string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.Username
}
// directStreamURL builds the URL a client should play. Video items stream from
// /Videos, everything else (audio, books, photos) from /Audio, which is the
// same file-serving path either way. The api_key is embedded because media
// elements cannot set headers.
func directStreamURL(it catalog.Item, apiKey string) string {
base := "/Audio/" + ItemID(it.ID) + "/stream"
if it.Kind == "video" {
base = "/Videos/" + ItemID(it.ID) + "/stream"
}
if apiKey == "" {
return base
}
return base + "?api_key=" + apiKey
}
// MsToTicks converts Vellum's milliseconds to Jellyfin's 100ns ticks.
func MsToTicks(ms int64) int64 { return ms * 10000 }
// TicksToMS converts Jellyfin's 100ns ticks to Vellum's milliseconds.
func TicksToMS(ticks int64) int64 { return ticks / 10000 }

View file

@ -0,0 +1,362 @@
package dto
import (
"encoding/json"
"github.com/mithral/vellum/internal/library"
"slices"
"testing"
"github.com/mithral/vellum/internal/catalog"
)
// These tests pin the WIRE contract. Real Jellyfin apps parse this JSON and
// there is no client in this repository that would fail if a field name
// changed, so every test here compares against a literal, not against a value
// recomputed by the same helpers the production code uses.
// jsonKeys returns the sorted top-level object keys of v, so a test can assert
// both the exact set and (with the golden tests) the exact rendering.
func jsonKeys(t *testing.T, v any) []string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal %s: %v", b, err)
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
slices.Sort(out)
return out
}
// sampleView is a fully populated ItemView: every optional field populated, so
// the golden JSON below contains every key BaseItemDto can emit.
func sampleView() ItemView {
return ItemView{
Item: catalog.Item{
ID: 42,
LibraryID: 7,
Kind: "video",
Path: "The Movie (2001).mp4",
Title: "The Movie (2001)",
Year: 2001,
},
File: catalog.File{
ID: 100,
Path: "The Movie (2001).mp4",
Size: 1000,
Container: "mp4",
DurationMS: 5000,
VCodec: "h264",
ACodec: "aac",
Width: 640,
Height: 360,
},
SortTitle: "the movie (2001)",
ParentID: 7,
HasCover: true,
PositionMS: 2500,
Percent: 0.5,
PlayCount: 3,
Favorite: true,
}
}
// TestToBaseItemDtoGoldenJSON pins every JSON field name and value of the
// browse DTO. RunTimeTicks is the load-bearing one: Jellyfin counts 100ns
// ticks and Vellum stores milliseconds, so 5000ms must be 50,000,000 ticks.
// Getting this wrong makes every client seek to the wrong offset.
func TestToBaseItemDtoGoldenJSON(t *testing.T) {
got, err := json.Marshal(ToBaseItemDto(sampleView(), false, ""))
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := `{"Id":"0000000000000000000000000000002a",` +
`"Name":"The Movie (2001)","Type":"Movie","MediaType":"Video",` +
`"Container":"mp4","RunTimeTicks":50000000,"ProductionYear":2001,` +
`"ParentId":"00000000000000000000000000000007","IsFolder":false,` +
`"SortName":"the movie (2001)","Path":"The Movie (2001).mp4",` +
`"ImageTags":{"Primary":"0000000000000000000000000000002a"},` +
`"UserData":{"PlaybackPositionTicks":25000000,"PlayedPercentage":50,` +
`"PlayCount":3,"IsFavorite":true,"Played":false}}`
if string(got) != want {
t.Errorf("BaseItemDto JSON changed.\n got: %s\nwant: %s", got, want)
}
}
// TestToBaseItemDtoExactKeys asserts the key set, so adding or dropping a field
// fails even if the golden above is regenerated.
func TestToBaseItemDtoExactKeys(t *testing.T) {
want := []string{
"Container", "Id", "ImageTags", "IsFolder", "MediaType", "Name",
"ParentId", "Path", "ProductionYear",
"RunTimeTicks", "SortName", "Type", "UserData",
}
got := jsonKeys(t, ToBaseItemDto(sampleView(), false, ""))
if !slices.Equal(got, want) {
t.Errorf("BaseItemDto keys = %v\nwant %v", got, want)
}
}
// TestToBaseItemDtoOmitsEmptyOptionals pins which keys are absent rather than
// zero-valued. A client that switches on the presence of MediaSources must not
// see an empty array where "not requested" is meant.
func TestToBaseItemDtoOmitsEmptyOptionals(t *testing.T) {
v := ItemView{Item: catalog.Item{ID: 1, Kind: "video", Title: "T"}}
got := jsonKeys(t, ToBaseItemDto(v, false, ""))
// MediaType is present because the kind is known; the omitted keys are the
// ones that are genuinely empty (Container, Path, ProductionYear,
// RunTimeTicks, ImageTags, ParentId).
want := []string{"Id", "IsFolder", "MediaType", "Name", "Type", "UserData"}
if !slices.Equal(got, want) {
t.Errorf("minimal keys = %v\nwant %v", got, want)
}
if keys := jsonKeys(t, ToBaseItemDto(v, true, "k")); !slices.Contains(keys, "MediaSources") {
t.Errorf("includeMediaSources=true must emit MediaSources; keys = %v", keys)
}
}
// TestToBaseItemDtoUserDataTicks pins the tick conversion on the per-user
// overlay: Vellum stores a resume position in ms, Jellyfin clients send and
// expect 100ns ticks.
func TestToBaseItemDtoUserDataTicks(t *testing.T) {
v := sampleView()
v.PositionMS = 1 // 1ms must be 10000 ticks, not 1
d := ToBaseItemDto(v, false, "")
if d.UserData.PlaybackPositionTicks != 10_000 {
t.Errorf("PlaybackPositionTicks = %d, want 10000", d.UserData.PlaybackPositionTicks)
}
if got := TicksToMS(d.UserData.PlaybackPositionTicks); got != 1 {
t.Errorf("TicksToMS round-trip = %d, want 1", got)
}
if d.RunTimeTicks != 50_000_000 {
t.Errorf("RunTimeTicks = %d, want 50000000", d.RunTimeTicks)
}
if MsToTicks(0) != 0 || MsToTicks(1) != 10_000 {
t.Errorf("MsToTicks broken: %d %d", MsToTicks(0), MsToTicks(1))
}
}
// TestToBaseItemDtoParentIDZero pins that ParentId is omitted, not sent as the
// zero id, when an item has no parent.
func TestToBaseItemDtoParentIDZero(t *testing.T) {
v := sampleView()
v.ParentID = 0
d := ToBaseItemDto(v, false, "")
if d.ParentId != "" {
t.Errorf("ParentId = %q, want empty for ParentID 0", d.ParentId)
}
if slices.Contains(jsonKeys(t, d), "ParentId") {
t.Error("ParentId key must be omitted when there is no parent")
}
}
// TestImageTagsOnlyWithCover: ImageTags is what makes a client fetch
// /Images/Primary. Emitting it for an item with no artwork produces a request
// that 404s on every browse.
func TestImageTagsOnlyWithCover(t *testing.T) {
v := sampleView()
v.HasCover = true
d := ToBaseItemDto(v, false, "")
want := map[string]string{"Primary": ItemID(42)}
if !jsonEq(want, d.ImageTags) {
t.Errorf("ImageTags = %v, want %v", d.ImageTags, want)
}
v.HasCover = false
d = ToBaseItemDto(v, false, "")
if d.ImageTags != nil {
t.Errorf("ImageTags = %v, want nil without a cover", d.ImageTags)
}
if slices.Contains(jsonKeys(t, d), "ImageTags") {
t.Error("ImageTags key must be absent without a cover")
}
}
// TestMediaSourceNeverTranscodes is the contract that matters most on a
// headless, GPU-less host: Vellum cannot transcode, and a client told
// transcoding is available will wait forever for a stream that never starts.
// The refusal happens in the handler (ErrorCode), but the source it is built
// from must never advertise transcoding.
func TestMediaSourceNeverTranscodes(t *testing.T) {
ms := BuildMediaSource(sampleView().Item, sampleView().File, "tok123")
if ms.SupportsTranscoding {
t.Error("SupportsTranscoding = true: Vellum never transcodes")
}
if !ms.SupportsDirectPlay {
t.Error("SupportsDirectPlay = false, want true")
}
if !ms.SupportsDirectStream {
t.Error("SupportsDirectStream = false, want true")
}
if ms.Protocol != "File" || ms.Type != "Default" || ms.IsRemote {
t.Errorf("protocol/type/remote = %q/%q/%v, want File/Default/false",
ms.Protocol, ms.Type, ms.IsRemote)
}
}
// TestDirectStreamURLCarriesAPIKey: media elements cannot set request headers,
// so the token has to be in the URL or playback fails with a 401 mid-stream.
// This is the single most common Jellyfin integration break.
func TestDirectStreamURLCarriesAPIKey(t *testing.T) {
item := catalog.Item{ID: 42, Kind: "video", Title: "T"}
if got, want := BuildMediaSource(item, catalog.File{}, "abc123").DirectStreamUrl,
"/Videos/"+ItemID(42)+"/stream?api_key=abc123"; got != want {
t.Errorf("video DirectStreamUrl = %q, want %q", got, want)
}
item.Kind = "audiobooks"
if got, want := BuildMediaSource(item, catalog.File{}, "abc123").DirectStreamUrl,
"/Audio/"+ItemID(42)+"/stream?api_key=abc123"; got != want {
t.Errorf("non-video DirectStreamUrl = %q, want %q", got, want)
}
item.Kind = "video"
if got, want := BuildMediaSource(item, catalog.File{}, "").DirectStreamUrl,
"/Videos/"+ItemID(42)+"/stream"; got != want {
t.Errorf("no-key DirectStreamUrl = %q, want %q", got, want)
}
}
// TestMediaStreamsFromProbeFacts: the codec facts are what let a CLIENT decide
// whether it can direct-play, which matters precisely because Vellum will not
// help it otherwise.
func TestMediaStreamsFromProbeFacts(t *testing.T) {
ms := BuildMediaSource(sampleView().Item, sampleView().File, "")
if len(ms.MediaStreams) != 2 {
t.Fatalf("MediaStreams = %d, want 2", len(ms.MediaStreams))
}
if ms.MediaStreams[0] != (MediaStream{Type: "Video", Codec: "h264", Index: 0, IsDefault: true, Width: 640, Height: 360}) {
t.Errorf("video stream = %+v", ms.MediaStreams[0])
}
if ms.MediaStreams[1] != (MediaStream{Type: "Audio", Codec: "aac", Index: 1, IsDefault: true}) {
t.Errorf("audio stream = %+v", ms.MediaStreams[1])
}
// A file with no probed codecs yields no streams rather than empty entries.
ms = BuildMediaSource(catalog.Item{ID: 1}, catalog.File{Container: "mp3"}, "")
if ms.MediaStreams != nil {
t.Errorf("MediaStreams = %v, want nil without probe facts", ms.MediaStreams)
}
if ms.Size != 0 || ms.RunTimeTicks != 0 {
t.Errorf("zero File must not invent facts: %+v", ms)
}
}
// TestMediaSourceGoldenJSON pins the source shape clients parse.
func TestMediaSourceGoldenJSON(t *testing.T) {
v := sampleView()
got, err := json.Marshal(BuildMediaSource(v.Item, v.File, "abc123"))
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := `{"Id":"0000000000000000000000000000002a","Name":"The Movie (2001)",` +
`"Path":"The Movie (2001).mp4","Container":"mp4","RunTimeTicks":50000000,` +
`"Size":1000,"SupportsDirectPlay":true,"SupportsDirectStream":true,` +
`"SupportsTranscoding":false,` +
`"DirectStreamUrl":"/Videos/0000000000000000000000000000002a/stream?api_key=abc123",` +
`"Protocol":"File","Type":"Default","IsRemote":false,` +
`"MediaStreams":[` +
`{"Type":"Video","Codec":"h264","Index":0,"IsDefault":true,"Width":640,"Height":360},` +
`{"Type":"Audio","Codec":"aac","Index":1,"IsDefault":true}]}`
if string(got) != want {
t.Errorf("MediaSourceInfo JSON changed.\n got: %s\nwant: %s", got, want)
}
}
// TestKindMappings pins the kind-to-type table. A mis-mapped kind does not
// crash: the client just renders an empty library view.
func TestKindMappings(t *testing.T) {
cases := []struct{ kind, item, media, collection string }{
{"video", "Movie", "Video", "movies"},
{"audiobooks", "Audio", "Audio", "music"},
{"music", "Audio", "Audio", "music"},
{"photos", "Photo", "Photo", "homevideos"},
{"ebooks", "Book", "Book", "books"},
{"comics", "Book", "Book", "books"},
{"games", "Folder", "", ""},
{"", "Folder", "", ""},
}
for _, c := range cases {
if got := ItemType(c.kind); got != c.item {
t.Errorf("ItemType(%q) = %q, want %q", c.kind, got, c.item)
}
if got := MediaType(c.kind); got != c.media {
t.Errorf("MediaType(%q) = %q, want %q", c.kind, got, c.media)
}
if got := CollectionType(c.kind); got != c.collection {
t.Errorf("CollectionType(%q) = %q, want %q", c.kind, got, c.collection)
}
}
}
// TestKindsForTypes pins the inverse map, including that an unknown Jellyfin
// type maps to NOTHING rather than to everything: "show me everything I did not
// ask for" is a data leak, not a convenience.
func TestKindsForTypes(t *testing.T) {
if got := KindsForTypes(nil); got != nil {
t.Errorf("KindsForTypes(nil) = %v, want nil", got)
}
if got := KindsForTypes([]string{"Episode", "Nonsense"}); got != nil {
t.Errorf("unknown types = %v, want nil", got)
}
if got, want := KindsForTypes([]string{"Movie"}), []string{"video"}; !slices.Equal(got, want) {
t.Errorf("Movie = %v, want %v", got, want)
}
if got, want := KindsForTypes([]string{"Audio"}), []string{"audiobooks", "music"}; !slices.Equal(got, want) {
t.Errorf("Audio = %v, want %v", got, want)
}
}
// TestLibraryDto: a library is a folder, and its CollectionType is what tells a
// client which view to render. A library rendered without CollectionType shows
// up as an empty generic folder.
func TestLibraryDto(t *testing.T) {
d := LibraryDto(libraryFixture())
if d.Id != LibraryID(7) {
t.Errorf("Id = %q, want %q", d.Id, LibraryID(7))
}
if d.Type != "CollectionFolder" || !d.IsFolder {
t.Errorf("Type/IsFolder = %q/%v, want CollectionFolder/true", d.Type, d.IsFolder)
}
if d.CollectionType != "movies" || d.Name != "Movies" || d.SortName != "Movies" {
t.Errorf("library dto = %+v", d)
}
// A folder carries no media facts.
keys := jsonKeys(t, d)
for _, k := range []string{"Container", "RunTimeTicks", "MediaSources", "Path"} {
if slices.Contains(keys, k) {
t.Errorf("folder DTO must not carry %q; keys = %v", k, keys)
}
}
}
// jsonEq compares two string maps without depending on map iteration order.
// libraryFixture is the library the LibraryDto test maps. CollectionType comes
// from the library's kind, so "video" must render as a movies collection.
func libraryFixture() library.Library {
return library.Library{
ID: 7,
Name: "Movies",
Kind: "video",
RootPath: "/media/movies",
}
}
func jsonEq(want, got map[string]string) bool {
if len(want) != len(got) {
return false
}
for k, v := range want {
if got[k] != v {
return false
}
}
return true
}

View file

@ -0,0 +1,51 @@
package dto
import (
"fmt"
"strconv"
"strings"
)
// Jellyfin identifies every object with an opaque 32-character lowercase hex
// id (a GUID with the dashes removed). Vellum's ids are int64, and both
// libraries and items must appear as Jellyfin ids — including as a ParentId,
// which a client hands back verbatim. Rendering both as plain decimal would
// make ParentId ambiguous between a library and an item, so libraries get a
// leading 'f' marker. A positive int64 rendered as 32 hex digits always starts
// with 0-7, so 'f' can never collide with an item id.
//
// The mapping is stable and deterministic: the same Vellum row always yields
// the same Jellyfin id, which clients rely on for caching and favourites.
// ItemID renders an item's Jellyfin id.
func ItemID(id int64) string { return fmt.Sprintf("%032x", uint64(id)) }
// LibraryID renders a library's Jellyfin id. The leading 'f' namespaces it
// away from item ids.
func LibraryID(id int64) string { return fmt.Sprintf("f%031x", uint64(id)) }
// DecodeID reverses ItemID/LibraryID. isLibrary reports which namespace the id
// came from; ok is false for anything that is not one of our 32-char hex ids.
// Input is lowercased first, because clients sometimes normalise case.
func DecodeID(s string) (id int64, isLibrary, ok bool) {
s = strings.ToLower(strings.TrimSpace(s))
if len(s) != 32 {
return 0, false, false
}
if s[0] == 'f' {
n, err := strconv.ParseUint(s[1:], 16, 64)
if err != nil {
return 0, false, false
}
return int64(n), true, true
}
n, err := strconv.ParseUint(s, 16, 64)
if err != nil {
return 0, false, false
}
// Item ids are positive int64; a value with the top bit set is malformed.
if n > 1<<63-1 {
return 0, false, false
}
return int64(n), false, true
}

263
internal/jellyfin/items.go Normal file
View file

@ -0,0 +1,263 @@
package jellyfin
import (
"context"
"database/sql"
"errors"
"net/http"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/jellyfin/dto"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/stream"
)
// handleItems is the browse workhorse for GET /Items. The /Users/{userId}/Items
// form delegates here; the token's user is authoritative for scoping.
func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.User) {
ctx := r.Context()
q := r.URL.Query()
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
if err != nil {
h.fail(w, "visible libraries", err)
return
}
if libs == nil {
libs = []int64{}
}
startIndex := atoiDefault(q.Get("StartIndex"), 0)
if startIndex < 0 {
startIndex = 0
}
limit := atoiDefault(q.Get("Limit"), defaultLimit)
if limit <= 0 || limit > maxLimit {
limit = maxLimit
}
parentRaw := strings.TrimSpace(q.Get("ParentId"))
recursive := parseBool(q.Get("Recursive"))
search := strings.TrimSpace(q.Get("SearchTerm"))
// Top level: no parent, not recursive, no search. Real clients expect the
// user's views here, so the visible libraries are returned as
// CollectionFolder items to drill into.
if parentRaw == "" && !recursive && search == "" {
h.writeViews(w, ctx, libs, startIndex, limit)
return
}
// A user with no grants gets an empty page, never an error and never
// content from another library.
if len(libs) == 0 {
writeItems(w, startIndex, 0, nil)
return
}
query := listQuery{
userID: u.ID,
libraryIDs: libs,
sortColumn: sortColumn(q.Get("SortBy")),
descending: isDescending(q.Get("SortOrder")),
limit: limit,
offset: startIndex,
searchTerm: search,
filters: splitCSV(q.Get("Filters")),
kinds: dto.KindsForTypes(splitCSV(q.Get("IncludeItemTypes"))),
includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"),
}
if parentRaw != "" {
pid, isLib, ok := dto.DecodeID(parentRaw)
if !ok {
// An unknown ParentId is an empty page, not an error: clients probe
// ids and expect to be told there is nothing there.
writeItems(w, startIndex, 0, nil)
return
}
if isLib {
if !containsInt64(libs, pid) {
writeItems(w, startIndex, 0, nil)
return
}
query.libraryIDs = []int64{pid}
} else {
// Children of an item, and only if that item is visible.
visible, err := h.itemVisible(ctx, u, pid)
if err != nil {
h.fail(w, "check parent", err)
return
}
if !visible {
writeItems(w, startIndex, 0, nil)
return
}
query.parentItemID = pid
}
}
items, total, err := h.queryItems(ctx, query)
if err != nil {
h.fail(w, "list items", err)
return
}
apiKey := tokenFromRequest(r)
dtos := make([]dto.BaseItemDto, 0, len(items))
for _, it := range items {
dtos = append(dtos, dto.ToBaseItemDto(it, query.includeSources, apiKey))
}
writeItems(w, startIndex, total, dtos)
}
// handleUserItems serves GET /Users/{userId}/Items. The token's user is
// authoritative; a mismatched path id is a 404 (cross-user reads are not
// implemented — see the package notes).
func (h *Handlers) handleUserItems(w http.ResponseWriter, r *http.Request, u auth.User) {
id, _, ok := dto.DecodeID(r.PathValue("userId"))
if !ok || id != u.ID {
httpx.WriteError(w, http.StatusNotFound, "user not found")
return
}
h.handleItems(w, r, u)
}
// handleItem serves one BaseItemDto. A library id is answered as a folder so a
// client that treats views as items still works.
func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) {
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
if !ok {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
if isLib {
lib, err := library.Get(r.Context(), h.db, id)
if errors.Is(err, library.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
if err != nil {
h.fail(w, "load library", err)
return
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, id)
if err != nil {
h.fail(w, "check access", err)
return
}
if !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
httpx.WriteJSON(w, http.StatusOK, dto.LibraryDto(lib))
return
}
it, ok := h.authorizedItem(w, r, u, id)
if !ok {
return
}
v := dto.ItemView{Item: it}
if err := h.attachFile(r.Context(), &v); err != nil {
h.fail(w, "load file", err)
return
}
if err := h.loadOverlay(r.Context(), u.ID, it.ID, &v); err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
h.fail(w, "load item state", err)
return
}
httpx.WriteJSON(w, http.StatusOK, dto.ToBaseItemDto(v, true, tokenFromRequest(r)))
}
// handleImagePrimary streams the cover bytes the scanner/web already extracted.
//
// It does not trigger extraction: NewHandlers has no cache directory, and the
// extraction path belongs to the web layer. An item whose cover has not been
// extracted yet simply 404s, which clients treat as "no artwork".
func (h *Handlers) handleImagePrimary(w http.ResponseWriter, r *http.Request, u auth.User) {
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
if !ok || isLib {
httpx.WriteError(w, http.StatusNotFound, "no cover available")
return
}
if _, ok := h.authorizedItem(w, r, u, id); !ok {
return
}
var path string
err := h.db.QueryRowContext(r.Context(),
`SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, id).Scan(&path)
if errors.Is(err, sql.ErrNoRows) {
httpx.WriteError(w, http.StatusNotFound, "no cover available")
return
}
if err != nil {
h.fail(w, "load cover", err)
return
}
w.Header().Set("Cache-Control", "public, max-age=86400")
if err := stream.ServeFile(w, r, path); err != nil {
httpx.WriteError(w, http.StatusNotFound, "no cover available")
return
}
}
// authorizedItem loads an item and confirms access, writing a 404 (never a 403,
// so existence is not disclosed) on failure. Both the load and the grant check
// live in catalog.LoadFor, which returns the same ErrNotFound for a missing
// item and an unreadable one.
func (h *Handlers) authorizedItem(w http.ResponseWriter, r *http.Request, u auth.User, id int64) (catalog.Item, bool) {
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return catalog.Item{}, false
}
if err != nil {
h.fail(w, "load item", err)
return catalog.Item{}, false
}
return it, true
}
// writeViews returns the caller's visible libraries as CollectionFolder items,
// paginated like any other browse result.
func (h *Handlers) writeViews(w http.ResponseWriter, ctx context.Context, libs []int64, startIndex, limit int) {
list, err := library.ListVisible(ctx, h.db, libs)
if err != nil {
h.fail(w, "list libraries", err)
return
}
total := len(list)
if startIndex > total {
startIndex = total
}
end := startIndex + limit
if end > total {
end = total
}
items := make([]dto.BaseItemDto, 0, end-startIndex)
for _, lib := range list[startIndex:end] {
items = append(items, dto.LibraryDto(lib))
}
writeItems(w, startIndex, total, items)
}
func writeItems(w http.ResponseWriter, startIndex, total int, items []dto.BaseItemDto) {
if items == nil {
items = []dto.BaseItemDto{}
}
httpx.WriteJSON(w, http.StatusOK, dto.ItemQueryResult{
Items: items,
TotalRecordCount: total,
StartIndex: startIndex,
})
}

View file

@ -0,0 +1,157 @@
// Package jellyfin translates Vellum's existing storage into the subset of the
// Jellyfin HTTP API that real clients (iOS, Android, Roku, TV apps) use to
// browse and play.
//
// It is a translation layer, not a second media server. Every query goes
// through the existing media_items / media_files / progress / assets tables,
// and every request resolves to an auth.User through the same grant chokepoint
// the web app uses (auth.VisibleLibraryIDs / auth.CanAccessLibrary). A Jellyfin
// token minted here is an apitoken.KindJellyfin token, so revoking it in the
// web UI revokes Jellyfin access too.
//
// # No transcoding
//
// Vellum runs on a headless, GPU-less host. PlaybackInfo therefore honestly
// reports SupportsTranscoding=false and hands out a direct stream URL.
// A client that demands a transcode (it disabled both direct play and direct
// stream) is told so via PlaybackInfo's ErrorCode "NoCompatibleStream" rather
// than being handed a TranscodingUrl that would 404 or burn CPU the host does
// not have.
//
// # Root mounting
//
// The routes are mounted at the ROOT because that is where Jellyfin clients
// look (/System/Info/Public, /Users/AuthenticateByName, /Items, ...). They do
// not collide with the web app: internal/httpapi/static.go owns "/", "/healthz"
// and "/api/", and every Jellyfin path is a more specific ServeMux pattern, so
// requests route here first. isAPIPath in static.go does not know these
// prefixes, so each Jellyfin subtree installs its own JSON 404 handler; an
// unknown /Items/... path returns JSON rather than the SPA shell.
package jellyfin
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"log/slog"
"net/http"
"sync"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
)
// Handlers serves the Jellyfin-compatible subset.
type Handlers struct {
db *sql.DB
log *slog.Logger
// a is kept for parity with the other HTTP layers and for ClientIP, but
// token resolution is done here: the authenticator's middleware only knows
// session cookies, which Jellyfin clients do not send.
a *auth.Authenticator
mu sync.Mutex
serverID string
}
// NewHandlers builds the Jellyfin HTTP layer. It deliberately takes no cache
// directory: cover bytes are served from the assets table the scanner already
// populated, rather than triggering a fresh extraction.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{db: db, log: log, a: a}
}
// Register mounts the routes. See the package comment for why they live at the
// root and why each subtree has a JSON 404 fallback.
func (h *Handlers) Register(mux *http.ServeMux) {
// Public discovery.
mux.HandleFunc("GET /System/Info/Public", h.handleSystemInfoPublic)
mux.HandleFunc("POST /Users/AuthenticateByName", h.handleAuthenticateByName)
// Authenticated: users.
mux.Handle("GET /Users/Me", h.require(h.handleUserMe))
mux.Handle("GET /Users/{userId}", h.require(h.handleUserByID))
// Authenticated: browsing.
mux.Handle("GET /Items", h.require(h.handleItems))
mux.Handle("GET /Users/{userId}/Items", h.require(h.handleUserItems))
mux.Handle("GET /Items/{itemId}", h.require(h.handleItem))
mux.Handle("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary))
mux.Handle("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo))
// Media. These MUST accept api_key in the query string: <video>/<audio>
// elements cannot set request headers, so a Jellyfin client appends
// ?api_key=... to the URL it plays.
mux.Handle("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
mux.Handle("GET /Audio/{itemId}/stream", h.require(h.handleAudioStream))
// Real clients ask for stream.<container> (stream.mp4, stream.mkv,
// stream.mp3). ServeMux wildcards are whole segments only, so a suffix
// pattern is impossible; a subtree handler dispatches those instead. The
// exact /stream pattern above is more specific and still wins.
mux.Handle("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
mux.Handle("GET /Audio/{itemId}/", h.require(h.handleAudioStream))
// Playback reporting. This is how a Jellyfin client tells us its resume
// position; it is written through the existing progress store.
mux.Handle("POST /Sessions/Playing", h.require(h.handlePlaying))
mux.Handle("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
mux.Handle("POST /Sessions/Playing/Stopped", h.require(h.handlePlayingStopped))
// JSON 404s for the rest of each Jellyfin subtree. Without these an
// unknown /Items/... falls through to the SPA shell, because static.go's
// isAPIPath only recognises /healthz and /api/.
for _, prefix := range []string{
"/System/", "/Users/", "/Items/", "/Videos/", "/Audio/", "/Sessions/",
} {
mux.HandleFunc(prefix, h.handleNotFound)
}
}
// serverID returns a stable, per-installation server id, created on first use
// and stored in the existing settings table so it survives restarts. Jellyfin
// clients cache it for discovery and treat a changing id as a different server.
func (h *Handlers) serverIDFor(ctx context.Context) string {
h.mu.Lock()
defer h.mu.Unlock()
if h.serverID != "" {
return h.serverID
}
var v string
err := h.db.QueryRowContext(ctx,
`SELECT v FROM settings WHERE k = 'jellyfin.server_id'`).Scan(&v)
if err == nil && v != "" {
h.serverID = v
return v
}
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
// A random id is a nicety; fall back to a fixed one rather than fail
// a discovery request.
h.serverID = "vellum0000000000000000000000000000"
return h.serverID
}
v = hex.EncodeToString(raw[:])
_, _ = h.db.ExecContext(ctx,
`INSERT INTO settings (k, v) VALUES ('jellyfin.server_id', ?)
ON CONFLICT (k) DO NOTHING`, v)
// Re-read: a concurrent first request may have won the insert.
if err := h.db.QueryRowContext(ctx,
`SELECT v FROM settings WHERE k = 'jellyfin.server_id'`).Scan(&v); err != nil || v == "" {
v = "vellum0000000000000000000000000000"
}
h.serverID = v
return v
}
func (h *Handlers) handleNotFound(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusNotFound, "not found")
}
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
h.log.Error(what, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
}

View file

@ -0,0 +1,375 @@
package jellyfin
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mithral/vellum/internal/apitoken"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/jellyfin/dto"
"github.com/mithral/vellum/migrations"
)
// These tests exist because internal/jellyfin is the most client-visible surface
// in the tree — real Jellyfin apps parse its JSON — and it had no tests at all.
// The two that matter most are the grant check (a wrong one leaks another
// user's library) and the transcode flag (a client told transcoding is
// available waits forever for a stream that never starts).
type fixture struct {
srv *httptest.Server
db *sql.DB
itemID int64
fileID int64
libID int64
admin string // token
granted string // token, non-admin, granted the library
none string // token, non-admin, no grants
}
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "jellyfin.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func newFixture(t *testing.T) *fixture {
t.Helper()
db := testDB(t)
ctx := context.Background()
// A real file on disk: the stream handler opens it, so a row alone is not
// enough to prove the streaming path.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "movie.mp4"), []byte("0123456789abcdefghij"), 0o644); err != nil {
t.Fatalf("write media: %v", err)
}
libID := mustExec(t, db, `INSERT INTO libraries (kind, name, root_path) VALUES ('video', 'Movies', ?)`, root)
itemID := mustExec(t, db, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
VALUES (?, 'video', 'movie.mp4', 'A Movie', 'a movie', 1999)`, libID)
fileID := mustExec(t, db, `
INSERT INTO media_files (item_id, path, size, mtime, container, duration_ms,
vcodec, acodec, width, height)
VALUES (?, 'movie.mp4', 20, '1700000000000000000', 'mp4', 5000, 'h264', 'aac', 640, 360)`, itemID)
// Three users: an admin, a non-admin granted the library, and a non-admin
// with no grants at all.
hash, err := auth.HashPassword("pw")
if err != nil {
t.Fatalf("hash: %v", err)
}
adminID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('admin','Admin',?,1)`, hash)
grantedID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('granted','Granted',?,0)`, hash)
noneID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('none','None',?,0)`, hash)
mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read) VALUES (?, ?, 1)`, grantedID, libID)
token := func(userID int64) string {
secret, err := apitoken.Mint(ctx, db, userID, apitoken.KindJellyfin, "test")
if err != nil {
t.Fatalf("mint: %v", err)
}
return secret
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
authn := auth.NewAuthenticator(db, "vellum_session", time.Hour, false, log)
mux := http.NewServeMux()
NewHandlers(db, log, authn).Register(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return &fixture{
srv: srv, db: db, itemID: itemID, fileID: fileID, libID: libID,
admin: token(adminID), granted: token(grantedID), none: token(noneID),
}
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) int64 {
t.Helper()
res, err := db.Exec(q, args...)
if err != nil {
t.Fatalf("exec %s: %v", q, err)
}
id, _ := res.LastInsertId()
return id
}
// do issues a request with an optional token supplied the given way.
func (f *fixture) do(t *testing.T, method, path, headerName, token string) (*http.Response, string) {
t.Helper()
req, err := http.NewRequest(method, f.srv.URL+path, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
if headerName != "" && token != "" {
if headerName == "query" {
req.URL.RawQuery = "api_key=" + token
} else {
req.Header.Set(headerName, token)
}
}
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp, string(body)
}
// ── auth surfaces ───────────────────────────────────────────────────────
// All three credential forms must work. The query form matters most: <video>
// and <audio> elements cannot set headers, so a client that can only use
// ?api_key= must still authenticate.
func TestAuthAcceptsEveryCredentialForm(t *testing.T) {
f := newFixture(t)
for _, tc := range []struct{ name, header, token string }{
{"X-Emby-Token", "X-Emby-Token", f.admin},
{"X-MediaBrowser-Token", "X-MediaBrowser-Token", f.admin},
{"api_key query", "query", f.admin},
{"MediaBrowser header", "Authorization", `MediaBrowser Token="` + f.admin + `"`},
} {
resp, _ := f.do(t, "GET", "/Users/Me", tc.header, tc.token)
if resp.StatusCode != http.StatusOK {
t.Errorf("%s: status = %d, want 200", tc.name, resp.StatusCode)
}
}
}
func TestAuthRejectsMissingAndBadTokens(t *testing.T) {
f := newFixture(t)
for _, tc := range []struct{ name, header, token string }{
{"no token", "", ""},
{"garbage token", "X-Emby-Token", "deadbeef"},
} {
resp, _ := f.do(t, "GET", "/Users/Me", tc.header, tc.token)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s: status = %d, want 401", tc.name, resp.StatusCode)
}
}
}
// ── the security property ───────────────────────────────────────────────
// A user with no grants must see nothing, and an item they cannot read must be
// INDISTINGUISHABLE from one that does not exist. A 403 would confirm the
// library is there, which leaks its existence.
func TestGrantScopingIsIndistinguishableFromAbsent(t *testing.T) {
f := newFixture(t)
// Empty listing for a user with no grants.
resp, body := f.do(t, "GET", "/Items?Recursive=true", "X-Emby-Token", f.none)
if resp.StatusCode != http.StatusOK {
t.Fatalf("no-grant /Items status = %d", resp.StatusCode)
}
var listing struct{ TotalRecordCount int }
if err := json.Unmarshal([]byte(body), &listing); err != nil {
t.Fatalf("decode listing: %v (body %s)", err, body)
}
if listing.TotalRecordCount != 0 {
t.Errorf("no-grant user sees %d items, want 0", listing.TotalRecordCount)
}
// Forbidden item.
unreadable := dto.ItemID(f.itemID)
respForbidden, bodyForbidden := f.do(t, "GET", "/Items/"+unreadable, "X-Emby-Token", f.none)
// Absent item.
respAbsent, bodyAbsent := f.do(t, "GET", "/Items/"+dto.ItemID(999999), "X-Emby-Token", f.none)
if respForbidden.StatusCode != http.StatusNotFound {
t.Errorf("unreadable item status = %d, want 404", respForbidden.StatusCode)
}
if respForbidden.StatusCode != respAbsent.StatusCode {
t.Errorf("unreadable=%d absent=%d: the difference leaks existence",
respForbidden.StatusCode, respAbsent.StatusCode)
}
if bodyForbidden != bodyAbsent {
t.Errorf("bodies differ and leak existence:\n forbidden=%s\n absent=%s",
bodyForbidden, bodyAbsent)
}
// Positive control: the granted user does get it, and so does the admin.
for _, tc := range []struct{ name, token string }{{"granted", f.granted}, {"admin", f.admin}} {
resp, _ := f.do(t, "GET", "/Items/"+unreadable, "X-Emby-Token", tc.token)
if resp.StatusCode != http.StatusOK {
t.Errorf("%s user status = %d, want 200", tc.name, resp.StatusCode)
}
}
}
// ── the transcode refusal ───────────────────────────────────────────────
// Vellum never transcodes. A client that disables BOTH direct play and direct
// stream is asking for a transcode, and must be told so rather than handed a
// TranscodingUrl that will never resolve.
func TestTranscodeDemandIsRefused(t *testing.T) {
f := newFixture(t)
id := dto.ItemID(f.itemID)
resp, body := f.do(t, "GET",
"/Items/"+id+"/PlaybackInfo?EnableDirectPlay=false&EnableDirectStream=false",
"X-Emby-Token", f.admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var pi struct {
ErrorCode string `json:"ErrorCode"`
MediaSources []struct {
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
SupportsDirectStream bool `json:"SupportsDirectStream"`
SupportsTranscoding bool `json:"SupportsTranscoding"`
} `json:"MediaSources"`
}
if err := json.Unmarshal([]byte(body), &pi); err != nil {
t.Fatalf("decode: %v (body %s)", err, body)
}
if pi.ErrorCode != "NoCompatibleStream" {
t.Errorf("ErrorCode = %q, want NoCompatibleStream", pi.ErrorCode)
}
// And the normal case must advertise direct play with transcoding OFF.
resp, body = f.do(t, "GET", "/Items/"+id+"/PlaybackInfo", "X-Emby-Token", f.admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("playbackinfo status = %d", resp.StatusCode)
}
if err := json.Unmarshal([]byte(body), &pi); err != nil {
t.Fatalf("decode: %v", err)
}
if len(pi.MediaSources) == 0 {
t.Fatal("no MediaSources")
}
ms := pi.MediaSources[0]
if !ms.SupportsDirectPlay || !ms.SupportsDirectStream {
t.Errorf("direct play/stream = %v/%v, want true/true", ms.SupportsDirectPlay, ms.SupportsDirectStream)
}
if ms.SupportsTranscoding {
t.Error("SupportsTranscoding is TRUE; this server cannot transcode and a client will wait forever")
}
}
// ── streaming ───────────────────────────────────────────────────────────
// The stream must work with ONLY ?api_key= (media elements cannot set headers)
// and must honour Range, or seeking breaks.
func TestStreamWithAPIKeyHonoursRange(t *testing.T) {
f := newFixture(t)
req, err := http.NewRequest("GET", f.srv.URL+"/Videos/"+dto.ItemID(f.itemID)+"/stream?api_key="+f.admin, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Range", "bytes=0-9")
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", resp.StatusCode)
}
if cr := resp.Header.Get("Content-Range"); cr == "" {
t.Error("no Content-Range on a 206")
}
got, _ := io.ReadAll(resp.Body)
if string(got) != "0123456789" {
t.Errorf("body = %q, want the first ten bytes", got)
}
}
// A token must not stream a file it cannot read.
func TestStreamRefusesUnreadableItem(t *testing.T) {
f := newFixture(t)
resp, _ := f.do(t, "GET", "/Videos/"+dto.ItemID(f.itemID)+"/stream", "X-Emby-Token", f.none)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("no-grant stream status = %d, want 404", resp.StatusCode)
}
}
// ── authenticate ────────────────────────────────────────────────────────
func TestAuthenticateByName(t *testing.T) {
f := newFixture(t)
// Good credentials mint a token that then works.
req, _ := http.NewRequest("POST", f.srv.URL+"/Users/AuthenticateByName",
strings.NewReader(`{"Username":"admin","Pw":"pw"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d body=%s", resp.StatusCode, body)
}
var out struct {
AccessToken string
User struct{ Name string }
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.AccessToken == "" {
t.Fatal("no AccessToken returned")
}
if out.User.Name == "" {
t.Error("no User in the response")
}
if resp, _ := f.do(t, "GET", "/Users/Me", "X-Emby-Token", out.AccessToken); resp.StatusCode != http.StatusOK {
t.Errorf("minted token does not authenticate: %d", resp.StatusCode)
}
// Bad credentials must not mint anything.
req, _ = http.NewRequest("POST", f.srv.URL+"/Users/AuthenticateByName",
strings.NewReader(`{"Username":"admin","Pw":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
resp, err = f.srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
body, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
if strings.Contains(string(body), "AccessToken") {
t.Errorf("bad credentials returned a token: %s", body)
}
}
// Discovery must work with no credential at all, or a client cannot find the
// server to log in to.
func TestSystemInfoPublicNeedsNoAuth(t *testing.T) {
f := newFixture(t)
resp, body := f.do(t, "GET", "/System/Info/Public", "", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var info struct{ ServerName, Version, ProductName string }
if err := json.Unmarshal([]byte(body), &info); err != nil {
t.Fatalf("decode: %v", err)
}
if info.ServerName == "" || info.Version == "" {
t.Errorf("incomplete discovery response: %s", body)
}
}

View file

@ -0,0 +1,127 @@
package jellyfin
import (
"errors"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/jellyfin/dto"
"github.com/mithral/vellum/internal/stream"
)
// handlePlaybackInfo answers GET /Items/{itemId}/PlaybackInfo.
//
// The response always describes the one thing Vellum can do — direct play — and
// reports SupportsTranscoding=false. If the client has disabled both direct
// play and direct stream it is asking for a transcode; that is refused with
// ErrorCode "NoCompatibleStream" and a warning, rather than by handing back a
// TranscodingUrl that would not exist.
func (h *Handlers) handlePlaybackInfo(w http.ResponseWriter, r *http.Request, u auth.User) {
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
if !ok || isLib {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
it, ok := h.authorizedItem(w, r, u, id)
if !ok {
return
}
f, err := h.firstFile(r.Context(), id)
if err != nil {
h.fail(w, "load file facts", err)
return
}
q := r.URL.Query()
directPlay := queryBoolDefault(q.Get("EnableDirectPlay"), true)
directStream := queryBoolDefault(q.Get("EnableDirectStream"), true)
src := dto.BuildMediaSource(it, f, tokenFromRequest(r))
resp := dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}}
if !directPlay && !directStream {
resp.ErrorCode = "NoCompatibleStream"
h.log.Warn("jellyfin client requires transcoding; refusing",
"item", it.ID, "kind", it.Kind)
}
httpx.WriteJSON(w, http.StatusOK, resp)
}
// handleVideoStream serves a video item's file.
func (h *Handlers) handleVideoStream(w http.ResponseWriter, r *http.Request, u auth.User) {
h.serveStream(w, r, u)
}
// handleAudioStream serves an audio (or other non-video) item's file. Jellyfin
// clients fetch audiobook and music tracks here.
func (h *Handlers) handleAudioStream(w http.ResponseWriter, r *http.Request, u auth.User) {
h.serveStream(w, r, u)
}
// serveStream streams the item's first file through internal/stream, so Range
// requests (seeking) work exactly as they do for the web app. Auth already came
// from tokenFromRequest, which includes the ?api_key= form that <video>/<audio>
// elements require.
func (h *Handlers) serveStream(w http.ResponseWriter, r *http.Request, u auth.User) {
// Accept /stream and /stream.<ext> (stream.mp4, stream.mkv, ...). The
// subtree pattern also catches other suffixes; refuse those.
last := path.Base(strings.TrimSuffix(r.URL.Path, "/"))
if last != "stream" && !strings.HasPrefix(last, "stream.") {
httpx.WriteError(w, http.StatusNotFound, "not found")
return
}
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
if !ok || isLib {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
it, ok := h.authorizedItem(w, r, u, id)
if !ok {
return
}
f, err := catalog.FirstFile(r.Context(), h.db, id)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item has no file")
return
}
if err != nil {
h.fail(w, "load file", err)
return
}
w.Header().Set("Content-Disposition", "inline")
abs := filepath.Join(it.RootPath, filepath.FromSlash(f.Path))
if err := stream.ServeFile(w, r, abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
httpx.WriteError(w, http.StatusNotFound, "file is not available")
return
}
if errors.Is(err, stream.ErrNotAFile) {
httpx.WriteError(w, http.StatusBadRequest, "item is not a file")
return
}
h.fail(w, "serve file", err)
return
}
}
// queryBoolDefault parses a Jellyfin boolean query parameter, returning def
// when it is absent or unparseable.
func queryBoolDefault(v string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "":
return def
case "1", "true", "yes":
return true
case "0", "false", "no":
return false
}
return def
}

360
internal/jellyfin/query.go Normal file
View file

@ -0,0 +1,360 @@
package jellyfin
import (
"context"
"database/sql"
"errors"
"sort"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/jellyfin/dto"
)
// defaultLimit caps an unbounded browse so a client that omits Limit cannot
// pull an entire library in one response. maxLimit is the hard ceiling.
const (
defaultLimit = 300
maxLimit = 1000
)
// overlayColumns / overlayFrom carry the per-user state and the two browse-only
// columns (sort title, parent) that catalog.Item does not expose. They never
// read media_files and never check a grant: catalog.LoadFor loads the item and
// enforces the grant, and catalog.FirstFile reads the file, so the one
// authorization rule lives in one place instead of a sixth copy here.
//
// Per-user state comes from a windowed progress join that picks the most
// recently updated row per item, plus item_views and favorites.
const overlayColumns = `i.sort_title, i.parent_id,
COALESCE(p.position,0), COALESCE(p.percent,0), COALESCE(p.finished,0),
COALESCE(v.views,0), COALESCE(fa.user_id,0),
COALESCE((SELECT 1 FROM assets a WHERE a.item_id=i.id AND a.role='cover' LIMIT 1),0)`
const overlayFrom = `FROM media_items i
LEFT JOIN (SELECT item_id, position, percent, finished,
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY updated_at DESC) AS rn
FROM progress WHERE user_id = ?) p ON p.item_id = i.id AND p.rn = 1
LEFT JOIN item_views v ON v.user_id = ? AND v.item_id = i.id
LEFT JOIN favorites fa ON fa.user_id = ? AND fa.item_id = i.id`
// itemColumns is the browse projection: the catalog.Item identity columns plus
// the per-user overlay. The list and single-item paths share it so the two can
// never disagree about what a browse row contains. File facts are attached
// separately via catalog.FirstFile.
const itemColumns = `i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.missing, ` +
overlayColumns
// listQuery is everything the browse query needs.
type listQuery struct {
userID int64
libraryIDs []int64
parentItemID int64 // 0 means "not scoped to an item's children"
kinds []string
sortColumn string
descending bool
limit int
offset int
searchTerm string
filters []string
includeSources bool
}
// queryItems runs the browse query and its matching count.
//
// The browse rows are drained before the per-item file lookups run: catalog
// reads one file at a time, and issuing those queries against a still-open
// result set is the classic nested-query footgun.
func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]dto.ItemView, int, error) {
where, whereArgs := buildWhere(q)
var total int
if err := h.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM media_items i WHERE `+where, whereArgs...).Scan(&total); err != nil {
return nil, 0, err
}
order := q.sortColumn
if q.descending {
order += " DESC"
} else {
order += " ASC"
}
sqlText := `SELECT ` + itemColumns + ` ` + overlayFrom +
` WHERE ` + where + ` ORDER BY ` + order + ` LIMIT ? OFFSET ?`
args := []any{q.userID, q.userID, q.userID}
args = append(args, whereArgs...)
args = append(args, q.limit, q.offset)
rows, err := h.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []dto.ItemView{}
for rows.Next() {
v, err := scanItem(rows)
if err != nil {
return nil, 0, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
rows.Close()
for i := range out {
if err := h.attachFile(ctx, &out[i]); err != nil {
return nil, 0, err
}
}
return out, total, nil
}
// attachFile fills the first-file facts from internal/catalog, the one place
// that reads media_files. An item with no file keeps a zero File, which is
// exactly the defaults the old media_files LEFT JOIN produced.
func (h *Handlers) attachFile(ctx context.Context, v *dto.ItemView) error {
f, err := h.firstFile(ctx, v.Item.ID)
if err != nil {
return err
}
v.File = f
return nil
}
// firstFile returns the item's first file, or a zero File when the item has
// none. It is catalog.FirstFile with the "no file" case flattened, for callers
// that only need the facts rather than a 404.
func (h *Handlers) firstFile(ctx context.Context, itemID int64) (catalog.File, error) {
f, err := catalog.FirstFile(ctx, h.db, itemID)
if errors.Is(err, catalog.ErrNotFound) {
return catalog.File{}, nil
}
return f, err
}
// loadOverlay fills the browse-only and per-user fields of an already-loaded
// item. It reads no file and performs no access check: catalog.LoadFor already
// did both.
func (h *Handlers) loadOverlay(ctx context.Context, userID, itemID int64, v *dto.ItemView) error {
row := h.db.QueryRowContext(ctx,
`SELECT `+overlayColumns+` `+overlayFrom+` WHERE i.id = ?`,
userID, userID, userID, itemID)
var (
parentID sql.NullInt64
finished int
favUser int64
hasCover int
)
if err := row.Scan(
&v.SortTitle, &parentID,
&v.PositionMS, &v.Percent, &finished,
&v.PlayCount, &favUser, &hasCover,
); err != nil {
return err
}
v.ParentID = parentID.Int64
v.Finished = finished == 1
v.Favorite = favUser != 0
v.HasCover = hasCover == 1
return nil
}
// itemVisible reports whether the caller may read the item's library. A missing
// item is false, not an error. It delegates to catalog.LoadFor, the same
// chokepoint every other surface uses, rather than reimplementing the grant
// rule here.
func (h *Handlers) itemVisible(ctx context.Context, u auth.User, id int64) (bool, error) {
if _, err := catalog.LoadFor(ctx, h.db, u, id); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
// scanItem reads one row of the shared item column list.
func scanItem(row interface{ Scan(...any) error }) (dto.ItemView, error) {
var (
v dto.ItemView
parentID sql.NullInt64
finished int
favUser int64
hasCover int
)
err := row.Scan(
&v.Item.ID, &v.Item.LibraryID, &v.Item.Kind, &v.Item.Path, &v.Item.Title,
&v.Item.Year, &v.Item.Missing,
&v.SortTitle, &parentID,
&v.PositionMS, &v.Percent, &finished,
&v.PlayCount, &favUser, &hasCover,
)
if err != nil {
return dto.ItemView{}, err
}
v.ParentID = parentID.Int64
v.Finished = finished == 1
v.Favorite = favUser != 0
v.HasCover = hasCover == 1
return v, nil
}
// buildWhere assembles the WHERE clause and its arguments. An empty library set
// matches nothing, which is the correct outcome for a user with no grants.
func buildWhere(q listQuery) (string, []any) {
clauses := []string{"i.missing = 0"}
var args []any
if len(q.libraryIDs) == 0 {
clauses = append(clauses, "1 = 0")
} else {
in, inArgs := placeholders(q.libraryIDs)
clauses = append(clauses, "i.library_id IN ("+in+")")
args = append(args, inArgs...)
}
if q.parentItemID > 0 {
clauses = append(clauses, "i.parent_id = ?")
args = append(args, q.parentItemID)
}
if len(q.kinds) > 0 {
in, inArgs := placeholdersStr(q.kinds)
clauses = append(clauses, "i.kind IN ("+in+")")
args = append(args, inArgs...)
}
if q.searchTerm != "" {
clauses = append(clauses, "i.title LIKE ?")
args = append(args, "%"+q.searchTerm+"%")
}
// Filters are sorted so the SQL (and its argument order) is deterministic.
filters := append([]string(nil), q.filters...)
sort.Strings(filters)
for _, f := range filters {
switch f {
case "IsResumable":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 0 AND p.position > 0)`)
args = append(args, q.userID)
case "IsPlayed":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1)`)
args = append(args, q.userID)
case "IsUnplayed":
clauses = append(clauses,
`NOT EXISTS (SELECT 1 FROM progress p WHERE p.user_id = ? AND p.item_id = i.id AND p.finished = 1)`)
args = append(args, q.userID)
case "IsFavorite":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM favorites f WHERE f.user_id = ? AND f.item_id = i.id)`)
args = append(args, q.userID)
}
}
return strings.Join(clauses, " AND "), args
}
// ── small parsing helpers ──────────────────────────────────────────────
func placeholders(ids []int64) (string, []any) {
parts := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
parts[i] = "?"
args[i] = id
}
return strings.Join(parts, ","), args
}
func placeholdersStr(vals []string) (string, []any) {
parts := make([]string, len(vals))
args := make([]any, len(vals))
for i, v := range vals {
parts[i] = "?"
args[i] = v
}
return strings.Join(parts, ","), args
}
// splitCSV splits a Jellyfin comma list, dropping empties.
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
out := []string{}
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func containsFold(list []string, want string) bool {
for _, v := range list {
if strings.EqualFold(v, want) {
return true
}
}
return false
}
func containsInt64(list []int64, want int64) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return n
}
func parseBool(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes":
return true
}
return false
}
// sortColumn maps a Jellyfin SortBy value to a SQL column. Only the first
// recognised name is used; unknown names fall back to sort_title.
func sortColumn(sortBy string) string {
for _, s := range strings.Split(sortBy, ",") {
switch strings.TrimSpace(s) {
case "DateCreated":
return "i.added_at"
case "ProductionYear":
return "i.year"
case "Random":
return "RANDOM()"
case "SortName":
return "i.sort_title"
}
}
return "i.sort_title"
}
// isDescending reads a Jellyfin SortOrder ("Descending"/"Desc"/"desc").
func isDescending(order string) bool {
switch strings.ToLower(strings.TrimSpace(order)) {
case "descending", "desc":
return true
}
return false
}

View file

@ -0,0 +1,96 @@
package jellyfin
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/jellyfin/dto"
"github.com/mithral/vellum/internal/progress"
)
// handlePlaying is POST /Sessions/Playing: the client has started (or resumed)
// an item.
func (h *Handlers) handlePlaying(w http.ResponseWriter, r *http.Request, u auth.User) {
h.writePlaybackProgress(w, r, u)
}
// handlePlayingProgress is POST /Sessions/Playing/Progress: periodic position
// updates, which are what keep the resume point current.
func (h *Handlers) handlePlayingProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
h.writePlaybackProgress(w, r, u)
}
// handlePlayingStopped is POST /Sessions/Playing/Stopped. The final position is
// written the same way; progress.Put decides whether it counts as finished.
func (h *Handlers) handlePlayingStopped(w http.ResponseWriter, r *http.Request, u auth.User) {
h.writePlaybackProgress(w, r, u)
}
// writePlaybackProgress turns a Jellyfin report into a progress row. This is
// how a Jellyfin client reports resume position, so it is written through the
// existing progress store rather than kept anywhere new.
func (h *Handlers) writePlaybackProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
var rep dto.PlayReport
// Not httpx.DecodeJSON: real clients add fields we do not model, and a
// rejected body would lose the user's position.
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&rep); err != nil {
if errors.Is(err, io.EOF) {
httpx.WriteError(w, http.StatusBadRequest, "request body is empty")
return
}
httpx.WriteError(w, http.StatusBadRequest, "invalid request body")
return
}
id, isLib, ok := dto.DecodeID(rep.ItemId)
if !ok || isLib {
httpx.WriteError(w, http.StatusBadRequest, "invalid ItemId")
return
}
it, ok := h.authorizedItem(w, r, u, id)
if !ok {
return
}
// Duration comes from the item's first file, the same source the browse
// DTOs use, so PlayedPercentage is consistent across endpoints.
f, err := h.firstFile(r.Context(), id)
if err != nil {
h.fail(w, "load duration", err)
return
}
durationMS := f.DurationMS
posMS := dto.TicksToMS(rep.PositionTicks)
if posMS < 0 {
posMS = 0
}
p := progress.Progress{
ItemID: id,
Kind: it.Kind,
Position: posMS,
Duration: durationMS,
}
if durationMS > 0 {
p.Percent = float64(posMS) / float64(durationMS)
if p.Percent > 1 {
p.Percent = 1
}
}
if _, err := progress.Put(r.Context(), h.db, u.ID, p); err != nil {
if errors.Is(err, progress.ErrNoFile) {
// Nothing to attach progress to. Jellyfin clients expect 204, and
// there is genuinely nothing to store, so do not fail playback.
w.WriteHeader(http.StatusNoContent)
return
}
h.fail(w, "save progress", err)
return
}
w.WriteHeader(http.StatusNoContent)
}

151
internal/jellyfin/system.go Normal file
View file

@ -0,0 +1,151 @@
package jellyfin
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/mithral/vellum/internal/apitoken"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/jellyfin/dto"
)
func (h *Handlers) handleSystemInfoPublic(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, dto.SystemInfoPublic{
Id: h.serverIDFor(r.Context()),
ServerName: "Vellum",
Version: dto.Version,
ProductName: "Vellum",
OperatingSystem: "Linux",
})
}
func (h *Handlers) handleAuthenticateByName(w http.ResponseWriter, r *http.Request) {
// Deliberately not httpx.DecodeJSON: that rejects unknown fields, and real
// clients add extras. We only read Username and Pw.
var req dto.AuthByNameRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" || req.Pw == "" {
httpx.WriteError(w, http.StatusBadRequest, "username and password are required")
return
}
u, err := auth.Authenticate(r.Context(), h.db, req.Username, req.Pw)
if err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
// Same message for unknown user and wrong password, as the web
// login does, so usernames are not enumerable.
httpx.WriteError(w, http.StatusUnauthorized, "invalid username or password")
return
}
h.fail(w, "authenticate", err)
return
}
label := deviceLabel(r)
token, err := apitoken.Mint(r.Context(), h.db, u.ID, apitoken.KindJellyfin, label)
if err != nil {
h.fail(w, "mint token", err)
return
}
dto, err := h.userDTO(r.Context(), u)
if err != nil {
h.fail(w, "build user", err)
return
}
h.log.Info("jellyfin login", "user", u.Username, "device", label)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"User": dto,
"AccessToken": token,
"ServerId": h.serverIDFor(r.Context()),
})
}
func (h *Handlers) handleUserMe(w http.ResponseWriter, r *http.Request, u auth.User) {
dto, err := h.userDTO(r.Context(), u)
if err != nil {
h.fail(w, "build user", err)
return
}
httpx.WriteJSON(w, http.StatusOK, dto)
}
func (h *Handlers) handleUserByID(w http.ResponseWriter, r *http.Request, u auth.User) {
id, _, ok := dto.DecodeID(r.PathValue("userId"))
if !ok || id != u.ID {
// A token may only address its own user. Cross-user reads (which
// Jellyfin permits for admins) are NOT implemented; a mismatched id is
// a 404 so account existence is not disclosed.
httpx.WriteError(w, http.StatusNotFound, "user not found")
return
}
dto, err := h.userDTO(r.Context(), u)
if err != nil {
h.fail(w, "build user", err)
return
}
httpx.WriteJSON(w, http.StatusOK, dto)
}
// userDTO renders the Jellyfin user shape, including the visible library
// folders so a client can scope its views.
func (h *Handlers) userDTO(ctx context.Context, u auth.User) (dto.UserDto, error) {
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
if err != nil {
return dto.UserDto{}, err
}
return dto.User(u, h.serverIDFor(ctx), libs), nil
}
// deviceLabel best-effort extracts a human device name from the
// X-Emby-Authorization header so the token list in the UI is recognisable.
func deviceLabel(r *http.Request) string {
hdr := r.Header.Get("X-Emby-Authorization")
if hdr == "" {
hdr = r.Header.Get("Authorization")
}
for _, f := range splitParams(hdr) {
if v, ok := cutParam(f, "Device="); ok && v != "" {
return "Jellyfin: " + v
}
}
return "Jellyfin"
}
// splitParams splits a MediaBrowser-style parameter list on commas or spaces.
func splitParams(s string) []string {
out := []string{}
cur := ""
for _, r := range s {
if r == ',' || r == ' ' {
if cur != "" {
out = append(out, cur)
}
cur = ""
continue
}
cur += string(r)
}
if cur != "" {
out = append(out, cur)
}
return out
}
func cutParam(field, prefix string) (string, bool) {
if len(field) < len(prefix) || field[:len(prefix)] != prefix {
return "", false
}
v := field[len(prefix):]
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
v = v[1 : len(v)-1]
}
return v, true
}

View file

@ -42,10 +42,12 @@ func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/libraries/{id}", h.auth.Require(h.handleGet))
mux.Handle("GET /api/v1/libraries/{id}/items", h.auth.Require(h.handleListItems))
mux.Handle("GET /api/v1/libraries/{id}/scan/status", h.auth.RequireAuth(http.HandlerFunc(h.handleScanStatus)))
mux.Handle("GET /api/v1/missing", h.auth.Require(h.handleMissing))
mux.Handle("POST /api/v1/libraries", h.auth.RequireAdmin(http.HandlerFunc(h.handleCreate)))
mux.Handle("DELETE /api/v1/libraries/{id}", h.auth.RequireAdmin(http.HandlerFunc(h.handleDelete)))
mux.Handle("POST /api/v1/libraries/{id}/scan", h.auth.RequireAdmin(http.HandlerFunc(h.handleScan)))
mux.Handle("POST /api/v1/libraries/{id}/purge", h.auth.RequireAdmin(http.HandlerFunc(h.handlePurge)))
}
// handleMediaTypes is public: the UI needs the list before login to render.
@ -262,8 +264,66 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "limit": limit})
}
// handleMissing lists the files the scanner has flagged missing in the
// libraries the caller may read. An optional ?library_id= narrows the result
// to a single library, and an id outside the caller's visible set simply
// yields nothing rather than leaking that the library exists.
func (h *Handlers) handleMissing(w http.ResponseWriter, r *http.Request, u auth.User) {
ids, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
if err != nil {
h.fail(w, "visible libraries", err)
return
}
if v := r.URL.Query().Get("library_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid library_id")
return
}
ids = narrowToLibrary(ids, id)
}
entries, err := ListMissing(r.Context(), h.db, ids)
if err != nil {
h.fail(w, "list missing", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": entries, "total": len(entries)})
}
// handlePurge deletes a library's genuinely missing items on explicit admin
// request. It is admin-only and never runs automatically: the delete cascades
// into progress, which cannot be regenerated. See PurgeMissing for the full
// cascade and the exact "actually missing" condition.
func (h *Handlers) handlePurge(w http.ResponseWriter, r *http.Request) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
n, err := PurgeMissing(r.Context(), h.db, id)
if err != nil {
h.fail(w, "purge missing", err)
return
}
h.log.Info("purged missing items", "library", id, "count", n)
httpx.WriteJSON(w, http.StatusOK, map[string]any{"purged": n})
}
// ── helpers ────────────────────────────────────────────────────────────
// narrowToLibrary keeps only id when the caller asks for one library. An id
// outside the visible set yields an empty set, so a non-admin cannot probe a
// library they cannot read.
func narrowToLibrary(ids []int64, id int64) []int64 {
for _, v := range ids {
if v == id {
return []int64{id}
}
}
return []int64{}
}
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
h.log.Error(what, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")

View file

@ -118,6 +118,113 @@ func CountItems(ctx context.Context, db *sql.DB, id int64) (int, error) {
return n, err
}
// MissingEntry is one missing file, surfaced so an admin can see what the
// scanner has flagged and decide whether to purge it.
//
// It carries both the file (file_id) and its owning item (item_id) because the
// file is what actually vanished, while the item is what a purge deletes. A
// multi-file item (an audiobook of tracks) produces one entry per missing file.
type MissingEntry struct {
ItemID int64 `json:"item_id"`
FileID int64 `json:"file_id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Path string `json:"path"`
MissingSince string `json:"missing_since"`
}
// ListMissing returns the missing files in the given libraries, grouped by
// library and then by title.
//
// It is keyed on media_files because a missing file is the thing the scanner
// saw disappear; an item whose whole group is gone has each of its files
// flagged, so every missing item still appears. ids is the caller's visible
// set, resolved by the auth layer — an empty set yields nothing, never "all",
// which is the same rule ListVisible follows.
func ListMissing(ctx context.Context, db *sql.DB, ids []int64) ([]MissingEntry, error) {
if len(ids) == 0 {
return []MissingEntry{}, nil
}
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
rows, err := db.QueryContext(ctx, `
SELECT i.id, f.id, i.library_id, i.kind, i.title, f.path,
CASE WHEN f.missing_since <> '' THEN f.missing_since ELSE i.missing_since END
FROM media_files f
JOIN media_items i ON i.id = f.item_id
WHERE f.missing = 1 AND i.library_id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY i.library_id, i.sort_title, f.path`, args...)
if err != nil {
return nil, fmt.Errorf("library: list missing: %w", err)
}
defer rows.Close()
out := []MissingEntry{}
for rows.Next() {
var e MissingEntry
if err := rows.Scan(&e.ItemID, &e.FileID, &e.LibraryID, &e.Kind, &e.Title, &e.Path, &e.MissingSince); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// PurgeMissing deletes the items in a library that are genuinely missing:
// flagged missing and with no file left present. It returns how many items were
// removed.
//
// This is the ONLY place media rows are deleted on purpose, and it is an
// explicit admin action rather than part of a scan because the delete is a
// one-way door. Deleting a media_items row cascades (foreign_keys is on) into:
//
// - media_files.item_id — the files themselves
// - progress.item_id — watch/read positions, which cannot be
// regenerated; this is why a scan never deletes
// - progress.file_id — via the cascaded media_files rows
// - item_people.item_id — cast and author credits
// - provider_ids.item_id — external metadata matches
// - metadata_fetch.item_id — provider attempt history
// - assets.item_id — covers, thumbnails, extracted pages
// - collection_items.item_id — collection membership
// - favorites.item_id
// - item_views.item_id — per-user view counts
// - game_saves.item_id — emulator save states
// - child media_items — the parent_id self-reference
//
// The WHERE clause is deliberately narrow: an item is purged only when it is
// flagged missing AND has no present file, so an item missing one track while
// its other tracks remain on disk is never touched.
func PurgeMissing(ctx context.Context, db *sql.DB, libraryID int64) (int64, error) {
res, err := db.ExecContext(ctx, `
DELETE FROM media_items
WHERE library_id = ?
AND missing = 1
AND NOT EXISTS (
SELECT 1 FROM media_files f
WHERE f.item_id = media_items.id AND f.missing = 0
)`, libraryID)
if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err)
}
return n, nil
}
// RootExists reports whether a library's root is currently reachable.
func RootExists(lib Library) bool {
info, err := os.Stat(lib.RootPath)
return err == nil && info.IsDir()
}
// ── helpers ────────────────────────────────────────────────────────────
func resolveRoot(rootPath string) (string, error) {

View file

@ -1,8 +1,12 @@
// Package library owns library definitions and the scanner that turns files on
// disk into rows in media_items / media_files.
// Package library owns library definitions — create, list, delete — and the
// media-kind registry that says which file extensions each kind of library owns.
//
// Modules declare what file extensions they care about via RegisterType; this
// package never imports a module.
// The registry lives here, not in internal/scanner, because it is not the
// scanner's business: modules register their types, the admin HTTP surface
// serves them, and the metadata enricher reads the grouping mode. Only one of
// those three is the scanner, so the registry belongs in the lower layer that
// all three already depend on. The scanner imports it from here; nothing here
// imports the scanner.
package library
import (
@ -134,8 +138,11 @@ func GroupModeFor(kind string) GroupMode {
return GroupByFile
}
// extensionsFor returns the extension set owned by a library kind.
func extensionsFor(kind string) map[string]bool {
// ExtensionsFor returns the extension set owned by a library kind, each entry
// lowercase with a leading dot. The scanner walks a tree by testing every file
// name against this set, which is why it lives in the registry rather than in
// the scanner: the answer is a property of the kind, not of the scan.
func ExtensionsFor(kind string) map[string]bool {
typesMu.RLock()
defer typesMu.RUnlock()
t, ok := types[kind]

View file

@ -1,205 +0,0 @@
package library
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestScanLifecycle(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
writeFile(t, filepath.Join(root, "sub", "c.epub"), "cccccccccccccccccccccccccccccc")
writeFile(t, filepath.Join(root, "notes.txt"), "not media")
writeFile(t, filepath.Join(root, ".hidden.epub"), "hidden")
writeFile(t, filepath.Join(root, ".git", "d.epub"), "in a hidden dir")
lib, err := Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
// First scan: three supported files, nothing else.
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("Scan: %v", err)
}
if res.Added != 3 || res.Scanned != 3 {
t.Fatalf("first scan added=%d scanned=%d, want 3/3 (%+v)", res.Added, res.Scanned, res)
}
if n, _ := CountItems(ctx, db, lib.ID); n != 3 {
t.Errorf("item count = %d, want 3", n)
}
// Second scan: nothing changed.
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Unchanged != 3 || res.Added != 0 || res.Updated != 0 {
t.Errorf("second scan unchanged=%d added=%d updated=%d, want 3/0/0", res.Unchanged, res.Added, res.Updated)
}
// Modify a.epub (different size).
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaa")
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Updated != 1 || res.Unchanged != 2 {
t.Errorf("after modify updated=%d unchanged=%d, want 1/2", res.Updated, res.Unchanged)
}
// Delete b.epub: it goes missing, it is not deleted.
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
t.Fatal(err)
}
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Missing != 1 {
t.Errorf("after delete missing=%d, want 1", res.Missing)
}
if n, _ := CountItems(ctx, db, lib.ID); n != 2 {
t.Errorf("item count (excluding missing) = %d, want 2", n)
}
// The row still exists, flagged missing.
var missing int
if err := db.QueryRowContext(ctx,
`SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "b.epub").Scan(&missing); err != nil {
t.Fatalf("missing item row was deleted: %v", err)
}
if missing != 1 {
t.Errorf("missing flag = %d, want 1", missing)
}
// Restore b.epub.
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Restored != 1 {
t.Errorf("after restore restored=%d, want 1 (%+v)", res.Restored, res)
}
if n, _ := CountItems(ctx, db, lib.ID); n != 3 {
t.Errorf("item count after restore = %d, want 3", n)
}
}
func TestScanGroupsDirectoryKinds(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
// An audiobook is a folder of chapters.
writeFile(t, filepath.Join(root, "Author", "Book", "01 - Chapter One.mp3"), "a")
writeFile(t, filepath.Join(root, "Author", "Book", "02 - Chapter Two.mp3"), "b")
writeFile(t, filepath.Join(root, "Author", "Book", "10 - Chapter Ten.mp3"), "c")
// A loose file at the root groups under the library name.
writeFile(t, filepath.Join(root, "stray.mp3"), "d")
lib, err := Create(ctx, db, "Audiobooks", "audiobooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("Scan: %v", err)
}
if res.Added != 4 {
t.Errorf("added = %d, want 4 (four tracks)", res.Added)
}
// Two items: the directory, and the root group.
var items int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
t.Fatal(err)
}
if items != 2 {
t.Fatalf("items = %d, want 2", items)
}
var title string
if err := db.QueryRowContext(ctx,
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Author/Book").Scan(&title); err != nil {
t.Fatalf("grouped item not found: %v", err)
}
if title != "Book" {
t.Errorf("grouped title = %q, want Book", title)
}
// Tracks must come back in natural order, not lexical.
rows, err := db.QueryContext(ctx, `
SELECT f.path FROM media_files f JOIN media_items i ON i.id = f.item_id
WHERE i.path = ? ORDER BY f.id`, "Author/Book")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
t.Fatal(err)
}
paths = append(paths, p)
}
want := []string{
"Author/Book/01 - Chapter One.mp3",
"Author/Book/02 - Chapter Two.mp3",
"Author/Book/10 - Chapter Ten.mp3",
}
if len(paths) != len(want) {
t.Fatalf("track count = %d, want %d: %v", len(paths), len(want), paths)
}
for i := range want {
if paths[i] != want[i] {
t.Errorf("track %d = %q, want %q", i, paths[i], want[i])
}
}
}
func TestScanUnreadableRoot(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "x")
lib, err := Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatal(err)
}
// Index it once so there is a known file to go missing.
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatal(err)
}
// Remove the root out from under the library.
if err := os.RemoveAll(root); err != nil {
t.Fatal(err)
}
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("scan should not fail on a missing root: %v", err)
}
if res.Added != 0 {
t.Errorf("added=%d, want 0", res.Added)
}
if res.Missing != 1 {
t.Errorf("missing=%d, want 1 (existing item now vanished)", res.Missing)
}
if len(res.Errors) == 0 {
t.Error("expected the walk error to be recorded")
}
}

View file

@ -0,0 +1,193 @@
package media
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"strconv"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
// These fixtures are duplicated from internal/home. Go test helpers cannot be
// shared across packages without being exported from non-test code, and the home
// screen's version is a different shape: it seeds progress and views so the
// shelves have something to aggregate.
// ── shared helpers ─────────────────────────────────────────────────────
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewHandlers(db, log, nil, t.TempDir())
}
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
t.Fatalf("query %q: %v", q, err)
}
return id
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
t.Fatalf("exec %q: %v", q, err)
}
}
func newLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 {
t.Helper()
return mustQueryID(t, db,
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`, name, kind, root)
}
func newUser(t *testing.T, db *sql.DB, name string) int64 {
t.Helper()
return mustQueryID(t, db,
`INSERT INTO users (username, password_hash) VALUES (?, 'x') RETURNING id`, name)
}
func grantRead(t *testing.T, db *sql.DB, userID, libID int64) {
t.Helper()
mustExec(t, db,
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`,
userID, libID)
}
// newItem inserts a media item plus a backing file, because progress rows carry
// a NOT NULL file_id foreign key.
func newItem(t *testing.T, db *sql.DB, libID int64, kind, path, title, sortTitle string, missing bool, addedAt string) int64 {
t.Helper()
m := 0
if missing {
m = 1
}
id := mustQueryID(t, db, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, added_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
libID, kind, path, title, sortTitle, m, addedAt)
mustExec(t, db,
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`,
id, "file-"+strconv.FormatInt(id, 10))
return id
}
func fileIDFor(t *testing.T, db *sql.DB, itemID int64) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(),
`SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id); err != nil {
t.Fatalf("file for item %d: %v", itemID, err)
}
return id
}
func newProgress(t *testing.T, db *sql.DB, userID, itemID int64, percent float64, finished bool, updatedAt string) {
t.Helper()
f := 0
if finished {
f = 1
}
mustExec(t, db, `
INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished, updated_at)
VALUES (?, ?, ?, 'ebooks', 1, ?, ?, ?)`,
userID, itemID, fileIDFor(t, db, itemID), percent, f, updatedAt)
}
func newView(t *testing.T, db *sql.DB, userID, itemID int64, views int, lastViewedAt string) {
t.Helper()
mustExec(t, db,
`INSERT INTO item_views (user_id, item_id, views, last_viewed_at) VALUES (?, ?, ?, ?)`,
userID, itemID, views, lastViewedAt)
}
// setupHome builds the two-library, three-user fixture the item-detail tests
// use: one item the caller may read, one in a library they may not, and one user
// with no grants at all.
func setupHome(t *testing.T) homeFixture {
t.Helper()
db := testDB(t)
f := homeFixture{db: db}
f.libA = newLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks")
f.libB = newLibrary(t, db, "Audio", "audiobooks", "/media/audio")
f.userA = newUser(t, db, "alice")
f.userB = newUser(t, db, "bob")
f.userNone = newUser(t, db, "carol")
grantRead(t, db, f.userA, f.libA)
grantRead(t, db, f.userB, f.libB)
f.a1 = newItem(t, db, f.libA, "ebooks", "a1.epub", "A One", "a one", false, "2026-01-01T00:00:01.000Z")
f.a2 = newItem(t, db, f.libA, "ebooks", "a2.epub", "A Two", "a two", false, "2026-01-01T00:00:02.000Z")
f.a3 = newItem(t, db, f.libA, "comics", "a3.cbz", "A Three", "a three", false, "2026-01-01T00:00:03.000Z")
f.a4 = newItem(t, db, f.libA, "ebooks", "a4.epub", "A Four", "a four", true, "2026-01-01T00:00:04.000Z")
f.a5 = newItem(t, db, f.libA, "ebooks", "a5.epub", "A Five", "a five", false, "2026-01-01T00:00:05.000Z")
f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "B One", "b one", false, "2026-01-01T00:00:06.000Z")
newProgress(t, db, f.userA, f.a1, 0.5, false, "2026-02-01T00:00:03.000Z")
newProgress(t, db, f.userA, f.a2, 1.0, true, "2026-02-01T00:00:02.000Z")
newProgress(t, db, f.userA, f.a4, 0.3, false, "2026-02-01T00:00:04.000Z")
newProgress(t, db, f.userB, f.b1, 0.2, false, "2026-02-02T00:00:01.000Z")
newView(t, db, f.userA, f.a1, 3, "2026-02-03T00:00:01.000Z")
newView(t, db, f.userA, f.a3, 1, "2026-02-03T00:00:02.000Z")
newView(t, db, f.userB, f.b1, 9, "2026-02-03T00:00:03.000Z")
ada := mustQueryID(t, db, `INSERT INTO people (name) VALUES ('Ada Author') RETURNING id`)
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a1, ada)
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a3, ada)
return f
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
type homeFixture struct {
db *sql.DB
libA int64
libB int64
userA int64
userB int64
userNone int64
a1 int64
a2 int64
a3 int64
a4 int64
a5 int64
b1 int64
}

View file

@ -8,8 +8,11 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/home"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/progress"
"github.com/mithral/vellum/internal/stream"
@ -30,8 +33,10 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir s
}
// Register mounts the routes.
//
// The home screen is not registered here: it is an aggregate over items, not an
// item resource, and it lives in internal/home.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome))
mux.Handle("GET /api/v1/items", h.auth.Require(h.handleListItems))
mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem))
mux.Handle("POST /api/v1/items/{id}/view", h.auth.Require(h.handleRecordView))
@ -158,10 +163,8 @@ func (h *Handlers) handleFileByID(w http.ResponseWriter, r *http.Request, u auth
// Confirm the file belongs to this item, so an item id cannot be paired
// with another item's file id.
var relPath string
err = h.db.QueryRowContext(r.Context(),
`SELECT path FROM media_files WHERE id = ? AND item_id = ?`, fileID, it.ID).Scan(&relPath)
if errors.Is(err, sql.ErrNoRows) {
f, err := catalog.FileByID(r.Context(), h.db, it.ID, fileID)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "file not found")
return
}
@ -170,7 +173,7 @@ func (h *Handlers) handleFileByID(w http.ResponseWriter, r *http.Request, u auth
return
}
abs := filepath.Join(it.RootPath, filepath.FromSlash(relPath))
abs := filepath.Join(it.RootPath, filepath.FromSlash(f.Path))
w.Header().Set("Content-Disposition", "inline")
if err := stream.ServeFile(w, r, abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
@ -272,10 +275,59 @@ func (h *Handlers) handleRecent(w http.ResponseWriter, r *http.Request, u auth.U
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": items})
}
// handleRecordView increments the user's view count for an item.
//
// The counting SQL lives in internal/home, which owns the shelves that read
// these numbers back; only the item-scoped route stays here.
func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
views, err := home.RecordView(r.Context(), h.db, u.ID, it.ID)
if err != nil {
h.fail(w, "record view", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"item_id": it.ID, "views": views})
}
// handleAuthors returns the people credited on an item.
func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
authors, err := h.authors(r, it.ID)
if err != nil {
h.fail(w, "authors", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors})
}
// ── helpers ────────────────────────────────────────────────────────────
// inClause builds "?,?,?" plus the matching args for an IN list.
//
// Duplicated from internal/home on purpose: a browse list is an item resource,
// and importing the home-screen package to borrow a query-string helper would
// invert the dependency for three lines.
func inClause(ids []int64) (string, []any) {
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
return strings.Join(placeholders, ","), args
}
// authorized loads an item and confirms the caller may read its library.
// On failure it has already written the response.
//
// The load and the access check both live in catalog.LoadFor, so this package
// no longer carries its own copy of the "may this user read this item" rule.
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (Item, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
@ -283,8 +335,8 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
return Item{}, false
}
it, err := LoadItem(r.Context(), h.db, id)
if errors.Is(err, ErrItemNotFound) {
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return Item{}, false
}
@ -292,38 +344,16 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
h.fail(w, "load item", err)
return Item{}, false
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
if err != nil {
h.fail(w, "check access", err)
return Item{}, false
}
if !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return Item{}, false
}
return it, true
}
func (h *Handlers) files(r *http.Request, itemID int64) ([]FileEntry, error) {
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, path, size, container, missing FROM media_files WHERE item_id = ? ORDER BY id`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []FileEntry{}
for rows.Next() {
var f FileEntry
var missing int
if err := rows.Scan(&f.ID, &f.Path, &f.Size, &f.Container, &missing); err != nil {
return nil, err
}
f.Missing = missing == 1
out = append(out, f)
}
return out, rows.Err()
// files returns an item's physical files with their ffprobe codec facts.
//
// The shape is catalog.File, whose JSON tags are frozen by MediaFile in
// web/src/lib/api.ts. The server reports what is IN the file and never
// transcodes; each client decides whether it can direct-play it.
func (h *Handlers) files(r *http.Request, itemID int64) ([]catalog.File, error) {
return catalog.Files(r.Context(), h.db, itemID)
}
func (h *Handlers) hasCover(r *http.Request, it Item) bool {

View file

@ -1,97 +1,39 @@
// Package media serves generic item endpoints: detail, the raw file, cover
// art, and progress. It knows nothing about any specific media format; modules
// register cover extractors and expose their own metadata endpoints.
// Package media serves generic item RESOURCES: the browse list, one item's
// detail, its raw file, cover art, progress, credits and view counts. It knows
// nothing about any specific media format; modules register cover extractors
// and expose their own metadata endpoints.
//
// What the landing screen shows is deliberately NOT here. The home shelves are
// AGGREGATES over many items rather than one resource, they answer a different
// question, and they have different callers, so they live in internal/home.
//
// Item and file ACCESS does not live here any more — it lives in
// internal/catalog, so that the web app, the modules, Jellyfin and Subsonic all
// share one implementation of "may this user read this item" instead of five.
// The names below are kept as aliases so existing callers do not have to move
// in the same step; new code should import catalog directly.
package media
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"time"
"github.com/mithral/vellum/internal/catalog"
)
// ErrItemNotFound means no item matched.
var ErrItemNotFound = errors.New("media: item not found")
// Item is one media item plus the root of its library. See catalog.Item.
type Item = catalog.Item
// Item is one media item plus the root of its library.
type Item struct {
ID int64
LibraryID int64
Kind string
Path string // path relative to the library root
Title string
Year int
Missing bool
// FileEntry is one physical file backing an item, with its codec facts.
// See catalog.File.
type FileEntry = catalog.File
// RootPath is the absolute library root, so AbsPath can be computed.
RootPath string
// ModTime is the backing file's modification time, used for conditional
// GETs. Zero when unknown.
ModTime time.Time
}
// AbsPath returns the absolute filesystem path of the item's file.
//
// item.path is stored relative to the library root and the root is stored
// resolved, so this join cannot escape the library.
func (i Item) AbsPath() string {
return filepath.Join(i.RootPath, filepath.FromSlash(i.Path))
}
// LoadItem loads an item and its library root.
func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) {
var it Item
var mtime string
err := db.QueryRowContext(ctx, `
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path,
COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), '')
FROM media_items i
JOIN libraries l ON l.id = i.library_id
WHERE i.id = ?`, id,
).Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.Missing, &it.RootPath, &mtime)
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrItemNotFound
}
if err != nil {
return Item{}, fmt.Errorf("media: load item: %w", err)
}
if nanos := parseUnixNano(mtime); nanos > 0 {
it.ModTime = time.Unix(0, nanos).UTC()
}
return it, nil
}
// parseUnixNano parses a base-10 nanosecond timestamp, returning 0 on anything
// that is not all digits.
func parseUnixNano(s string) int64 {
if s == "" {
return 0
}
var n int64
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0
}
n = n*10 + int64(s[i]-'0')
}
return n
}
// FileEntry is one physical file backing an item.
type FileEntry struct {
ID int64 `json:"id"`
Path string `json:"path"`
Size int64 `json:"size"`
Container string `json:"container"`
Missing bool `json:"missing"`
}
// ErrItemNotFound means no item matched, or the caller may not read it. The two
// are deliberately indistinguishable so a handler cannot leak the existence of
// another user's library. See catalog.ErrNotFound.
var ErrItemNotFound = catalog.ErrNotFound
// FileCount returns how many files back an item.
func FileCount(ctx context.Context, db *sql.DB, itemID int64) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_files WHERE item_id = ?`, itemID).Scan(&n)
return n, err
return catalog.FileCount(ctx, db, itemID)
}

View file

@ -1,3 +1,8 @@
// Package metadata enriches catalogue items from external providers.
//
// It owns the policy: which provider to try, whether a fuzzy candidate is
// actually the item being asked about, and what gets written back. The HTTP
// clients live in internal/providers, which knows nothing about the database.
package metadata
import (
@ -15,9 +20,11 @@ import (
"strings"
"time"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/media"
"github.com/mithral/vellum/internal/providers"
)
// JobKind is the queue job that enriches a single item.
@ -40,7 +47,7 @@ type Enricher struct {
// providers resolves the provider list for a media kind. It defaults to
// the global registry; tests override it to avoid network and global state.
providers func(kind string) []Provider
providers func(kind string) []providers.Provider
}
// NewEnricher builds the enricher.
@ -50,7 +57,7 @@ func NewEnricher(db *sql.DB, log *slog.Logger, cacheDir string) *Enricher {
log: log,
cacheDir: cacheDir,
client: &http.Client{Timeout: 20 * time.Second},
providers: For,
providers: providers.For,
}
}
@ -76,9 +83,14 @@ func (e *Enricher) Handle(ctx context.Context, payload json.RawMessage) error {
// returns an error only for a database failure, because a missing match is a
// normal outcome and must not fail the job.
func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
it, err := media.LoadItem(ctx, e.db, itemID)
// This runs as a background job, not a request, so there is no user and no
// grant to check. catalog.Load is the unchecked read, which is correct
// here: the job acts on an item the scanner already admitted to the
// catalogue, and a job that silently skipped its work because of a grant
// check it cannot perform would be a bug.
it, err := catalog.LoadUnchecked(ctx, e.db, itemID)
if err != nil {
if errors.Is(err, media.ErrItemNotFound) {
if errors.Is(err, catalog.ErrNotFound) {
return nil // deleted since the job was queued
}
return err
@ -94,13 +106,13 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
return nil
}
providers := e.providers(it.Kind)
if len(providers) == 0 {
provs := e.providers(it.Kind)
if len(provs) == 0 {
return nil
}
q := Query{Title: searchTitle(it.Title), Author: artistHint(it), Kind: it.Kind}
for _, p := range providers {
q := providers.Query{Title: searchTitle(it.Title), Author: artistHint(it), Kind: it.Kind}
for _, p := range provs {
if e.triedRecently(ctx, itemID, p.Name()) {
continue
}
@ -150,7 +162,7 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
// so the parent directory is a strong signal that the provider search should
// use to disambiguate. Without it, "Random Access Memories" matches a release
// by an unrelated artist.
func artistHint(it media.Item) string {
func artistHint(it catalog.Item) string {
if library.GroupModeFor(it.Kind) != library.GroupByDirectory {
return ""
}
@ -195,10 +207,10 @@ func searchTitle(s string) string {
// direction) but not absent: it accepts "The Hobbit" against "The Hobbit, or
// There and Back Again" and rejects "Random Access Memories" against an
// unrelated album that merely shares a word.
func pickBest(candidates []Candidate, want string) (Candidate, bool) {
func pickBest(candidates []providers.Candidate, want string) (providers.Candidate, bool) {
w := normalizeTitle(want)
if len(w) < 3 {
return Candidate{}, false
return providers.Candidate{}, false
}
for _, c := range candidates {
got := normalizeTitle(c.Title)
@ -209,7 +221,7 @@ func pickBest(candidates []Candidate, want string) (Candidate, bool) {
return c, true
}
}
return Candidate{}, false
return providers.Candidate{}, false
}
// normalizeTitle lowercases and strips punctuation so titles can be compared
@ -237,7 +249,7 @@ func normalizeTitle(s string) string {
}
// apply writes a chosen candidate back to the database.
func (e *Enricher) apply(ctx context.Context, it media.Item, c Candidate) error {
func (e *Enricher) apply(ctx context.Context, it catalog.Item, c providers.Candidate) error {
tx, err := e.db.BeginTx(ctx, nil)
if err != nil {
return err
@ -301,7 +313,7 @@ func (e *Enricher) apply(ctx context.Context, it media.Item, c Candidate) error
}
// downloadCover fetches a cover into the cache and records it as an asset.
func (e *Enricher) downloadCover(ctx context.Context, it media.Item, c Candidate) error {
func (e *Enricher) downloadCover(ctx context.Context, it catalog.Item, c providers.Candidate) error {
var existing int
if err := e.db.QueryRowContext(ctx,
`SELECT count(*) FROM assets WHERE item_id = ? AND role = 'cover'`, it.ID).Scan(&existing); err != nil {

View file

@ -11,6 +11,7 @@ import (
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/media"
"github.com/mithral/vellum/internal/providers"
"github.com/mithral/vellum/migrations"
)
@ -44,7 +45,7 @@ func seedItem(t *testing.T, db *sql.DB, title string) int64 {
libID, title, title).Scan(&itemID); err != nil {
t.Fatal(err)
}
// A file row so media.LoadItem can resolve a modtime.
// A file row so catalog.Load can resolve a modtime.
if _, err := db.ExecContext(ctx,
`INSERT INTO media_files (item_id, path, size, mtime) VALUES (?, 'book.epub', 1, '1')`, itemID); err != nil {
t.Fatal(err)
@ -56,22 +57,22 @@ func seedItem(t *testing.T, db *sql.DB, title string) int64 {
type fakeProvider struct {
name string
kinds []string
results []Candidate
results []providers.Candidate
err error
calls int
}
func (f *fakeProvider) Name() string { return f.name }
func (f *fakeProvider) Kinds() []string { return f.kinds }
func (f *fakeProvider) Search(context.Context, Query) ([]Candidate, error) {
func (f *fakeProvider) Search(context.Context, providers.Query) ([]providers.Candidate, error) {
f.calls++
return f.results, f.err
}
func enricherWith(t *testing.T, db *sql.DB, provs ...Provider) *Enricher {
func enricherWith(t *testing.T, db *sql.DB, provs ...providers.Provider) *Enricher {
t.Helper()
e := NewEnricher(db, discard(), t.TempDir())
e.providers = func(string) []Provider { return provs }
e.providers = func(string) []providers.Provider { return provs }
return e
}
@ -82,7 +83,7 @@ func TestEnrichAppliesCandidate(t *testing.T) {
fake := &fakeProvider{
name: "fake",
kinds: []string{"ebooks"},
results: []Candidate{{
results: []providers.Candidate{{
Provider: "fake",
ExternalID: "OL123W",
Title: "The Hobbit",
@ -148,7 +149,7 @@ func TestEnrichFallsBackToSecondProvider(t *testing.T) {
good := &fakeProvider{
name: "good",
kinds: []string{"ebooks"},
results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
results: []providers.Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
}
e := enricherWith(t, db, empty, good)
@ -177,7 +178,7 @@ func TestEnrichContinuesAfterProviderError(t *testing.T) {
good := &fakeProvider{
name: "good",
kinds: []string{"ebooks"},
results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
results: []providers.Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
}
e := enricherWith(t, db, broken, good)
@ -203,7 +204,7 @@ func TestEnrichSkipsAlreadyMatched(t *testing.T) {
itemID := seedItem(t, db, "book")
fake := &fakeProvider{name: "fake", kinds: []string{"ebooks"},
results: []Candidate{{Provider: "fake", ExternalID: "x", Title: "Book"}}}
results: []providers.Candidate{{Provider: "fake", ExternalID: "x", Title: "Book"}}}
e := enricherWith(t, db, fake)
if err := e.EnrichItem(context.Background(), itemID); err != nil {
@ -277,7 +278,7 @@ func TestSearchTitleStripsReleaseTags(t *testing.T) {
}
func TestPickBestRejectsUnrelated(t *testing.T) {
candidates := []Candidate{
candidates := []providers.Candidate{
{Title: "Random Access Memories", ExternalID: "right"},
}
@ -287,7 +288,7 @@ func TestPickBestRejectsUnrelated(t *testing.T) {
}
// Containment in either direction is accepted.
long := []Candidate{{Title: "The Hobbit, or There and Back Again"}}
long := []providers.Candidate{{Title: "The Hobbit, or There and Back Again"}}
if _, ok := pickBest(long, "The Hobbit"); !ok {
t.Error("containment should be accepted")
}

View file

@ -8,9 +8,9 @@ import (
"strconv"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/jobs"
"github.com/mithral/vellum/internal/media"
)
// Handlers serves metadata endpoints.
@ -97,20 +97,17 @@ func (h *Handlers) handleItemMetadata(w http.ResponseWriter, r *http.Request, u
return
}
it, err := media.LoadItem(r.Context(), h.db, id)
if errors.Is(err, media.ErrItemNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
if err != nil {
// catalog.LoadFor is the authorization chokepoint: it returns the same
// ErrNotFound for a missing item and an unreadable one, so this stays a
// 404 either way and never leaks the existence of another user's library.
if _, err := catalog.LoadFor(r.Context(), h.db, u, id); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
h.fail(w, "load item", err)
return
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
if err != nil || !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
type providerID struct {
Provider string `json:"provider"`

View file

@ -9,6 +9,7 @@ import (
"strconv"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/media"
"github.com/mithral/vellum/internal/stream"
@ -84,6 +85,9 @@ func (h *Handlers) handlePage(w http.ResponseWriter, r *http.Request, u auth.Use
}
// authorized loads an item, confirms access, and checks it is a comic.
//
// The load and the access check both live in catalog.LoadFor; this package no
// longer reimplements "may this user read this item".
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (media.Item, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
@ -91,8 +95,8 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
return media.Item{}, false
}
it, err := media.LoadItem(r.Context(), h.db, id)
if errors.Is(err, media.ErrItemNotFound) {
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
@ -102,11 +106,6 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
return media.Item{}, false
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
if err != nil || !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
if it.Kind != Kind {
httpx.WriteError(w, http.StatusBadRequest, "item is not a comic")
return media.Item{}, false

View file

@ -8,8 +8,8 @@ import (
"strconv"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/media"
)
// Handlers serves ebook-specific endpoints.
@ -40,8 +40,8 @@ func (h *Handlers) handleMetadata(w http.ResponseWriter, r *http.Request, u auth
return
}
it, err := media.LoadItem(r.Context(), h.db, id)
if errors.Is(err, media.ErrItemNotFound) {
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
@ -51,11 +51,6 @@ func (h *Handlers) handleMetadata(w http.ResponseWriter, r *http.Request, u auth
return
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
if err != nil || !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
if it.Kind != Kind {
httpx.WriteError(w, http.StatusBadRequest, "item is not an ebook")
return

View file

@ -10,6 +10,7 @@ import (
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/media"
)
@ -174,6 +175,10 @@ func slotName(w http.ResponseWriter, r *http.Request) (string, bool) {
return slot, true
}
// authorized loads an item, confirms access, and checks it is a game.
//
// The load and the access check both live in catalog.LoadFor; this package no
// longer reimplements "may this user read this item".
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (media.Item, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
@ -181,8 +186,8 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
return media.Item{}, false
}
it, err := media.LoadItem(r.Context(), h.db, id)
if errors.Is(err, media.ErrItemNotFound) {
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
@ -191,11 +196,6 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
return media.Item{}, false
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
if err != nil || !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return media.Item{}, false
}
if it.Kind != Kind {
httpx.WriteError(w, http.StatusBadRequest, "item is not a game")
return media.Item{}, false

View file

@ -0,0 +1,106 @@
// Package playback exposes the server side of direct-play selection: the list
// of playback engines and the decision of whether a particular item can be
// direct-played by the engines a client declares.
//
// Vellum does not transcode. The server reports what is in each file (codec
// facts written by the scanner) and matches it against the client's declared
// engines; the client is the only party that knows its own hardware. The web
// app declares "html5"; a native client declares "mpv"/"vlc"/"mdk" as well.
package playback
import (
"errors"
"log/slog"
"net/http"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/engine"
"github.com/mithral/vellum/internal/httpx"
)
// Handlers serves the playback selection endpoints.
type Handlers struct {
// items is the catalog seam. This package talks to a Store rather than to
// *sql.DB, so the storage engine is swappable without touching the handler —
// and a test can substitute a fake with no database at all.
items catalog.Store
log *slog.Logger
auth *auth.Authenticator
}
// NewHandlers builds the playback HTTP layer.
func NewHandlers(items catalog.Store, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{items: items, log: log, auth: a}
}
// Register mounts the routes. Both require an authenticated user; the resolve
// endpoint additionally scopes to items the caller may read.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/playback/engines", h.auth.Require(h.handleEngines))
mux.Handle("POST /api/v1/playback/resolve", h.auth.Require(h.handleResolve))
}
// handleEngines lists every engine the server knows about. A client uses this to
// show what it could offer and to name engines in a resolve request.
func (h *Handlers) handleEngines(w http.ResponseWriter, r *http.Request, u auth.User) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"engines": engine.All()})
}
// handleResolve answers whether the caller's client can direct-play an item.
//
// The body is {"item_id":N,"engines":["html5",...]}. The item's codec facts
// (container, vcodec, acodec) are read from the database and handed to
// engine.Resolve together with the client's declared engines.
func (h *Handlers) handleResolve(w http.ResponseWriter, r *http.Request, u auth.User) {
var req struct {
ItemID int64 `json:"item_id"`
Engines []string `json:"engines"`
}
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if req.ItemID <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid item_id")
return
}
// catalog.LoadFor is the shared access-control chokepoint: it reads the
// item and confirms the caller may read its library, returning the same
// ErrNotFound for a missing item and an unreadable one. That keeps an
// inaccessible item a 404, never a 403, so its existence is not disclosed.
if _, err := h.items.ItemFor(r.Context(), u, req.ItemID); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
h.fail(w, "load item", err)
return
}
// The first file carries the codec facts the player will try. An item with
// no file cannot be played, but is still a 200 with a resolution so the UI
// has one shape to render.
f, err := h.items.FirstFile(r.Context(), req.ItemID)
if errors.Is(err, catalog.ErrNotFound) {
httpx.WriteJSON(w, http.StatusOK, engine.Resolution{
Playable: false,
Reason: "nofile",
Message: "This item has no file to play.",
})
return
}
if err != nil {
h.fail(w, "load file facts", err)
return
}
res := engine.Resolve(engine.Facts(f.Container, f.VCodec, f.ACodec), req.Engines)
httpx.WriteJSON(w, http.StatusOK, res)
}
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
h.log.Error(what, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
}

View file

@ -0,0 +1,231 @@
package playback
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/engine"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "playback.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
t.Fatalf("exec %q: %v", q, err)
}
}
func mustID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
t.Fatalf("query %q: %v", q, err)
}
return id
}
func newHandlers(t *testing.T, db *sql.DB) *Handlers {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
a := auth.NewAuthenticator(db, "vellum_session", 0, false, log)
return NewHandlers(catalog.New(db), log, a)
}
// fixture builds two libraries with one item each and a user granted only the
// first. The item in the second library must be invisible.
type fixture struct {
db *sql.DB
libA int64
libB int64
userA int64
itemA int64 // mp4 h264 aac, readable
itemB int64 // mp4 hevc aac, NOT readable
}
func setup(t *testing.T) fixture {
t.Helper()
db := testDB(t)
f := fixture{db: db}
f.libA = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('A','video','/a') RETURNING id`)
f.libB = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('B','video','/b') RETURNING id`)
f.userA = mustID(t, db, `INSERT INTO users (username, password_hash) VALUES ('alice','x') RETURNING id`)
mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libA)
f.itemA = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'a.mp4', 'A') RETURNING id`, f.libA)
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'a.mp4', 1, 'mp4', 'h264', 'aac')`, f.itemA)
f.itemB = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'b.mp4', 'B') RETURNING id`, f.libB)
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'b.mp4', 1, 'mp4', 'hevc', 'aac')`, f.itemB)
return f
}
func resolve(t *testing.T, h *Handlers, u auth.User, body string) (int, engine.Resolution) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/resolve", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handleResolve(rec, req, u)
var res engine.Resolution
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
t.Fatalf("decode resolution: %v (body %s)", err, rec.Body.String())
}
}
return rec.Code, res
}
func TestEnginesEndpoint(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/playback/engines", nil)
rec := httptest.NewRecorder()
h.handleEngines(rec, req, auth.User{ID: f.userA})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var body struct {
Engines []engine.Engine `json:"engines"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode engines: %v", err)
}
got := map[string]bool{}
for _, e := range body.Engines {
got[e.ID] = true
}
for _, id := range []string{"html5", "mpv", "vlc", "mdk"} {
if !got[id] {
t.Errorf("engines response is missing %q: %s", id, rec.Body.String())
}
}
}
func TestResolvePlayableAndNot(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
u := auth.User{ID: f.userA}
code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "html5" {
t.Fatalf("h264 resolve = %d %+v, want playable html5", code, res)
}
// The readable item's facts are h264, so to test HEVC we point at itemB but
// grant it first — scoping is tested separately.
mustExec(t, f.db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libB)
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
if code != http.StatusOK || res.Playable {
t.Fatalf("hevc resolve = %d %+v, want not playable", code, res)
}
if res.Reason != "vcodec:hevc" {
t.Errorf("reason = %q, want vcodec:hevc", res.Reason)
}
if !strings.Contains(res.Message, "HEVC") {
t.Errorf("message = %q, want it to name HEVC", res.Message)
}
// A native engine changes the answer.
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5","mpv"]}`)
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "mpv" {
t.Fatalf("hevc+mpv resolve = %d %+v, want playable mpv", code, res)
}
}
// TestResolveGrantScoping pins the chokepoint: an item in a library the caller
// cannot read must be a 404, indistinguishable from a missing item.
func TestResolveGrantScoping(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
// userA may read libA only; itemB lives in libB.
code, _ := resolve(t, h, auth.User{ID: f.userA}, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("inaccessible item status = %d, want 404", code)
}
// A user with no grants gets the same 404 for a readable-by-someone item.
none := mustID(t, f.db, `INSERT INTO users (username, password_hash) VALUES ('carol','x') RETURNING id`)
code, _ = resolve(t, h, auth.User{ID: none}, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("no-grant item status = %d, want 404", code)
}
// A nonexistent item is also a 404, not a leak.
code, _ = resolve(t, h, auth.User{ID: f.userA}, `{"item_id":99999,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("missing item status = %d, want 404", code)
}
}
func TestResolveValidation(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
u := auth.User{ID: f.userA}
for _, body := range []string{
`{"item_id":0,"engines":["html5"]}`,
`{"item_id":-3,"engines":["html5"]}`,
} {
if code, _ := resolve(t, h, u, body); code != http.StatusBadRequest {
t.Errorf("body %s status = %d, want 400", body, code)
}
}
// Missing engines is allowed: the handler falls back to the html5 baseline.
if code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`}`); code != http.StatusOK || !res.Playable {
t.Errorf("missing engines = %d %+v, want playable fallback", code, res)
}
}
// TestRegisterRequiresAuth proves the routes are mounted behind the auth
// middleware: an unauthenticated request is rejected with 401.
func TestRegisterRequiresAuth(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
mux := http.NewServeMux()
h.Register(mux)
for _, tc := range []struct{ method, path, body string }{
{http.MethodGet, "/api/v1/playback/engines", ""},
{http.MethodPost, "/api/v1/playback/resolve", `{"item_id":1,"engines":["html5"]}`},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s status = %d, want 401", tc.method, tc.path, rec.Code)
}
}
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }

133
internal/probe/probe.go Normal file
View file

@ -0,0 +1,133 @@
// Package probe reads the container and codec facts out of a media file with
// ffprobe, so a client can decide for itself whether it can direct-play the
// file.
//
// Vellum never transcodes. The server has no spare CPU, and measured against
// real files, playability turned out to be a property of the CLIENT, not the
// file: Chrome on Linux plays an MKV carrying H.264 perfectly well, yet cannot
// decode an MP4 carrying HEVC at all — it plays the audio track and reports
// videoWidth 0. So the server's only job is to expose what is actually inside
// the file; each client decides what it can handle.
//
// ffprobe is OPTIONAL. Vellum runs without it, just with less information:
// Available reports whether it is on PATH, and callers skip probing when it is
// absent rather than treating that as an error.
package probe
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
// timeout bounds a single ffprobe invocation. A file on a stalled network mount
// must never hang a scan, so ffprobe is killed after this and the error is
// recorded like any other probe failure.
const timeout = 15 * time.Second
// Info is the subset of ffprobe facts Vellum stores per file.
//
// Zero values are meaningful: an empty VCodec means there is no video track
// (audio, or a container whose only "video" stream is embedded cover art), and
// an empty ACodec means there is no audio track. DurationMS and the dimensions
// stay zero when ffprobe could not determine them.
type Info struct {
DurationMS int64
VCodec string
ACodec string
Width int
Height int
}
// Available reports whether ffprobe is on PATH.
//
// Callers use it to skip probing entirely, so Vellum still works on a host with
// no ffmpeg installed — the library just carries no codec facts, and every
// client is left to try the file and find out.
func Available() bool {
_, err := exec.LookPath("ffprobe")
return err == nil
}
// File probes one file and returns its codec facts. The returned context
// deadline is capped at timeout regardless of the caller's deadline.
func File(ctx context.Context, path string) (Info, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-print_format", "json",
"-show_format",
"-show_streams",
path,
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return Info{}, fmt.Errorf("probe %s: %w", path, ctx.Err())
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return Info{}, fmt.Errorf("probe %s: %s", path, msg)
}
var out ffprobeOutput
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
return Info{}, fmt.Errorf("probe %s: parse ffprobe output: %w", path, err)
}
info := Info{}
for _, s := range out.Streams {
switch s.CodecType {
case "video":
// Embedded cover art (an mp3's album image) is reported as a video
// stream. It is not a playable video track, so ignore it and leave
// VCodec empty for audio files.
if s.Disposition.AttachedPic != 0 || info.VCodec != "" {
continue
}
info.VCodec = s.CodecName
info.Width = s.Width
info.Height = s.Height
case "audio":
if info.ACodec == "" {
info.ACodec = s.CodecName
}
}
}
// ffprobe reports the container duration in seconds as a decimal string.
// It is absent for some live/streamed inputs; leave DurationMS zero then.
if d, err := strconv.ParseFloat(out.Format.Duration, 64); err == nil && d > 0 {
info.DurationMS = int64(d * 1000)
}
return info, nil
}
// ffprobeOutput mirrors only the JSON fields Vellum reads from
// `ffprobe -print_format json -show_format -show_streams`.
type ffprobeOutput struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
Disposition struct {
AttachedPic int `json:"attached_pic"`
} `json:"disposition"`
} `json:"streams"`
}

View file

@ -0,0 +1,96 @@
package probe
import (
"context"
"os/exec"
"path/filepath"
"testing"
)
// TestAvailable never skips: the answer is a plain bool and must not panic on a
// host without ffprobe.
func TestAvailable(t *testing.T) {
_ = Available()
}
// TestFile probes a fixture generated on the fly. It skips cleanly when ffprobe
// or ffmpeg is missing, so the suite passes on a machine with neither.
func TestFile(t *testing.T) {
if !Available() {
t.Skip("ffprobe not on PATH; skipping probe test")
}
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not on PATH; cannot generate a fixture")
}
path := filepath.Join(t.TempDir(), "clip.mp4")
// One second of H.264 video and AAC audio in an MP4.
gen := exec.Command(ffmpeg, "-v", "error", "-y",
"-f", "lavfi", "-i", "testsrc=size=64x48:rate=10:duration=1",
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-c:v", "libx264", "-c:a", "aac", "-shortest", path,
)
if out, err := gen.CombinedOutput(); err != nil {
t.Skipf("ffmpeg cannot produce a fixture here: %v\n%s", err, out)
}
info, err := File(context.Background(), path)
if err != nil {
t.Fatalf("File: %v", err)
}
if info.VCodec != "h264" {
t.Errorf("VCodec = %q, want h264", info.VCodec)
}
if info.ACodec != "aac" {
t.Errorf("ACodec = %q, want aac", info.ACodec)
}
if info.Width != 64 || info.Height != 48 {
t.Errorf("dimensions = %dx%d, want 64x48", info.Width, info.Height)
}
if info.DurationMS < 500 || info.DurationMS > 2000 {
t.Errorf("DurationMS = %d, want ~1000", info.DurationMS)
}
}
// TestFileAudioOnly confirms an audio file reports no video codec, so a client
// does not mistake an mp3 for a video with a missing track.
func TestFileAudioOnly(t *testing.T) {
if !Available() {
t.Skip("ffprobe not on PATH; skipping probe test")
}
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not on PATH; cannot generate a fixture")
}
path := filepath.Join(t.TempDir(), "tone.m4a")
gen := exec.Command(ffmpeg, "-v", "error", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-c:a", "aac", path,
)
if out, err := gen.CombinedOutput(); err != nil {
t.Skipf("ffmpeg cannot produce a fixture here: %v\n%s", err, out)
}
info, err := File(context.Background(), path)
if err != nil {
t.Fatalf("File: %v", err)
}
if info.VCodec != "" {
t.Errorf("VCodec = %q, want empty for audio-only", info.VCodec)
}
if info.ACodec != "aac" {
t.Errorf("ACodec = %q, want aac", info.ACodec)
}
}
// TestFileMissing confirms a missing file is an error, not a silent zero Info.
func TestFileMissing(t *testing.T) {
if !Available() {
t.Skip("ffprobe not on PATH; skipping probe test")
}
if _, err := File(context.Background(), filepath.Join(t.TempDir(), "nope.mp4")); err == nil {
t.Fatal("File on a missing path: want error, got nil")
}
}

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"
@ -13,12 +13,17 @@ import (
// OpenLibrary matches books by title and author. No API key, no registration.
type OpenLibrary struct {
Client *http.Client
// BaseURL is overridable so tests can point at a local server.
BaseURL string
Client *http.Client
}
// NewOpenLibrary builds the provider with a bounded HTTP client.
func NewOpenLibrary() *OpenLibrary {
return &OpenLibrary{Client: &http.Client{Timeout: 10 * time.Second}}
return &OpenLibrary{
BaseURL: "https://openlibrary.org",
Client: &http.Client{Timeout: 10 * time.Second},
}
}
func (p *OpenLibrary) Name() string { return "openlibrary" }
@ -57,7 +62,7 @@ func (p *OpenLibrary) Search(ctx context.Context, q Query) ([]Candidate, error)
return nil, nil
}
endpoint := "https://openlibrary.org/search.json?" + params.Encode()
endpoint := strings.TrimRight(p.BaseURL, "/") + "/search.json?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err

View file

@ -0,0 +1,100 @@
package providers
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestOpenLibrarySearchDBFree is the point of the split: a provider is an HTTP
// client, so it is exercisable end to end against a stub server with no
// database anywhere in sight.
func TestOpenLibrarySearchDBFree(t *testing.T) {
var gotTitle, gotAuthor, gotLimit, gotUA string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search.json" {
t.Errorf("path = %q, want /search.json", r.URL.Path)
}
q := r.URL.Query()
gotTitle, gotAuthor, gotLimit = q.Get("title"), q.Get("author"), q.Get("limit")
gotUA = r.Header.Get("User-Agent")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"docs":[
{"key":"/works/OL45804W","title":"The Hobbit","author_name":["J.R.R. Tolkien"],
"first_publish_year":1937,"publisher":["Allen & Unwin"],"language":["eng"],
"cover_i":8225261,"isbn":["9780261102217"]},
{"key":"/works/OL1W","title":"","author_name":["Nobody"]}
]}`))
}))
defer srv.Close()
p := NewOpenLibrary()
p.BaseURL = srv.URL
cands, err := p.Search(context.Background(), Query{Title: "The Hobbit", Author: "Tolkien"})
if err != nil {
t.Fatalf("Search: %v", err)
}
// The untitled doc is dropped rather than surfaced as an empty candidate.
if len(cands) != 1 {
t.Fatalf("got %d candidates, want 1", len(cands))
}
c := cands[0]
if c.Provider != "openlibrary" || c.ExternalID != "/works/OL45804W" {
t.Errorf("provider id = %s/%s", c.Provider, c.ExternalID)
}
if c.Title != "The Hobbit" || c.Year != 1937 {
t.Errorf("title/year = %q/%d", c.Title, c.Year)
}
if len(c.Authors) != 1 || c.Authors[0] != "J.R.R. Tolkien" {
t.Errorf("Authors = %v", c.Authors)
}
if c.Publisher != "Allen & Unwin" || c.Language != "eng" {
t.Errorf("publisher/language = %q/%q", c.Publisher, c.Language)
}
if c.ISBN != "9780261102217" {
t.Errorf("ISBN = %q", c.ISBN)
}
if !strings.Contains(c.CoverURL, "covers.openlibrary.org/b/id/8225261") {
t.Errorf("CoverURL = %q", c.CoverURL)
}
if gotTitle != "The Hobbit" || gotAuthor != "Tolkien" {
t.Errorf("query title/author = %q/%q", gotTitle, gotAuthor)
}
if gotLimit != "5" {
t.Errorf("limit = %q, want 5", gotLimit)
}
if gotUA == "" {
t.Error("no User-Agent sent; Open Library asks callers to identify themselves")
}
}
func TestOpenLibrarySkipsRequestWithoutATerm(t *testing.T) {
p := NewOpenLibrary()
cands, err := p.Search(context.Background(), Query{})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(cands) != 0 {
t.Errorf("got %d candidates for an empty query, want 0", len(cands))
}
}
func TestOpenLibraryReportsHTTPFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
p := NewOpenLibrary()
p.BaseURL = srv.URL
if _, err := p.Search(context.Background(), Query{Title: "The Hobbit"}); err == nil {
t.Fatal("expected an error for a 429 response")
}
}

View file

@ -1,9 +1,14 @@
// Package metadata resolves media items against external catalogues.
// Package providers talks to external metadata catalogues.
//
// One client per service, no database, no policy: a provider turns a Query into
// candidates and knows nothing about what the caller does with them. Matching
// policy and persistence belong to the caller (internal/metadata), which is
// what keeps these clients testable against an httptest server alone.
//
// Providers are optional by design: a library with no network still browses,
// because a failed lookup is recorded and skipped, never surfaced as a scan or
// request failure.
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,14 @@
package library
// Package scanner walks a library's root and reconciles it with the database:
// files that appeared become items, files that changed are re-upserted with
// fresh codec facts, and files that vanished are flagged missing rather than
// deleted.
//
// It is separate from internal/library because the two have different callers.
// A scan runs in a background job with no user and no HTTP request, tolerates a
// partially readable tree, and must never delete; library CRUD is an admin
// surface where a bad request is a 400 and a bad path is a 404. Scanner imports
// library (for Library and the media-kind registry), never the other way round.
package scanner
import (
"context"
@ -6,7 +16,6 @@ import (
"fmt"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
"sort"
@ -16,7 +25,9 @@ import (
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/jobs"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/natsort"
"github.com/mithral/vellum/internal/probe"
)
const (
@ -86,11 +97,11 @@ type group struct {
// rather than deleted, so their progress survives a temporarily unavailable
// mount. A scan never fails because of a single unreadable file — it records
// the error and continues, because a partial library is better than none.
func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanResult, error) {
func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger) (ScanResult, error) {
start := time.Now()
res := ScanResult{LibraryID: lib.ID}
exts := extensionsFor(lib.Kind)
exts := library.ExtensionsFor(lib.Kind)
if len(exts) == 0 {
return res, fmt.Errorf("library %d: no extensions registered for kind %q", lib.ID, lib.Kind)
}
@ -130,6 +141,17 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR
}
batch := groupKeys[start:end]
// Probe the changed media files in this batch BEFORE opening the write
// transaction, so a slow ffprobe (a stalled mount, a huge file) can
// never hold SQLite's write lock.
//
// The probe map is deliberately built from the same changed-only test
// the transaction uses below. Probing on the unchanged path would turn
// every rescan into a full ffprobe sweep of the entire library; the
// size+mtime skip is what keeps rescans cheap, and the probe must live
// on the changed side of it.
probed := probeBatch(ctx, lib, groups, batch, knownFiles, &res)
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
for _, key := range batch {
g := groups[key]
@ -150,7 +172,8 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR
continue
}
if err := upsertFile(ctx, tx, itemID, c); err != nil {
info, wasProbed := probed[c.relPath]
if err := upsertFile(ctx, tx, itemID, c, info, wasProbed, now); err != nil {
res.addError(fmt.Sprintf("%s: %v", c.relPath, err))
continue
}
@ -224,7 +247,7 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
for _, rel := range batch {
if err := markFileMissing(ctx, tx, rel); err != nil {
if err := markFileMissing(ctx, tx, rel, now); err != nil {
res.addError(fmt.Sprintf("%s: %v", rel, err))
}
}
@ -238,7 +261,7 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR
res.Duration = time.Since(start).Milliseconds()
log.Info("scan complete",
"library", lib.Name, "kind", lib.Kind, "grouping", GroupModeFor(lib.Kind),
"library", lib.Name, "kind", lib.Kind, "grouping", library.GroupModeFor(lib.Kind),
"scanned", res.Scanned, "added", res.Added, "updated", res.Updated,
"restored", res.Restored, "missing", res.Missing, "unchanged", res.Unchanged,
"duration_ms", res.Duration, "errors", len(res.Errors),
@ -267,23 +290,23 @@ func commitBatch(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) err
// buildGroups turns found files into items. Directory-grouped kinds (audiobooks,
// music) collapse a folder of tracks into one item; everything else is one item
// per file.
func buildGroups(lib Library, found map[string]candidate) map[string]group {
mode := GroupModeFor(lib.Kind)
func buildGroups(lib library.Library, found map[string]candidate) map[string]group {
mode := library.GroupModeFor(lib.Kind)
groups := make(map[string]group)
for _, rel := range sortedCandidateKeys(found) {
c := found[rel]
var key, title string
if mode == GroupByDirectory {
if mode == library.GroupByDirectory {
dir := path.Dir(rel)
if dir == "." {
key, title = ".", lib.Name
} else {
key, title = dir, CleanTitle(path.Base(dir))
key, title = dir, library.CleanTitle(path.Base(dir))
}
} else {
key, title = rel, CleanTitle(rel)
key, title = rel, library.CleanTitle(rel)
}
g := groups[key]
@ -415,13 +438,13 @@ func loadKnownItems(ctx context.Context, db *sql.DB, libraryID int64) (map[strin
return out, rows.Err()
}
func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, key, title string) (int64, error) {
func upsertItem(ctx context.Context, tx *sql.Tx, lib library.Library, key, title string) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing)
VALUES (?, ?, ?, ?, ?, 0)
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, missing_since)
VALUES (?, ?, ?, ?, ?, 0, '')
ON CONFLICT (library_id, path)
DO UPDATE SET missing = 0
DO UPDATE SET missing = 0, missing_since = ''
RETURNING id`,
lib.ID, lib.Kind, key, title, strings.ToLower(title),
).Scan(&id)
@ -431,30 +454,121 @@ func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, key, title string)
return id, nil
}
func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate) error {
func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info probe.Info, probed bool, now string) error {
// A non-probed file (an ebook, a photo, or any file on a host without
// ffprobe) must keep whatever codec facts a previous scan recorded, so its
// upsert never mentions those columns at all.
if !probed {
_, err := tx.ExecContext(ctx, `
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, missing_since)
VALUES (?, ?, ?, ?, ?, ?, 0, '')
ON CONFLICT (path)
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
mtime = excluded.mtime, inode = excluded.inode,
container = excluded.container, missing = 0,
missing_since = ''`,
itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext)
if err != nil {
return fmt.Errorf("upsert file: %w", err)
}
return nil
}
_, err := tx.ExecContext(ctx, `
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing)
VALUES (?, ?, ?, ?, ?, ?, 0)
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, missing_since,
duration_ms, vcodec, acodec, width, height, probed_at)
VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?, ?, ?, ?, ?)
ON CONFLICT (path)
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
mtime = excluded.mtime, inode = excluded.inode,
container = excluded.container, missing = 0`,
itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext)
container = excluded.container, missing = 0,
missing_since = '',
duration_ms = excluded.duration_ms, vcodec = excluded.vcodec,
acodec = excluded.acodec, width = excluded.width,
height = excluded.height, probed_at = excluded.probed_at`,
itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext,
info.DurationMS, info.VCodec, info.ACodec, info.Width, info.Height, now)
if err != nil {
return fmt.Errorf("upsert file: %w", err)
}
return nil
}
// probeBatch probes every new or changed video/audio file in one batch and
// returns the facts keyed by relative path.
//
// Only files the transaction will treat as changed (a size+mtime mismatch, or a
// row that is absent or flagged missing) are probed; unchanged files are absent
// from the result, so no ffprobe runs and no codec column is rewritten for
// them. Non-media kinds (ebooks, comics, photos, games) are never probed: they
// hold no media streams, and running ffprobe over a library of them would be
// pure waste.
//
// It runs before commitBatch opens the write transaction, so the write lock is
// never held across an ffprobe. A probe failure is recorded through the bounded
// error mechanism and the file is written with empty codec fields rather than
// failing the scan.
func probeBatch(ctx context.Context, lib library.Library, groups map[string]group, batch []string, known map[string]knownFile, res *ScanResult) map[string]probe.Info {
if !probeable(lib.Kind) || !probe.Available() {
return nil
}
out := make(map[string]probe.Info)
for _, key := range batch {
for _, c := range groups[key].files {
if prev, ok := known[c.relPath]; ok && !prev.missing && prev.size == c.size && prev.mtime == c.mtime {
continue // unchanged: never re-probe
}
abs := filepath.Join(lib.RootPath, filepath.FromSlash(c.relPath))
info, err := probe.File(ctx, abs)
if err != nil {
res.addError(fmt.Sprintf("%s: %v", c.relPath, err))
// Record the attempt with empty fields; the codecs stay empty
// until the file changes again.
out[c.relPath] = probe.Info{}
continue
}
out[c.relPath] = info
}
}
return out
}
// probeable reports whether a library kind holds files worth probing for codec
// facts. Only video and the audio kinds have media streams; ebooks, comics,
// photos, and games are never probed.
func probeable(kind string) bool {
switch kind {
case "video", "audiobooks", "music":
return true
default:
return false
}
}
// markItemMissing flags an item whose whole group has vanished. missing_since
// records when it first disappeared, and is written only on that transition: a
// rescan of an already-missing item must not refresh the age, or a file gone
// for months would look "just now missing" on every pass. An empty timestamp
// (a row that went missing before this column existed) is filled once, so the
// age is still real rather than permanently blank.
func markItemMissing(ctx context.Context, tx *sql.Tx, libraryID int64, key, now string) error {
_, err := tx.ExecContext(ctx, `
UPDATE media_items SET missing = 1
WHERE library_id = ? AND path = ?`, libraryID, key)
UPDATE media_items SET missing = 1, missing_since = ?
WHERE library_id = ? AND path = ? AND (missing = 0 OR missing_since = '')`,
now, libraryID, key)
return err
}
func markFileMissing(ctx context.Context, tx *sql.Tx, rel string) error {
_, err := tx.ExecContext(ctx, `UPDATE media_files SET missing = 1 WHERE path = ?`, rel)
// markFileMissing flags a file that vanished while its item survived. It obeys
// the same transition rule as markItemMissing: the timestamp is set once and
// never rewritten by a later scan.
func markFileMissing(ctx context.Context, tx *sql.Tx, rel, now string) error {
_, err := tx.ExecContext(ctx, `
UPDATE media_files SET missing = 1, missing_since = ?
WHERE path = ? AND (missing = 0 OR missing_since = '')`,
now, rel)
return err
}
@ -486,9 +600,3 @@ func parseUnixNano(s string) int64 {
}
return n
}
// RootExists reports whether a library's root is currently reachable.
func RootExists(lib Library) bool {
info, err := os.Stat(lib.RootPath)
return err == nil && info.IsDir()
}

View file

@ -0,0 +1,430 @@
package scanner
import (
"context"
"database/sql"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "scan.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestScanLifecycle(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
writeFile(t, filepath.Join(root, "sub", "c.epub"), "cccccccccccccccccccccccccccccc")
writeFile(t, filepath.Join(root, "notes.txt"), "not media")
writeFile(t, filepath.Join(root, ".hidden.epub"), "hidden")
writeFile(t, filepath.Join(root, ".git", "d.epub"), "in a hidden dir")
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
// First scan: three supported files, nothing else.
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("Scan: %v", err)
}
if res.Added != 3 || res.Scanned != 3 {
t.Fatalf("first scan added=%d scanned=%d, want 3/3 (%+v)", res.Added, res.Scanned, res)
}
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
t.Errorf("item count = %d, want 3", n)
}
// Second scan: nothing changed.
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Unchanged != 3 || res.Added != 0 || res.Updated != 0 {
t.Errorf("second scan unchanged=%d added=%d updated=%d, want 3/0/0", res.Unchanged, res.Added, res.Updated)
}
// Modify a.epub (different size).
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaa")
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Updated != 1 || res.Unchanged != 2 {
t.Errorf("after modify updated=%d unchanged=%d, want 1/2", res.Updated, res.Unchanged)
}
// Delete b.epub: it goes missing, it is not deleted.
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
t.Fatal(err)
}
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Missing != 1 {
t.Errorf("after delete missing=%d, want 1", res.Missing)
}
if n, _ := library.CountItems(ctx, db, lib.ID); n != 2 {
t.Errorf("item count (excluding missing) = %d, want 2", n)
}
// The row still exists, flagged missing.
var missing int
if err := db.QueryRowContext(ctx,
`SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "b.epub").Scan(&missing); err != nil {
t.Fatalf("missing item row was deleted: %v", err)
}
if missing != 1 {
t.Errorf("missing flag = %d, want 1", missing)
}
// Restore b.epub.
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
res, err = Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatal(err)
}
if res.Restored != 1 {
t.Errorf("after restore restored=%d, want 1 (%+v)", res.Restored, res)
}
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
t.Errorf("item count after restore = %d, want 3", n)
}
}
func TestScanGroupsDirectoryKinds(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
// An audiobook is a folder of chapters.
writeFile(t, filepath.Join(root, "Author", "Book", "01 - Chapter One.mp3"), "a")
writeFile(t, filepath.Join(root, "Author", "Book", "02 - Chapter Two.mp3"), "b")
writeFile(t, filepath.Join(root, "Author", "Book", "10 - Chapter Ten.mp3"), "c")
// A loose file at the root groups under the library name.
writeFile(t, filepath.Join(root, "stray.mp3"), "d")
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("Scan: %v", err)
}
if res.Added != 4 {
t.Errorf("added = %d, want 4 (four tracks)", res.Added)
}
// Two items: the directory, and the root group.
var items int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
t.Fatal(err)
}
if items != 2 {
t.Fatalf("items = %d, want 2", items)
}
var title string
if err := db.QueryRowContext(ctx,
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Author/Book").Scan(&title); err != nil {
t.Fatalf("grouped item not found: %v", err)
}
if title != "Book" {
t.Errorf("grouped title = %q, want Book", title)
}
// Tracks must come back in natural order, not lexical.
rows, err := db.QueryContext(ctx, `
SELECT f.path FROM media_files f JOIN media_items i ON i.id = f.item_id
WHERE i.path = ? ORDER BY f.id`, "Author/Book")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
t.Fatal(err)
}
paths = append(paths, p)
}
want := []string{
"Author/Book/01 - Chapter One.mp3",
"Author/Book/02 - Chapter Two.mp3",
"Author/Book/10 - Chapter Ten.mp3",
}
if len(paths) != len(want) {
t.Fatalf("track count = %d, want %d: %v", len(paths), len(want), paths)
}
for i := range want {
if paths[i] != want[i] {
t.Errorf("track %d = %q, want %q", i, paths[i], want[i])
}
}
}
func TestScanUnreadableRoot(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "x")
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatal(err)
}
// Index it once so there is a known file to go missing.
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatal(err)
}
// Remove the root out from under the library.
if err := os.RemoveAll(root); err != nil {
t.Fatal(err)
}
res, err := Scan(ctx, db, lib, testLogger())
if err != nil {
t.Fatalf("scan should not fail on a missing root: %v", err)
}
if res.Added != 0 {
t.Errorf("added=%d, want 0", res.Added)
}
if res.Missing != 1 {
t.Errorf("missing=%d, want 1 (existing item now vanished)", res.Missing)
}
if len(res.Errors) == 0 {
t.Error("expected the walk error to be recorded")
}
}
func TestMissingSinceLifecycle(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("first Scan: %v", err)
}
// A present file has no missing timestamp.
var since string
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != "" {
t.Errorf("present file missing_since = %q, want empty", since)
}
// Delete b.epub: it goes missing and the timestamp is recorded.
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("missing Scan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since == "" {
t.Fatal("missing_since was not set on the transition into missing")
}
first := since
// A rescan must not refresh the age.
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("rescan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != first {
t.Errorf("missing_since reset on rescan: %q -> %q", first, since)
}
// ListMissing surfaces the file with its age.
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
if err != nil {
t.Fatalf("ListMissing: %v", err)
}
if len(entries) != 1 {
t.Fatalf("ListMissing returned %d entries, want 1", len(entries))
}
e := entries[0]
if e.Title != "b" || e.Path != "b.epub" || e.Kind != "ebooks" || e.LibraryID != lib.ID {
t.Errorf("unexpected missing entry: %+v", e)
}
if e.MissingSince != first {
t.Errorf("entry missing_since = %q, want %q", e.MissingSince, first)
}
// Restore the file: missing_since must be cleared, not left behind.
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("restore Scan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != "" {
t.Errorf("missing_since after restore = %q, want empty", since)
}
if got, _ := library.ListMissing(ctx, db, []int64{lib.ID}); len(got) != 0 {
t.Errorf("ListMissing after restore returned %d entries, want 0", len(got))
}
}
func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
// Two audiobooks, each a folder of tracks.
writeFile(t, filepath.Join(root, "Book A", "01.mp3"), "a1")
writeFile(t, filepath.Join(root, "Book A", "02.mp3"), "a2")
writeFile(t, filepath.Join(root, "Book B", "01.mp3"), "b1")
writeFile(t, filepath.Join(root, "Book B", "02.mp3"), "b2")
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("Scan: %v", err)
}
// A second library with its own missing item, to prove purge is scoped.
otherRoot := t.TempDir()
writeFile(t, filepath.Join(otherRoot, "gone.epub"), "x")
other, err := library.Create(ctx, db, "Other", "ebooks", otherRoot)
if err != nil {
t.Fatalf("Create other: %v", err)
}
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
t.Fatalf("Scan other: %v", err)
}
if err := os.Remove(filepath.Join(otherRoot, "gone.epub")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
t.Fatalf("Scan other (missing): %v", err)
}
// Remove one track from Book A: the item is not missing, only that file.
// Remove Book B entirely: the item and both its files are missing.
if err := os.Remove(filepath.Join(root, "Book A", "02.mp3")); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(root, "Book B")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("second Scan: %v", err)
}
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
if err != nil {
t.Fatalf("ListMissing: %v", err)
}
if len(entries) != 3 {
t.Fatalf("ListMissing returned %d entries, want 3 (Book A/02 + Book B/01 + Book B/02)", len(entries))
}
// Purge must remove only Book B.
n, err := library.PurgeMissing(ctx, db, lib.ID)
if err != nil {
t.Fatalf("PurgeMissing: %v", err)
}
if n != 1 {
t.Errorf("purged = %d, want 1 (only the fully missing item)", n)
}
// Book A survives, with its present and missing files intact.
var items int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
t.Fatal(err)
}
if items != 1 {
t.Errorf("media_items after purge = %d, want 1", items)
}
var bookATitle string
if err := db.QueryRowContext(ctx,
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Book A").Scan(&bookATitle); err != nil {
t.Fatalf("a non-missing item was purged: %v", err)
}
var files int
if err := db.QueryRowContext(ctx, `
SELECT count(*) FROM media_files f JOIN media_items i ON i.id = f.item_id
WHERE i.library_id = ? AND i.path = ?`, lib.ID, "Book A").Scan(&files); err != nil {
t.Fatal(err)
}
if files != 2 {
t.Errorf("Book A files after purge = %d, want 2", files)
}
// The other library's missing item is untouched.
var otherItems int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, other.ID).Scan(&otherItems); err != nil {
t.Fatal(err)
}
if otherItems != 1 {
t.Errorf("other library items after purge = %d, want 1 (purge must be scoped)", otherItems)
}
// A second purge has nothing left to remove.
if n, err := library.PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 {
t.Errorf("second purge = %d, %v; want 0, nil", n, err)
}
}

View file

@ -38,11 +38,17 @@ var contentTypes = map[string]string{
".ogg": "audio/ogg",
".wav": "audio/wav",
// Video.
//
// This table and the library extension registry (internal/library/registry.go)
// must agree: an extension in the video registry with no MIME entry here is
// served as application/octet-stream, which browsers download instead of
// playing. .avi was in the registry but missing here.
".mp4": "video/mp4",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".webm": "video/webm",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
// Images (comic pages, covers, photos).
".jpg": "image/jpeg",
".jpeg": "image/jpeg",

113
internal/subsonic/auth.go Normal file
View file

@ -0,0 +1,113 @@
package subsonic
import (
"crypto/md5"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"strings"
"github.com/mithral/vellum/internal/apitoken"
"github.com/mithral/vellum/internal/auth"
)
// authenticate resolves the Subsonic credentials on a request to a user.
//
// Every accepted form is checked against the user's recoverable Subsonic key
// (apitoken.Plain), which is the only credential this protocol can verify:
//
// - t = md5(key + s), with s the salt — the preferred form.
// - p = key directly (Navidrome accepts this, and so must we for older
// clients).
// - apiKey = key directly, the OpenSubsonic extension.
// - token = key-derived md5 with s — the same as t under the alternate
// parameter name some clients use.
//
// A missing user, a user with no Subsonic key, or a wrong credential all
// collapse to "not authenticated": the caller must not be able to tell them
// apart, and the response is always error code 40.
func (h *Handlers) authenticate(r *http.Request) (auth.User, bool) {
ctx := r.Context()
q := r.FormValue
username := strings.TrimSpace(q("u"))
apiKey := strings.TrimSpace(q("apiKey"))
// apiKey auth carries no username; resolve the key to its owner.
if username == "" {
if apiKey == "" {
return auth.User{}, false
}
userID, err := apitoken.Resolve(ctx, h.db, apiKey)
if err != nil {
return auth.User{}, false
}
u, err := auth.GetUserByID(ctx, h.db, userID)
if err != nil {
return auth.User{}, false
}
return u, true
}
u, err := auth.GetUserByUsername(ctx, h.db, username)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, false
}
if err != nil {
h.log.Error("subsonic: load user", "err", err)
return auth.User{}, false
}
key, err := apitoken.Plain(ctx, h.db, u.ID)
if errors.Is(err, apitoken.ErrNotFound) {
// The user has no Subsonic key yet; they must mint one from the web
// UI. Treated as a credential failure, not a server error.
return auth.User{}, false
}
if err != nil {
h.log.Error("subsonic: load key", "err", err)
return auth.User{}, false
}
// p = the key directly. "enc:" is the spec's hex-encoded form.
if p := q("p"); p != "" {
if raw, ok := strings.CutPrefix(p, "enc:"); ok {
decoded, err := hex.DecodeString(raw)
if err == nil {
p = string(decoded)
}
}
if constantTimeEqual(p, key) {
return u, true
}
}
// apiKey alongside u: same direct comparison.
if apiKey != "" && constantTimeEqual(apiKey, key) {
return u, true
}
// t (or token) = md5(key + s).
token := q("t")
if token == "" {
token = q("token")
}
salt := q("s")
if token != "" && salt != "" {
sum := md5.Sum([]byte(key + salt))
want := hex.EncodeToString(sum[:])
if constantTimeEqual(strings.ToLower(token), want) {
return u, true
}
}
return auth.User{}, false
}
// constantTimeEqual compares two credential strings without leaking their
// common prefix through timing.
func constantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

264
internal/subsonic/browse.go Normal file
View file

@ -0,0 +1,264 @@
package subsonic
import (
"context"
"net/http"
"path"
"sort"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/subsonic/dto"
)
// directoryForLibrary renders the root of one audio library.
func (h *Handlers) directoryForLibrary(w http.ResponseWriter, r *http.Request, u auth.User, libID int64) {
libs, err := h.audioLibraries(r.Context(), u)
if err != nil {
h.internal(w, r, "directory", err)
return
}
name := ""
for _, l := range libs {
if l.id == libID {
name = l.name
break
}
}
if name == "" {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.writeDirectory(w, r, u, libID, "", name)
}
// directoryForPath renders one directory inside an audio library.
func (h *Handlers) directoryForPath(w http.ResponseWriter, r *http.Request, u auth.User, libID int64, rel string) {
// Confirm the library is one the caller may read before exposing anything.
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "directory", err)
return
}
if !containsID(libs, libID) {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
rel = normalizeRel(rel)
name := path.Base(rel)
if rel == "." {
name = ""
}
h.writeDirectory(w, r, u, libID, rel, name)
}
// writeDirectory builds a <directory> element for a library path prefix.
//
// Vellum's scanner groups audio by directory and never writes parent_id, so the
// tree is reconstructed here from media_items.path: every item whose parent
// directory is the prefix is a direct album child, and every deeper item
// contributes one directory child for the next path segment.
func (h *Handlers) writeDirectory(w http.ResponseWriter, r *http.Request, u auth.User, libID int64, rel, name string) {
rel = normalizeRel(rel)
if name == "" {
name = path.Base(rel)
if rel == "." {
if root, err := h.libraryRoot(r.Context(), libID); err == nil {
name = path.Base(root)
}
if name == "" || name == "." || name == "/" {
name = "Music"
}
}
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, path, title FROM media_items
WHERE library_id = ? AND missing = 0 AND kind IN ('music','audiobooks')
ORDER BY path`, libID)
if err != nil {
h.internal(w, r, "directory", err)
return
}
defer rows.Close()
var (
childAlbumIDs []int64
subdirs = map[string]bool{}
)
for rows.Next() {
var (
id int64
ipath string
title string
)
if err := rows.Scan(&id, &ipath, &title); err != nil {
h.internal(w, r, "directory", err)
return
}
dir := normalizeRel(ipath)
parent := path.Dir(dir)
if parent == "" {
parent = "."
}
switch {
case parent == rel:
childAlbumIDs = append(childAlbumIDs, id)
case rel == "." && dir != ".":
subdirs[firstSegment(dir)] = true
case rel != "." && strings.HasPrefix(dir, rel+"/"):
rest := strings.TrimPrefix(dir, rel+"/")
if seg := firstSegment(rest); seg != "" {
subdirs[seg] = true
}
}
}
if err := rows.Err(); err != nil {
h.internal(w, r, "directory", err)
return
}
dirNode := dto.El("directory",
dto.At("id", dto.DirID(libID, rel)),
dto.At("name", name),
).Arrays("child")
subNames := make([]string, 0, len(subdirs))
for s := range subdirs {
subNames = append(subNames, s)
}
sort.Strings(subNames)
for _, seg := range subNames {
sub := seg
if rel != "." {
sub = rel + "/" + seg
}
dirNode.Add(dto.El("child",
dto.At("id", dto.DirID(libID, sub)),
dto.At("parent", dto.DirID(libID, rel)),
dto.At("isDir", true),
dto.At("title", path.Base(seg)),
dto.At("album", path.Base(seg)),
dto.At("artist", ""),
))
}
albums, err := h.albumsByIDs(r.Context(), libID, childAlbumIDs)
if err != nil {
h.internal(w, r, "directory albums", err)
return
}
for _, a := range albums {
dirNode.Add(dto.ChildNode(a))
}
root := envelope("ok")
root.Add(dirNode)
h.write(w, r, root)
}
// directoryForAlbum renders an album as a directory whose children are songs.
func (h *Handlers) directoryForAlbum(w http.ResponseWriter, r *http.Request, u auth.User, itemID int64) {
a, ok, err := h.loadAlbum(r.Context(), u, itemID)
if err != nil {
h.internal(w, r, "directory", err)
return
}
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
tracks, err := h.tracksForItem(r.Context(), itemID)
if err != nil {
h.internal(w, r, "directory songs", err)
return
}
dirNode := dto.El("directory",
dto.At("id", dto.AlbumID(a.ID)),
dto.At("parent", dto.DirID(a.LibraryID, parentDirOf(a.Path))),
dto.At("name", a.Title),
).Arrays("child")
for _, t := range tracks {
child := dto.SongNode(t)
child.Name = "child"
dirNode.Add(child)
}
root := envelope("ok")
root.Add(dirNode)
h.write(w, r, root)
}
// directoryForArtist renders an artist as a directory whose children are the
// artist's albums. Legacy clients reach an artist through getMusicDirectory
// rather than getArtist.
func (h *Handlers) directoryForArtist(w http.ResponseWriter, r *http.Request, u auth.User, personID int64) {
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "directory", err)
return
}
albums, err := h.albumsByArtist(r.Context(), libs, personID)
if err != nil {
h.internal(w, r, "directory albums", err)
return
}
if len(albums) == 0 {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
name, _ := h.artistName(r.Context(), personID)
dirNode := dto.El("directory",
dto.At("id", dto.ArtistID(personID)),
dto.At("name", name),
).Arrays("child")
for _, a := range albums {
dirNode.Add(dto.ChildNode(a))
}
root := envelope("ok")
root.Add(dirNode)
h.write(w, r, root)
}
// albumsByIDs loads the album projection for a specific id set within one
// library, preserving album-title order.
func (h *Handlers) albumsByIDs(ctx context.Context, libID int64, ids []int64) ([]dto.AlbumInfo, error) {
if len(ids) == 0 {
return []dto.AlbumInfo{}, nil
}
in, args := inClause(ids)
return h.albumsWhere(ctx, []int64{libID}, " AND i.id IN ("+in+")", args, "i.sort_title", 500, 0)
}
func containsID(ids []int64, id int64) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}
// normalizeRel maps the library root, spelled "" or ".", to the canonical ".".
func normalizeRel(rel string) string {
rel = strings.Trim(path.Clean("/"+rel), "/")
if rel == "" {
return "."
}
return rel
}
func firstSegment(p string) string {
if i := strings.IndexByte(p, '/'); i >= 0 {
return p[:i]
}
return p
}
// parentDirOf returns the parent directory of a media_items path, mapping the
// root to ".".
func parentDirOf(p string) string {
p = normalizeRel(p)
return path.Dir(p)
}

View file

@ -0,0 +1,31 @@
package subsonic
import (
"errors"
"net/http"
"github.com/mithral/vellum/internal/apitoken"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
)
// handleCredentials returns the username and Subsonic key a user configures a
// client with. The key is minted on first request so the user never has to
// manage tokens by hand; a later request returns the same key. This endpoint is
// cookie-authenticated, not Subsonic-authenticated: the user is in the web UI,
// not in the client.
func (h *Handlers) handleCredentials(w http.ResponseWriter, r *http.Request, u auth.User) {
key, err := apitoken.Plain(r.Context(), h.db, u.ID)
if errors.Is(err, apitoken.ErrNotFound) {
key, err = apitoken.Mint(r.Context(), h.db, u.ID, apitoken.KindSubsonic, "Subsonic client")
}
if err != nil {
h.log.Error("subsonic: credentials", "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"username": u.Username,
"key": key,
})
}

View file

@ -0,0 +1,215 @@
package dto
import (
"path/filepath"
"strconv"
"strings"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/stream"
)
// Vellum has two id spaces — media_items and media_files — and Subsonic has
// one opaque string id, so every id this layer emits is prefixed to say what it
// names. The prefix also lets stream/getCoverArt resolve an id without a
// separate lookup table.
//
// al-<itemID> an album / directory-grouped item
// mf-<fileID> a song, i.e. one physical media_files row
// ar-<personID> an artist, from people/item_people
// fd-<libID> a music folder (a whole audio library)
// d-<libID>:<relpath> a browsable directory within a library
const (
PrefixAlbum = "al-"
PrefixFile = "mf-"
PrefixArtist = "ar-"
PrefixFolder = "fd-"
PrefixDir = "d-"
)
// AlbumID renders an album id.
func AlbumID(itemID int64) string { return PrefixAlbum + strconv.FormatInt(itemID, 10) }
// FileID renders a song id.
func FileID(fileID int64) string { return PrefixFile + strconv.FormatInt(fileID, 10) }
// ArtistID renders an artist id.
func ArtistID(personID int64) string { return PrefixArtist + strconv.FormatInt(personID, 10) }
// FolderID renders a music folder id.
func FolderID(libID int64) string { return PrefixFolder + strconv.FormatInt(libID, 10) }
// DirID renders a browsable directory id.
func DirID(libID int64, rel string) string {
return PrefixDir + strconv.FormatInt(libID, 10) + ":" + rel
}
// ParseID splits a prefixed id, returning the numeric part.
func ParseID(id, prefix string) (int64, bool) {
rest, ok := strings.CutPrefix(id, prefix)
if !ok {
return 0, false
}
n, err := strconv.ParseInt(rest, 10, 64)
if err != nil || n <= 0 {
return 0, false
}
return n, true
}
// ParseDirID splits "d-<libID>:<relpath>". An empty relpath is the library root.
func ParseDirID(id string) (libID int64, rel string, ok bool) {
rest, ok := strings.CutPrefix(id, PrefixDir)
if !ok {
return 0, "", false
}
lib, rel, found := strings.Cut(rest, ":")
if !found {
return 0, "", false
}
n, err := strconv.ParseInt(lib, 10, 64)
if err != nil || n <= 0 {
return 0, "", false
}
return n, rel, true
}
// ── entities ───────────────────────────────────────────────────────────
// AlbumInfo is one album: a directory-grouped media_items row of an audio kind.
type AlbumInfo struct {
ID int64
LibraryID int64
Kind string
Path string
Title string
Year int
AddedAt string
ArtistID int64
Artist string
SongCount int
DurationMS int64
HasCover bool
}
// Track is one song: a media_files row together with its album's context.
type Track struct {
FileID int64
ItemID int64
LibraryID int64
Kind string
AlbumPath string
AlbumTitle string
Year int
AddedAt string
RelPath string
Size int64
DurationMS int64
Container string
ArtistID int64
Artist string
Ordinal int
HasCover bool
}
// SongNode renders a track as the Subsonic Child/Song element. getSong returns
// it bare; getAlbum nests it under <album>.
func SongNode(t Track) *Node {
ct := stream.ContentType(t.RelPath)
suffix := strings.TrimPrefix(strings.ToLower(filepath.Ext(t.RelPath)), ".")
n := El("song",
At("id", FileID(t.FileID)),
At("parent", AlbumID(t.ItemID)),
At("isDir", false),
At("title", library.CleanTitle(filepath.Base(t.RelPath))),
At("album", t.AlbumTitle),
At("albumId", AlbumID(t.ItemID)),
At("artist", t.Artist),
At("type", "music"),
At("isVideo", false),
)
if t.ArtistID > 0 {
n.Attrs = append(n.Attrs, At("artistId", ArtistID(t.ArtistID)))
}
if t.HasCover {
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(t.ItemID)))
}
if t.Ordinal > 0 {
n.Attrs = append(n.Attrs, At("track", int64(t.Ordinal)))
}
if t.Year > 0 {
n.Attrs = append(n.Attrs, At("year", int64(t.Year)))
}
n.Attrs = append(n.Attrs,
At("size", t.Size),
At("contentType", ct),
At("suffix", suffix),
At("duration", t.DurationMS/1000),
At("path", t.RelPath),
)
if t.AddedAt != "" {
n.Attrs = append(n.Attrs, At("created", t.AddedAt))
}
return n
}
// AlbumNode renders an album as the Subsonic AlbumID3 element. Extra legacy
// fields (title, album, isDir) are included because older clients read them and
// they cost nothing.
func AlbumNode(a AlbumInfo) *Node {
parent := FolderID(a.LibraryID)
if a.ArtistID > 0 {
parent = ArtistID(a.ArtistID)
}
n := El("album",
At("id", AlbumID(a.ID)),
At("parent", parent),
At("name", a.Title),
At("title", a.Title),
At("album", a.Title),
At("isDir", true),
At("artist", a.Artist),
At("songCount", int64(a.SongCount)),
At("duration", a.DurationMS/1000),
At("playCount", int64(0)),
)
if a.ArtistID > 0 {
n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID)))
}
if a.HasCover {
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID)))
}
if a.Year > 0 {
n.Attrs = append(n.Attrs, At("year", int64(a.Year)))
}
if a.AddedAt != "" {
n.Attrs = append(n.Attrs, At("created", a.AddedAt))
}
return n
}
// ChildNode renders an album or directory entry for getMusicDirectory /
// getIndexes, where the element is a generic Child rather than AlbumID3.
func ChildNode(a AlbumInfo) *Node {
n := El("child",
At("id", AlbumID(a.ID)),
At("parent", FolderID(a.LibraryID)),
At("isDir", true),
At("title", a.Title),
At("album", a.Title),
At("artist", a.Artist),
At("isVideo", false),
)
if a.ArtistID > 0 {
n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID)))
}
if a.HasCover {
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID)))
}
if a.Year > 0 {
n.Attrs = append(n.Attrs, At("year", int64(a.Year)))
}
return n
}

View file

@ -0,0 +1,243 @@
// Package dto is the Subsonic wire contract: the response envelope, the
// element tree it carries, the protocol's error codes and the opaque id scheme.
//
// It is deliberately separate from internal/subsonic, which owns the HTTP
// handlers and the queries. A node here knows nothing about a request, a
// response writer or a database row: it maps data structures into the two
// serialisations Subsonic clients speak (XML attributes and flattened JSON).
//
// The node names and attribute names below are load-bearing. Symfonium,
// Ultrasonic, play:Sub and every other client branch on them, and a rename here
// fails silently on devices nobody in this repository can see. Likewise error
// code 40: a client must read "wrong credentials" out of the envelope, never an
// HTTP 401.
package dto
import (
"bytes"
"encoding/json"
"encoding/xml"
"io"
"strconv"
)
const (
// ProtocolVersion is the Subsonic API version this layer implements. It is
// advertised in every envelope.
ProtocolVersion = "1.16.1"
// ServerType identifies the server implementation in the envelope.
ServerType = "vellum"
)
// ServerVersion is advertised as serverVersion in the envelope. The main
// binary may set it to its build version; it defaults to "dev".
var ServerVersion = "dev"
// Subsonic error codes. The protocol defines these; clients branch on them.
const (
ErrGeneric = 0 // a generic error
ErrMissingParameter = 10 // required parameter is missing
ErrWrongCredentials = 40 // wrong username or password
ErrNotAuthorized = 50 // user is not authorized for the operation
ErrDataNotFound = 70 // the requested data was not found
)
// StatusOK / StatusFailed are the two envelope `status` values.
const (
StatusOK = "ok"
StatusFailed = "failed"
)
// Attr is one attribute of an element. The value is typed — string, int64 or
// bool — rather than a string so the JSON form can emit a real number or
// boolean where the protocol expects one, while XML always stringifies it.
type Attr struct {
Name string
Value any
}
// Node is one element in a response tree. Attributes and children are kept in
// insertion order, which XML needs (attribute order is cosmetic but stable
// output is easier to test) and which lets JSON grouping stay deterministic.
//
// The tree exists instead of Go structs because Subsonic's XML and JSON forms
// are not the same shape: XML uses attributes, while JSON flattens attributes
// and child elements into one object and turns repeated child names into
// arrays. A single tree with two serializers keeps the two in lockstep.
type Node struct {
Name string
Attrs []Attr
Kids []*Node
// arrayKids names child elements to emit as JSON arrays even when there is
// exactly one (or none). Whether "song" is an object or an array depends on
// the parent: getSong returns one object, but album/searchResult3 contain
// arrays. That is a property of the container, so it is recorded here rather
// than inferred from the element name.
arrayKids map[string]bool
}
// El starts an element.
func El(name string, attrs ...Attr) *Node {
return &Node{Name: name, Attrs: attrs}
}
// Arrays marks child names that should always be JSON arrays under this node,
// and that should appear as [] when there are none, so a client that indexes
// the array never sees a missing key.
func (n *Node) Arrays(names ...string) *Node {
if n.arrayKids == nil {
n.arrayKids = make(map[string]bool, len(names))
}
for _, name := range names {
n.arrayKids[name] = true
}
return n
}
// At is one attribute.
func At(name string, value any) Attr { return Attr{Name: name, Value: value} }
// Add appends child elements and returns the parent, so trees can be built
// inline.
func (n *Node) Add(kids ...*Node) *Node {
n.Kids = append(n.Kids, kids...)
return n
}
// Envelope starts a response. XML needs the namespace; nodeJSON drops it.
func Envelope(status string) *Node {
return El("subsonic-response",
At("xmlns", "http://subsonic.org/restapi"),
At("status", status),
At("version", ProtocolVersion),
At("type", ServerType),
At("serverVersion", ServerVersion),
)
}
// ErrorNode is the <error> element clients read out of a failed envelope.
func ErrorNode(code int, msg string) *Node {
return El("error", At("code", code), At("message", msg))
}
// ── JSON ───────────────────────────────────────────────────────────────
// NodeJSON converts a node into the JSON value Subsonic clients expect. A
// "folder" leaf whose only attribute is id is flattened to the bare id, which
// is how getuser's folder list is specified ([1, 3], not [{"id":1}]).
func NodeJSON(n *Node) any {
if n.Name == "folder" && len(n.Attrs) == 1 && len(n.Kids) == 0 && n.Attrs[0].Name == "id" {
return n.Attrs[0].Value
}
m := make(map[string]any, len(n.Attrs)+len(n.Kids))
for _, a := range n.Attrs {
if a.Name == "xmlns" {
continue
}
m[a.Name] = a.Value
}
order := make([]string, 0, len(n.Kids))
groups := make(map[string][]*Node, len(n.Kids))
for _, k := range n.Kids {
if _, ok := groups[k.Name]; !ok {
order = append(order, k.Name)
}
groups[k.Name] = append(groups[k.Name], k)
}
for _, name := range order {
kids := groups[name]
if len(kids) == 1 && !n.arrayKids[name] {
m[name] = NodeJSON(kids[0])
continue
}
arr := make([]any, 0, len(kids))
for _, k := range kids {
arr = append(arr, NodeJSON(k))
}
m[name] = arr
}
for name := range n.arrayKids {
if _, ok := m[name]; !ok {
m[name] = []any{}
}
}
return m
}
// JSONBody encodes the response envelope. HTML escaping is disabled so a path
// containing "&" or "<" stays readable rather than becoming &.
func JSONBody(root *Node) ([]byte, error) {
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
if err := enc.Encode(map[string]any{"subsonic-response": NodeJSON(root)}); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// ── XML ────────────────────────────────────────────────────────────────
// XMLBody renders the response envelope including the XSD namespace and the XML
// declaration. It writes to a buffer rather than straight to the response
// because the envelope's status and content type are decided by the handler,
// not by the tree.
func XMLBody(root *Node) []byte {
var b bytes.Buffer
b.WriteString(xml.Header)
_ = writeXML(&b, root)
return b.Bytes()
}
func writeXML(w io.Writer, n *Node) error {
var b bytes.Buffer
b.WriteByte('<')
b.WriteString(n.Name)
for _, a := range n.Attrs {
b.WriteByte(' ')
b.WriteString(a.Name)
b.WriteString(`="`)
_ = xml.EscapeText(&b, []byte(xmlString(a.Value)))
b.WriteByte('"')
}
if len(n.Kids) == 0 {
b.WriteString("/>")
_, err := w.Write(b.Bytes())
return err
}
b.WriteByte('>')
if _, err := w.Write(b.Bytes()); err != nil {
return err
}
for _, k := range n.Kids {
if err := writeXML(w, k); err != nil {
return err
}
}
_, err := io.WriteString(w, "</"+n.Name+">")
return err
}
// xmlString renders a typed attribute value for XML. Booleans are lower-case,
// which is what the Subsonic schema uses.
func xmlString(v any) string {
switch t := v.(type) {
case string:
return t
case bool:
if t {
return "true"
}
return "false"
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
default:
return ""
}
}

View file

@ -0,0 +1,624 @@
package subsonic
import (
"database/sql"
"errors"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/progress"
"github.com/mithral/vellum/internal/stream"
"github.com/mithral/vellum/internal/subsonic/dto"
)
// ignoredArticles is advertised in getIndexes/getArtists. It is the list the
// Subsonic schema documents; the indexer strips a leading article so "The
// Beatles" files under B.
const ignoredArticles = "The El La Los Las Le Les"
// Handlers serves the Subsonic-compatible subset.
type Handlers struct {
db *sql.DB
log *slog.Logger
// auth is kept for parity with the other HTTP layers and for the cookie
// credentials endpoint. Subsonic requests authenticate themselves from
// query/form parameters, because the middleware only knows session cookies.
auth *auth.Authenticator
}
// NewHandlers builds the Subsonic HTTP layer. It takes no cache directory:
// cover bytes are served from the assets table the scanner already populated,
// rather than triggering a fresh extraction.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{db: db, log: log, auth: a}
}
// Register mounts the routes. See the package comment for why /rest/ lives at
// the root and why it installs its own 404 rather than relying on the SPA
// fallback in internal/httpapi/static.go.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /rest/{method}", h.dispatch)
mux.HandleFunc("POST /rest/{method}", h.dispatch)
// Subtree fallback for /rest/ itself and multi-segment paths. Without it an
// unknown /rest/... falls through to "/" and static.go's isAPIPath — which
// does not recognise /rest/ — would serve the SPA shell as text/html.
mux.HandleFunc("/rest/", h.handleUnknownRest)
// Client configuration. This one IS cookie-authenticated: the user is in
// the web UI, not a Subsonic client.
mux.Handle("GET /api/v1/subsonic/credentials", h.auth.Require(h.handleCredentials))
}
func (h *Handlers) handleUnknownRest(w http.ResponseWriter, r *http.Request) {
h.fail(w, r, dto.ErrGeneric, "unknown Subsonic method")
}
// dispatch authenticates and routes one /rest/<method>.view call. Every method
// shares the same credential scheme, so authentication happens once, here.
func (h *Handlers) dispatch(w http.ResponseWriter, r *http.Request) {
method := strings.ToLower(strings.TrimSuffix(r.PathValue("method"), ".view"))
u, ok := h.authenticate(r)
if !ok {
// The single most common integration failure: a bad credential must be
// an envelope with code 40, not an HTTP 401. Clients read the envelope.
h.fail(w, r, dto.ErrWrongCredentials, "Wrong username or password")
return
}
switch method {
case "ping":
h.handlePing(w, r)
case "getlicense":
h.handleGetLicense(w, r)
case "getmusicfolders":
h.handleGetMusicFolders(w, r, u)
case "getindexes":
h.handleGetIndexes(w, r, u)
case "getartists":
h.handleGetArtists(w, r, u)
case "getartist":
h.handleGetArtist(w, r, u)
case "getalbum":
h.handleGetAlbum(w, r, u)
case "getmusicdirectory":
h.handleGetMusicDirectory(w, r, u)
case "getsong":
h.handleGetSong(w, r, u)
case "search3":
h.handleSearch3(w, r, u)
case "stream":
h.handleStream(w, r, u)
case "getcoverart":
h.handleGetCoverArt(w, r, u)
case "getalbumlist2":
h.handleGetAlbumList2(w, r, u)
case "getrandomsongs":
h.handleGetRandomSongs(w, r, u)
case "scrobble":
h.handleScrobble(w, r, u)
case "getuser":
h.handleGetUser(w, r, u)
case "getplaylists":
h.handleGetPlaylists(w, r)
default:
h.fail(w, r, dto.ErrGeneric, "unknown method: "+method)
}
}
// ── system ─────────────────────────────────────────────────────────────
func (h *Handlers) handlePing(w http.ResponseWriter, r *http.Request) {
h.write(w, r, envelope("ok"))
}
func (h *Handlers) handleGetLicense(w http.ResponseWriter, r *http.Request) {
root := envelope("ok")
// Vellum is not Subsonic and has no licence or trial. Clients only check
// `valid`; a permanent valid licence is the honest answer.
root.Add(dto.El("license", dto.At("valid", true)))
h.write(w, r, root)
}
// ── browsing ───────────────────────────────────────────────────────────
func (h *Handlers) handleGetMusicFolders(w http.ResponseWriter, r *http.Request, u auth.User) {
libs, err := h.audioLibraries(r.Context(), u)
if err != nil {
h.internal(w, r, "music folders", err)
return
}
folders := dto.El("musicFolders").Arrays("musicFolder")
for _, lib := range libs {
folders.Add(dto.El("musicFolder", dto.At("id", lib.id), dto.At("name", lib.name)))
}
root := envelope("ok")
root.Add(folders)
h.write(w, r, root)
}
func (h *Handlers) handleGetIndexes(w http.ResponseWriter, r *http.Request, u auth.User) {
artists, err := h.artists(r.Context(), u)
if err != nil {
h.internal(w, r, "indexes", err)
return
}
indexes := dto.El("indexes", dto.At("ignoredArticles", ignoredArticles)).Arrays("index")
addIndexes(indexes, artists, false)
root := envelope("ok")
root.Add(indexes)
h.write(w, r, root)
}
func (h *Handlers) handleGetArtists(w http.ResponseWriter, r *http.Request, u auth.User) {
artists, err := h.artists(r.Context(), u)
if err != nil {
h.internal(w, r, "artists", err)
return
}
node := dto.El("artists", dto.At("ignoredArticles", ignoredArticles)).Arrays("index")
addIndexes(node, artists, true)
root := envelope("ok")
root.Add(node)
h.write(w, r, root)
}
func (h *Handlers) handleGetArtist(w http.ResponseWriter, r *http.Request, u auth.User) {
personID, ok := dto.ParseID(r.FormValue("id"), dto.PrefixArtist)
if !ok {
// The synthetic "unknown artist" bucket uses ar-0.
if r.FormValue("id") != dto.PrefixArtist+"0" {
h.fail(w, r, dto.ErrDataNotFound, "artist not found")
return
}
personID = 0
}
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "artist", err)
return
}
albums, err := h.albumsByArtist(r.Context(), libs, personID)
if err != nil {
h.internal(w, r, "artist albums", err)
return
}
if len(albums) == 0 {
h.fail(w, r, dto.ErrDataNotFound, "artist not found")
return
}
name, _ := h.artistName(r.Context(), personID)
artist := dto.El("artist",
dto.At("id", dto.ArtistID(personID)),
dto.At("name", name),
dto.At("albumCount", int64(len(albums))),
).Arrays("album")
if cover := firstCover(albums); cover != 0 {
artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.AlbumID(cover)))
}
for _, a := range albums {
artist.Add(dto.AlbumNode(a))
}
root := envelope("ok")
root.Add(artist)
h.write(w, r, root)
}
func (h *Handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request, u auth.User) {
itemID, ok := dto.ParseID(r.FormValue("id"), dto.PrefixAlbum)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "album not found")
return
}
a, ok, err := h.loadAlbum(r.Context(), u, itemID)
if err != nil {
h.internal(w, r, "album", err)
return
}
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "album not found")
return
}
tracks, err := h.tracksForItem(r.Context(), itemID)
if err != nil {
h.internal(w, r, "album songs", err)
return
}
node := dto.AlbumNode(a).Arrays("song")
for _, t := range tracks {
node.Add(dto.SongNode(t))
}
root := envelope("ok")
root.Add(node)
h.write(w, r, root)
}
func (h *Handlers) handleGetSong(w http.ResponseWriter, r *http.Request, u auth.User) {
fileIDVal, ok := dto.ParseID(r.FormValue("id"), dto.PrefixFile)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "song not found")
return
}
t, ok, err := h.loadTrack(r.Context(), u, fileIDVal)
if err != nil {
h.internal(w, r, "song", err)
return
}
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "song not found")
return
}
root := envelope("ok")
root.Add(dto.SongNode(t))
h.write(w, r, root)
}
// handleGetMusicDirectory browses one level of the tree. Vellum does not store
// a parent_id for audio (the scanner groups by directory), so the hierarchy is
// derived from media_items.path: a directory id names a path prefix, and its
// children are the items whose parent directory is that prefix plus the next
// path segment of every deeper item.
func (h *Handlers) handleGetMusicDirectory(w http.ResponseWriter, r *http.Request, u auth.User) {
id := r.FormValue("id")
switch {
case strings.HasPrefix(id, dto.PrefixFolder):
libID, ok := dto.ParseID(id, dto.PrefixFolder)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.directoryForLibrary(w, r, u, libID)
case strings.HasPrefix(id, dto.PrefixDir):
libID, rel, ok := dto.ParseDirID(id)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.directoryForPath(w, r, u, libID, rel)
case strings.HasPrefix(id, dto.PrefixAlbum):
itemID, ok := dto.ParseID(id, dto.PrefixAlbum)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.directoryForAlbum(w, r, u, itemID)
case strings.HasPrefix(id, dto.PrefixArtist):
personID, ok := dto.ParseID(id, dto.PrefixArtist)
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
return
}
h.directoryForArtist(w, r, u, personID)
default:
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
}
}
// ── search ─────────────────────────────────────────────────────────────
// handleSearch3 is the endpoint clients lean on hardest. It is answered with
// plain LIKE queries scoped to the caller's visible audio libraries rather than
// the FTS index, because the FTS index is over media_items (albums), while a
// Subsonic song is a media_files row and a Subsonic artist is a person. An
// empty query returns everything, which OpenSubsonic requires for offline sync.
func (h *Handlers) handleSearch3(w http.ResponseWriter, r *http.Request, u auth.User) {
q := r.FormValue("query")
artistCount, artistOffset := countOffset(r, "artistCount", "artistOffset")
albumCount, albumOffset := countOffset(r, "albumCount", "albumOffset")
songCount, songOffset := countOffset(r, "songCount", "songOffset")
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "search", err)
return
}
artists, err := h.searchArtists(r.Context(), libs, q, artistCount, artistOffset)
if err != nil {
h.internal(w, r, "search artists", err)
return
}
albums, err := h.searchAlbums(r.Context(), libs, q, albumCount, albumOffset)
if err != nil {
h.internal(w, r, "search albums", err)
return
}
songs, err := h.searchSongs(r.Context(), libs, q, songCount, songOffset)
if err != nil {
h.internal(w, r, "search songs", err)
return
}
result := dto.El("searchResult3").Arrays("artist", "album", "song")
for _, a := range artists {
artist := dto.El("artist", dto.At("id", dto.ArtistID(a.id)), dto.At("name", a.name), dto.At("albumCount", int64(a.albumCount)))
if a.coverAlbum != 0 {
artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.AlbumID(a.coverAlbum)))
}
result.Add(artist)
}
for _, a := range albums {
result.Add(dto.AlbumNode(a))
}
for _, s := range songs {
result.Add(dto.SongNode(s))
}
root := envelope("ok")
root.Add(result)
h.write(w, r, root)
}
// ── lists ──────────────────────────────────────────────────────────────
func (h *Handlers) handleGetAlbumList2(w http.ResponseWriter, r *http.Request, u auth.User) {
kind := strings.ToLower(r.FormValue("type"))
size := intParam(r, "size", 10, 500)
offset := intParam(r, "offset", 0, 1_000_000)
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "album list", err)
return
}
// Only newest and random are required; the alphabetical sorts are cheap and
// are what a client pages through, so they are included. Types this server
// cannot answer meaningfully (highest, frequent, starred, byGenre) fall back
// to newest rather than returning an error, so a client is never stuck.
order := "i.added_at DESC"
where := ""
var args []any
switch kind {
case "random":
order = "RANDOM()"
case "alphabeticalbyname":
order = "i.sort_title"
case "alphabeticalbyartist":
order = "artist_name, i.sort_title"
case "byyear":
from := intParam(r, "fromYear", 0, 9999)
to := intParam(r, "toYear", 0, 9999)
where = " AND COALESCE(i.year,0) >= ? AND COALESCE(i.year,0) <= ?"
args = append(args, from, to)
default: // newest, recent, and every unsupported type
order = "i.added_at DESC"
}
albums, err := h.albumsWhere(r.Context(), libs, where, args, order, size, offset)
if err != nil {
h.internal(w, r, "album list", err)
return
}
list := dto.El("albumList2").Arrays("album")
for _, a := range albums {
list.Add(dto.AlbumNode(a))
}
root := envelope("ok")
root.Add(list)
h.write(w, r, root)
}
func (h *Handlers) handleGetRandomSongs(w http.ResponseWriter, r *http.Request, u auth.User) {
size := intParam(r, "size", 10, 500)
libs, err := h.audioLibraryIDs(r.Context(), u)
if err != nil {
h.internal(w, r, "random songs", err)
return
}
songs, err := h.songsWhere(r.Context(), libs, "", nil, "RANDOM()", size, 0)
if err != nil {
h.internal(w, r, "random songs", err)
return
}
node := dto.El("randomSongs").Arrays("song")
for _, s := range songs {
node.Add(dto.SongNode(s))
}
root := envelope("ok")
root.Add(node)
h.write(w, r, root)
}
// ── media ──────────────────────────────────────────────────────────────
// handleStream serves the original file. Vellum never transcodes: a request for
// any format other than raw (or none) is refused with a clear message rather
// than silently handing back the original under a different label. Range
// requests are handled by stream.ServeFile via http.ServeContent, so seeking
// works.
func (h *Handlers) handleStream(w http.ResponseWriter, r *http.Request, u auth.User) {
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
if format != "" && format != "raw" {
h.fail(w, r, dto.ErrGeneric, "transcoding is not supported by this server; request format=raw")
return
}
// maxBitRate and estimateContentLength are accepted and honestly ignored:
// there is no transcoder to apply either to. They are not rejected because
// clients send them unconditionally.
abs, ok, err := h.resolveAudioFile(r.Context(), u, r.FormValue("id"))
if err != nil {
h.internal(w, r, "stream", err)
return
}
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "song not found")
return
}
if err := stream.ServeFile(w, r, abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
h.fail(w, r, dto.ErrDataNotFound, "file is not available")
return
}
h.log.Error("subsonic: stream file", "err", err)
}
}
func (h *Handlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request, u auth.User) {
itemID, ok, err := h.coverItemID(r.Context(), u, r.FormValue("id"))
if err != nil {
h.internal(w, r, "cover", err)
return
}
if !ok {
h.fail(w, r, dto.ErrDataNotFound, "cover art not found")
return
}
var coverPath string
err = h.db.QueryRowContext(r.Context(),
`SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, itemID).Scan(&coverPath)
if errors.Is(err, sql.ErrNoRows) {
h.fail(w, r, dto.ErrDataNotFound, "cover art not found")
return
}
if err != nil {
h.internal(w, r, "cover", err)
return
}
if _, statErr := os.Stat(coverPath); statErr != nil {
h.fail(w, r, dto.ErrDataNotFound, "cover art not available")
return
}
w.Header().Set("Cache-Control", "public, max-age=86400")
if err := stream.ServeFile(w, r, coverPath); err != nil {
h.log.Error("subsonic: serve cover", "err", err)
}
}
// ── playback reporting ─────────────────────────────────────────────────
// handleScrobble writes the client's playback position through the existing
// progress store. Subsonic's scrobble carries a wall-clock time rather than a
// position, but several clients additionally send `position` in milliseconds;
// when it is present it is honoured so the web app can resume where the client
// left off. A "now playing" notification (submission=false) records the start.
func (h *Handlers) handleScrobble(w http.ResponseWriter, r *http.Request, u auth.User) {
ids := r.Form["id"]
if len(ids) == 0 {
h.fail(w, r, dto.ErrMissingParameter, "Required parameter is missing")
return
}
submission := strings.ToLower(r.FormValue("submission")) != "false"
for _, id := range ids {
fileIDVal, ok := dto.ParseID(id, dto.PrefixFile)
if !ok {
continue
}
t, ok, err := h.loadTrack(r.Context(), u, fileIDVal)
if err != nil {
h.internal(w, r, "scrobble", err)
return
}
if !ok {
continue
}
position := int64(0)
if v := r.FormValue("position"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n >= 0 {
position = n
}
}
p := progress.Progress{
ItemID: t.ItemID,
FileID: t.FileID,
Kind: t.Kind,
Position: position,
Duration: t.DurationMS,
}
if t.DurationMS > 0 {
p.Percent = float64(position) / float64(t.DurationMS)
}
if submission && t.DurationMS > 0 && position >= t.DurationMS {
p.Finished = true
}
if _, err := progress.Put(r.Context(), h.db, u.ID, p); err != nil && !errors.Is(err, progress.ErrNoFile) {
h.internal(w, r, "scrobble progress", err)
return
}
}
h.write(w, r, envelope("ok"))
}
// ── user ───────────────────────────────────────────────────────────────
func (h *Handlers) handleGetUser(w http.ResponseWriter, r *http.Request, u auth.User) {
target := strings.TrimSpace(r.FormValue("username"))
// The parameter is required by the spec; defaulting to the caller is the
// behaviour clients expect when they omit it.
if target != "" && !strings.EqualFold(target, u.Username) {
if !u.IsAdmin {
h.fail(w, r, dto.ErrNotAuthorized, "User is not authorized for the given operation")
return
}
other, err := auth.GetUserByUsername(r.Context(), h.db, target)
if errors.Is(err, sql.ErrNoRows) {
h.fail(w, r, dto.ErrDataNotFound, "user not found")
return
}
if err != nil {
h.internal(w, r, "user", err)
return
}
u = other
}
libIDs, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
if err != nil {
h.internal(w, r, "user folders", err)
return
}
user := dto.El("user",
dto.At("username", u.Username),
dto.At("email", ""),
dto.At("scrobblingEnabled", true),
dto.At("adminRole", u.IsAdmin),
dto.At("settingsRole", u.IsAdmin),
dto.At("downloadRole", true),
dto.At("uploadRole", false),
dto.At("playlistRole", true),
dto.At("coverArtRole", true),
dto.At("commentRole", false),
dto.At("podcastRole", false),
dto.At("streamRole", true),
dto.At("jukeboxRole", false),
dto.At("shareRole", false),
dto.At("videoConversionRole", false),
).Arrays("folder")
for _, id := range libIDs {
user.Add(dto.El("folder", dto.At("id", id)))
}
root := envelope("ok")
root.Add(user)
h.write(w, r, root)
}
// handleGetPlaylists returns an empty list. Vellum has a collections table but
// no Subsonic playlist support yet; an empty <playlists/> is what keeps a
// client from erroring on login.
func (h *Handlers) handleGetPlaylists(w http.ResponseWriter, r *http.Request) {
root := envelope("ok")
root.Add(dto.El("playlists").Arrays("playlist"))
h.write(w, r, root)
}
// ── helpers ────────────────────────────────────────────────────────────
func (h *Handlers) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
h.log.Error("subsonic: "+what, "err", err)
h.fail(w, r, dto.ErrGeneric, "internal server error")
}

24
internal/subsonic/node.go Normal file
View file

@ -0,0 +1,24 @@
package subsonic
import (
"net/http"
"github.com/mithral/vellum/internal/subsonic/dto"
)
// writeJSONResponse writes the JSON envelope.
func writeJSONResponse(w http.ResponseWriter, root *dto.Node) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
body, err := dto.JSONBody(root)
if err != nil {
return
}
_, _ = w.Write(body)
}
// writeXMLResponse writes the XML envelope, including the namespace the XSD
// declares.
func writeXMLResponse(w http.ResponseWriter, root *dto.Node) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
_, _ = w.Write(dto.XMLBody(root))
}

547
internal/subsonic/store.go Normal file
View file

@ -0,0 +1,547 @@
package subsonic
import (
"context"
"database/sql"
"errors"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
"unicode"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/subsonic/dto"
)
// libraryRef is an audio library as getMusicFolders shows it.
type libraryRef struct {
id int64
name string
}
// audioLibraries returns the caller's visible music/audiobook libraries. The
// web UI's video, ebook and photo libraries are not Subsonic music folders.
func (h *Handlers) audioLibraries(ctx context.Context, u auth.User) ([]libraryRef, error) {
ids, err := auth.VisibleLibraryIDs(ctx, h.db, u)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return []libraryRef{}, nil
}
in, args := inClause(ids)
rows, err := h.db.QueryContext(ctx, `
SELECT id, name FROM libraries
WHERE kind IN ('music','audiobooks') AND id IN (`+in+`)
ORDER BY name`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []libraryRef{}
for rows.Next() {
var l libraryRef
if err := rows.Scan(&l.id, &l.name); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func (h *Handlers) audioLibraryIDs(ctx context.Context, u auth.User) ([]int64, error) {
libs, err := h.audioLibraries(ctx, u)
if err != nil {
return nil, err
}
out := make([]int64, 0, len(libs))
for _, l := range libs {
out = append(out, l.id)
}
return out, nil
}
// libraryRoot returns the absolute root path of a library.
func (h *Handlers) libraryRoot(ctx context.Context, libID int64) (string, error) {
var root string
err := h.db.QueryRowContext(ctx, `SELECT root_path FROM libraries WHERE id = ?`, libID).Scan(&root)
return root, err
}
// ── albums ─────────────────────────────────────────────────────────────
const albumSelect = `
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.added_at,
COALESCE((SELECT ip.person_id FROM item_people ip
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), 0) AS artist_id,
(SELECT count(*) FROM media_files f WHERE f.item_id = i.id AND f.missing = 0) AS song_count,
COALESCE((SELECT sum(f.duration_ms) FROM media_files f WHERE f.item_id = i.id AND f.missing = 0), 0) AS duration_ms,
EXISTS(SELECT 1 FROM assets a WHERE a.item_id = i.id AND a.role = 'cover') AS has_cover,
COALESCE((SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), '') AS artist_name
FROM media_items i`
// albumsWhere runs the album projection with a caller-supplied extra predicate
// and order. order MUST come from a fixed set, never from user input; the
// callers below pass only literals. The final projected column is aliased
// artist_name so alphabeticalByArtist can order by it without a second join.
func (h *Handlers) albumsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]dto.AlbumInfo, error) {
if limit <= 0 || limit > 500 {
limit = 500
}
in, args := inClause(libs)
args = append(args, extraArgs...)
q := albumSelect + `
WHERE i.missing = 0 AND i.kind IN ('music','audiobooks')
AND i.library_id IN (` + in + `)` + extra + `
ORDER BY ` + order + ` LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := h.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAlbums(rows)
}
func (h *Handlers) albumsByArtist(ctx context.Context, libs []int64, personID int64) ([]dto.AlbumInfo, error) {
if personID == 0 {
return h.albumsWhere(ctx, libs,
` AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`,
nil, "i.sort_title", 500, 0)
}
return h.albumsWhere(ctx, libs,
` AND EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author' AND ip.person_id = ?)`,
[]any{personID}, "i.sort_title", 500, 0)
}
// loadAlbum authorizes and loads one album for a request.
//
// catalog.LoadFor is the one access rule: it reads the item and confirms the
// caller may read its library, returning the same ErrNotFound for a missing
// item and a forbidden one. The album projection below is Subsonic's own
// presentation concern — song count, duration, artist credit and cover are not
// part of catalog's item model — and no longer decides access.
func (h *Handlers) loadAlbum(ctx context.Context, u auth.User, itemID int64) (dto.AlbumInfo, bool, error) {
it, err := catalog.LoadFor(ctx, h.db, u, itemID)
if err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return dto.AlbumInfo{}, false, nil
}
return dto.AlbumInfo{}, false, err
}
albums, err := h.albumsWhere(ctx, []int64{it.LibraryID}, ` AND i.id = ?`, []any{itemID}, "i.id", 1, 0)
if err != nil {
return dto.AlbumInfo{}, false, err
}
if len(albums) == 0 {
return dto.AlbumInfo{}, false, nil
}
return albums[0], true, nil
}
func scanAlbums(rows *sql.Rows) ([]dto.AlbumInfo, error) {
out := []dto.AlbumInfo{}
for rows.Next() {
var a dto.AlbumInfo
var hasCover int
if err := rows.Scan(&a.ID, &a.LibraryID, &a.Kind, &a.Path, &a.Title, &a.Year, &a.AddedAt,
&a.ArtistID, &a.SongCount, &a.DurationMS, &hasCover, &a.Artist); err != nil {
return nil, err
}
a.HasCover = hasCover == 1
out = append(out, a)
}
return out, rows.Err()
}
// ── tracks ─────────────────────────────────────────────────────────────
const trackSelect = `
SELECT f.id, f.item_id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0), i.added_at,
f.path, f.size, f.duration_ms, f.container,
COALESCE((SELECT ip.person_id FROM item_people ip
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), 0),
COALESCE((SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = i.id AND ip.role = 'author' ORDER BY ip.ord LIMIT 1), ''),
EXISTS(SELECT 1 FROM assets a WHERE a.item_id = i.id AND a.role = 'cover')
FROM media_files f
JOIN media_items i ON i.id = f.item_id`
func (h *Handlers) tracksForItem(ctx context.Context, itemID int64) ([]dto.Track, error) {
rows, err := h.db.QueryContext(ctx, trackSelect+`
WHERE f.item_id = ? AND f.missing = 0 AND i.missing = 0
ORDER BY f.path`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
tracks, err := scanTracks(rows)
if err != nil {
return nil, err
}
for i := range tracks {
tracks[i].Ordinal = i + 1
}
return tracks, nil
}
// loadTrack authorizes and loads one song (a media_files row) for a request.
//
// The song projection above is Subsonic's own presentation shape — album
// context, artist credit, cover — so it stays here. Access is not its job:
// catalog.LoadFor is the one access rule, returning the same ErrNotFound for a
// missing item and a forbidden one so a 404 cannot leak existence.
func (h *Handlers) loadTrack(ctx context.Context, u auth.User, fileIDVal int64) (dto.Track, bool, error) {
rows, err := h.db.QueryContext(ctx, trackSelect+`
WHERE f.id = ? AND f.missing = 0 AND i.missing = 0`, fileIDVal)
if err != nil {
return dto.Track{}, false, err
}
defer rows.Close()
tracks, err := scanTracks(rows)
if err != nil {
return dto.Track{}, false, err
}
if len(tracks) == 0 {
return dto.Track{}, false, nil
}
if _, err := catalog.LoadFor(ctx, h.db, u, tracks[0].ItemID); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return dto.Track{}, false, nil
}
return dto.Track{}, false, err
}
return tracks[0], true, nil
}
func (h *Handlers) songsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]dto.Track, error) {
if limit <= 0 || limit > 500 {
limit = 500
}
in, args := inClause(libs)
args = append(args, extraArgs...)
q := trackSelect + `
WHERE i.missing = 0 AND f.missing = 0
AND i.kind IN ('music','audiobooks')
AND i.library_id IN (` + in + `)` + extra + `
ORDER BY ` + order + ` LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := h.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTracks(rows)
}
func scanTracks(rows *sql.Rows) ([]dto.Track, error) {
out := []dto.Track{}
for rows.Next() {
var t dto.Track
var hasCover int
if err := rows.Scan(&t.FileID, &t.ItemID, &t.LibraryID, &t.Kind, &t.AlbumPath, &t.AlbumTitle,
&t.Year, &t.AddedAt, &t.RelPath, &t.Size, &t.DurationMS, &t.Container,
&t.ArtistID, &t.Artist, &hasCover); err != nil {
return nil, err
}
t.HasCover = hasCover == 1
out = append(out, t)
}
return out, rows.Err()
}
// ── artists ────────────────────────────────────────────────────────────
type artistInfo struct {
id int64
name string
albumCount int
coverAlbum int64
}
// artists returns every artist with at least one visible audio album, plus a
// synthetic "Unknown Artist" bucket when some albums carry no author credit.
func (h *Handlers) artists(ctx context.Context, u auth.User) ([]artistInfo, error) {
libs, err := h.audioLibraryIDs(ctx, u)
if err != nil {
return nil, err
}
return h.searchArtists(ctx, libs, "", 500, 0)
}
func (h *Handlers) searchArtists(ctx context.Context, libs []int64, q string, limit, offset int) ([]artistInfo, error) {
if limit <= 0 || limit > 500 {
limit = 500
}
in, args := inClause(libs)
args = append(args, q, q, limit, offset)
rows, err := h.db.QueryContext(ctx, `
SELECT p.id, p.name, count(DISTINCT i.id),
COALESCE((SELECT a.item_id
FROM item_people ip2
JOIN media_items i2 ON i2.id = ip2.item_id
JOIN assets a ON a.item_id = i2.id AND a.role = 'cover'
WHERE ip2.person_id = p.id AND ip2.role = 'author' AND i2.missing = 0
ORDER BY i2.id LIMIT 1), 0)
FROM people p
JOIN item_people ip ON ip.person_id = p.id
JOIN media_items i ON i.id = ip.item_id
WHERE ip.role = 'author' AND i.missing = 0
AND i.kind IN ('music','audiobooks')
AND i.library_id IN (`+in+`)
AND (? = '' OR p.name LIKE '%' || ? || '%')
GROUP BY p.id, p.name
ORDER BY p.name
LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []artistInfo{}
for rows.Next() {
var a artistInfo
if err := rows.Scan(&a.id, &a.name, &a.albumCount, &a.coverAlbum); err != nil {
return nil, err
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Include the uncredited bucket only when there is something in it, and
// only on the unfiltered listing (a name search should not surface it).
if q == "" {
n, err := h.uncreditedAlbumCount(ctx, libs)
if err != nil {
return nil, err
}
if n > 0 {
out = append(out, artistInfo{id: 0, name: "Unknown Artist", albumCount: n})
}
}
return out, nil
}
func (h *Handlers) uncreditedAlbumCount(ctx context.Context, libs []int64) (int, error) {
in, args := inClause(libs)
var n int
err := h.db.QueryRowContext(ctx, `
SELECT count(*) FROM media_items i
WHERE i.missing = 0 AND i.kind IN ('music','audiobooks')
AND i.library_id IN (`+in+`)
AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`,
args...).Scan(&n)
return n, err
}
func (h *Handlers) artistName(ctx context.Context, personID int64) (string, error) {
if personID == 0 {
return "Unknown Artist", nil
}
var name string
err := h.db.QueryRowContext(ctx, `SELECT name FROM people WHERE id = ?`, personID).Scan(&name)
if err == sql.ErrNoRows {
return "Unknown Artist", nil
}
return name, err
}
// searchAlbums filters the album projection by title or credited artist.
func (h *Handlers) searchAlbums(ctx context.Context, libs []int64, q string, limit, offset int) ([]dto.AlbumInfo, error) {
extra := ` AND (? = '' OR i.title LIKE '%' || ? || '%'
OR EXISTS (SELECT 1 FROM item_people ip JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = i.id AND ip.role = 'author' AND p.name LIKE '%' || ? || '%'))`
return h.albumsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title", limit, offset)
}
func (h *Handlers) searchSongs(ctx context.Context, libs []int64, q string, limit, offset int) ([]dto.Track, error) {
extra := ` AND (? = '' OR f.path LIKE '%' || ? || '%' OR i.title LIKE '%' || ? || '%')`
return h.songsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title, f.path", limit, offset)
}
// addIndexes groups artists by their index letter and appends <index> children
// to parent. withCount controls whether the ArtistID3 albumCount is emitted
// (getArtists) or the leaner Child shape is used (getIndexes).
func addIndexes(parent *dto.Node, artists []artistInfo, withCount bool) {
sorted := append([]artistInfo(nil), artists...)
sort.Slice(sorted, func(i, j int) bool {
li, lj := indexLetter(sorted[i].name), indexLetter(sorted[j].name)
if li != lj {
return li < lj
}
return sorted[i].name < sorted[j].name
})
var current *dto.Node
currentLetter := ""
for _, a := range sorted {
letter := indexLetter(a.name)
if current == nil || letter != currentLetter {
current = dto.El("index", dto.At("name", letter)).Arrays("artist")
parent.Add(current)
currentLetter = letter
}
artist := dto.El("artist", dto.At("id", dto.ArtistID(a.id)), dto.At("name", a.name))
if withCount {
artist.Attrs = append(artist.Attrs, dto.At("albumCount", int64(a.albumCount)))
}
if a.coverAlbum != 0 {
artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.AlbumID(a.coverAlbum)))
}
current.Add(artist)
}
}
// indexLetter returns the letter an artist files under, skipping a leading
// ignored article ("The Beatles" -> B) and folding anything non-alphabetic
// into "#".
func indexLetter(name string) string {
name = strings.TrimSpace(name)
lower := strings.ToLower(name)
for _, article := range strings.Fields(ignoredArticles) {
prefix := strings.ToLower(article) + " "
if strings.HasPrefix(lower, prefix) {
name = name[len(prefix):]
break
}
}
for _, r := range name {
if unicode.IsLetter(r) {
return strings.ToUpper(string(r))
}
break
}
return "#"
}
func firstCover(albums []dto.AlbumInfo) int64 {
for _, a := range albums {
if a.HasCover {
return a.ID
}
}
return 0
}
// ── id resolution ──────────────────────────────────────────────────────
// resolveAudioFile turns a Subsonic song/album id into an absolute path on
// disk, after the library-access chokepoint.
func (h *Handlers) resolveAudioFile(ctx context.Context, u auth.User, id string) (string, bool, error) {
var (
rel string
lib int64
)
switch {
case strings.HasPrefix(id, dto.PrefixFile):
fileIDVal, ok := dto.ParseID(id, dto.PrefixFile)
if !ok {
return "", false, nil
}
t, ok, err := h.loadTrack(ctx, u, fileIDVal)
if err != nil || !ok {
return "", false, err
}
rel, lib = t.RelPath, t.LibraryID
case strings.HasPrefix(id, dto.PrefixAlbum):
itemID, ok := dto.ParseID(id, dto.PrefixAlbum)
if !ok {
return "", false, nil
}
a, ok, err := h.loadAlbum(ctx, u, itemID)
if err != nil || !ok {
return "", false, err
}
tracks, err := h.tracksForItem(ctx, itemID)
if err != nil || len(tracks) == 0 {
return "", false, err
}
rel, lib = tracks[0].RelPath, a.LibraryID
default:
return "", false, nil
}
root, err := h.libraryRoot(ctx, lib)
if err != nil {
return "", false, err
}
return filepath.Join(root, filepath.FromSlash(rel)), true, nil
}
// coverItemID maps a coverArt id to the media_items row that owns the cover.
// Artist covers are not produced by any extractor, so an ar- id resolves to
// nothing rather than an arbitrary album.
func (h *Handlers) coverItemID(ctx context.Context, u auth.User, id string) (int64, bool, error) {
switch {
case strings.HasPrefix(id, dto.PrefixAlbum):
itemID, ok := dto.ParseID(id, dto.PrefixAlbum)
if !ok {
return 0, false, nil
}
if _, ok, err := h.loadAlbum(ctx, u, itemID); err != nil || !ok {
return 0, false, err
}
return itemID, true, nil
case strings.HasPrefix(id, dto.PrefixFile):
fileIDVal, ok := dto.ParseID(id, dto.PrefixFile)
if !ok {
return 0, false, nil
}
t, ok, err := h.loadTrack(ctx, u, fileIDVal)
if err != nil || !ok {
return 0, false, err
}
return t.ItemID, true, nil
default:
return 0, false, nil
}
}
// ── small helpers ──────────────────────────────────────────────────────
// inClause builds a placeholder list for an IN (...) predicate. An empty set
// yields "NULL", which matches nothing — never "all", which is the whole point
// of the access-control model.
func inClause(ids []int64) (string, []any) {
if len(ids) == 0 {
return "NULL", nil
}
ph := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
ph[i] = "?"
args[i] = id
}
return strings.Join(ph, ","), args
}
func intParam(r *http.Request, name string, def, max int) int {
v := r.FormValue(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return def
}
if n > max {
return max
}
return n
}
func countOffset(r *http.Request, countName, offsetName string) (int, int) {
return intParam(r, countName, 20, 500), intParam(r, offsetName, 0, 1_000_000)
}

View file

@ -0,0 +1,81 @@
// Package subsonic translates Vellum's storage into the frozen Subsonic REST
// API (1.16.1) that real clients — Symfonium, Ultrasonic, play:Sub, DSub,
// Substreamer, Feishin — speak to browse and stream.
//
// It is a translation layer, not a second media server. Every query goes
// through media_items / media_files / people / item_people / assets, and every
// request resolves to an auth.User through the same grant chokepoint the web
// app uses (auth.VisibleLibraryIDs / auth.CanAccessLibrary). Credentials are
// apitoken.KindSubsonic tokens, so revoking one in the web UI revokes the
// client too.
//
// The wire shapes — the response envelope, the element names, the error codes,
// the opaque ids — live in internal/subsonic/dto, which is the contract with
// clients. This package owns the HTTP handlers and the queries.
//
// # Authentication
//
// Subsonic carries its credentials on every request, not in a cookie: u names
// the user and one of p (the key directly), t=md5(key+salt) with s, or apiKey.
// A bad credential is a Subsonic error envelope with code 40, never an HTTP
// 401 — clients read the envelope and many treat a non-200 as a network fault.
//
// # No transcoding
//
// Vellum runs on a headless, GPU-less host. stream serves the original file
// (format=raw) with full Range support so seeking works, and refuses a request
// for any other target format with a clear error instead of pretending. ffmpeg
// is never invoked.
//
// # Root mounting
//
// Routes live under /rest/ at the ROOT because that is where Subsonic clients
// look. internal/httpapi/static.go owns "/", "/healthz" and "/api/"; "/rest/"
// is a more specific ServeMux pattern, so requests route here first and the SPA
// fallback is never shadowed. isAPIPath in static.go does not know /rest/, so
// this package installs its own JSON 404 for the subtree rather than letting an
// unknown /rest/... path fall through to the SPA shell.
package subsonic
import (
"net/http"
"strings"
"github.com/mithral/vellum/internal/subsonic/dto"
)
// envelope starts a response. XML needs the namespace; nodeJSON drops it.
func envelope(status string) *dto.Node {
return dto.Envelope(status)
}
// wantsJSON reports whether the caller asked for the JSON form. The default,
// per the spec, is XML. jsonp is treated as json: the wrapping callback is a
// browser convenience this LAN server does not need, and returning the plain
// JSON body is still parseable by a jsonp client that ignores the callback.
func wantsJSON(r *http.Request) bool {
switch strings.ToLower(r.FormValue("f")) {
case "json", "jsonp":
return true
default:
return false
}
}
// write serialises a response in the requested format.
func (h *Handlers) write(w http.ResponseWriter, r *http.Request, root *dto.Node) {
if wantsJSON(r) {
writeJSONResponse(w, root)
return
}
writeXMLResponse(w, root)
}
// fail writes a Subsonic error envelope. The HTTP status stays 200 on purpose:
// Subsonic clients read the envelope's status/error, and a non-200 is often
// surfaced as a transport error rather than "wrong password".
func (h *Handlers) fail(w http.ResponseWriter, r *http.Request, code int, msg string) {
root := envelope(dto.StatusFailed)
root.Add(dto.ErrorNode(code, msg))
h.write(w, r, root)
}

View file

@ -0,0 +1,502 @@
package subsonic
import (
"context"
"crypto/md5"
"database/sql"
"encoding/hex"
"encoding/json"
"encoding/xml"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mithral/vellum/internal/apitoken"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/subsonic/dto"
"github.com/mithral/vellum/migrations"
)
// fixture is one seeded Subsonic server.
type fixture struct {
db *sql.DB
userID int64
key string
libID int64
album int64
file int64
root string
}
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "subsonic.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := core.Migrate(context.Background(), db, migrations.FS, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
// newFixture seeds one non-admin user with a granted music library holding a
// single three-track album, an artist credit and a cover.
func newFixture(t *testing.T) (*httptest.Server, *fixture) {
t.Helper()
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
for _, name := range []string{
"Artist/Album/01 - One.mp3",
"Artist/Album/02 - Two.mp3",
"Artist/Album/03 - Three.mp3",
} {
p := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
// 100 deterministic bytes so a Range read is verifiable.
if err := os.WriteFile(p, []byte(strings.Repeat("v", 100)), 0o644); err != nil {
t.Fatal(err)
}
}
coverPath := filepath.Join(root, "cover.png")
if err := os.WriteFile(coverPath, []byte("\x89PNG\r\n\x1a\ncoverbytes"), 0o644); err != nil {
t.Fatal(err)
}
user, err := auth.CreateUser(ctx, db, "alice", "Alice", "password123", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
var libID int64
if err := db.QueryRowContext(ctx,
`INSERT INTO libraries (name, kind, root_path) VALUES ('Music','music',?) RETURNING id`,
root).Scan(&libID); err != nil {
t.Fatalf("insert library: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?,?,1,1)`,
user.ID, libID); err != nil {
t.Fatalf("grant: %v", err)
}
var albumID int64
if err := db.QueryRowContext(ctx, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
VALUES (?, 'music', 'Artist/Album', 'Album', 'album', 2013) RETURNING id`,
libID).Scan(&albumID); err != nil {
t.Fatalf("insert album: %v", err)
}
var firstFile int64
for i, name := range []string{
"Artist/Album/01 - One.mp3",
"Artist/Album/02 - Two.mp3",
"Artist/Album/03 - Three.mp3",
} {
var id int64
if err := db.QueryRowContext(ctx, `
INSERT INTO media_files (item_id, path, size, container, duration_ms)
VALUES (?,?,100,'mp3',60000) RETURNING id`,
albumID, name).Scan(&id); err != nil {
t.Fatalf("insert file: %v", err)
}
if i == 0 {
firstFile = id
}
}
var personID int64
if err := db.QueryRowContext(ctx,
`INSERT INTO people (name) VALUES ('The Artist') RETURNING id`).Scan(&personID); err != nil {
t.Fatalf("insert person: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?,?,'author',0)`,
albumID, personID); err != nil {
t.Fatalf("insert credit: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO assets (item_id, role, path, bytes) VALUES (?, 'cover', ?, 20)`,
albumID, coverPath); err != nil {
t.Fatalf("insert asset: %v", err)
}
key, err := apitoken.Mint(ctx, db, user.ID, apitoken.KindSubsonic, "test")
if err != nil {
t.Fatalf("Mint: %v", err)
}
authenticator := auth.NewAuthenticator(db, "vellum_session", 0, false, slog.New(slog.NewTextHandler(io.Discard, nil)))
h := NewHandlers(db, slog.New(slog.NewTextHandler(io.Discard, nil)), authenticator)
mux := http.NewServeMux()
h.Register(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &fixture{db: db, userID: user.ID, key: key, libID: libID, album: albumID, file: firstFile, root: root}
}
// authQuery is the preferred t=md5(key+salt) credential form.
func (f *fixture) authQuery() string {
salt := "abc123"
sum := md5.Sum([]byte(f.key + salt))
return "u=alice&s=" + salt + "&t=" + hex.EncodeToString(sum[:]) + "&v=1.16.1&c=test"
}
// get performs a request and returns the recorder.
func get(t *testing.T, url string) *http.Response {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
return resp
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(b)
}
// jsonEnvelope decodes a Subsonic JSON response into the root object.
func jsonEnvelope(t *testing.T, body string) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode json %q: %v", body, err)
}
root, ok := out["subsonic-response"].(map[string]any)
if !ok {
t.Fatalf("missing subsonic-response in %q", body)
}
return root
}
// ── auth ───────────────────────────────────────────────────────────────
// TestAuthTokenForm proves the preferred md5(key+salt) path succeeds.
func TestAuthTokenForm(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "ok" {
t.Fatalf("status = %v, want ok; body=%s", root["status"], body)
}
}
// TestAuthWrongTokenIsEnvelope pins the single most common integration failure:
// a bad token is HTTP 200 with status failed and error code 40, never a 401.
func TestAuthWrongTokenIsEnvelope(t *testing.T) {
srv, _ := newFixture(t)
resp := get(t, srv.URL+"/rest/ping.view?u=alice&s=abc123&t=deadbeef&v=1.16.1&c=test&f=json")
if resp.StatusCode != http.StatusOK {
t.Errorf("HTTP status = %d, want 200 (errors belong in the envelope)", resp.StatusCode)
}
body := readBody(t, resp)
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("status = %v, want failed; body=%s", root["status"], body)
}
errObj, ok := root["error"].(map[string]any)
if !ok {
t.Fatalf("missing error object; body=%s", body)
}
if code, _ := errObj["code"].(float64); int(code) != 40 {
t.Errorf("error code = %v, want 40; body=%s", errObj["code"], body)
}
}
// TestAuthPlainKey proves p=<key> is accepted (Navidrome compatibility).
func TestAuthPlainKey(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=alice&p="+f.key+"&v=1.16.1&c=test&f=json"))
if root := jsonEnvelope(t, body); root["status"] != "ok" {
t.Fatalf("p=<key> status = %v, want ok; body=%s", root["status"], body)
}
}
// TestAuthUnknownUserIsCode40 proves an unknown user is the same envelope error.
func TestAuthUnknownUserIsCode40(t *testing.T) {
srv, _ := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=nobody&p=x&v=1.16.1&c=test&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("unknown user status = %v, want failed", root["status"])
}
if code, _ := root["error"].(map[string]any)["code"].(float64); int(code) != 40 {
t.Errorf("unknown user code = %v, want 40", root["error"])
}
}
// ── system / browse ────────────────────────────────────────────────────
func TestPingJSONAndXML(t *testing.T) {
srv, f := newFixture(t)
jroot := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
if jroot["status"] != "ok" || jroot["version"] != dto.ProtocolVersion || jroot["type"] != dto.ServerType {
t.Errorf("json envelope = %v", jroot)
}
xmlBody := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=xml"))
var parsed struct {
XMLName xml.Name `xml:"subsonic-response"`
Status string `xml:"status,attr"`
Version string `xml:"version,attr"`
}
if err := xml.Unmarshal([]byte(xmlBody), &parsed); err != nil {
t.Fatalf("xml parse: %v; body=%s", err, xmlBody)
}
if parsed.Status != "ok" || parsed.Version != dto.ProtocolVersion {
t.Errorf("xml envelope = %+v", parsed)
}
}
func TestGetMusicFolders(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getMusicFolders.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
folders, ok := root["musicFolders"].(map[string]any)
if !ok {
t.Fatalf("missing musicFolders; body=%s", body)
}
list, ok := folders["musicFolder"].([]any)
if !ok || len(list) != 1 {
t.Fatalf("musicFolder = %v, want one entry; body=%s", folders["musicFolder"], body)
}
if list[0].(map[string]any)["name"] != "Music" {
t.Errorf("folder = %v", list[0])
}
}
func TestGetIndexesAndArtists(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getArtists.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
artists := root["artists"].(map[string]any)
indexes := artists["index"].([]any)
if len(indexes) != 1 {
t.Fatalf("indexes = %v", indexes)
}
first := indexes[0].(map[string]any)
if first["name"] != "A" {
t.Errorf("index name = %v, want A (The Artist)", first["name"])
}
if first["artist"].([]any)[0].(map[string]any)["name"] != "The Artist" {
t.Errorf("artist = %v", first["artist"])
}
// getIndexes returns the same index but a leaner artist element.
body = readBody(t, get(t, srv.URL+"/rest/getIndexes.view?"+f.authQuery()+"&f=json"))
root = jsonEnvelope(t, body)
if _, ok := root["indexes"]; !ok {
t.Fatalf("missing indexes; body=%s", body)
}
}
func TestSearch3(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/search3.view?"+f.authQuery()+"&query=Artist&f=json"))
root := jsonEnvelope(t, body)
result := root["searchResult3"].(map[string]any)
albums := result["album"].([]any)
if len(albums) != 1 {
t.Fatalf("albums = %v, want one; body=%s", albums, body)
}
if albums[0].(map[string]any)["name"] != "Album" {
t.Errorf("album = %v", albums[0])
}
if len(result["song"].([]any)) != 3 {
t.Errorf("songs = %v, want three", result["song"])
}
if len(result["artist"].([]any)) != 1 {
t.Errorf("artists = %v, want one", result["artist"])
}
}
func TestGetAlbumList2Newest(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getAlbumList2.view?"+f.authQuery()+"&type=newest&f=json"))
root := jsonEnvelope(t, body)
list := root["albumList2"].(map[string]any)
albums := list["album"].([]any)
if len(albums) != 1 {
t.Fatalf("albums = %v; body=%s", albums, body)
}
if albums[0].(map[string]any)["songCount"].(float64) != 3 {
t.Errorf("songCount = %v, want 3", albums[0].(map[string]any)["songCount"])
}
}
func TestGetAlbumAndSong(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getAlbum.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album)+"&f=json"))
root := jsonEnvelope(t, body)
album := root["album"].(map[string]any)
if len(album["song"].([]any)) != 3 {
t.Errorf("getAlbum songs = %v", album["song"])
}
body = readBody(t, get(t, srv.URL+"/rest/getSong.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&f=json"))
root = jsonEnvelope(t, body)
song := root["song"].(map[string]any)
if song["album"] != "Album" || song["artist"] != "The Artist" {
t.Errorf("song = %v", song)
}
}
func TestGetMusicDirectory(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+dto.FolderID(f.libID)+"&f=json"))
root := jsonEnvelope(t, body)
dir := root["directory"].(map[string]any)
children := dir["child"].([]any)
if len(children) != 1 || children[0].(map[string]any)["isDir"] != true {
t.Fatalf("root children = %v; body=%s", children, body)
}
// Drill into the album directory and confirm its songs appear.
body = readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album)+"&f=json"))
root = jsonEnvelope(t, body)
dir = root["directory"].(map[string]any)
if len(dir["child"].([]any)) != 3 {
t.Errorf("album directory children = %v", dir["child"])
}
}
func TestGetUser(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getUser.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
user := root["user"].(map[string]any)
if user["username"] != "alice" {
t.Errorf("username = %v", user["username"])
}
if user["adminRole"] != false || user["streamRole"] != true {
t.Errorf("roles = %v", user)
}
}
func TestGetPlaylistsEmpty(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getPlaylists.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
pl, ok := root["playlists"].(map[string]any)
if !ok {
t.Fatalf("missing playlists; body=%s", body)
}
if list, ok := pl["playlist"].([]any); !ok || len(list) != 0 {
t.Errorf("playlist = %v, want empty array", pl["playlist"])
}
}
// ── media ──────────────────────────────────────────────────────────────
func TestStreamRawAndRange(t *testing.T) {
srv, f := newFixture(t)
resp := get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw")
if resp.StatusCode != http.StatusOK {
t.Fatalf("stream status = %d, want 200", resp.StatusCode)
}
if got := readBody(t, resp); len(got) != 100 {
t.Fatalf("stream body length = %d, want 100", len(got))
}
req, _ := http.NewRequest(http.MethodGet,
srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw", nil)
req.Header.Set("Range", "bytes=0-9")
rangeResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("range request: %v", err)
}
defer rangeResp.Body.Close()
if rangeResp.StatusCode != http.StatusPartialContent {
t.Fatalf("range status = %d, want 206", rangeResp.StatusCode)
}
if cr := rangeResp.Header.Get("Content-Range"); cr != "bytes 0-9/100" {
t.Errorf("Content-Range = %q, want bytes 0-9/100", cr)
}
body, _ := io.ReadAll(rangeResp.Body)
if len(body) != 10 {
t.Errorf("range body length = %d, want 10", len(body))
}
}
// TestStreamRejectsTranscode proves a non-raw format is refused honestly rather
// than silently served untranscoded.
func TestStreamRejectsTranscode(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=mp3&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("format=mp3 status = %v, want failed; body=%s", root["status"], body)
}
if !strings.Contains(root["error"].(map[string]any)["message"].(string), "transcod") {
t.Errorf("error message = %v, want a transcoding refusal", root["error"])
}
}
func TestGetCoverArt(t *testing.T) {
srv, f := newFixture(t)
resp := get(t, srv.URL+"/rest/getCoverArt.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album))
if resp.StatusCode != http.StatusOK {
t.Fatalf("cover status = %d, want 200", resp.StatusCode)
}
if got := readBody(t, resp); !strings.HasPrefix(got, "\x89PNG") {
t.Errorf("cover bytes = %q", got)
}
}
// TestScrobbleWritesProgress proves scrobble lands in the progress store.
func TestScrobbleWritesProgress(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/scrobble.view?"+f.authQuery()+
"&id="+dto.FileID(f.file)+"&submission=true&position=30000&f=json"))
if root := jsonEnvelope(t, body); root["status"] != "ok" {
t.Fatalf("scrobble status = %v; body=%s", root["status"], body)
}
var position, duration int64
if err := f.db.QueryRow(
`SELECT position, duration FROM progress WHERE user_id = ? AND item_id = ? AND file_id = ?`,
f.userID, f.album, f.file).Scan(&position, &duration); err != nil {
t.Fatalf("progress row: %v", err)
}
if position != 30000 || duration != 60000 {
t.Errorf("progress = (%d,%d), want (30000,60000)", position, duration)
}
}
// TestAccessScopedToGrants proves a user without a grant cannot stream.
func TestAccessScopedToGrants(t *testing.T) {
srv, f := newFixture(t)
// Revoke the grant, then request the same file.
if _, err := f.db.Exec(`DELETE FROM library_grants WHERE user_id = ?`, f.userID); err != nil {
t.Fatal(err)
}
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("revoked grant stream status = %v, want failed; body=%s", root["status"], body)
}
}

View file

@ -0,0 +1,28 @@
-- 0010_media_probe.sql — record the codec facts a client needs to choose direct
-- play.
--
-- Vellum does not transcode. The server has no spare CPU, and measured against
-- real files, playability turned out to be a property of the CLIENT, not the
-- file: Chrome on Linux plays an MKV carrying H.264 perfectly well, yet cannot
-- decode an MP4 carrying HEVC at all — it plays the audio track and reports
-- videoWidth 0. So the server's job is only to expose what is actually inside
-- each file and let every client decide for itself. These columns are that
-- exposure; without them a client has nothing to base the decision on.
--
-- The values come from ffprobe at scan time. They are empty/zero until the
-- scanner probes the file, and stay that way when ffprobe is not installed:
-- Vellum still works without it, just with less information. duration_ms
-- already exists on media_files from 0001, so it is reused rather than re-added.
-- ffprobe codec name, e.g. "h264", "hevc", "vp9", "av1". "" when there is no
-- video track (audio, or embedded cover art only).
ALTER TABLE media_files ADD COLUMN vcodec TEXT NOT NULL DEFAULT '';
-- ffprobe codec name, e.g. "aac", "opus", "flac", "mp3". "" when there is no
-- audio track.
ALTER TABLE media_files ADD COLUMN acodec TEXT NOT NULL DEFAULT '';
-- Pixel dimensions of the video track; 0 when there is none.
ALTER TABLE media_files ADD COLUMN width INTEGER NOT NULL DEFAULT 0;
ALTER TABLE media_files ADD COLUMN height INTEGER NOT NULL DEFAULT 0;
-- When the codec facts above were last written, so a rescan can be proven not
-- to have re-probed an unchanged file. '' means never probed.
ALTER TABLE media_files ADD COLUMN probed_at TEXT NOT NULL DEFAULT '';

View file

@ -0,0 +1,11 @@
-- 0011_missing_since.sql — record how long a file or item has been missing.
--
-- `missing` says a file is gone; `missing_since` says for how long. That age is
-- the whole point: it is what lets an admin tell a drive that is merely
-- unplugged from content that is genuinely deleted, and therefore whether the
-- explicit purge is safe to run. Without it every rescan would refresh the
-- timestamp and a file that has been gone for months would look "just now
-- missing" on every pass.
ALTER TABLE media_items ADD COLUMN missing_since TEXT NOT NULL DEFAULT '';
ALTER TABLE media_files ADD COLUMN missing_since TEXT NOT NULL DEFAULT '';

View file

@ -0,0 +1,34 @@
-- 0012_api_tokens.sql — tokens for non-browser clients.
--
-- Jellyfin clients send `X-Emby-Token`, Subsonic clients send `t=`, and neither
-- speaks the session cookie the web app uses. Rather than weaken the cookie
-- model, these live beside it: a token is a second, revocable way to be a user,
-- and every request still resolves to the same auth.User and goes through the
-- same grant chokepoint.
--
-- Two storage shapes, for a real reason:
--
-- * Jellyfin sends the token as a bearer value, so only a hash is needed —
-- same reasoning as sessions. A stolen database yields no usable token.
-- * Subsonic's `t` parameter is md5(apiKey + salt), and the server must
-- compute that to compare. That requires the KEY itself, so a Subsonic key
-- is stored in the clear. It is a generated random secret, never a user's
-- password, and it is revocable — but it is worth being explicit that this
-- column is the one place a usable credential sits in the database.
--
-- `token_plain` is therefore empty for Jellyfin and populated for Subsonic.
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- 'jellyfin' | 'subsonic'
label TEXT NOT NULL DEFAULT '', -- shown in the UI, e.g. "Living room TV"
token_hash TEXT NOT NULL, -- sha256 hex, used for lookup
token_plain TEXT NOT NULL DEFAULT '', -- Subsonic only; see above
created_at TEXT NOT NULL DEFAULT '',
last_used_at TEXT NOT NULL DEFAULT '',
expires_at TEXT NOT NULL DEFAULT '' -- '' means never
);
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id, kind);

10
web/dist/.gitkeep vendored Normal file
View file

@ -0,0 +1,10 @@
# Keeps web/dist present in a fresh clone.
#
# //go:embed all:dist in web/embed.go fails to compile if the directory is
# absent, so a clone with no frontend build could not run `go build` at all.
# With this file present the build succeeds and the server logs
# "web UI not embedded; run the frontend build to include it", which is the
# intended behaviour for someone who only wants the API.
#
# `pnpm build` overwrites dist/ with the real bundle; the Dockerfile builds the
# frontend first and overlays it.

View file

@ -121,6 +121,24 @@ export const api = {
scanLibrary: (id: number) =>
request<{ queued: number; job_id: number }>('POST', `/api/v1/libraries/${id}/scan`),
// ── playback engines ──
engines: () => request<{ engines: EngineSupport[] }>('GET', '/api/v1/playback/engines'),
resolvePlayback: (itemId: number, engines: string[]) =>
request<PlaybackResolution>('POST', '/api/v1/playback/resolve', {
item_id: itemId,
engines,
}),
// ── missing files ──
missing: (libraryId?: number) => {
const q = libraryId ? `?library_id=${libraryId}` : ''
return request<{ items: MissingEntry[]; total: number }>('GET', `/api/v1/missing${q}`)
},
purgeMissing: (libraryId: number) =>
request<{ purged: number }>('POST', `/api/v1/libraries/${libraryId}/purge`),
scanStatus: (id: number) =>
request<{
state: 'queued' | 'running' | 'done' | 'failed' | 'idle'
@ -269,14 +287,76 @@ export interface Home {
total_items: number
}
// MediaFile carries the ffprobe facts about one file on disk.
//
// The codec fields are what make "direct play only" workable: playability is a
// property of the CLIENT, not the file, so the server reports what is in the
// file and each client decides for itself. An empty vcodec means there is no
// video track (audio, or a comic).
export interface MediaFile {
id: number
path: string
size: number
container: string
missing: boolean
duration_ms: number
/** ffprobe codec name, e.g. "h264", "hevc", "vp9", "av1". "" when absent. */
vcodec: string
/** ffprobe codec name, e.g. "aac", "opus", "flac", "mp3". "" when absent. */
acodec: string
width: number
height: number
}
export interface ItemDetail {
item: Item
files: { id: number; path: string; size: number; container: string; missing: boolean }[]
files: MediaFile[]
has_cover: boolean
authors: string[] | null
progress?: Progress
}
// ── playback engines ────────────────────────────────────────────────────
// Playability is a property of the CLIENT, so a client declares the engines it
// has and the server matches them against the file's codec facts. A browser
// offers "html5"; a native client offers "mpv", "vlc", "mdk" as well.
export interface EngineSupport {
id: string
name: string
kind: 'browser' | 'native'
/** Lowercase container extensions it can open, e.g. "mkv". */
containers: string[]
/** Video codec names it can decode, e.g. "h264", "hevc". */
vcodecs: string[]
/** Audio codec names it can decode. */
acodecs: string[]
}
export interface PlaybackResolution {
playable: boolean
/** The engine that should play it, or null when nothing can. */
engine: string | null
/** Machine-readable cause when not playable, e.g. "vcodec:hevc". */
reason: string
/** Human text for the UI. Empty when playable. */
message: string
}
// ── missing files ───────────────────────────────────────────────────────
// A scan never deletes: a file that vanishes is marked missing so its progress
// survives a temporarily unmounted drive. Nothing is removed until an admin
// explicitly purges, which is what these two calls are for.
export interface MissingEntry {
item_id: number
file_id: number
library_id: number
kind: string
title: string
path: string
/** When the file was first seen missing, RFC3339. */
missing_since: string
}
export interface ScanResult {
library_id: number
scanned: number

110
web/src/lib/capabilities.ts Normal file
View file

@ -0,0 +1,110 @@
// Client-side playback capability detection.
//
// Vellum does not transcode, so whether a file plays is a property of THIS
// client — its GPU, its decoder, its power budget — not of the file. The server
// keeps a conservative per-engine baseline (internal/engine) but cannot know the
// client's hardware. This module is how the client answers for itself.
//
// The web app's only engine is HTML5. `navigator.mediaCapabilities` is the real
// hardware test: unlike `canPlayType`, it distinguishes "cannot decode at all"
// from "decodes, but will stutter and drain the battery". It is absent in some
// browsers, so every path here is best-effort and degrades to the server's
// baseline rather than throwing — nothing in this file may ever break playback.
import type { MediaFile } from './api'
import { sourceType } from './playback'
/** What MediaCapabilities reports about one file's video configuration. */
export interface DecodingInfo {
/** The client can decode this. */
supported: boolean
/** It can decode it smoothly, at the requested rate and resolution. */
smooth: boolean
/** It can decode it without leaning on a power-hungry path. */
powerEfficient: boolean
}
/**
* A verdict with the "could not ask" case made explicit. When `unknown` is
* true the client had no way to test itself, so the caller must fall back to the
* server's baseline and keep the element as the final authority.
*/
export interface CodecVerdict extends DecodingInfo {
unknown: boolean
}
/** The engine IDs this client can offer. The web app is an HTML5 client only. */
export async function clientEngines(): Promise<string[]> {
return ['html5']
}
/** Whether this browser can be asked about decoding at all. */
export function mediaCapabilitiesAvailable(): boolean {
return (
typeof navigator !== 'undefined' &&
typeof navigator.mediaCapabilities !== 'undefined' &&
typeof navigator.mediaCapabilities.decodingInfo === 'function'
)
}
/**
* Ask the client whether it can decode one video configuration.
*
* Returns null when the API is unavailable or the query is rejected; callers
* treat null as "no opinion" and keep the server's baseline.
*/
export async function decodingInfo(config: {
contentType: string
width?: number
height?: number
bitrate?: number
framerate?: number
}): Promise<DecodingInfo | null> {
if (!config.contentType || !mediaCapabilitiesAvailable()) return null
try {
// The dimensions/bitrate/framerate are what let MediaCapabilities judge
// smoothness and power, not just support, so sensible defaults are supplied
// when the file facts are incomplete.
const info = await navigator.mediaCapabilities.decodingInfo({
type: 'file',
video: {
contentType: config.contentType,
width: config.width || 1920,
height: config.height || 1080,
bitrate: config.bitrate || 8_000_000,
framerate: config.framerate || 30,
},
})
return {
supported: info.supported,
smooth: info.smooth,
powerEfficient: info.powerEfficient,
}
} catch {
// A malformed codec string, an unsupported query shape, a browser quirk:
// none of it should stop the file from being tried.
return null
}
}
/**
* Test one file against this client's real decoder.
*
* The codec string comes from the shared mapping in playback.ts, so the same
* RFC 6381 parameters are used for the element's `type` hint and this probe.
* Returns an explicit `unknown` verdict when there is nothing to ask or the
* container is unrecognised.
*/
export async function probeFile(file: MediaFile): Promise<CodecVerdict> {
const unknown: CodecVerdict = { supported: true, smooth: true, powerEfficient: true, unknown: true }
const contentType = sourceType(file)
if (!contentType) return unknown
const info = await decodingInfo({
contentType,
width: file.width,
height: file.height,
})
if (!info) return unknown
return { ...info, unknown: false }
}

View file

@ -1,11 +1,35 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item } from '../api'
import {
setNowPlaying,
setHandlers,
setPosition,
setPlaybackState,
clear as clearMediaSession,
} from '../mediaSession'
let { itemId }: { itemId: number } = $props()
// Playback speed steps. 1 is the default; the choice is per-device and
// survives both chapter changes and opening a different book.
const SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2] as const
type Speed = (typeof SPEEDS)[number]
const SPEED_KEY = 'vellum.audiobook.speed'
// Seek step for the keyboard shortcuts and the OS seek actions, in seconds.
// 15s is the audiobook convention (a sentence or two), not the 10s of music.
const SEEK_STEP = 15
const SLEEP_CHOICES = [5, 15, 30, 45, 60] as const
type SleepMode = 'off' | 'chapter' | number
let audio = $state<HTMLAudioElement>()
// Hidden element that warms the next chapter's metadata so the hand-off on
// 'ended' is as close to gapless as the platform allows.
let preloader = $state<HTMLAudioElement>()
let item = $state<Item | null>(null)
let authors = $state<string[] | null>(null)
let tracks = $state<{ id: number; path: string; size: number }[]>([])
let index = $state(0)
let loading = $state(true)
@ -15,19 +39,29 @@
let currentTime = $state(0)
let duration = $state(0)
let speed = $state<Speed>(readSpeed())
let sleepMode = $state<SleepMode>('off')
let sleepRemaining = $state(0)
let sleepInterval: number | undefined
let saveTimer: number | undefined
let resumeAt = 0
let pendingPlay = false
let finalFlushed = false
// Set when the user pauses on purpose (button, keyboard or OS control) so a
// running sleep timer can be cancelled without reacting to our own pause.
let manualPause = false
onMount(async () => {
// Persist the final position even if the page is torn down: keepalive lets a
// PUT outlive the document, which sendBeacon (POST-only) cannot.
window.addEventListener('pagehide', finalFlush)
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeydown)
try {
const detail = await api.item(itemId)
item = detail.item
authors = detail.authors
tracks = detail.files ?? []
if (tracks.length === 0) {
error = 'This item has no audio tracks.'
@ -41,6 +75,8 @@
resumeAt = detail.progress.position / 1000
}
}
publishNowPlaying()
registerHandlers()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this audiobook'
} finally {
@ -51,6 +87,13 @@
onDestroy(() => {
window.removeEventListener('pagehide', finalFlush)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeydown)
clearSleep()
clearMediaSession()
if (preloader) {
preloader.removeAttribute('src')
preloader.load()
}
finalFlush()
})
@ -59,9 +102,45 @@
else finalFlushed = false
}
// ── MediaSession ─────────────────────────────────────────────────────
// The OS media keys, lock screen and browser media popup read from here; the
// helper guards every call so an unsupported browser never breaks playback.
function publishNowPlaying() {
setNowPlaying({
title: item?.title ?? 'Audiobook',
artist: authors?.length ? authors.join(', ') : undefined,
album: 'Audiobook',
artwork: api.coverUrl(itemId),
duration,
})
}
function registerHandlers() {
setHandlers({
play: () => {
if (audio) void audio.play().catch(() => {})
},
pause: () => pauseManual(),
next: () => goToTrack(index + 1),
previous: () => goToTrack(index - 1),
seekBackward: (d) => seekBy(-(d?.seekOffset ?? SEEK_STEP)),
seekForward: (d) => seekBy(d?.seekOffset ?? SEEK_STEP),
seekTo: (d) => {
if (audio && d?.seekTime != null) {
audio.currentTime = d.seekTime
currentTime = d.seekTime
}
},
})
}
function onLoadedMetadata() {
if (!audio) return
duration = audio.duration || 0
// Re-apply the chosen speed to the freshly-loaded source, otherwise the
// element silently resets to 1x on every chapter change.
audio.playbackRate = speed
if (resumeAt > 0 && resumeAt < duration) {
audio.currentTime = resumeAt
resumeAt = 0
@ -71,25 +150,42 @@
pendingPlay = false
void audio.play().catch(() => {})
}
publishNowPlaying()
setPosition(audio.currentTime, duration, speed)
}
function onTimeUpdate() {
if (!audio) return
currentTime = audio.currentTime
setPosition(currentTime, duration, speed)
scheduleSave()
}
function toggle() {
if (!audio) return
if (audio.paused) void audio.play()
else audio.pause()
else pauseManual()
}
function pauseManual() {
if (!audio || audio.paused) return
manualPause = true
audio.pause()
}
function onPlay() {
playing = true
setPlaybackState('playing')
}
function onPause() {
playing = false
setPlaybackState('paused')
// A deliberate pause cancels a pending sleep timer so it cannot fire later
// against a session the user resumed on their own.
if (manualPause) {
manualPause = false
if (sleepMode !== 'off') clearSleep()
}
}
function goToTrack(i: number) {
@ -107,10 +203,123 @@
}
function onEnded() {
// "End of chapter" sleep: stop here instead of rolling into the next file.
if (sleepMode === 'chapter') {
clearSleep()
if (audio) audio.pause()
return
}
if (index + 1 < tracks.length) goToTrack(index + 1)
else flush(true)
}
// ── Playback speed ───────────────────────────────────────────────────
function readSpeed(): Speed {
try {
const raw = localStorage.getItem(SPEED_KEY)
const n = raw ? Number(raw) : NaN
if ((SPEEDS as readonly number[]).includes(n)) return n as Speed
} catch {
// Private browsing or no storage: fall back to 1x for this session.
}
return 1
}
function cycleSpeed() {
const i = SPEEDS.indexOf(speed)
speed = SPEEDS[(i + 1) % SPEEDS.length]
try {
localStorage.setItem(SPEED_KEY, String(speed))
} catch {
// Keep the change for this session even if it cannot be persisted.
}
if (audio) audio.playbackRate = speed
}
// ── Sleep timer ──────────────────────────────────────────────────────
function setSleep(mode: SleepMode) {
clearSleep()
if (mode === 'off') return
sleepMode = mode
if (mode === 'chapter') return
sleepRemaining = mode * 60
sleepInterval = window.setInterval(tickSleep, 1000)
}
function tickSleep() {
sleepRemaining -= 1
if (sleepRemaining > 0) return
const wasPlaying = !!audio && !audio.paused
clearSleep()
if (wasPlaying) audio!.pause()
}
function clearSleep() {
if (sleepInterval !== undefined) {
clearInterval(sleepInterval)
sleepInterval = undefined
}
sleepMode = 'off'
sleepRemaining = 0
}
function onSleepChange(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value
if (value === 'off') setSleep('off')
else if (value === 'chapter') setSleep('chapter')
else setSleep(Number(value))
}
// ── Keyboard ─────────────────────────────────────────────────────────
// Space = play/pause, ←/→ = seek ∓15s, , / . = previous / next track.
// Ignored while typing so the shortcuts never eat a search or form field.
function onKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable)
) {
return
}
switch (event.key) {
case ' ':
case 'Spacebar':
event.preventDefault()
toggle()
break
case 'ArrowLeft':
event.preventDefault()
seekBy(-SEEK_STEP)
break
case 'ArrowRight':
event.preventDefault()
seekBy(SEEK_STEP)
break
case ',':
event.preventDefault()
goToTrack(index - 1)
break
case '.':
event.preventDefault()
goToTrack(index + 1)
break
}
}
function seekBy(delta: number) {
if (!audio) return
const limit = isFinite(audio.duration) && audio.duration > 0 ? audio.duration : Infinity
const next = Math.min(Math.max(audio.currentTime + delta, 0), limit)
audio.currentTime = next
currentTime = next
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = window.setTimeout(() => flush(), 2000)
@ -204,6 +413,17 @@
onended={onEnded}
></audio>
{#if index + 1 < tracks.length}
<audio
bind:this={preloader}
class="preload"
src={api.fileUrlById(itemId, tracks[index + 1].id)}
preload="metadata"
aria-hidden="true"
tabindex="-1"
></audio>
{/if}
<input
class="seek"
type="range"
@ -228,8 +448,35 @@
onclick={() => goToTrack(index + 1)}
disabled={index >= tracks.length - 1}>⏭</button
>
<button class="btn btn-ghost speed" onclick={cycleSpeed} title="Playback speed — saved across chapters and books"
>{speed}×</button
>
</div>
<div class="sleep">
<label class="muted" for="sleep-timer">Sleep timer</label>
<select
id="sleep-timer"
value={sleepMode === 'off' ? 'off' : String(sleepMode)}
onchange={onSleepChange}
>
<option value="off">Off</option>
{#each SLEEP_CHOICES as minutes (minutes)}
<option value={String(minutes)}>{minutes} min</option>
{/each}
<option value="chapter">End of chapter</option>
</select>
{#if sleepMode !== 'off'}
<span class="countdown mono" aria-live="polite">
{sleepMode === 'chapter' ? 'at chapter end' : `pauses in ${fmt(sleepRemaining)}`}
</span>
{/if}
</div>
<p class="hint muted">
Space play/pause · ←/→ seek {SEEK_STEP}s · , / . previous / next
</p>
{#if tracks.length > 1}
<ol class="tracks">
{#each tracks as t, i (t.id)}
@ -276,6 +523,14 @@
display: flex;
flex-direction: column;
}
/* Warm the next chapter without rendering a second control surface. */
.preload {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.seek {
width: 100%;
accent-color: var(--accent);
@ -295,6 +550,28 @@
min-width: 96px;
justify-content: center;
}
.speed {
min-width: 52px;
justify-content: center;
font-variant-numeric: tabular-nums;
}
.sleep {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: .857rem;
}
.sleep select {
padding: 4px var(--space-2);
}
.countdown {
color: var(--accent);
}
.hint {
margin: 0;
font-size: .786rem;
text-align: center;
}
.tracks {
list-style: none;
margin: var(--space-2) 0 0;

View file

@ -0,0 +1,231 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, ApiError, type MissingEntry } from '../api'
import { libraries } from '../session'
let entries = $state<MissingEntry[]>([])
let loading = $state(true)
let busy = $state<number | null>(null)
let error = $state<string | null>(null)
let notice = $state<string | null>(null)
onMount(load)
async function load() {
loading = true
error = null
try {
const res = await api.missing()
entries = res.items ?? []
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not load missing files'
} finally {
loading = false
}
}
// Group by library so each group can offer its own purge. Purging deletes
// items, not files, so the count an admin confirms is distinct item ids — a
// multi-track audiobook is one item even though it contributes several files.
const groups = $derived.by(() => {
const byLib = new Map<number, MissingEntry[]>()
for (const e of entries) {
const list = byLib.get(e.library_id)
if (list) list.push(e)
else byLib.set(e.library_id, [e])
}
return [...byLib.entries()]
.map(([libraryId, items]) => ({
libraryId,
name: $libraries.find((l) => l.id === libraryId)?.name ?? `Library ${libraryId}`,
items,
itemCount: new Set(items.map((i) => i.item_id)).size,
}))
.sort((a, b) => a.name.localeCompare(b.name))
})
const totalFiles = $derived(entries.length)
const totalItems = $derived(new Set(entries.map((e) => e.item_id)).size)
async function purge(libraryId: number, name: string, itemCount: number) {
const noun = itemCount === 1 ? 'item' : 'items'
const ok = confirm(
`Purge ${itemCount} missing ${noun} from “${name}”?\n\n` +
'This permanently deletes each item, its files, and any watch or read ' +
'progress recorded for them. Progress cannot be regenerated. This ' +
'cannot be undone.',
)
if (!ok) return
busy = libraryId
error = null
notice = null
try {
const res = await api.purgeMissing(libraryId)
notice = `Purged ${res.purged} ${res.purged === 1 ? 'item' : 'items'} from “${name}”.`
await load()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not purge missing files'
} finally {
busy = null
}
}
// Human age from an RFC3339 timestamp, e.g. "3 days". The age is the point:
// a drive unplugged this morning is not the same as content gone for months.
function age(since: string): string {
if (!since) return 'unknown'
const then = Date.parse(since)
if (Number.isNaN(then)) return 'unknown'
let value = Math.max(0, Math.floor((Date.now() - then) / 1000))
const steps: [number, string][] = [
[60, 'second'],
[60, 'minute'],
[24, 'hour'],
[7, 'day'],
[4, 'week'],
[12, 'month'],
[Infinity, 'year'],
]
let label = 'second'
for (const [size, name] of steps) {
if (value < size) {
label = name
break
}
value = Math.floor(value / size)
label = name
}
return `${value} ${label}${value === 1 ? '' : 's'}`
}
</script>
{#if error}<div class="alert">{error}</div>{/if}
{#if notice}<div class="ok">{notice}</div>{/if}
{#if loading}
<div class="empty"><span class="spinner"></span><p>Looking for missing files…</p></div>
{:else if entries.length === 0}
<div class="empty">
<h3>Nothing is missing</h3>
<p>Every file the scanner knows about is still on disk. Files that vanish are kept here until you purge them.</p>
</div>
{:else}
<p class="summary muted">
{totalFiles} missing {totalFiles === 1 ? 'file' : 'files'} across
{totalItems} {totalItems === 1 ? 'item' : 'items'}. A rescan never deletes; purge is the only way to remove them.
</p>
{#each groups as group (group.libraryId)}
<section class="card group">
<header class="head">
<div class="titles">
<strong>{group.name}</strong>
<span class="muted">{group.itemCount} {group.itemCount === 1 ? 'item' : 'items'} · {group.items.length} {group.items.length === 1 ? 'file' : 'files'}</span>
</div>
<button
class="btn btn-ghost danger"
disabled={busy !== null}
onclick={() => purge(group.libraryId, group.name, group.itemCount)}
>
{busy === group.libraryId ? 'Purging…' : 'Purge'}
</button>
</header>
<ul class="files">
{#each group.items as entry (entry.file_id)}
<li class="file">
<div class="what">
<span class="title">{entry.title || entry.path}</span>
<span class="path mono">{entry.path}</span>
</div>
<span class="badge kind">{entry.kind}</span>
<span class="since muted" title={entry.missing_since || 'unknown'}>missing {age(entry.missing_since)}</span>
</li>
{/each}
</ul>
</section>
{/each}
{/if}
<style>
.summary {
margin: 0 0 var(--space-4);
font-size: var(--fs-md);
}
.group {
margin-bottom: var(--space-4);
overflow: hidden;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
}
.titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.titles .muted {
font-size: var(--fs-sm);
}
.danger {
color: var(--danger);
}
.files {
list-style: none;
margin: 0;
padding: 0;
}
.file {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
}
.file + .file {
border-top: 1px solid var(--border);
}
.what {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path {
color: var(--faint);
word-break: break-all;
}
.kind {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.since {
font-size: var(--fs-sm);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.empty .spinner {
margin-bottom: var(--space-2);
}
@media (max-width: 640px) {
.file {
grid-template-columns: 1fr auto;
}
.since {
grid-column: 1 / -1;
}
}
</style>

View file

@ -0,0 +1,580 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item } from '../api'
import * as mediaSession from '../mediaSession'
let { itemId }: { itemId: number } = $props()
type Track = { id: number; path: string; size: number }
type RepeatMode = 'off' | 'all' | 'one'
let audio = $state<HTMLAudioElement>()
let preload = $state<HTMLAudioElement>()
let item = $state<Item | null>(null)
let authors = $state<string[]>([])
let tracks = $state<Track[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
// Play order is a permutation of track indices, walked by `pos`. Shuffling
// builds that permutation once so "previous" is meaningful and no track
// repeats until the queue is exhausted — rather than picking at random per
// step.
let order = $state<number[]>([])
let pos = $state(0)
let shuffle = $state(false)
let repeat = $state<RepeatMode>('off')
let playing = $state(false)
let currentTime = $state(0)
let duration = $state(0)
// Learned from loadedmetadata as tracks are visited (and from the preloader
// for the upcoming track), so durations fill in as playback moves along.
let durations = $state<Record<number, number>>({})
let saveTimer: number | undefined
let pendingPlay = false
let finalFlushed = false
const currentTrack = $derived(tracks[order[pos]] ?? null)
const currentIndex = $derived(order[pos] ?? -1)
// What to warm up in the hidden element. Wrapping honours repeat so the
// metadata for the track that will actually follow is already in memory.
const nextTrack = $derived.by(() => {
if (tracks.length === 0) return null
if (repeat === 'one') return tracks[order[pos]] ?? null
const n = order[pos + 1]
if (n !== undefined) return tracks[n] ?? null
if (repeat === 'all') return tracks[order[0]] ?? null
return null
})
onMount(async () => {
// Persist the final position even if the page is torn down: keepalive lets a
// PUT outlive the document, which sendBeacon (POST-only) cannot.
window.addEventListener('pagehide', finalFlush)
document.addEventListener('visibilitychange', onVisibilityChange)
// OS/lock-screen controls. Registered up front so media keys work as soon
// as playback starts.
mediaSession.setHandlers({
play: () => {
if (audio) void audio.play().catch(() => {})
},
pause: () => audio?.pause(),
next: () => next(),
previous: () => previous(),
seekTo: (details) => {
if (audio && typeof details?.seekTime === 'number') {
audio.currentTime = details.seekTime
currentTime = details.seekTime
}
},
seekBackward: (details) => seekBy(-(details?.seekOffset ?? 10)),
seekForward: (details) => seekBy(details?.seekOffset ?? 10),
})
try {
const detail = await api.item(itemId)
item = detail.item
authors = detail.authors ?? []
tracks = detail.files ?? []
if (tracks.length === 0) {
error = 'This album has no tracks.'
return
}
order = tracks.map((_, i) => i)
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this album'
} finally {
loading = false
}
})
onDestroy(() => {
window.removeEventListener('pagehide', finalFlush)
document.removeEventListener('visibilitychange', onVisibilityChange)
finalFlush()
audio?.pause()
// Release the preloader's decoder and network handle.
if (preload) {
preload.removeAttribute('src')
preload.load()
}
mediaSession.clear()
})
// Keep the OS metadata in step with the current track. Runs once per track
// change; the helper is guarded, so an unsupported browser is harmless.
$effect(() => {
const t = currentTrack
if (!t) return
mediaSession.setNowPlaying({
title: cleanTitle(t.path),
album: item?.title ?? '',
artist: authors.join(', '),
artwork: api.coverUrl(itemId),
})
})
function onVisibilityChange() {
if (document.visibilityState === 'hidden') finalFlush()
else finalFlushed = false
}
function onLoadedMetadata() {
if (!audio) return
duration = audio.duration || 0
const i = currentIndex
if (i >= 0 && isFinite(duration) && duration > 0) durations[tracks[i].id] = duration
// The new source is loaded: autoplay a track we just switched to.
if (pendingPlay) {
pendingPlay = false
void audio.play().catch(() => {})
}
}
function onPreloadMetadata(event: Event) {
const el = event.currentTarget as HTMLAudioElement
const id = Number(el.dataset.track)
const d = el.duration
if (id && isFinite(d) && d > 0) durations[id] = d
}
function onTimeUpdate() {
if (!audio) return
currentTime = audio.currentTime
mediaSession.setPosition(currentTime, audio.duration || duration)
scheduleSave()
}
function toggle() {
if (!audio) return
if (audio.paused) void audio.play()
else audio.pause()
}
function onPlay() {
playing = true
mediaSession.setPlaybackState('playing')
}
function onPause() {
playing = false
mediaSession.setPlaybackState('paused')
}
// Build a fresh walk with `first` at the front and everything else shuffled.
function shuffledOrder(first: number): number[] {
const rest = tracks.map((_, i) => i).filter((i) => i !== first)
for (let i = rest.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[rest[i], rest[j]] = [rest[j], rest[i]]
}
return [first, ...rest]
}
function toggleShuffle() {
const cur = currentIndex
shuffle = !shuffle
if (shuffle) {
order = shuffledOrder(cur < 0 ? 0 : cur)
pos = 0
} else {
order = tracks.map((_, i) => i)
pos = cur < 0 ? 0 : cur
}
}
function cycleRepeat() {
repeat = repeat === 'off' ? 'all' : repeat === 'all' ? 'one' : 'off'
}
function goTo(i: number) {
if (i < 0 || i >= tracks.length) return
flush()
if (i === currentIndex) {
if (audio) void audio.play().catch(() => {})
return
}
// Clicking a row plays it and the queue continues from there: list order
// when unshuffled, or a fresh shuffled walk when shuffle is on.
if (shuffle) {
order = shuffledOrder(i)
pos = 0
} else {
pos = i
}
// Autoplay once Svelte has actually swapped the src and it has loaded;
// playing from a microtask can race the DOM update and replay the old track.
pendingPlay = true
}
function step(delta: number) {
if (order.length === 0) return
const n = pos + delta
let target = -1
if (n >= 0 && n < order.length) target = n
else if (repeat === 'all') target = delta > 0 ? 0 : order.length - 1
if (target < 0) return
flush()
// A single-track queue (or a wrap back onto the current track) does not
// change the src, so no loadedmetadata will fire to autoplay it: restart
// in place instead.
if (target === pos) {
if (audio) {
audio.currentTime = 0
void audio.play().catch(() => {})
}
return
}
pos = target
pendingPlay = true
}
function next() {
step(1)
}
function previous() {
// Restart the current track first, the way a hardware player does.
if (audio && audio.currentTime > 3) {
audio.currentTime = 0
currentTime = 0
return
}
step(-1)
}
function onEnded() {
if (repeat === 'one') {
if (audio) {
audio.currentTime = 0
void audio.play().catch(() => {})
}
return
}
if (pos + 1 < order.length || repeat === 'all') step(1)
else flush(true)
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = window.setTimeout(() => flush(), 2000)
}
// Progress is milliseconds and is keyed by file, so the album's last-played
// track and offset round-trip.
function progressBody(finished: boolean) {
if (!audio || !currentTrack) return null
const at = Math.floor(audio.currentTime * 1000)
const dur = Math.floor((audio.duration || 0) * 1000)
return {
file_id: currentTrack.id,
position: at,
duration: dur,
percent: dur > 0 ? at / dur : 0,
finished,
}
}
function flush(finished = false) {
const body = progressBody(finished)
if (!body) return
void api.putProgress(itemId, body).catch(() => {})
}
// Last-chance save on teardown. Uses fetch with keepalive so the PUT survives
// the document being unloaded, and latches so the overlapping pagehide /
// visibilitychange / onDestroy events only fire one request. Progress is
// PUT-only, so sendBeacon cannot be used.
function finalFlush() {
if (finalFlushed) return
const body = progressBody(false)
if (!body) return
finalFlushed = true
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = undefined
}
void fetch(`/api/v1/items/${itemId}/progress`, {
method: 'PUT',
keepalive: true,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch(() => {})
}
function seek(event: Event) {
const value = Number((event.currentTarget as HTMLInputElement).value)
if (audio) audio.currentTime = value
currentTime = value
}
function seekBy(delta: number) {
if (!audio) return
const t = Math.min(Math.max(audio.currentTime + delta, 0), audio.duration || 0)
audio.currentTime = t
currentTime = t
}
// Mirrors the server's CleanTitle: strip the extension, turn separators into
// spaces, collapse runs.
function cleanTitle(path: string): string {
const base = path.split('/').pop() ?? path
const dot = base.lastIndexOf('.')
const stem = dot > 0 ? base.slice(0, dot) : base
return stem.replace(/[_.]+/g, ' ').split(/\s+/).filter(Boolean).join(' ')
}
function fmt(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) seconds = 0
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const mm = String(m).padStart(2, '0')
const ss = String(s).padStart(2, '0')
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`
}
</script>
{#if error}
<div class="alert">{error}</div>
{:else if loading}
<div class="center"><div class="spinner"></div></div>
{:else}
<div class="player card">
<div class="head">
<img
class="cover"
src={api.coverUrl(itemId)}
alt=""
onerror={(e) => (e.currentTarget as HTMLImageElement).style.display = 'none'}
/>
<div class="meta">
<strong>{item?.title}</strong>
{#if authors.length}<span class="muted">{authors.join(', ')}</span>{/if}
<span class="muted">{[tracks.length, tracks.length === 1 ? 'track' : 'tracks'].join(' ')}</span>
</div>
</div>
<audio
bind:this={audio}
src={currentTrack ? api.fileUrlById(itemId, currentTrack.id) : undefined}
preload="metadata"
onloadedmetadata={onLoadedMetadata}
ontimeupdate={onTimeUpdate}
onplay={onPlay}
onpause={onPause}
onended={onEnded}
></audio>
{#if nextTrack}
<audio
bind:this={preload}
src={api.fileUrlById(itemId, nextTrack.id)}
data-track={nextTrack.id}
preload="metadata"
onloadedmetadata={onPreloadMetadata}
hidden
></audio>
{/if}
<div class="now">
<span class="mono idx">{currentIndex >= 0 ? String(currentIndex + 1).padStart(2, '0') : '--'}</span>
<strong class="now-title">{currentTrack ? cleanTitle(currentTrack.path) : ''}</strong>
<span class="queue mono muted">{order.length ? `${pos + 1}/${order.length}` : ''}</span>
</div>
<input
class="seek"
type="range"
min="0"
max={duration || 0}
step="1"
value={currentTime}
oninput={seek}
aria-label="Seek"
/>
<div class="times mono">
<span>{fmt(currentTime)}</span>
<span class="muted">{fmt(duration)}</span>
</div>
<div class="controls">
<button
class="btn"
class:btn-primary={shuffle}
class:btn-ghost={!shuffle}
onclick={toggleShuffle}
aria-pressed={shuffle}
title="Shuffle">Shuffle</button
>
<button class="btn btn-ghost" onclick={() => previous()} title="Previous">⏮</button>
<button class="btn btn-primary play" onclick={toggle}>{playing ? 'Pause' : 'Play'}</button>
<button class="btn btn-ghost" onclick={() => next()} title="Next">⏭</button>
<button
class="btn"
class:btn-primary={repeat !== 'off'}
class:btn-ghost={repeat === 'off'}
onclick={cycleRepeat}
title="Repeat"
>
{repeat === 'off' ? 'Repeat off' : repeat === 'all' ? 'Repeat all' : 'Repeat one'}
</button>
</div>
<ol class="tracks">
{#each tracks as t, i (t.id)}
<li>
<button class="track" class:current={i === currentIndex} onclick={() => goTo(i)}>
<span class="mono num">{String(i + 1).padStart(2, '0')}</span>
<span class="ind" aria-hidden="true">
{#if i === currentIndex}
{playing ? '▶' : '❚❚'}
{/if}
</span>
<span class="name">{cleanTitle(t.path)}</span>
<span class="dur mono muted">{durations[t.id] ? fmt(durations[t.id]) : ''}</span>
</button>
</li>
{/each}
</ol>
</div>
{/if}
<style>
.center {
display: grid;
place-items: center;
padding: var(--space-6);
}
.player {
max-width: 560px;
margin: 0 auto;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.head {
display: flex;
align-items: center;
gap: var(--space-4);
}
.cover {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg);
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.meta strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta .muted {
font-size: var(--fs-sm);
}
.now {
display: flex;
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
.idx {
color: var(--faint);
}
.now-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--fs-md);
}
.queue {
flex: none;
}
.seek {
width: 100%;
accent-color: var(--accent);
}
.times {
display: flex;
justify-content: space-between;
font-size: var(--fs-sm);
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.play {
min-width: 96px;
justify-content: center;
}
.tracks {
list-style: none;
margin: var(--space-2) 0 0;
padding: 0;
max-height: 320px;
overflow-y: auto;
border-top: 1px solid var(--border);
}
.track {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: 7px var(--space-2);
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
text-align: left;
cursor: pointer;
}
.track:hover {
background: var(--surface-2);
color: var(--text);
}
.track.current {
color: var(--accent);
}
.num {
flex: none;
color: var(--faint);
}
.track.current .num {
color: var(--accent);
}
.ind {
flex: none;
width: 1.1em;
font-size: var(--fs-xs);
color: var(--accent);
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dur {
flex: none;
color: var(--faint);
}
</style>

View file

@ -3,9 +3,11 @@
import { api, ApiError, type Item, type EbookMetadata } from '../api'
import ComicReader from './ComicReader.svelte'
import AudiobookPlayer from './AudiobookPlayer.svelte'
import MusicPlayer from './MusicPlayer.svelte'
import PhotoViewer from './PhotoViewer.svelte'
import PdfReader from './PdfReader.svelte'
import GamePlayer from './GamePlayer.svelte'
import VideoPlayer from './VideoPlayer.svelte'
let { itemId }: { itemId: number } = $props()
@ -139,12 +141,16 @@
<div class="alert">{error}</div>
{:else if item && item.kind === 'games'}
<GamePlayer {itemId} />
{:else if item && item.kind === 'video'}
<VideoPlayer {itemId} />
{:else if item && fileContainer === 'pdf'}
<PdfReader {itemId} />
{:else if item && item.kind === 'comics'}
<ComicReader {itemId} />
{:else if item && (item.kind === 'audiobooks' || item.kind === 'music')}
{:else if item && item.kind === 'audiobooks'}
<AudiobookPlayer {itemId} />
{:else if item && item.kind === 'music'}
<MusicPlayer {itemId} />
{:else if item && item.kind === 'photos'}
<PhotoViewer {itemId} />
{:else if item && item.kind !== 'ebooks'}

View file

@ -10,8 +10,13 @@
import SearchResults from './SearchResults.svelte'
import Libraries from './Libraries.svelte'
import Users from './Users.svelte'
import MissingFiles from './MissingFiles.svelte'
import Settings from './Settings.svelte'
// The admin "Missing files" view is shell-local, so the shell widens the
// shared View union here rather than forking nav.ts for one admin screen.
type ShellView = View | { name: 'missing' }
let searchBox = $state('')
let scrolled = $state(false)
let contentEl = $state<HTMLDivElement>()
@ -19,6 +24,7 @@
// A stable key for the current view, so navigating transitions the content
// instead of swapping it instantly.
const viewKey = $derived(JSON.stringify($view))
const current = $derived($view as ShellView)
function onScroll() {
scrolled = (contentEl?.scrollTop ?? 0) > 8
@ -43,7 +49,7 @@
if (q) go({ name: 'search', query: q })
}
function title(v: View): string {
function title(v: ShellView): string {
switch (v.name) {
case 'home':
return 'Home'
@ -60,6 +66,8 @@
return 'Libraries'
case 'users':
return 'Users'
case 'missing':
return 'Missing files'
case 'settings':
return 'Appearance'
}
@ -104,6 +112,13 @@
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
<span>Users</span>
</button>
<button
class="navlink"
class:active={current.name === 'missing'}
onclick={() => go({ name: 'missing' } as unknown as View)}
>
<span>Missing files</span>
</button>
</nav>
{/if}
@ -157,6 +172,8 @@
<Libraries />
{:else if $view.name === 'users'}
<Users />
{:else if current.name === 'missing'}
<MissingFiles />
{:else if $view.name === 'settings'}
<Settings />
{/if}

View file

@ -0,0 +1,767 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item, type MediaFile } from '../api'
import * as playback from '../playback'
import { clientEngines, probeFile } from '../capabilities'
import {
setNowPlaying,
setHandlers,
setPosition,
setPlaybackState,
clear as clearMediaSession,
} from '../mediaSession'
let { itemId }: { itemId: number } = $props()
// Seek step for the keyboard shortcuts and the OS seek actions, in seconds.
// 10s is the video convention (a few seconds of a scene), not the 15s of an
// audiobook chapter.
const SEEK_STEP = 10
// How close to the end counts as "finished", so a video that is not watched
// to the last frame still drops off "Continue watching".
const FINISH_WINDOW_MS = 10_000
// How long to let a freshly loaded element publish its dimensions before we
// conclude the video track is undecodable. Long enough for a slow start, short
// enough that a black frame is never mistaken for a working picture.
const DECODE_GRACE_MS = 1200
// Belt-and-braces: if the element says nothing at all after a source change
// (neither loadedmetadata nor error), it rejected the resource during
// selection. Only a timer will notice, and showing the honest notice is the
// whole point of this component.
const LOAD_GRACE_MS = 2500
let video = $state<HTMLVideoElement>()
let item = $state<Item | null>(null)
let files = $state<MediaFile[]>([])
let index = $state(0)
let loading = $state(true)
let error = $state<string | null>(null)
let playing = $state(false)
let muted = $state(false)
let currentTime = $state(0)
let duration = $state(0)
// ── Honest-path state ────────────────────────────────────────────────
// `notice` is non-empty only when this client genuinely cannot decode the
// video track; `undecodable` drives the "audio only" presentation. Both are
// cleared the moment a real picture appears.
let notice = $state('')
let undecodable = $state(false)
let videoDecoded = $state(false)
// `serverNote` is the server's advisory verdict for the item's first file
// (api.resolvePlayback). It SUPPLEMENTS the element watchdog, never replaces
// it: it is shown only until the element reports a real picture, and the
// element's own verdict always wins once it has one.
let serverNote = $state('')
// `qualityNote` is this client's MediaCapabilities refinement — a codec it can
// decode, but not smoothly. Advisory only; it never blocks playback.
let qualityNote = $state('')
let saveTimer: number | undefined
let verifyTimer: number | undefined
let loadWatchdog: number | undefined
let resumeAt = 0
let pendingPlay = false
let finalFlushed = false
const file = $derived(files[index] ?? null)
const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined)
const srcType = $derived(file ? playback.sourceType(file) : '')
// The server resolves an ITEM using its first file's codec facts, so its
// advisory applies only while that first file is selected. Once the element
// has decoded a picture the server note is hidden regardless: the element is
// the source of truth.
const serverApplies = $derived(files.length > 0 && index === 0)
const displayedNotice = $derived(notice || (serverApplies && !videoDecoded ? serverNote : ''))
// `type` belongs on a <source>, but putting it there lets the browser SKIP a
// file whose canPlayType answer is wrong — it would refuse to try mov+h264
// (which plays) and would never even parse HEVC, so `videoWidth` stays 0 and
// no `error` fires; the honest path below would never see it. Setting src on
// the element makes the browser load the file and report `videoWidth`/`error`
// for real, which is the measured behaviour playback.ts describes. The type is
// kept only as an ignored hint.
const videoAttrs = $derived(srcType ? { type: srcType } : {})
// Setting the src attribute restarts the element's load algorithm, so the
// picker can switch between a multi-file item's files without an explicit
// load(). The watchdog covers the case where the element never responds.
$effect(() => {
src
const el = video
if (!el) return
resetDecodeState()
clearWatchdog()
loadWatchdog = window.setTimeout(() => {
loadWatchdog = undefined
const v = video
if (!v) return
if (playback.hasDecodedVideo(v)) return
// readyState < HAVE_METADATA means it never parsed the container at all.
if (v.readyState < 1) showUndecodable(file)
}, LOAD_GRACE_MS)
})
function clearWatchdog() {
if (loadWatchdog !== undefined) {
clearTimeout(loadWatchdog)
loadWatchdog = undefined
}
}
onMount(async () => {
// Persist the final position even if the page is torn down: keepalive lets a
// PUT outlive the document, which sendBeacon (POST-only) cannot. Progress is
// PUT-only, so sendBeacon is not an option.
window.addEventListener('pagehide', finalFlush)
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeydown)
try {
const detail = await api.item(itemId)
item = detail.item
files = detail.files ?? []
if (files.length === 0) {
error = 'This item has no video file.'
return
}
// Resume on the file that was playing, at the saved offset.
if (detail.progress) {
const i = files.findIndex((f) => f.id === detail.progress!.file_id)
if (i >= 0) {
index = i
resumeAt = detail.progress.position / 1000
}
}
publishNowPlaying()
registerHandlers()
// Ask the server (and this client's own decoder) what they make of the
// file. Best-effort: the element watchdog below is still the authority,
// so this never blocks the player and a failure is never shown as an error.
void refinePlayback()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this video'
} finally {
loading = false
}
})
onDestroy(() => {
window.removeEventListener('pagehide', finalFlush)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeydown)
if (saveTimer) clearTimeout(saveTimer)
if (verifyTimer !== undefined) clearTimeout(verifyTimer)
clearWatchdog()
video?.pause()
clearMediaSession()
finalFlush()
})
function onVisibilityChange() {
if (document.visibilityState === 'hidden') finalFlush()
else finalFlushed = false
}
// ── MediaSession ─────────────────────────────────────────────────────
// The OS media keys, lock screen and browser media popup read from here; the
// helper guards every call so an unsupported browser never breaks playback.
function publishNowPlaying() {
setNowPlaying({
title: item?.title ?? 'Video',
artwork: api.coverUrl(itemId),
duration,
})
}
function registerHandlers() {
setHandlers({
play: () => {
if (video) void video.play().catch(() => {})
},
pause: () => video?.pause(),
seekTo: (d) => {
if (video && d?.seekTime != null) {
video.currentTime = d.seekTime
currentTime = d.seekTime
}
},
seekBackward: (d) => seekBy(-(d?.seekOffset ?? SEEK_STEP)),
seekForward: (d) => seekBy(d?.seekOffset ?? SEEK_STEP),
})
}
// ── Honest path ──────────────────────────────────────────────────────
// `canPlayType` is a hint and is wrong in both directions (it says "no" for
// mov+h264, which plays, and "maybe" for mkv+hevc, whose video never
// decodes). The only reliable answer is the loaded element's own dimensions,
// so the player always confirms by actually loading the file.
function resetDecodeState() {
if (verifyTimer !== undefined) {
clearTimeout(verifyTimer)
verifyTimer = undefined
}
notice = ''
undecodable = false
videoDecoded = false
}
function honestNotice(f: MediaFile | null): string {
const note = f ? playback.playbackNote(f) : ''
if (note) return note
return 'This browser cannot decode the video track in this file, so you would only hear audio over a blank picture. Vellum does not transcode.'
}
// Ask the server which of this client's engines can play the item, then let
// the client's own decoder sharpen the answer. Both results are ADVISORY: the
// loaded element is still the source of truth (see checkDecoded / onError),
// so a server or MediaCapabilities failure must never surface as an error and
// must never stop the file from being tried.
async function refinePlayback() {
try {
const engines = await clientEngines()
const res = await api.resolvePlayback(itemId, engines)
if (!res.playable && res.message) serverNote = res.message
} catch {
// Endpoint absent (older server) or unreachable: the element watchdog
// still covers the honest path.
}
const f = files[index] ?? files[0]
if (!f) return
try {
const verdict = await probeFile(f)
if (verdict.unknown) return
if (!verdict.supported) {
// The client's own hardware check disagrees with the server baseline:
// report it rather than promising a picture that will not come.
if (!serverNote) {
serverNote =
'This browser reports it cannot decode this file’s video, so you would only hear audio over a blank picture.'
}
} else if (!verdict.smooth) {
// Supported, but the client says it will stutter or drain the battery.
qualityNote = 'This browser can decode this file, but its own hardware check says it may not play smoothly.'
}
} catch {
// Best-effort refinement only.
}
}
function showUndecodable(f: MediaFile | null, fallback = '') {
notice = honestNotice(f) || fallback
undecodable = true
videoDecoded = false
}
// Called after metadata loads. If the file carries a video codec and this
// client cannot decode it, the element plays AUDIO while videoWidth stays 0 —
// exactly what HEVC does in Chrome. A file with no probed codec gets the
// benefit of the doubt: it is only condemned once the element itself confirms
// 0x0 or errors, never on missing metadata.
function checkDecoded(f: MediaFile | null) {
const el = video
if (!el || !f) return
if (playback.hasDecodedVideo(el)) {
markDecoded()
return
}
// No picture yet: give a slow start a moment before declaring failure.
if (verifyTimer !== undefined) return
verifyTimer = window.setTimeout(() => {
verifyTimer = undefined
const v = video
if (!v) return
if (playback.hasDecodedVideo(v)) markDecoded()
else showUndecodable(f)
}, DECODE_GRACE_MS)
}
function markDecoded() {
if (verifyTimer !== undefined) {
clearTimeout(verifyTimer)
verifyTimer = undefined
}
videoDecoded = true
undecodable = false
notice = ''
}
function onResize() {
// The element fires `resize` when videoWidth/videoHeight first become
// non-zero, which is the moment a picture actually appears.
if (video && playback.hasDecodedVideo(video)) markDecoded()
}
function onError() {
const el = video
clearWatchdog()
const code = el?.error?.code
// 4 is MEDIA_ERR_SRC_NOT_SUPPORTED — the common one for avi/mpeg4 and for
// an mkv whose tracks are not understood. It means the file will never play
// here, so say so rather than showing a spinner forever.
const suffix = code === 4 ? ' (the file format or codec is not supported here)' : ''
showUndecodable(file, (el?.error?.message || 'This video failed to load.') + suffix)
}
// ── Playback ─────────────────────────────────────────────────────────
function onLoadedMetadata() {
const el = video
if (!el) return
clearWatchdog()
duration = el.duration || 0
resetDecodeState()
checkDecoded(file)
if (resumeAt > 0 && isFinite(el.duration) && resumeAt < el.duration) {
el.currentTime = resumeAt
resumeAt = 0
}
// The new source is loaded: autoplay a file we just switched to.
if (pendingPlay) {
pendingPlay = false
void el.play().catch(() => {})
}
publishNowPlaying()
setPosition(el.currentTime, duration)
}
function onTimeUpdate() {
const el = video
if (!el) return
currentTime = el.currentTime
setPosition(currentTime, el.duration || duration)
scheduleSave()
}
function toggle() {
if (!video) return
if (video.paused) void video.play().catch(() => {})
else video.pause()
}
function onPlay() {
playing = true
setPlaybackState('playing')
}
function onPause() {
playing = false
setPlaybackState('paused')
}
function onEnded() {
flush(true)
}
function seekBy(delta: number) {
const el = video
if (!el) return
const limit = isFinite(el.duration) && el.duration > 0 ? el.duration : Infinity
const next = Math.min(Math.max(el.currentTime + delta, 0), limit)
el.currentTime = next
currentTime = next
}
function toggleMute() {
const el = video
if (!el) return
el.muted = !el.muted
muted = el.muted
}
function toggleFullscreen() {
const el = video
if (!el) return
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {})
else void el.requestFullscreen().catch(() => {})
}
// ── File picker ──────────────────────────────────────────────────────
// A video library groups by file, so an item can carry more than one. The
// picker switches the element's source between them.
function selectFile(i: number) {
if (i < 0 || i >= files.length || i === index) return
flush()
index = i
resumeAt = 0
resetDecodeState()
// Autoplay once Svelte has swapped the src and it has loaded; playing from a
// microtask can race the DOM update and replay the old file.
pendingPlay = true
}
// ── Keyboard ─────────────────────────────────────────────────────────
// Space = play/pause, ←/→ = seek ∓10s, F = fullscreen, M = mute. Ignored
// while typing so the shortcuts never eat a search or form field.
function onKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable)
) {
return
}
switch (event.key) {
case ' ':
case 'Spacebar':
event.preventDefault()
toggle()
break
case 'ArrowLeft':
event.preventDefault()
seekBy(-SEEK_STEP)
break
case 'ArrowRight':
event.preventDefault()
seekBy(SEEK_STEP)
break
case 'f':
case 'F':
event.preventDefault()
toggleFullscreen()
break
case 'm':
case 'M':
event.preventDefault()
toggleMute()
break
}
}
// ── Progress ─────────────────────────────────────────────────────────
// Progress is milliseconds and is keyed by file, so a multi-file item
// resumes exactly where it stopped.
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = window.setTimeout(() => flush(), 2000)
}
function progressBody(forceFinished = false) {
const el = video
const f = file
if (!el || !f) return null
const pos = Math.floor(el.currentTime * 1000)
const dur = Math.floor((el.duration || 0) * 1000)
const nearEnd = dur > 0 && dur - pos <= FINISH_WINDOW_MS
return {
file_id: f.id,
position: pos,
duration: dur,
percent: dur > 0 ? pos / dur : 0,
finished: forceFinished || nearEnd,
}
}
function flush(finished = false) {
const body = progressBody(finished)
if (!body) return
void api.putProgress(itemId, body).catch(() => {})
}
// Last-chance save on teardown. Uses fetch with keepalive so the PUT survives
// the document being unloaded, and latches so the overlapping pagehide /
// visibilitychange / onDestroy events only fire one request. Progress is
// PUT-only, so sendBeacon cannot be used.
function finalFlush() {
if (finalFlushed) return
const body = progressBody(false)
if (!body) return
finalFlushed = true
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = undefined
}
void fetch(`/api/v1/items/${itemId}/progress`, {
method: 'PUT',
keepalive: true,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch(() => {})
}
function seek(event: Event) {
const value = Number((event.currentTarget as HTMLInputElement).value)
if (video) video.currentTime = value
currentTime = value
}
function fmt(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) seconds = 0
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const mm = String(m).padStart(2, '0')
const ss = String(s).padStart(2, '0')
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`
}
function codecSummary(f: MediaFile): string {
const parts: string[] = []
if (f.vcodec) parts.push(f.vcodec)
if (f.acodec) parts.push(f.acodec)
if (f.width && f.height) parts.push(`${f.width}×${f.height}`)
return parts.join(' · ')
}
</script>
{#if error}
<div class="alert">{error}</div>
{:else if loading}
<div class="center"><div class="spinner"></div></div>
{:else if files.length === 0}
<div class="empty">
<h3>{item?.title ?? 'Video'}</h3>
<p>This item has no video file to play.</p>
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
</div>
{:else}
<div class="player card" class:undecodable>
<div class="head">
<div class="meta">
<strong>{item?.title}</strong>
<span class="muted">
{file?.path.split('/').pop() ?? ''}
{#if files.length > 1}· {index + 1} of {files.length}{/if}
</span>
{#if file && codecSummary(file)}
<span class="badge mono">{codecSummary(file)}</span>
{/if}
</div>
<a class="btn btn-ghost dl" href={api.fileUrl(itemId)} download>Download</a>
</div>
{#if displayedNotice}
<div class="notice" role="alert">
<strong>{undecodable ? 'Video cannot play here' : 'Playback may not work here'}</strong>
<p>{displayedNotice}</p>
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
</div>
{/if}
<div class="stage">
<!-- svelte-ignore a11y_media_has_caption -->
<!-- Direct play only: Vellum serves the file untouched and has no subtitle
tracks to offer, so there is nothing to caption. src is on the element
(not a <source>) so the browser actually loads the file and reports
videoWidth / error; see videoAttrs above. -->
<video
bind:this={video}
class="video"
src={src}
{...videoAttrs}
controls
playsinline
preload="metadata"
onloadedmetadata={onLoadedMetadata}
ontimeupdate={onTimeUpdate}
onplay={onPlay}
onpause={onPause}
onended={onEnded}
onerror={onError}
onresize={onResize}
></video>
</div>
{#if videoDecoded}
<p class="ok-note" aria-live="polite">Playing video directly — no transcoding.</p>
{/if}
{#if qualityNote && !undecodable}
<p class="quality-note muted" aria-live="polite">{qualityNote}</p>
{/if}
<input
class="seek"
type="range"
min="0"
max={duration || 0}
step="1"
value={currentTime}
oninput={seek}
aria-label="Seek"
/>
<div class="times mono">
<span>{fmt(currentTime)}</span>
<span class="muted">{fmt(duration)}</span>
</div>
<div class="controls">
<button class="btn btn-primary play" onclick={toggle}>{playing ? 'Pause' : 'Play'}</button>
<button class="btn btn-ghost" onclick={toggleMute} aria-pressed={muted} title="Mute (M)">
{muted ? 'Unmute' : 'Mute'}
</button>
<button class="btn btn-ghost" onclick={toggleFullscreen} title="Fullscreen (F)">Fullscreen</button>
</div>
<p class="hint muted">Space play/pause · ←/→ seek {SEEK_STEP}s · F fullscreen · M mute</p>
{#if files.length > 1}
<ol class="files">
{#each files as f, i (f.id)}
<li>
<button class="file" class:current={i === index} onclick={() => selectFile(i)}>
<span class="mono">{String(i + 1).padStart(2, '0')}</span>
<span class="name">{f.path.split('/').pop()}</span>
<span class="muted mono">{codecSummary(f)}</span>
</button>
</li>
{/each}
</ol>
{/if}
</div>
{/if}
<style>
.center {
display: grid;
place-items: center;
padding: var(--space-6);
}
.player {
max-width: 960px;
margin: 0 auto;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.head {
display: flex;
align-items: center;
gap: var(--space-4);
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
flex: 1;
}
.meta strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta .muted {
font-size: var(--fs-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.dl {
flex: none;
}
.notice {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--danger) 10%, transparent);
font-size: var(--fs-md);
}
.notice p {
margin: 0;
color: var(--muted);
}
.stage {
background: #000;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.video {
display: block;
width: 100%;
max-height: calc(100vh - 52px - 220px);
background: #000;
}
/* The file loaded but only audio decodes: keep the audio reachable, but make
the black frame read as a known limitation rather than a working picture. */
.undecodable .stage {
border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
}
.ok-note {
margin: 0;
font-size: var(--fs-sm);
color: var(--success);
}
.quality-note {
margin: 0;
font-size: var(--fs-sm);
}
.seek {
width: 100%;
accent-color: var(--accent);
}
.times {
display: flex;
justify-content: space-between;
font-size: var(--fs-sm);
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
}
.play {
min-width: 96px;
justify-content: center;
}
.hint {
margin: 0;
font-size: var(--fs-xs);
text-align: center;
}
.files {
list-style: none;
margin: var(--space-2) 0 0;
padding: 0;
max-height: 260px;
overflow-y: auto;
border-top: 1px solid var(--border);
}
.file {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: 7px var(--space-2);
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
text-align: left;
cursor: pointer;
}
.file:hover {
background: var(--surface-2);
color: var(--text);
}
.file.current {
color: var(--accent);
}
.file .name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

130
web/src/lib/mediaSession.ts Normal file
View file

@ -0,0 +1,130 @@
// MediaSession wiring for the audio players.
//
// The OS media keys, the lock screen and the browser's own media popup all read
// from `navigator.mediaSession`. Without this, pressing the play/pause key on a
// keyboard or a pair of headphones does nothing, and the lock screen shows a
// blank entry — which for an audiobook or an album is the difference between
// "a web page playing audio" and "a player".
//
// Everything here is best-effort: the API is absent in some browsers, and an
// unsupported action handler must never break playback. Every call is guarded
// and every registration is wrapped.
export interface NowPlaying {
title: string
artist?: string
album?: string
/** Cover art URL. Same-origin, so the browser can fetch it. */
artwork?: string
/** Seconds. */
duration?: number
}
type ActionHandler = (details?: { seekTime?: number; seekOffset?: number }) => void
function session(): MediaSession | undefined {
// Feature-detected rather than assumed: Firefox shipped this late and some
// embedded webviews never did.
return typeof navigator !== 'undefined' ? navigator.mediaSession : undefined
}
/** Whether the OS-level media controls are available at all. */
export function supported(): boolean {
return session() !== undefined && typeof MediaMetadata !== 'undefined'
}
/**
* Publish what is playing. Safe to call on every track change; callers do not
* need to check support first.
*/
export function setNowPlaying(np: NowPlaying): void {
const s = session()
if (!s || typeof MediaMetadata === 'undefined') return
try {
s.metadata = new MediaMetadata({
title: np.title,
artist: np.artist ?? '',
album: np.album ?? '',
artwork: np.artwork
? [{ src: np.artwork, sizes: '512x512', type: 'image/png' }]
: [],
})
} catch {
// A malformed metadata object must not stop playback.
}
}
/** Register the actions the player can honour. Unlisted actions stay absent. */
export function setHandlers(handlers: {
play?: ActionHandler
pause?: ActionHandler
next?: ActionHandler
previous?: ActionHandler
seekBackward?: ActionHandler
seekForward?: ActionHandler
seekTo?: ActionHandler
}): void {
const s = session()
if (!s) return
const pairs: [MediaSessionAction, ActionHandler | undefined][] = [
['play', handlers.play],
['pause', handlers.pause],
['nexttrack', handlers.next],
['previoustrack', handlers.previous],
['seekbackward', handlers.seekBackward],
['seekforward', handlers.seekForward],
['seekto', handlers.seekTo],
]
for (const [action, fn] of pairs) {
try {
// null clears a previously registered handler; passing undefined would
// throw in some browsers.
s.setActionHandler(action, fn ?? null)
} catch {
// Unsupported action for this browser — skip it.
}
}
}
/**
* Tell the OS where playback is, so the lock-screen scrubber is accurate.
* Some browsers reject a position without a duration, so this is skipped
* until the track has loaded metadata.
*/
export function setPosition(position: number, duration: number, rate = 1): void {
const s = session()
if (!s || typeof s.setPositionState !== 'function') return
if (!isFinite(duration) || duration <= 0) return
const pos = Math.min(Math.max(position, 0), duration)
try {
s.setPositionState({ duration, playbackRate: rate, position: pos })
} catch {
// Out-of-range or unsupported; the scrubber just stays as it was.
}
}
/** Mirror the element's play/pause state onto the OS controls. */
export function setPlaybackState(state: 'playing' | 'paused' | 'none'): void {
const s = session()
if (!s) return
try {
s.playbackState = state
} catch {
// Ignore.
}
}
/** Drop metadata and handlers when the player unmounts. */
export function clear(): void {
const s = session()
if (!s) return
try {
s.metadata = null
s.playbackState = 'none'
} catch {
// Ignore.
}
setHandlers({})
}

147
web/src/lib/playback.ts Normal file
View file

@ -0,0 +1,147 @@
// Direct-play decisions.
//
// Vellum does not transcode — the server has no spare CPU for it — so a file
// either plays in the client or it does not. Which one it is depends on the
// CLIENT, not the file: Chrome on Linux cannot decode HEVC, Safari can, and the
// native Jellyfin apps handle far more than any browser. So nothing here is a
// server-side verdict; it is a per-client guess, and the player always confirms
// it by actually loading the file.
//
// The mappings below were measured, not guessed. Files generated with known
// codecs and played through the server's own stream endpoint in Chrome 155 on
// Linux gave this:
//
// mp4 h264 + aac plays mkv h264 + aac PLAYS (container is fine!)
// webm vp9 + opus plays mov h264 + aac plays
// mp4 av1 + opus plays webm vp8 + vorbis plays
// mp4 hevc + aac AUDIO ONLY — videoWidth stays 0
// mkv hevc + flac AUDIO ONLY
// avi mpeg4 + mp3 fails outright
//
// Two things that follow, and that the UI must respect:
//
// 1. Container matters far less than codec. mkv plays fine when the video
// track is h264; mp4 fails when the track is HEVC. Judging a file by its
// extension is wrong.
// 2. `canPlayType` is a hint, not an answer. It said "no" for mov+h264 (which
// plays) and "maybe" for mkv+hevc (whose video never decodes). The only
// reliable test is to load the file and check `videoWidth`.
import type { MediaFile } from './api'
/** Container extension → MIME type. Mirrors internal/stream's table. */
const CONTAINER_MIME: Record<string, string> = {
mp4: 'video/mp4',
m4v: 'video/mp4',
mov: 'video/quicktime',
mkv: 'video/x-matroska',
webm: 'video/webm',
avi: 'video/x-msvideo',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
m4b: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
opus: 'audio/ogg',
ogg: 'audio/ogg',
wav: 'audio/wav',
}
/**
* ffprobe codec name → an RFC 6381 codec parameter good enough for
* `canPlayType`. The full parameter string (profile, level, tier) is not
* recoverable from a bare codec name, so these are the shortest forms browsers
* accept.
*/
const VCODEC: Record<string, string> = {
h264: 'avc1.42E01E',
avc1: 'avc1.42E01E',
hevc: 'hvc1.1.6.L93.B0',
h265: 'hvc1.1.6.L93.B0',
vp8: 'vp8',
vp9: 'vp09.00.10.08',
av1: 'av01.0.04M.08',
mpeg4: 'mp4v.20.9',
mpeg2video: 'mp2v',
vc1: 'vc-1',
theora: 'theora',
}
const ACODEC: Record<string, string> = {
aac: 'mp4a.40.2',
mp3: 'mp3',
opus: 'opus',
vorbis: 'vorbis',
flac: 'flac',
ac3: 'ac-3',
eac3: 'ec-3',
dts: 'dts',
pcm_s16le: 'pcm',
}
export type Verdict = 'probably' | 'maybe' | 'no'
/** The MIME type for a file's container, or '' if it is unknown. */
export function mimeFor(file: MediaFile): string {
return CONTAINER_MIME[(file.container || '').toLowerCase()] ?? ''
}
/**
* Build the `type` string for a media element, e.g.
* `video/mp4; codecs="avc1.42E01E, mp4a.40.2"`. Returns '' when the container
* is unknown, in which case the caller should let the element sniff the file.
*/
export function sourceType(file: MediaFile): string {
const mime = mimeFor(file)
if (!mime) return ''
const codecs: string[] = []
const v = VCODEC[(file.vcodec || '').toLowerCase()]
const a = ACODEC[(file.acodec || '').toLowerCase()]
if (v) codecs.push(v)
if (a) codecs.push(a)
return codecs.length ? `${mime}; codecs="${codecs.join(', ')}"` : mime
}
/**
* Ask this browser whether it can play the file. A hint only — see the note at
* the top of this file about how wrong `canPlayType` is in both directions.
*/
export function canDirectPlay(file: MediaFile): Verdict {
if (typeof document === 'undefined') return 'maybe'
const type = sourceType(file)
if (!type) return 'maybe'
const el = document.createElement('video')
const verdict = el.canPlayType(type)
if (verdict === 'probably') return 'probably'
if (verdict === 'maybe') return 'maybe'
return 'no'
}
/**
* A short human explanation for a file that is not expected to play, or '' when
* there is nothing worth saying. The wording avoids blaming the user's machine
* and points at the actual cause.
*/
export function playbackNote(file: MediaFile): string {
if (canDirectPlay(file) !== 'no') return ''
const codec = (file.vcodec || '').toLowerCase()
if (codec === 'hevc' || codec === 'h265') {
return 'This file uses HEVC video, which this browser cannot decode. Safari and the native apps can.'
}
if (codec === 'mpeg4' || codec === 'mpeg2video' || codec === 'vc1') {
return `This file uses ${codec} video, which browsers no longer support.`
}
return 'This browser cannot play this file directly, and Vellum does not transcode.'
}
/**
* Whether a loaded media element actually decoded a video track.
*
* This is the reliable test, and the reason the player never trusts
* `canPlayType`: an element whose metadata loaded but whose video track is
* undecodable reports a duration and plays AUDIO while `videoWidth` stays 0.
* That is exactly what HEVC does in Chrome.
*/
export function hasDecodedVideo(el: HTMLVideoElement): boolean {
return el.videoWidth > 0 && el.videoHeight > 0
}