Compare commits
No commits in common. "051169a18a9efd2d74da53930fc7d266e4d4928e" and "09474b0e0899dd39957332cacfa9f46b858e8a83" have entirely different histories.
051169a18a
...
09474b0e08
93 changed files with 513 additions and 12211 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,4 +18,3 @@
|
||||||
|
|
||||||
# local preview instance
|
# local preview instance
|
||||||
demo/
|
demo/
|
||||||
.archify/
|
|
||||||
|
|
|
||||||
37
README.md
37
README.md
|
|
@ -83,58 +83,35 @@ the server logs a warning and refuses to invent a default password.
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/vellum/ main — wiring and lifecycle only, the one place packages meet
|
cmd/vellum/ main — wiring and lifecycle only
|
||||||
internal/
|
internal/
|
||||||
core/ config, logging, database, migrations, timestamps
|
core/ config, logging, database, migration runner
|
||||||
httpx/ shared JSON helpers
|
httpx/ shared JSON helpers
|
||||||
httpapi/ router, middleware, security headers, static SPA serving
|
httpapi/ router, middleware, static SPA serving
|
||||||
auth/ argon2id, sessions, grants, bootstrap
|
auth/ argon2id, sessions, grants, bootstrap
|
||||||
apitoken/ credentials for non-browser clients (Jellyfin, Subsonic)
|
library/ library CRUD, media-type registry, scanner
|
||||||
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
|
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
|
progress/ the polymorphic per-user progress store
|
||||||
stream/ range-capable file serving
|
stream/ range-capable file serving
|
||||||
jobs/ table-backed job queue
|
|
||||||
natsort/ natural ordering for pages and tracks
|
natsort/ natural ordering for pages and tracks
|
||||||
jellyfin/ Jellyfin protocol adapter (+ dto/)
|
jobs/ table-backed job queue
|
||||||
subsonic/ Subsonic protocol adapter (+ dto/)
|
metadata/ provider interface, Open Library, Google Books, enricher
|
||||||
arch/ import-rule tests; holds no code
|
search/ FTS5 queries
|
||||||
modules/
|
modules/
|
||||||
ebooks/ EPUB parsing and cover extraction
|
ebooks/ EPUB parsing and cover extraction
|
||||||
comics/ CBZ page listing and extraction
|
comics/ CBZ page listing and extraction
|
||||||
games/ platforms, IGDB, cloud saves, EmulatorJS
|
|
||||||
photos/ thumbnail generation
|
photos/ thumbnail generation
|
||||||
web/ Svelte app; dist/ is embedded via web/embed.go
|
web/ Svelte app; dist/ is embedded via web/embed.go
|
||||||
migrations/ embedded SQL, applied in order at boot
|
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
|
**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
|
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.
|
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.
|
**Schema rule:** media items are global; everything user-specific is a join.
|
||||||
Nothing may hold per-user state on a media row.
|
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
|
## Database
|
||||||
|
|
||||||
SQLite in WAL mode, opened through `modernc.org/sqlite` — a pure-Go driver, so
|
SQLite in WAL mode, opened through `modernc.org/sqlite` — a pure-Go driver, so
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,9 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
|
||||||
"github.com/mithral/vellum/internal/cli"
|
"github.com/mithral/vellum/internal/cli"
|
||||||
"github.com/mithral/vellum/internal/core"
|
"github.com/mithral/vellum/internal/core"
|
||||||
"github.com/mithral/vellum/internal/home"
|
|
||||||
"github.com/mithral/vellum/internal/httpapi"
|
"github.com/mithral/vellum/internal/httpapi"
|
||||||
"github.com/mithral/vellum/internal/jellyfin"
|
|
||||||
"github.com/mithral/vellum/internal/jobs"
|
"github.com/mithral/vellum/internal/jobs"
|
||||||
"github.com/mithral/vellum/internal/library"
|
"github.com/mithral/vellum/internal/library"
|
||||||
"github.com/mithral/vellum/internal/media"
|
"github.com/mithral/vellum/internal/media"
|
||||||
|
|
@ -29,11 +26,7 @@ import (
|
||||||
"github.com/mithral/vellum/internal/modules/ebooks"
|
"github.com/mithral/vellum/internal/modules/ebooks"
|
||||||
"github.com/mithral/vellum/internal/modules/games"
|
"github.com/mithral/vellum/internal/modules/games"
|
||||||
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
|
_ "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/search"
|
||||||
"github.com/mithral/vellum/internal/subsonic"
|
|
||||||
"github.com/mithral/vellum/migrations"
|
"github.com/mithral/vellum/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -107,26 +100,10 @@ func run(cfg core.Config, log *slog.Logger) error {
|
||||||
|
|
||||||
library.NewHandlers(db, log, authenticator, queue).Register(api.Mux())
|
library.NewHandlers(db, log, authenticator, queue).Register(api.Mux())
|
||||||
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).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())
|
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
|
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
games.NewHandlers(db, log, authenticator).Register(api.Mux())
|
games.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
search.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
|
// Scans run on the queue rather than inside the request: a real library
|
||||||
// takes longer than any sensible timeout, and a hanging request is
|
// takes longer than any sensible timeout, and a hanging request is
|
||||||
|
|
@ -141,7 +118,7 @@ func run(cfg core.Config, log *slog.Logger) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil // deleted since the job was queued
|
return nil // deleted since the job was queued
|
||||||
}
|
}
|
||||||
res, err := scanner.Scan(ctx, db, lib, log)
|
res, err := library.Scan(ctx, db, lib, log)
|
||||||
if jobID, ok := jobs.JobID(ctx); ok {
|
if jobID, ok := jobs.JobID(ctx); ok {
|
||||||
if serr := jobs.SetResult(ctx, db, jobID, res); serr != nil {
|
if serr := jobs.SetResult(ctx, db, jobID, res); serr != nil {
|
||||||
log.Warn("record scan result", "err", serr)
|
log.Warn("record scan result", "err", serr)
|
||||||
|
|
@ -152,16 +129,16 @@ func run(cfg core.Config, log *slog.Logger) error {
|
||||||
|
|
||||||
// Providers are registered primary-first; a fallback is only consulted
|
// Providers are registered primary-first; a fallback is only consulted
|
||||||
// when the primary returns nothing.
|
// when the primary returns nothing.
|
||||||
providers.Register(providers.NewOpenLibrary())
|
metadata.Register(metadata.NewOpenLibrary())
|
||||||
providers.Register(providers.NewGoogleBooks())
|
metadata.Register(metadata.NewGoogleBooks())
|
||||||
providers.Register(providers.NewMusicBrainz())
|
metadata.Register(metadata.NewMusicBrainz())
|
||||||
if cfg.ComicVineAPIKey != "" {
|
if cfg.ComicVineAPIKey != "" {
|
||||||
providers.Register(providers.NewComicVine(cfg.ComicVineAPIKey))
|
metadata.Register(metadata.NewComicVine(cfg.ComicVineAPIKey))
|
||||||
} else {
|
} else {
|
||||||
log.Info("comic metadata disabled: set VELLUM_COMICVINE_API_KEY to enable ComicVine lookups")
|
log.Info("comic metadata disabled: set VELLUM_COMICVINE_API_KEY to enable ComicVine lookups")
|
||||||
}
|
}
|
||||||
if cfg.IGDBClientID != "" && cfg.IGDBClientSecret != "" {
|
if cfg.IGDBClientID != "" && cfg.IGDBClientSecret != "" {
|
||||||
providers.Register(providers.NewIGDB(cfg.IGDBClientID, cfg.IGDBClientSecret))
|
metadata.Register(metadata.NewIGDB(cfg.IGDBClientID, cfg.IGDBClientSecret))
|
||||||
} else {
|
} else {
|
||||||
log.Info("game metadata disabled: set VELLUM_IGDB_CLIENT_ID and VELLUM_IGDB_CLIENT_SECRET to enable IGDB lookups")
|
log.Info("game metadata disabled: set VELLUM_IGDB_CLIENT_ID and VELLUM_IGDB_CLIENT_SECRET to enable IGDB lookups")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// 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
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
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,16 +42,6 @@ func run(ctx context.Context, args []string, cfg core.Config, log *slog.Logger,
|
||||||
return false, nil
|
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)
|
db, err := core.OpenDB(cfg.DBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true, err
|
return true, err
|
||||||
|
|
|
||||||
|
|
@ -16,22 +16,9 @@ import (
|
||||||
|
|
||||||
// testConfig points a Config at a fresh database inside a temp dir. The CLI
|
// 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.
|
// 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 {
|
func testConfig(t *testing.T) core.Config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
return core.Config{DBPath: filepath.Join(t.TempDir(), "vellum.db")}
|
||||||
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 {
|
func testLogger() *slog.Logger {
|
||||||
|
|
|
||||||
|
|
@ -94,26 +94,14 @@ func LoadConfig() (Config, error) {
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// 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
|
// 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.
|
// warning: silently scanning nothing is worse than failing loudly at boot.
|
||||||
func (c Config) Validate() error {
|
func (c Config) Validate() error {
|
||||||
if err := c.EnsureDirs(); err != nil {
|
for _, dir := range []string{c.DataDir, c.ConfigDir} {
|
||||||
return err
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", dir, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, dir := range c.MediaDirs {
|
for _, dir := range c.MediaDirs {
|
||||||
info, err := os.Stat(dir)
|
info, err := os.Stat(dir)
|
||||||
|
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|
@ -41,12 +41,7 @@ func findAsset(t *testing.T, suffix string) string {
|
||||||
t.Fatalf("walk dist: %v", err)
|
t.Fatalf("walk dist: %v", err)
|
||||||
}
|
}
|
||||||
if found == "" {
|
if found == "" {
|
||||||
// A fresh clone has no built frontend: web/dist holds only .gitkeep, so
|
t.Fatalf("no embedded asset ending in %q", suffix)
|
||||||
// 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
|
return found
|
||||||
}
|
}
|
||||||
|
|
@ -59,10 +54,7 @@ func readAsset(t *testing.T, name string) []byte {
|
||||||
}
|
}
|
||||||
b, err := fs.ReadFile(sub, name)
|
b, err := fs.ReadFile(sub, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Same reason as findAsset: without `pnpm build` the only thing in dist
|
t.Fatalf("read %s: %v", name, err)
|
||||||
// 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
|
return b
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,405 +0,0 @@
|
||||||
// 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 }
|
|
||||||
|
|
@ -1,362 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
|
|
@ -1,375 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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,12 +42,10 @@ 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}", 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}/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/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("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("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}/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.
|
// handleMediaTypes is public: the UI needs the list before login to render.
|
||||||
|
|
@ -264,66 +262,8 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut
|
||||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "limit": limit})
|
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 ────────────────────────────────────────────────────────────
|
// ── 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) {
|
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
||||||
h.log.Error(what, "err", err)
|
h.log.Error(what, "err", err)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||||
|
|
|
||||||
|
|
@ -118,113 +118,6 @@ func CountItems(ctx context.Context, db *sql.DB, id int64) (int, error) {
|
||||||
return n, err
|
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 ────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func resolveRoot(rootPath string) (string, error) {
|
func resolveRoot(rootPath string) (string, error) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
// Package library owns library definitions — create, list, delete — and the
|
// Package library owns library definitions and the scanner that turns files on
|
||||||
// media-kind registry that says which file extensions each kind of library owns.
|
// disk into rows in media_items / media_files.
|
||||||
//
|
//
|
||||||
// The registry lives here, not in internal/scanner, because it is not the
|
// Modules declare what file extensions they care about via RegisterType; this
|
||||||
// scanner's business: modules register their types, the admin HTTP surface
|
// package never imports a module.
|
||||||
// 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
|
package library
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -138,11 +134,8 @@ func GroupModeFor(kind string) GroupMode {
|
||||||
return GroupByFile
|
return GroupByFile
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionsFor returns the extension set owned by a library kind, each entry
|
// extensionsFor returns the extension set owned by a library kind.
|
||||||
// lowercase with a leading dot. The scanner walks a tree by testing every file
|
func extensionsFor(kind string) map[string]bool {
|
||||||
// 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()
|
typesMu.RLock()
|
||||||
defer typesMu.RUnlock()
|
defer typesMu.RUnlock()
|
||||||
t, ok := types[kind]
|
t, ok := types[kind]
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,4 @@
|
||||||
// Package scanner walks a library's root and reconciles it with the database:
|
package library
|
||||||
// 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -16,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
@ -25,9 +16,7 @@ import (
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/core"
|
"github.com/mithral/vellum/internal/core"
|
||||||
"github.com/mithral/vellum/internal/jobs"
|
"github.com/mithral/vellum/internal/jobs"
|
||||||
"github.com/mithral/vellum/internal/library"
|
|
||||||
"github.com/mithral/vellum/internal/natsort"
|
"github.com/mithral/vellum/internal/natsort"
|
||||||
"github.com/mithral/vellum/internal/probe"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -97,11 +86,11 @@ type group struct {
|
||||||
// rather than deleted, so their progress survives a temporarily unavailable
|
// rather than deleted, so their progress survives a temporarily unavailable
|
||||||
// mount. A scan never fails because of a single unreadable file — it records
|
// 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.
|
// the error and continues, because a partial library is better than none.
|
||||||
func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger) (ScanResult, error) {
|
func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanResult, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
res := ScanResult{LibraryID: lib.ID}
|
res := ScanResult{LibraryID: lib.ID}
|
||||||
|
|
||||||
exts := library.ExtensionsFor(lib.Kind)
|
exts := extensionsFor(lib.Kind)
|
||||||
if len(exts) == 0 {
|
if len(exts) == 0 {
|
||||||
return res, fmt.Errorf("library %d: no extensions registered for kind %q", lib.ID, lib.Kind)
|
return res, fmt.Errorf("library %d: no extensions registered for kind %q", lib.ID, lib.Kind)
|
||||||
}
|
}
|
||||||
|
|
@ -141,17 +130,6 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger
|
||||||
}
|
}
|
||||||
batch := groupKeys[start:end]
|
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 {
|
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
|
||||||
for _, key := range batch {
|
for _, key := range batch {
|
||||||
g := groups[key]
|
g := groups[key]
|
||||||
|
|
@ -172,8 +150,7 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
info, wasProbed := probed[c.relPath]
|
if err := upsertFile(ctx, tx, itemID, c); err != nil {
|
||||||
if err := upsertFile(ctx, tx, itemID, c, info, wasProbed, now); err != nil {
|
|
||||||
res.addError(fmt.Sprintf("%s: %v", c.relPath, err))
|
res.addError(fmt.Sprintf("%s: %v", c.relPath, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +224,7 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger
|
||||||
|
|
||||||
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
|
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
|
||||||
for _, rel := range batch {
|
for _, rel := range batch {
|
||||||
if err := markFileMissing(ctx, tx, rel, now); err != nil {
|
if err := markFileMissing(ctx, tx, rel); err != nil {
|
||||||
res.addError(fmt.Sprintf("%s: %v", rel, err))
|
res.addError(fmt.Sprintf("%s: %v", rel, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +238,7 @@ func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger
|
||||||
|
|
||||||
res.Duration = time.Since(start).Milliseconds()
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
log.Info("scan complete",
|
log.Info("scan complete",
|
||||||
"library", lib.Name, "kind", lib.Kind, "grouping", library.GroupModeFor(lib.Kind),
|
"library", lib.Name, "kind", lib.Kind, "grouping", GroupModeFor(lib.Kind),
|
||||||
"scanned", res.Scanned, "added", res.Added, "updated", res.Updated,
|
"scanned", res.Scanned, "added", res.Added, "updated", res.Updated,
|
||||||
"restored", res.Restored, "missing", res.Missing, "unchanged", res.Unchanged,
|
"restored", res.Restored, "missing", res.Missing, "unchanged", res.Unchanged,
|
||||||
"duration_ms", res.Duration, "errors", len(res.Errors),
|
"duration_ms", res.Duration, "errors", len(res.Errors),
|
||||||
|
|
@ -290,23 +267,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,
|
// buildGroups turns found files into items. Directory-grouped kinds (audiobooks,
|
||||||
// music) collapse a folder of tracks into one item; everything else is one item
|
// music) collapse a folder of tracks into one item; everything else is one item
|
||||||
// per file.
|
// per file.
|
||||||
func buildGroups(lib library.Library, found map[string]candidate) map[string]group {
|
func buildGroups(lib Library, found map[string]candidate) map[string]group {
|
||||||
mode := library.GroupModeFor(lib.Kind)
|
mode := GroupModeFor(lib.Kind)
|
||||||
groups := make(map[string]group)
|
groups := make(map[string]group)
|
||||||
|
|
||||||
for _, rel := range sortedCandidateKeys(found) {
|
for _, rel := range sortedCandidateKeys(found) {
|
||||||
c := found[rel]
|
c := found[rel]
|
||||||
|
|
||||||
var key, title string
|
var key, title string
|
||||||
if mode == library.GroupByDirectory {
|
if mode == GroupByDirectory {
|
||||||
dir := path.Dir(rel)
|
dir := path.Dir(rel)
|
||||||
if dir == "." {
|
if dir == "." {
|
||||||
key, title = ".", lib.Name
|
key, title = ".", lib.Name
|
||||||
} else {
|
} else {
|
||||||
key, title = dir, library.CleanTitle(path.Base(dir))
|
key, title = dir, CleanTitle(path.Base(dir))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
key, title = rel, library.CleanTitle(rel)
|
key, title = rel, CleanTitle(rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
g := groups[key]
|
g := groups[key]
|
||||||
|
|
@ -438,13 +415,13 @@ func loadKnownItems(ctx context.Context, db *sql.DB, libraryID int64) (map[strin
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func upsertItem(ctx context.Context, tx *sql.Tx, lib library.Library, key, title string) (int64, error) {
|
func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, key, title string) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
err := tx.QueryRowContext(ctx, `
|
err := tx.QueryRowContext(ctx, `
|
||||||
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, missing_since)
|
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, '')
|
VALUES (?, ?, ?, ?, ?, 0)
|
||||||
ON CONFLICT (library_id, path)
|
ON CONFLICT (library_id, path)
|
||||||
DO UPDATE SET missing = 0, missing_since = ''
|
DO UPDATE SET missing = 0
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
lib.ID, lib.Kind, key, title, strings.ToLower(title),
|
lib.ID, lib.Kind, key, title, strings.ToLower(title),
|
||||||
).Scan(&id)
|
).Scan(&id)
|
||||||
|
|
@ -454,19 +431,14 @@ func upsertItem(ctx context.Context, tx *sql.Tx, lib library.Library, key, title
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info probe.Info, probed bool, now string) error {
|
func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate) 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, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, missing_since)
|
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 0, '')
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||||
ON CONFLICT (path)
|
ON CONFLICT (path)
|
||||||
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
||||||
mtime = excluded.mtime, inode = excluded.inode,
|
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)
|
itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("upsert file: %w", err)
|
return fmt.Errorf("upsert file: %w", err)
|
||||||
|
|
@ -474,101 +446,15 @@ func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info
|
||||||
return nil
|
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 {
|
func markItemMissing(ctx context.Context, tx *sql.Tx, libraryID int64, key, now string) error {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
UPDATE media_items SET missing = 1, missing_since = ?
|
UPDATE media_items SET missing = 1
|
||||||
WHERE library_id = ? AND path = ? AND (missing = 0 OR missing_since = '')`,
|
WHERE library_id = ? AND path = ?`, libraryID, key)
|
||||||
now, libraryID, key)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// markFileMissing flags a file that vanished while its item survived. It obeys
|
func markFileMissing(ctx context.Context, tx *sql.Tx, rel string) error {
|
||||||
// the same transition rule as markItemMissing: the timestamp is set once and
|
_, err := tx.ExecContext(ctx, `UPDATE media_files SET missing = 1 WHERE path = ?`, rel)
|
||||||
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -600,3 +486,9 @@ func parseUnixNano(s string) int64 {
|
||||||
}
|
}
|
||||||
return n
|
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()
|
||||||
|
}
|
||||||
205
internal/library/scan_test.go
Normal file
205
internal/library/scan_test.go
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package home
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,11 +1,31 @@
|
||||||
package home
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"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 {
|
type fixture struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
libA int64
|
libA int64
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
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,11 +8,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"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/httpx"
|
||||||
"github.com/mithral/vellum/internal/progress"
|
"github.com/mithral/vellum/internal/progress"
|
||||||
"github.com/mithral/vellum/internal/stream"
|
"github.com/mithral/vellum/internal/stream"
|
||||||
|
|
@ -33,10 +30,8 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register mounts the routes.
|
// 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) {
|
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", h.auth.Require(h.handleListItems))
|
||||||
mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem))
|
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))
|
mux.Handle("POST /api/v1/items/{id}/view", h.auth.Require(h.handleRecordView))
|
||||||
|
|
@ -163,8 +158,10 @@ 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
|
// Confirm the file belongs to this item, so an item id cannot be paired
|
||||||
// with another item's file id.
|
// with another item's file id.
|
||||||
f, err := catalog.FileByID(r.Context(), h.db, it.ID, fileID)
|
var relPath string
|
||||||
if errors.Is(err, catalog.ErrNotFound) {
|
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) {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "file not found")
|
httpx.WriteError(w, http.StatusNotFound, "file not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +170,7 @@ func (h *Handlers) handleFileByID(w http.ResponseWriter, r *http.Request, u auth
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
abs := filepath.Join(it.RootPath, filepath.FromSlash(f.Path))
|
abs := filepath.Join(it.RootPath, filepath.FromSlash(relPath))
|
||||||
w.Header().Set("Content-Disposition", "inline")
|
w.Header().Set("Content-Disposition", "inline")
|
||||||
if err := stream.ServeFile(w, r, abs); err != nil {
|
if err := stream.ServeFile(w, r, abs); err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
|
@ -275,59 +272,10 @@ func (h *Handlers) handleRecent(w http.ResponseWriter, r *http.Request, u auth.U
|
||||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": items})
|
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 ────────────────────────────────────────────────────────────
|
// ── 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.
|
// authorized loads an item and confirms the caller may read its library.
|
||||||
// On failure it has already written the response.
|
// 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) {
|
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (Item, bool) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
|
|
@ -335,8 +283,8 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
return Item{}, false
|
return Item{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
|
it, err := LoadItem(r.Context(), h.db, id)
|
||||||
if errors.Is(err, catalog.ErrNotFound) {
|
if errors.Is(err, ErrItemNotFound) {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
return Item{}, false
|
return Item{}, false
|
||||||
}
|
}
|
||||||
|
|
@ -344,16 +292,38 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
h.fail(w, "load item", err)
|
h.fail(w, "load item", err)
|
||||||
return Item{}, false
|
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
|
return it, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// files returns an item's physical files with their ffprobe codec facts.
|
func (h *Handlers) files(r *http.Request, itemID int64) ([]FileEntry, error) {
|
||||||
//
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
// The shape is catalog.File, whose JSON tags are frozen by MediaFile in
|
SELECT id, path, size, container, missing FROM media_files WHERE item_id = ? ORDER BY id`, itemID)
|
||||||
// web/src/lib/api.ts. The server reports what is IN the file and never
|
if err != nil {
|
||||||
// transcodes; each client decides whether it can direct-play it.
|
return nil, err
|
||||||
func (h *Handlers) files(r *http.Request, itemID int64) ([]catalog.File, error) {
|
}
|
||||||
return catalog.Files(r.Context(), h.db, itemID)
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) hasCover(r *http.Request, it Item) bool {
|
func (h *Handlers) hasCover(r *http.Request, it Item) bool {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,7 @@
|
||||||
// Package home computes what the landing screen shows: the five shelves and the
|
package media
|
||||||
// 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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -24,23 +10,6 @@ import (
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"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.
|
// shelfItem is one card on the home screen.
|
||||||
type shelfItem struct {
|
type shelfItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|
@ -190,9 +159,32 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
httpx.WriteJSON(w, http.StatusOK, resp)
|
httpx.WriteJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
// handleRecordView increments the user's view count for an item.
|
||||||
h.log.Error(what, "err", err)
|
func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
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 scanShelf(rows *sql.Rows) ([]shelfItem, error) {
|
func scanShelf(rows *sql.Rows) ([]shelfItem, error) {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package home
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -9,37 +9,20 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/core"
|
|
||||||
"github.com/mithral/vellum/migrations"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── shared helpers ─────────────────────────────────────────────────────
|
// ── shared helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
func homeHandlers(t *testing.T, db *sql.DB) *Handlers {
|
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
return NewHandlers(db, log, 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(), "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 {
|
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
|
||||||
|
|
@ -222,7 +205,7 @@ func getHome(t *testing.T, h *Handlers, u auth.User) (homeResponse, []byte) {
|
||||||
|
|
||||||
func TestHandleHomeShelvesAndCounts(t *testing.T) {
|
func TestHandleHomeShelvesAndCounts(t *testing.T) {
|
||||||
f := setupHome(t)
|
f := setupHome(t)
|
||||||
h := homeHandlers(t, f.db)
|
h := mediaHandlers(t, f.db)
|
||||||
resp, body := getHome(t, h, auth.User{ID: f.userA})
|
resp, body := getHome(t, h, auth.User{ID: f.userA})
|
||||||
|
|
||||||
// The landing screen contract: all five shelves plus the sidebar counts.
|
// The landing screen contract: all five shelves plus the sidebar counts.
|
||||||
|
|
@ -283,7 +266,7 @@ func TestHandleHomeShelvesAndCounts(t *testing.T) {
|
||||||
|
|
||||||
func TestHandleHomeGrantScoping(t *testing.T) {
|
func TestHandleHomeGrantScoping(t *testing.T) {
|
||||||
f := setupHome(t)
|
f := setupHome(t)
|
||||||
h := homeHandlers(t, f.db)
|
h := mediaHandlers(t, f.db)
|
||||||
|
|
||||||
// userA holds a grant to libA only. Nothing from libB may surface anywhere.
|
// userA holds a grant to libA only. Nothing from libB may surface anywhere.
|
||||||
resp, _ := getHome(t, h, auth.User{ID: f.userA})
|
resp, _ := getHome(t, h, auth.User{ID: f.userA})
|
||||||
|
|
@ -321,7 +304,7 @@ func TestHandleHomeGrantScoping(t *testing.T) {
|
||||||
|
|
||||||
func TestHandleHomeIsolatesUsers(t *testing.T) {
|
func TestHandleHomeIsolatesUsers(t *testing.T) {
|
||||||
f := setupHome(t)
|
f := setupHome(t)
|
||||||
h := homeHandlers(t, f.db)
|
h := mediaHandlers(t, f.db)
|
||||||
resp, _ := getHome(t, h, auth.User{ID: f.userB})
|
resp, _ := getHome(t, h, auth.User{ID: f.userB})
|
||||||
|
|
||||||
if got := ids(resp.Continue); len(got) != 1 || got[0] != f.b1 {
|
if got := ids(resp.Continue); len(got) != 1 || got[0] != f.b1 {
|
||||||
|
|
@ -1,39 +1,97 @@
|
||||||
// Package media serves generic item RESOURCES: the browse list, one item's
|
// Package media serves generic item endpoints: detail, the raw file, cover
|
||||||
// detail, its raw file, cover art, progress, credits and view counts. It knows
|
// art, and progress. It knows nothing about any specific media format; modules
|
||||||
// nothing about any specific media format; modules register cover extractors
|
// register cover extractors and expose their own metadata endpoints.
|
||||||
// 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
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Item is one media item plus the root of its library. See catalog.Item.
|
// ErrItemNotFound means no item matched.
|
||||||
type Item = catalog.Item
|
var ErrItemNotFound = errors.New("media: item not found")
|
||||||
|
|
||||||
// FileEntry is one physical file backing an item, with its codec facts.
|
// Item is one media item plus the root of its library.
|
||||||
// See catalog.File.
|
type Item struct {
|
||||||
type FileEntry = catalog.File
|
ID int64
|
||||||
|
LibraryID int64
|
||||||
|
Kind string
|
||||||
|
Path string // path relative to the library root
|
||||||
|
Title string
|
||||||
|
Year int
|
||||||
|
Missing bool
|
||||||
|
|
||||||
// ErrItemNotFound means no item matched, or the caller may not read it. The two
|
// RootPath is the absolute library root, so AbsPath can be computed.
|
||||||
// are deliberately indistinguishable so a handler cannot leak the existence of
|
RootPath string
|
||||||
// another user's library. See catalog.ErrNotFound.
|
|
||||||
var ErrItemNotFound = catalog.ErrNotFound
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
// FileCount returns how many files back an item.
|
// FileCount returns how many files back an item.
|
||||||
func FileCount(ctx context.Context, db *sql.DB, itemID int64) (int, error) {
|
func FileCount(ctx context.Context, db *sql.DB, itemID int64) (int, error) {
|
||||||
return catalog.FileCount(ctx, db, itemID)
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_files WHERE item_id = ?`, itemID).Scan(&n)
|
||||||
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package providers
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package providers
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
// 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
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -20,11 +15,9 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
|
||||||
"github.com/mithral/vellum/internal/core"
|
"github.com/mithral/vellum/internal/core"
|
||||||
"github.com/mithral/vellum/internal/library"
|
"github.com/mithral/vellum/internal/library"
|
||||||
"github.com/mithral/vellum/internal/media"
|
"github.com/mithral/vellum/internal/media"
|
||||||
"github.com/mithral/vellum/internal/providers"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// JobKind is the queue job that enriches a single item.
|
// JobKind is the queue job that enriches a single item.
|
||||||
|
|
@ -47,7 +40,7 @@ type Enricher struct {
|
||||||
|
|
||||||
// providers resolves the provider list for a media kind. It defaults to
|
// providers resolves the provider list for a media kind. It defaults to
|
||||||
// the global registry; tests override it to avoid network and global state.
|
// the global registry; tests override it to avoid network and global state.
|
||||||
providers func(kind string) []providers.Provider
|
providers func(kind string) []Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEnricher builds the enricher.
|
// NewEnricher builds the enricher.
|
||||||
|
|
@ -57,7 +50,7 @@ func NewEnricher(db *sql.DB, log *slog.Logger, cacheDir string) *Enricher {
|
||||||
log: log,
|
log: log,
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
client: &http.Client{Timeout: 20 * time.Second},
|
client: &http.Client{Timeout: 20 * time.Second},
|
||||||
providers: providers.For,
|
providers: For,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,14 +76,9 @@ 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
|
// returns an error only for a database failure, because a missing match is a
|
||||||
// normal outcome and must not fail the job.
|
// normal outcome and must not fail the job.
|
||||||
func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
|
func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
|
||||||
// This runs as a background job, not a request, so there is no user and no
|
it, err := media.LoadItem(ctx, e.db, itemID)
|
||||||
// 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 err != nil {
|
||||||
if errors.Is(err, catalog.ErrNotFound) {
|
if errors.Is(err, media.ErrItemNotFound) {
|
||||||
return nil // deleted since the job was queued
|
return nil // deleted since the job was queued
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|
@ -106,13 +94,13 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
provs := e.providers(it.Kind)
|
providers := e.providers(it.Kind)
|
||||||
if len(provs) == 0 {
|
if len(providers) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
q := providers.Query{Title: searchTitle(it.Title), Author: artistHint(it), Kind: it.Kind}
|
q := Query{Title: searchTitle(it.Title), Author: artistHint(it), Kind: it.Kind}
|
||||||
for _, p := range provs {
|
for _, p := range providers {
|
||||||
if e.triedRecently(ctx, itemID, p.Name()) {
|
if e.triedRecently(ctx, itemID, p.Name()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +150,7 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
|
||||||
// so the parent directory is a strong signal that the provider search should
|
// so the parent directory is a strong signal that the provider search should
|
||||||
// use to disambiguate. Without it, "Random Access Memories" matches a release
|
// use to disambiguate. Without it, "Random Access Memories" matches a release
|
||||||
// by an unrelated artist.
|
// by an unrelated artist.
|
||||||
func artistHint(it catalog.Item) string {
|
func artistHint(it media.Item) string {
|
||||||
if library.GroupModeFor(it.Kind) != library.GroupByDirectory {
|
if library.GroupModeFor(it.Kind) != library.GroupByDirectory {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -207,10 +195,10 @@ func searchTitle(s string) string {
|
||||||
// direction) but not absent: it accepts "The Hobbit" against "The Hobbit, or
|
// direction) but not absent: it accepts "The Hobbit" against "The Hobbit, or
|
||||||
// There and Back Again" and rejects "Random Access Memories" against an
|
// There and Back Again" and rejects "Random Access Memories" against an
|
||||||
// unrelated album that merely shares a word.
|
// unrelated album that merely shares a word.
|
||||||
func pickBest(candidates []providers.Candidate, want string) (providers.Candidate, bool) {
|
func pickBest(candidates []Candidate, want string) (Candidate, bool) {
|
||||||
w := normalizeTitle(want)
|
w := normalizeTitle(want)
|
||||||
if len(w) < 3 {
|
if len(w) < 3 {
|
||||||
return providers.Candidate{}, false
|
return Candidate{}, false
|
||||||
}
|
}
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
got := normalizeTitle(c.Title)
|
got := normalizeTitle(c.Title)
|
||||||
|
|
@ -221,7 +209,7 @@ func pickBest(candidates []providers.Candidate, want string) (providers.Candidat
|
||||||
return c, true
|
return c, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return providers.Candidate{}, false
|
return Candidate{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeTitle lowercases and strips punctuation so titles can be compared
|
// normalizeTitle lowercases and strips punctuation so titles can be compared
|
||||||
|
|
@ -249,7 +237,7 @@ func normalizeTitle(s string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply writes a chosen candidate back to the database.
|
// apply writes a chosen candidate back to the database.
|
||||||
func (e *Enricher) apply(ctx context.Context, it catalog.Item, c providers.Candidate) error {
|
func (e *Enricher) apply(ctx context.Context, it media.Item, c Candidate) error {
|
||||||
tx, err := e.db.BeginTx(ctx, nil)
|
tx, err := e.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -313,7 +301,7 @@ func (e *Enricher) apply(ctx context.Context, it catalog.Item, c providers.Candi
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadCover fetches a cover into the cache and records it as an asset.
|
// downloadCover fetches a cover into the cache and records it as an asset.
|
||||||
func (e *Enricher) downloadCover(ctx context.Context, it catalog.Item, c providers.Candidate) error {
|
func (e *Enricher) downloadCover(ctx context.Context, it media.Item, c Candidate) error {
|
||||||
var existing int
|
var existing int
|
||||||
if err := e.db.QueryRowContext(ctx,
|
if err := e.db.QueryRowContext(ctx,
|
||||||
`SELECT count(*) FROM assets WHERE item_id = ? AND role = 'cover'`, it.ID).Scan(&existing); err != nil {
|
`SELECT count(*) FROM assets WHERE item_id = ? AND role = 'cover'`, it.ID).Scan(&existing); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import (
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/core"
|
"github.com/mithral/vellum/internal/core"
|
||||||
"github.com/mithral/vellum/internal/media"
|
"github.com/mithral/vellum/internal/media"
|
||||||
"github.com/mithral/vellum/internal/providers"
|
|
||||||
"github.com/mithral/vellum/migrations"
|
"github.com/mithral/vellum/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -45,7 +44,7 @@ func seedItem(t *testing.T, db *sql.DB, title string) int64 {
|
||||||
libID, title, title).Scan(&itemID); err != nil {
|
libID, title, title).Scan(&itemID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// A file row so catalog.Load can resolve a modtime.
|
// A file row so media.LoadItem can resolve a modtime.
|
||||||
if _, err := db.ExecContext(ctx,
|
if _, err := db.ExecContext(ctx,
|
||||||
`INSERT INTO media_files (item_id, path, size, mtime) VALUES (?, 'book.epub', 1, '1')`, itemID); err != nil {
|
`INSERT INTO media_files (item_id, path, size, mtime) VALUES (?, 'book.epub', 1, '1')`, itemID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -57,22 +56,22 @@ func seedItem(t *testing.T, db *sql.DB, title string) int64 {
|
||||||
type fakeProvider struct {
|
type fakeProvider struct {
|
||||||
name string
|
name string
|
||||||
kinds []string
|
kinds []string
|
||||||
results []providers.Candidate
|
results []Candidate
|
||||||
err error
|
err error
|
||||||
calls int
|
calls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeProvider) Name() string { return f.name }
|
func (f *fakeProvider) Name() string { return f.name }
|
||||||
func (f *fakeProvider) Kinds() []string { return f.kinds }
|
func (f *fakeProvider) Kinds() []string { return f.kinds }
|
||||||
func (f *fakeProvider) Search(context.Context, providers.Query) ([]providers.Candidate, error) {
|
func (f *fakeProvider) Search(context.Context, Query) ([]Candidate, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
return f.results, f.err
|
return f.results, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func enricherWith(t *testing.T, db *sql.DB, provs ...providers.Provider) *Enricher {
|
func enricherWith(t *testing.T, db *sql.DB, provs ...Provider) *Enricher {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
e := NewEnricher(db, discard(), t.TempDir())
|
e := NewEnricher(db, discard(), t.TempDir())
|
||||||
e.providers = func(string) []providers.Provider { return provs }
|
e.providers = func(string) []Provider { return provs }
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,7 +82,7 @@ func TestEnrichAppliesCandidate(t *testing.T) {
|
||||||
fake := &fakeProvider{
|
fake := &fakeProvider{
|
||||||
name: "fake",
|
name: "fake",
|
||||||
kinds: []string{"ebooks"},
|
kinds: []string{"ebooks"},
|
||||||
results: []providers.Candidate{{
|
results: []Candidate{{
|
||||||
Provider: "fake",
|
Provider: "fake",
|
||||||
ExternalID: "OL123W",
|
ExternalID: "OL123W",
|
||||||
Title: "The Hobbit",
|
Title: "The Hobbit",
|
||||||
|
|
@ -149,7 +148,7 @@ func TestEnrichFallsBackToSecondProvider(t *testing.T) {
|
||||||
good := &fakeProvider{
|
good := &fakeProvider{
|
||||||
name: "good",
|
name: "good",
|
||||||
kinds: []string{"ebooks"},
|
kinds: []string{"ebooks"},
|
||||||
results: []providers.Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
|
results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
|
||||||
}
|
}
|
||||||
e := enricherWith(t, db, empty, good)
|
e := enricherWith(t, db, empty, good)
|
||||||
|
|
||||||
|
|
@ -178,7 +177,7 @@ func TestEnrichContinuesAfterProviderError(t *testing.T) {
|
||||||
good := &fakeProvider{
|
good := &fakeProvider{
|
||||||
name: "good",
|
name: "good",
|
||||||
kinds: []string{"ebooks"},
|
kinds: []string{"ebooks"},
|
||||||
results: []providers.Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
|
results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
|
||||||
}
|
}
|
||||||
e := enricherWith(t, db, broken, good)
|
e := enricherWith(t, db, broken, good)
|
||||||
|
|
||||||
|
|
@ -204,7 +203,7 @@ func TestEnrichSkipsAlreadyMatched(t *testing.T) {
|
||||||
itemID := seedItem(t, db, "book")
|
itemID := seedItem(t, db, "book")
|
||||||
|
|
||||||
fake := &fakeProvider{name: "fake", kinds: []string{"ebooks"},
|
fake := &fakeProvider{name: "fake", kinds: []string{"ebooks"},
|
||||||
results: []providers.Candidate{{Provider: "fake", ExternalID: "x", Title: "Book"}}}
|
results: []Candidate{{Provider: "fake", ExternalID: "x", Title: "Book"}}}
|
||||||
e := enricherWith(t, db, fake)
|
e := enricherWith(t, db, fake)
|
||||||
|
|
||||||
if err := e.EnrichItem(context.Background(), itemID); err != nil {
|
if err := e.EnrichItem(context.Background(), itemID); err != nil {
|
||||||
|
|
@ -278,7 +277,7 @@ func TestSearchTitleStripsReleaseTags(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPickBestRejectsUnrelated(t *testing.T) {
|
func TestPickBestRejectsUnrelated(t *testing.T) {
|
||||||
candidates := []providers.Candidate{
|
candidates := []Candidate{
|
||||||
{Title: "Random Access Memories", ExternalID: "right"},
|
{Title: "Random Access Memories", ExternalID: "right"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,7 +287,7 @@ func TestPickBestRejectsUnrelated(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Containment in either direction is accepted.
|
// Containment in either direction is accepted.
|
||||||
long := []providers.Candidate{{Title: "The Hobbit, or There and Back Again"}}
|
long := []Candidate{{Title: "The Hobbit, or There and Back Again"}}
|
||||||
if _, ok := pickBest(long, "The Hobbit"); !ok {
|
if _, ok := pickBest(long, "The Hobbit"); !ok {
|
||||||
t.Error("containment should be accepted")
|
t.Error("containment should be accepted")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package providers
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -8,9 +8,9 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
"github.com/mithral/vellum/internal/jobs"
|
"github.com/mithral/vellum/internal/jobs"
|
||||||
|
"github.com/mithral/vellum/internal/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handlers serves metadata endpoints.
|
// Handlers serves metadata endpoints.
|
||||||
|
|
@ -97,17 +97,20 @@ func (h *Handlers) handleItemMetadata(w http.ResponseWriter, r *http.Request, u
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// catalog.LoadFor is the authorization chokepoint: it returns the same
|
it, err := media.LoadItem(r.Context(), h.db, id)
|
||||||
// ErrNotFound for a missing item and an unreadable one, so this stays a
|
if errors.Is(err, media.ErrItemNotFound) {
|
||||||
// 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")
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
h.fail(w, "load item", err)
|
h.fail(w, "load item", err)
|
||||||
return
|
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 {
|
type providerID struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package providers
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package providers
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package providers
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package providers
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -13,17 +13,12 @@ import (
|
||||||
|
|
||||||
// OpenLibrary matches books by title and author. No API key, no registration.
|
// OpenLibrary matches books by title and author. No API key, no registration.
|
||||||
type OpenLibrary struct {
|
type OpenLibrary struct {
|
||||||
// BaseURL is overridable so tests can point at a local server.
|
|
||||||
BaseURL string
|
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOpenLibrary builds the provider with a bounded HTTP client.
|
// NewOpenLibrary builds the provider with a bounded HTTP client.
|
||||||
func NewOpenLibrary() *OpenLibrary {
|
func NewOpenLibrary() *OpenLibrary {
|
||||||
return &OpenLibrary{
|
return &OpenLibrary{Client: &http.Client{Timeout: 10 * time.Second}}
|
||||||
BaseURL: "https://openlibrary.org",
|
|
||||||
Client: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *OpenLibrary) Name() string { return "openlibrary" }
|
func (p *OpenLibrary) Name() string { return "openlibrary" }
|
||||||
|
|
@ -62,7 +57,7 @@ func (p *OpenLibrary) Search(ctx context.Context, q Query) ([]Candidate, error)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := strings.TrimRight(p.BaseURL, "/") + "/search.json?" + params.Encode()
|
endpoint := "https://openlibrary.org/search.json?" + params.Encode()
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
// Package providers talks to external metadata catalogues.
|
// Package metadata resolves media items against external 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,
|
// 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
|
// because a failed lookup is recorded and skipped, never surfaced as a scan or
|
||||||
// request failure.
|
// request failure.
|
||||||
package providers
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
"github.com/mithral/vellum/internal/media"
|
"github.com/mithral/vellum/internal/media"
|
||||||
"github.com/mithral/vellum/internal/stream"
|
"github.com/mithral/vellum/internal/stream"
|
||||||
|
|
@ -85,9 +84,6 @@ 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.
|
// 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) {
|
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)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
|
|
@ -95,8 +91,8 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
return media.Item{}, false
|
return media.Item{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
|
it, err := media.LoadItem(r.Context(), h.db, id)
|
||||||
if errors.Is(err, catalog.ErrNotFound) {
|
if errors.Is(err, media.ErrItemNotFound) {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
return media.Item{}, false
|
return media.Item{}, false
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +102,11 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
return media.Item{}, false
|
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 {
|
if it.Kind != Kind {
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "item is not a comic")
|
httpx.WriteError(w, http.StatusBadRequest, "item is not a comic")
|
||||||
return media.Item{}, false
|
return media.Item{}, false
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
"github.com/mithral/vellum/internal/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handlers serves ebook-specific endpoints.
|
// Handlers serves ebook-specific endpoints.
|
||||||
|
|
@ -40,8 +40,8 @@ func (h *Handlers) handleMetadata(w http.ResponseWriter, r *http.Request, u auth
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
|
it, err := media.LoadItem(r.Context(), h.db, id)
|
||||||
if errors.Is(err, catalog.ErrNotFound) {
|
if errors.Is(err, media.ErrItemNotFound) {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +51,11 @@ func (h *Handlers) handleMetadata(w http.ResponseWriter, r *http.Request, u auth
|
||||||
return
|
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 {
|
if it.Kind != Kind {
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "item is not an ebook")
|
httpx.WriteError(w, http.StatusBadRequest, "item is not an ebook")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
"github.com/mithral/vellum/internal/media"
|
"github.com/mithral/vellum/internal/media"
|
||||||
)
|
)
|
||||||
|
|
@ -175,10 +174,6 @@ func slotName(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||||
return slot, true
|
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) {
|
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)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
|
|
@ -186,8 +181,8 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
return media.Item{}, false
|
return media.Item{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
it, err := catalog.LoadFor(r.Context(), h.db, u, id)
|
it, err := media.LoadItem(r.Context(), h.db, id)
|
||||||
if errors.Is(err, catalog.ErrNotFound) {
|
if errors.Is(err, media.ErrItemNotFound) {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
return media.Item{}, false
|
return media.Item{}, false
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +191,11 @@ func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
return media.Item{}, false
|
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 {
|
if it.Kind != Kind {
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "item is not a game")
|
httpx.WriteError(w, http.StatusBadRequest, "item is not a game")
|
||||||
return media.Item{}, false
|
return media.Item{}, false
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
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) }
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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,100 +0,0 @@
|
||||||
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,430 +0,0 @@
|
||||||
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,17 +38,11 @@ var contentTypes = map[string]string{
|
||||||
".ogg": "audio/ogg",
|
".ogg": "audio/ogg",
|
||||||
".wav": "audio/wav",
|
".wav": "audio/wav",
|
||||||
// Video.
|
// 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",
|
".mp4": "video/mp4",
|
||||||
".m4v": "video/mp4",
|
".m4v": "video/mp4",
|
||||||
".mkv": "video/x-matroska",
|
".mkv": "video/x-matroska",
|
||||||
".webm": "video/webm",
|
".webm": "video/webm",
|
||||||
".mov": "video/quicktime",
|
".mov": "video/quicktime",
|
||||||
".avi": "video/x-msvideo",
|
|
||||||
// Images (comic pages, covers, photos).
|
// Images (comic pages, covers, photos).
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
// 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 ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,624 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
@ -1,547 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,502 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
-- 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 '';
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
-- 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 '';
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
-- 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
10
web/dist/.gitkeep
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
# 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,24 +121,6 @@ export const api = {
|
||||||
scanLibrary: (id: number) =>
|
scanLibrary: (id: number) =>
|
||||||
request<{ queued: number; job_id: number }>('POST', `/api/v1/libraries/${id}/scan`),
|
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) =>
|
scanStatus: (id: number) =>
|
||||||
request<{
|
request<{
|
||||||
state: 'queued' | 'running' | 'done' | 'failed' | 'idle'
|
state: 'queued' | 'running' | 'done' | 'failed' | 'idle'
|
||||||
|
|
@ -287,76 +269,14 @@ export interface Home {
|
||||||
total_items: number
|
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 {
|
export interface ItemDetail {
|
||||||
item: Item
|
item: Item
|
||||||
files: MediaFile[]
|
files: { id: number; path: string; size: number; container: string; missing: boolean }[]
|
||||||
has_cover: boolean
|
has_cover: boolean
|
||||||
authors: string[] | null
|
authors: string[] | null
|
||||||
progress?: Progress
|
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 {
|
export interface ScanResult {
|
||||||
library_id: number
|
library_id: number
|
||||||
scanned: number
|
scanned: number
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
// 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,35 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { api, ApiError, type Item } from '../api'
|
import { api, ApiError, type Item } from '../api'
|
||||||
import {
|
|
||||||
setNowPlaying,
|
|
||||||
setHandlers,
|
|
||||||
setPosition,
|
|
||||||
setPlaybackState,
|
|
||||||
clear as clearMediaSession,
|
|
||||||
} from '../mediaSession'
|
|
||||||
|
|
||||||
let { itemId }: { itemId: number } = $props()
|
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>()
|
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 item = $state<Item | null>(null)
|
||||||
let authors = $state<string[] | null>(null)
|
|
||||||
let tracks = $state<{ id: number; path: string; size: number }[]>([])
|
let tracks = $state<{ id: number; path: string; size: number }[]>([])
|
||||||
let index = $state(0)
|
let index = $state(0)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
@ -39,29 +15,19 @@
|
||||||
let currentTime = $state(0)
|
let currentTime = $state(0)
|
||||||
let duration = $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 saveTimer: number | undefined
|
||||||
let resumeAt = 0
|
let resumeAt = 0
|
||||||
let pendingPlay = false
|
let pendingPlay = false
|
||||||
let finalFlushed = 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 () => {
|
onMount(async () => {
|
||||||
// Persist the final position even if the page is torn down: keepalive lets a
|
// Persist the final position even if the page is torn down: keepalive lets a
|
||||||
// PUT outlive the document, which sendBeacon (POST-only) cannot.
|
// PUT outlive the document, which sendBeacon (POST-only) cannot.
|
||||||
window.addEventListener('pagehide', finalFlush)
|
window.addEventListener('pagehide', finalFlush)
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
window.addEventListener('keydown', onKeydown)
|
|
||||||
try {
|
try {
|
||||||
const detail = await api.item(itemId)
|
const detail = await api.item(itemId)
|
||||||
item = detail.item
|
item = detail.item
|
||||||
authors = detail.authors
|
|
||||||
tracks = detail.files ?? []
|
tracks = detail.files ?? []
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
error = 'This item has no audio tracks.'
|
error = 'This item has no audio tracks.'
|
||||||
|
|
@ -75,8 +41,6 @@
|
||||||
resumeAt = detail.progress.position / 1000
|
resumeAt = detail.progress.position / 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
publishNowPlaying()
|
|
||||||
registerHandlers()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'could not open this audiobook'
|
error = err instanceof ApiError ? err.message : 'could not open this audiobook'
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -87,13 +51,6 @@
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener('pagehide', finalFlush)
|
window.removeEventListener('pagehide', finalFlush)
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
window.removeEventListener('keydown', onKeydown)
|
|
||||||
clearSleep()
|
|
||||||
clearMediaSession()
|
|
||||||
if (preloader) {
|
|
||||||
preloader.removeAttribute('src')
|
|
||||||
preloader.load()
|
|
||||||
}
|
|
||||||
finalFlush()
|
finalFlush()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -102,45 +59,9 @@
|
||||||
else finalFlushed = false
|
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() {
|
function onLoadedMetadata() {
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
duration = audio.duration || 0
|
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) {
|
if (resumeAt > 0 && resumeAt < duration) {
|
||||||
audio.currentTime = resumeAt
|
audio.currentTime = resumeAt
|
||||||
resumeAt = 0
|
resumeAt = 0
|
||||||
|
|
@ -150,42 +71,25 @@
|
||||||
pendingPlay = false
|
pendingPlay = false
|
||||||
void audio.play().catch(() => {})
|
void audio.play().catch(() => {})
|
||||||
}
|
}
|
||||||
publishNowPlaying()
|
|
||||||
setPosition(audio.currentTime, duration, speed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTimeUpdate() {
|
function onTimeUpdate() {
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
currentTime = audio.currentTime
|
currentTime = audio.currentTime
|
||||||
setPosition(currentTime, duration, speed)
|
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
if (audio.paused) void audio.play()
|
if (audio.paused) void audio.play()
|
||||||
else pauseManual()
|
else audio.pause()
|
||||||
}
|
|
||||||
|
|
||||||
function pauseManual() {
|
|
||||||
if (!audio || audio.paused) return
|
|
||||||
manualPause = true
|
|
||||||
audio.pause()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPlay() {
|
function onPlay() {
|
||||||
playing = true
|
playing = true
|
||||||
setPlaybackState('playing')
|
|
||||||
}
|
}
|
||||||
function onPause() {
|
function onPause() {
|
||||||
playing = false
|
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) {
|
function goToTrack(i: number) {
|
||||||
|
|
@ -203,123 +107,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEnded() {
|
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)
|
if (index + 1 < tracks.length) goToTrack(index + 1)
|
||||||
else flush(true)
|
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() {
|
function scheduleSave() {
|
||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
saveTimer = window.setTimeout(() => flush(), 2000)
|
saveTimer = window.setTimeout(() => flush(), 2000)
|
||||||
|
|
@ -413,17 +204,6 @@
|
||||||
onended={onEnded}
|
onended={onEnded}
|
||||||
></audio>
|
></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
|
<input
|
||||||
class="seek"
|
class="seek"
|
||||||
type="range"
|
type="range"
|
||||||
|
|
@ -448,35 +228,8 @@
|
||||||
onclick={() => goToTrack(index + 1)}
|
onclick={() => goToTrack(index + 1)}
|
||||||
disabled={index >= tracks.length - 1}>⏭</button
|
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>
|
||||||
|
|
||||||
<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}
|
{#if tracks.length > 1}
|
||||||
<ol class="tracks">
|
<ol class="tracks">
|
||||||
{#each tracks as t, i (t.id)}
|
{#each tracks as t, i (t.id)}
|
||||||
|
|
@ -523,14 +276,6 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.seek {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
|
|
@ -550,28 +295,6 @@
|
||||||
min-width: 96px;
|
min-width: 96px;
|
||||||
justify-content: center;
|
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 {
|
.tracks {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: var(--space-2) 0 0;
|
margin: var(--space-2) 0 0;
|
||||||
|
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,580 +0,0 @@
|
||||||
<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,11 +3,9 @@
|
||||||
import { api, ApiError, type Item, type EbookMetadata } from '../api'
|
import { api, ApiError, type Item, type EbookMetadata } from '../api'
|
||||||
import ComicReader from './ComicReader.svelte'
|
import ComicReader from './ComicReader.svelte'
|
||||||
import AudiobookPlayer from './AudiobookPlayer.svelte'
|
import AudiobookPlayer from './AudiobookPlayer.svelte'
|
||||||
import MusicPlayer from './MusicPlayer.svelte'
|
|
||||||
import PhotoViewer from './PhotoViewer.svelte'
|
import PhotoViewer from './PhotoViewer.svelte'
|
||||||
import PdfReader from './PdfReader.svelte'
|
import PdfReader from './PdfReader.svelte'
|
||||||
import GamePlayer from './GamePlayer.svelte'
|
import GamePlayer from './GamePlayer.svelte'
|
||||||
import VideoPlayer from './VideoPlayer.svelte'
|
|
||||||
|
|
||||||
let { itemId }: { itemId: number } = $props()
|
let { itemId }: { itemId: number } = $props()
|
||||||
|
|
||||||
|
|
@ -141,16 +139,12 @@
|
||||||
<div class="alert">{error}</div>
|
<div class="alert">{error}</div>
|
||||||
{:else if item && item.kind === 'games'}
|
{:else if item && item.kind === 'games'}
|
||||||
<GamePlayer {itemId} />
|
<GamePlayer {itemId} />
|
||||||
{:else if item && item.kind === 'video'}
|
|
||||||
<VideoPlayer {itemId} />
|
|
||||||
{:else if item && fileContainer === 'pdf'}
|
{:else if item && fileContainer === 'pdf'}
|
||||||
<PdfReader {itemId} />
|
<PdfReader {itemId} />
|
||||||
{:else if item && item.kind === 'comics'}
|
{:else if item && item.kind === 'comics'}
|
||||||
<ComicReader {itemId} />
|
<ComicReader {itemId} />
|
||||||
{:else if item && item.kind === 'audiobooks'}
|
{:else if item && (item.kind === 'audiobooks' || item.kind === 'music')}
|
||||||
<AudiobookPlayer {itemId} />
|
<AudiobookPlayer {itemId} />
|
||||||
{:else if item && item.kind === 'music'}
|
|
||||||
<MusicPlayer {itemId} />
|
|
||||||
{:else if item && item.kind === 'photos'}
|
{:else if item && item.kind === 'photos'}
|
||||||
<PhotoViewer {itemId} />
|
<PhotoViewer {itemId} />
|
||||||
{:else if item && item.kind !== 'ebooks'}
|
{:else if item && item.kind !== 'ebooks'}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,8 @@
|
||||||
import SearchResults from './SearchResults.svelte'
|
import SearchResults from './SearchResults.svelte'
|
||||||
import Libraries from './Libraries.svelte'
|
import Libraries from './Libraries.svelte'
|
||||||
import Users from './Users.svelte'
|
import Users from './Users.svelte'
|
||||||
import MissingFiles from './MissingFiles.svelte'
|
|
||||||
import Settings from './Settings.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 searchBox = $state('')
|
||||||
let scrolled = $state(false)
|
let scrolled = $state(false)
|
||||||
let contentEl = $state<HTMLDivElement>()
|
let contentEl = $state<HTMLDivElement>()
|
||||||
|
|
@ -24,7 +19,6 @@
|
||||||
// A stable key for the current view, so navigating transitions the content
|
// A stable key for the current view, so navigating transitions the content
|
||||||
// instead of swapping it instantly.
|
// instead of swapping it instantly.
|
||||||
const viewKey = $derived(JSON.stringify($view))
|
const viewKey = $derived(JSON.stringify($view))
|
||||||
const current = $derived($view as ShellView)
|
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
scrolled = (contentEl?.scrollTop ?? 0) > 8
|
scrolled = (contentEl?.scrollTop ?? 0) > 8
|
||||||
|
|
@ -49,7 +43,7 @@
|
||||||
if (q) go({ name: 'search', query: q })
|
if (q) go({ name: 'search', query: q })
|
||||||
}
|
}
|
||||||
|
|
||||||
function title(v: ShellView): string {
|
function title(v: View): string {
|
||||||
switch (v.name) {
|
switch (v.name) {
|
||||||
case 'home':
|
case 'home':
|
||||||
return 'Home'
|
return 'Home'
|
||||||
|
|
@ -66,8 +60,6 @@
|
||||||
return 'Libraries'
|
return 'Libraries'
|
||||||
case 'users':
|
case 'users':
|
||||||
return 'Users'
|
return 'Users'
|
||||||
case 'missing':
|
|
||||||
return 'Missing files'
|
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return 'Appearance'
|
return 'Appearance'
|
||||||
}
|
}
|
||||||
|
|
@ -112,13 +104,6 @@
|
||||||
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
|
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
|
||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
class="navlink"
|
|
||||||
class:active={current.name === 'missing'}
|
|
||||||
onclick={() => go({ name: 'missing' } as unknown as View)}
|
|
||||||
>
|
|
||||||
<span>Missing files</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -172,8 +157,6 @@
|
||||||
<Libraries />
|
<Libraries />
|
||||||
{:else if $view.name === 'users'}
|
{:else if $view.name === 'users'}
|
||||||
<Users />
|
<Users />
|
||||||
{:else if current.name === 'missing'}
|
|
||||||
<MissingFiles />
|
|
||||||
{:else if $view.name === 'settings'}
|
{:else if $view.name === 'settings'}
|
||||||
<Settings />
|
<Settings />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,767 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
// 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({})
|
|
||||||
}
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
// 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