Compare commits
10 commits
09474b0e08
...
051169a18a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
051169a18a | ||
|
|
129ad80f14 | ||
|
|
8bd6b8a417 | ||
|
|
608ac5c6d6 | ||
|
|
35442e4aff | ||
|
|
ac1bd04df3 | ||
|
|
6da194045b | ||
|
|
67f145a9b1 | ||
|
|
915f390156 | ||
|
|
785d3ed5ee |
93 changed files with 12211 additions and 513 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@
|
|||
|
||||
# local preview instance
|
||||
demo/
|
||||
.archify/
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
316
docs/ARCHITECTURE.md
Normal 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.
|
||||
67
docs/decisions/ADR-001-sqlite-with-a-seam.md
Normal file
67
docs/decisions/ADR-001-sqlite-with-a-seam.md
Normal 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.
|
||||
102
docs/decisions/ADR-002-direct-play-only.md
Normal file
102
docs/decisions/ADR-002-direct-play-only.md
Normal 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.
|
||||
65
docs/decisions/ADR-003-mark-missing-never-delete.md
Normal file
65
docs/decisions/ADR-003-mark-missing-never-delete.md
Normal 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.
|
||||
57
docs/decisions/ADR-004-modular-monolith.md
Normal file
57
docs/decisions/ADR-004-modular-monolith.md
Normal 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.
|
||||
77
docs/decisions/ADR-005-one-item-access-chokepoint.md
Normal file
77
docs/decisions/ADR-005-one-item-access-chokepoint.md
Normal 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.
|
||||
62
docs/decisions/ADR-006-table-backed-job-queue.md
Normal file
62
docs/decisions/ADR-006-table-backed-job-queue.md
Normal 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.
|
||||
82
docs/decisions/ADR-007-fixed-width-timestamps.md
Normal file
82
docs/decisions/ADR-007-fixed-width-timestamps.md
Normal 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
29
docs/decisions/README.md
Normal 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.
|
||||
172
internal/apitoken/apitoken.go
Normal file
172
internal/apitoken/apitoken.go
Normal 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
7
internal/arch/arch.go
Normal 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
360
internal/arch/arch_test.go
Normal 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
308
internal/catalog/catalog.go
Normal 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
|
||||
}
|
||||
221
internal/catalog/catalog_test.go
Normal file
221
internal/catalog/catalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
208
internal/catalog/store_test.go
Normal file
208
internal/catalog/store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
203
internal/engine/engine.go
Normal 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
|
||||
}
|
||||
245
internal/engine/engine_test.go
Normal file
245
internal/engine/engine_test.go
Normal 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
156
internal/engine/resolve.go
Normal 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 ""
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package media
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -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
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
89
internal/jellyfin/auth.go
Normal 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 ""
|
||||
}
|
||||
405
internal/jellyfin/dto/dto.go
Normal file
405
internal/jellyfin/dto/dto.go
Normal 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 }
|
||||
362
internal/jellyfin/dto/dto_test.go
Normal file
362
internal/jellyfin/dto/dto_test.go
Normal 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
|
||||
}
|
||||
51
internal/jellyfin/dto/ids.go
Normal file
51
internal/jellyfin/dto/ids.go
Normal 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
263
internal/jellyfin/items.go
Normal 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,
|
||||
})
|
||||
}
|
||||
157
internal/jellyfin/jellyfin.go
Normal file
157
internal/jellyfin/jellyfin.go
Normal 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")
|
||||
}
|
||||
375
internal/jellyfin/jellyfin_test.go
Normal file
375
internal/jellyfin/jellyfin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
127
internal/jellyfin/playback.go
Normal file
127
internal/jellyfin/playback.go
Normal 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
360
internal/jellyfin/query.go
Normal 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
|
||||
}
|
||||
96
internal/jellyfin/sessions.go
Normal file
96
internal/jellyfin/sessions.go
Normal 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
151
internal/jellyfin/system.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
193
internal/media/fixtures_test.go
Normal file
193
internal/media/fixtures_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// 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
|
||||
}
|
||||
if err != nil {
|
||||
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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
internal/playback/handlers.go
Normal file
106
internal/playback/handlers.go
Normal 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")
|
||||
}
|
||||
231
internal/playback/handlers_test.go
Normal file
231
internal/playback/handlers_test.go
Normal 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
133
internal/probe/probe.go
Normal 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"`
|
||||
}
|
||||
96
internal/probe/probe_test.go
Normal file
96
internal/probe/probe_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -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 {
|
||||
// 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
|
||||
100
internal/providers/openlibrary_test.go
Normal file
100
internal/providers/openlibrary_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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,14 +454,19 @@ 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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||
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`,
|
||||
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)
|
||||
|
|
@ -446,15 +474,101 @@ func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate) erro
|
|||
return nil
|
||||
}
|
||||
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
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,
|
||||
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()
|
||||
}
|
||||
430
internal/scanner/scan_test.go
Normal file
430
internal/scanner/scan_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
113
internal/subsonic/auth.go
Normal 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
264
internal/subsonic/browse.go
Normal 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)
|
||||
}
|
||||
31
internal/subsonic/credentials.go
Normal file
31
internal/subsonic/credentials.go
Normal 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,
|
||||
})
|
||||
}
|
||||
215
internal/subsonic/dto/ids.go
Normal file
215
internal/subsonic/dto/ids.go
Normal 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
|
||||
}
|
||||
243
internal/subsonic/dto/node.go
Normal file
243
internal/subsonic/dto/node.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
624
internal/subsonic/handlers.go
Normal file
624
internal/subsonic/handlers.go
Normal 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
24
internal/subsonic/node.go
Normal 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
547
internal/subsonic/store.go
Normal 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)
|
||||
}
|
||||
81
internal/subsonic/subsonic.go
Normal file
81
internal/subsonic/subsonic.go
Normal 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)
|
||||
}
|
||||
502
internal/subsonic/subsonic_test.go
Normal file
502
internal/subsonic/subsonic_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
28
migrations/0010_media_probe.sql
Normal file
28
migrations/0010_media_probe.sql
Normal 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 '';
|
||||
11
migrations/0011_missing_since.sql
Normal file
11
migrations/0011_missing_since.sql
Normal 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 '';
|
||||
34
migrations/0012_api_tokens.sql
Normal file
34
migrations/0012_api_tokens.sql
Normal 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
10
web/dist/.gitkeep
vendored
Normal 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.
|
||||
|
|
@ -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
110
web/src/lib/capabilities.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
231
web/src/lib/components/MissingFiles.svelte
Normal file
231
web/src/lib/components/MissingFiles.svelte
Normal 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>
|
||||
580
web/src/lib/components/MusicPlayer.svelte
Normal file
580
web/src/lib/components/MusicPlayer.svelte
Normal 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>
|
||||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
767
web/src/lib/components/VideoPlayer.svelte
Normal file
767
web/src/lib/components/VideoPlayer.svelte
Normal 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
130
web/src/lib/mediaSession.ts
Normal 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
147
web/src/lib/playback.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue