Phase 2: split the fat packages along real boundaries
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.
internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
the package that authorizes the item; it imports home.RecordView for that one
thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
return different explore ids, which is the non-determinism the Explore rewrite
exists for.
internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
are not the scanner (modules register types, handlers serve the list, metadata
reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
not of the scan. The one genuinely scanner-owned helper became
library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
detection still short-circuits.
internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
the database writes. The split's whole point is that a provider is testable
WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
because real false matches were found.
dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
grep. That is what makes them reviewable.
- No field name, tag or error code changed.
internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.
Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.
Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.
Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
This commit is contained in:
parent
ac1bd04df3
commit
35442e4aff
43 changed files with 1818 additions and 1281 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@
|
||||||
|
|
||||||
# local preview instance
|
# local preview instance
|
||||||
demo/
|
demo/
|
||||||
|
.archify/
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"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/jellyfin"
|
||||||
"github.com/mithral/vellum/internal/jobs"
|
"github.com/mithral/vellum/internal/jobs"
|
||||||
|
|
@ -28,6 +29,8 @@ import (
|
||||||
"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/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/internal/subsonic"
|
||||||
"github.com/mithral/vellum/migrations"
|
"github.com/mithral/vellum/migrations"
|
||||||
|
|
@ -103,6 +106,9 @@ 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())
|
||||||
|
|
@ -134,7 +140,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 := library.Scan(ctx, db, lib, log)
|
res, err := scanner.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)
|
||||||
|
|
@ -145,16 +151,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.
|
||||||
metadata.Register(metadata.NewOpenLibrary())
|
providers.Register(providers.NewOpenLibrary())
|
||||||
metadata.Register(metadata.NewGoogleBooks())
|
providers.Register(providers.NewGoogleBooks())
|
||||||
metadata.Register(metadata.NewMusicBrainz())
|
providers.Register(providers.NewMusicBrainz())
|
||||||
if cfg.ComicVineAPIKey != "" {
|
if cfg.ComicVineAPIKey != "" {
|
||||||
metadata.Register(metadata.NewComicVine(cfg.ComicVineAPIKey))
|
providers.Register(providers.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 != "" {
|
||||||
metadata.Register(metadata.NewIGDB(cfg.IGDBClientID, cfg.IGDBClientSecret))
|
providers.Register(providers.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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,17 @@ var layers = map[string]int{
|
||||||
"internal/engine": 1,
|
"internal/engine": 1,
|
||||||
"internal/apitoken": 1,
|
"internal/apitoken": 1,
|
||||||
|
|
||||||
// 2 — the data seam: items and access.
|
// 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/catalog": 2,
|
||||||
|
"internal/providers": 2,
|
||||||
|
|
||||||
// 3 — the application.
|
// 3 — the application.
|
||||||
"internal/library": 3,
|
"internal/library": 3,
|
||||||
|
"internal/scanner": 3,
|
||||||
"internal/media": 3,
|
"internal/media": 3,
|
||||||
|
"internal/home": 3,
|
||||||
"internal/search": 3,
|
"internal/search": 3,
|
||||||
"internal/metadata": 3,
|
"internal/metadata": 3,
|
||||||
"internal/playback": 3,
|
"internal/playback": 3,
|
||||||
|
|
@ -58,7 +63,9 @@ var layers = map[string]int{
|
||||||
// 4 — adapters: HTTP surfaces and foreign protocols.
|
// 4 — adapters: HTTP surfaces and foreign protocols.
|
||||||
"internal/httpapi": 4,
|
"internal/httpapi": 4,
|
||||||
"internal/jellyfin": 4,
|
"internal/jellyfin": 4,
|
||||||
|
"internal/jellyfin/dto": 3,
|
||||||
"internal/subsonic": 4,
|
"internal/subsonic": 4,
|
||||||
|
"internal/subsonic/dto": 3,
|
||||||
"internal/cli": 4,
|
"internal/cli": 4,
|
||||||
|
|
||||||
// 5 — the only place everything is wired together.
|
// 5 — the only place everything is wired together.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package media
|
package home
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,31 +1,11 @@
|
||||||
package media
|
package home
|
||||||
|
|
||||||
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,7 +1,21 @@
|
||||||
package media
|
// Package home computes what the landing screen shows: the five shelves and the
|
||||||
|
// counts the sidebar needs.
|
||||||
|
//
|
||||||
|
// It is separated from internal/media because the two answer different
|
||||||
|
// questions. Media serves item RESOURCES — one item, its file, its cover, its
|
||||||
|
// progress. Home computes AGGREGATES over many items: what you were in the
|
||||||
|
// middle of, what you keep opening, what to try next. Neither can be built from
|
||||||
|
// the other, and a change to a shelf query has no business touching the endpoint
|
||||||
|
// that streams a file.
|
||||||
|
//
|
||||||
|
// It depends on internal/catalog only for the shape of an item; the SQL is its
|
||||||
|
// own because the shelves are the only queries in the server that aggregate
|
||||||
|
// across libraries, kinds and users at once.
|
||||||
|
package home
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -10,6 +24,23 @@ 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"`
|
||||||
|
|
@ -159,32 +190,9 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRecordView increments the user's view count for an item.
|
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
||||||
func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) {
|
h.log.Error(what, "err", err)
|
||||||
it, ok := h.authorized(w, r, u)
|
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||||
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 media
|
package home
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -9,20 +9,37 @@ 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 mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
|
func homeHandlers(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, t.TempDir())
|
return NewHandlers(db, log, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "home.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
|
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
|
||||||
|
|
@ -205,7 +222,7 @@ func getHome(t *testing.T, h *Handlers, u auth.User) (homeResponse, []byte) {
|
||||||
|
|
||||||
func TestHandleHomeShelvesAndCounts(t *testing.T) {
|
func TestHandleHomeShelvesAndCounts(t *testing.T) {
|
||||||
f := setupHome(t)
|
f := setupHome(t)
|
||||||
h := mediaHandlers(t, f.db)
|
h := homeHandlers(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.
|
||||||
|
|
@ -266,7 +283,7 @@ func TestHandleHomeShelvesAndCounts(t *testing.T) {
|
||||||
|
|
||||||
func TestHandleHomeGrantScoping(t *testing.T) {
|
func TestHandleHomeGrantScoping(t *testing.T) {
|
||||||
f := setupHome(t)
|
f := setupHome(t)
|
||||||
h := mediaHandlers(t, f.db)
|
h := homeHandlers(t, f.db)
|
||||||
|
|
||||||
// userA holds a grant to libA only. Nothing from libB may surface anywhere.
|
// 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})
|
||||||
|
|
@ -304,7 +321,7 @@ func TestHandleHomeGrantScoping(t *testing.T) {
|
||||||
|
|
||||||
func TestHandleHomeIsolatesUsers(t *testing.T) {
|
func TestHandleHomeIsolatesUsers(t *testing.T) {
|
||||||
f := setupHome(t)
|
f := setupHome(t)
|
||||||
h := mediaHandlers(t, f.db)
|
h := homeHandlers(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,9 +1,22 @@
|
||||||
package jellyfin
|
// 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 (
|
import (
|
||||||
"database/sql"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
"github.com/mithral/vellum/internal/catalog"
|
||||||
|
"github.com/mithral/vellum/internal/library"
|
||||||
)
|
)
|
||||||
|
|
||||||
// This file defines a documented SUBSET of the Jellyfin DTOs. Jellyfin's
|
// This file defines a documented SUBSET of the Jellyfin DTOs. Jellyfin's
|
||||||
|
|
@ -25,7 +38,7 @@ type UserDto struct {
|
||||||
|
|
||||||
// UserPolicy carries the two things a client needs from a user's policy: the
|
// 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
|
// admin flag and which library folders the account can see. EnabledFolders is
|
||||||
// the user's VisibleLibraryIDs rendered as Jellyfin ids, so a client can hide
|
// the user's visible library ids rendered as Jellyfin ids, so a client can hide
|
||||||
// views it is not allowed to open.
|
// views it is not allowed to open.
|
||||||
type UserPolicy struct {
|
type UserPolicy struct {
|
||||||
IsAdministrator bool `json:"IsAdministrator"`
|
IsAdministrator bool `json:"IsAdministrator"`
|
||||||
|
|
@ -112,14 +125,46 @@ type PlaybackInfoResponse struct {
|
||||||
ErrorCode string `json:"ErrorCode,omitempty"`
|
ErrorCode string `json:"ErrorCode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// itemQueryResult is the browse envelope.
|
// ItemQueryResult is the browse envelope.
|
||||||
type itemQueryResult struct {
|
type ItemQueryResult struct {
|
||||||
Items []BaseItemDto `json:"Items"`
|
Items []BaseItemDto `json:"Items"`
|
||||||
TotalRecordCount int `json:"TotalRecordCount"`
|
TotalRecordCount int `json:"TotalRecordCount"`
|
||||||
StartIndex int `json:"StartIndex"`
|
StartIndex int `json:"StartIndex"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// itemView is one browsable item assembled for a Jellyfin DTO.
|
// 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
|
// 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
|
// file — with the per-user overlay (progress, view count, favourite) and the
|
||||||
|
|
@ -128,14 +173,15 @@ type itemQueryResult struct {
|
||||||
// item and enforces the grant, and catalog.FirstFile reads the file, so there
|
// 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
|
// is exactly one implementation of "may this user read this item" and exactly
|
||||||
// one place item and file rows are read.
|
// one place item and file rows are read.
|
||||||
type itemView struct {
|
type ItemView struct {
|
||||||
Item catalog.Item
|
Item catalog.Item
|
||||||
File catalog.File
|
File catalog.File
|
||||||
|
|
||||||
// SortTitle and ParentID are browse-only columns; the DTO maps them to
|
// SortTitle is a browse-only column; the DTO maps it to SortName.
|
||||||
// SortName and ParentId.
|
|
||||||
SortTitle string
|
SortTitle string
|
||||||
ParentID sql.NullInt64
|
// 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 is true when the scanner extracted a primary image.
|
||||||
HasCover bool
|
HasCover bool
|
||||||
|
|
@ -148,10 +194,10 @@ type itemView struct {
|
||||||
Favorite bool
|
Favorite bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// itemType maps a Vellum media kind to the Jellyfin item type. The four types
|
// 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
|
// the task requires are Movie, Audio, Book and Photo; anything Vellum adds
|
||||||
// later (games) degrades to Folder so a client can still show it.
|
// later (games) degrades to Folder so a client can still show it.
|
||||||
func itemType(kind string) string {
|
func ItemType(kind string) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
case "video":
|
case "video":
|
||||||
return "Movie"
|
return "Movie"
|
||||||
|
|
@ -166,9 +212,9 @@ func itemType(kind string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaType maps a Vellum media kind to the Jellyfin MediaType. It is the
|
// MediaType maps a Vellum media kind to the Jellyfin MediaType. It is the
|
||||||
// coarser classification ("Video"/"Audio"/"Book"/"Photo").
|
// coarser classification ("Video"/"Audio"/"Book"/"Photo").
|
||||||
func mediaType(kind string) string {
|
func MediaType(kind string) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
case "video":
|
case "video":
|
||||||
return "Video"
|
return "Video"
|
||||||
|
|
@ -183,10 +229,27 @@ func mediaType(kind string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// kindsForTypes maps requested Jellyfin IncludeItemTypes back to Vellum kinds,
|
// 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
|
// so a client asking for Movies does not receive ebooks. An unknown type maps
|
||||||
// to nothing rather than to everything.
|
// to nothing rather than to everything.
|
||||||
func kindsForTypes(types []string) []string {
|
func KindsForTypes(types []string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
for _, t := range types {
|
for _, t := range types {
|
||||||
switch t {
|
switch t {
|
||||||
|
|
@ -203,53 +266,52 @@ func kindsForTypes(types []string) []string {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// toBaseItemDto renders one itemView. includeMediaSources is set from the
|
// ToBaseItemDto renders one ItemView. includeMediaSources is set from the
|
||||||
// Fields query parameter; lists normally omit sources to keep responses small.
|
// Fields query parameter; lists normally omit sources to keep responses small.
|
||||||
func toBaseItemDto(v itemView, includeMediaSources bool, apiKey string) BaseItemDto {
|
func ToBaseItemDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto {
|
||||||
dto := BaseItemDto{
|
dto := BaseItemDto{
|
||||||
Id: itemID(v.Item.ID),
|
Id: ItemID(v.Item.ID),
|
||||||
Name: v.Item.Title,
|
Name: v.Item.Title,
|
||||||
Type: itemType(v.Item.Kind),
|
Type: ItemType(v.Item.Kind),
|
||||||
MediaType: mediaType(v.Item.Kind),
|
MediaType: MediaType(v.Item.Kind),
|
||||||
Container: v.File.Container,
|
Container: v.File.Container,
|
||||||
RunTimeTicks: msToTicks(v.File.DurationMS),
|
RunTimeTicks: MsToTicks(v.File.DurationMS),
|
||||||
ProductionYear: v.Item.Year,
|
ProductionYear: v.Item.Year,
|
||||||
IsFolder: false,
|
IsFolder: false,
|
||||||
SortName: v.SortTitle,
|
SortName: v.SortTitle,
|
||||||
Path: v.File.Path,
|
Path: v.File.Path,
|
||||||
}
|
}
|
||||||
if v.ParentID.Valid {
|
if v.ParentID > 0 {
|
||||||
dto.ParentId = itemID(v.ParentID.Int64)
|
dto.ParentId = ItemID(v.ParentID)
|
||||||
}
|
}
|
||||||
if v.HasCover {
|
if v.HasCover {
|
||||||
// The tag is the item id: stable, and it changes when the item changes.
|
// 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.ImageTags = map[string]string{"Primary": ItemID(v.Item.ID)}
|
||||||
}
|
}
|
||||||
dto.UserData = &UserItemDataDto{
|
dto.UserData = &UserItemDataDto{
|
||||||
PlaybackPositionTicks: msToTicks(v.PositionMS),
|
PlaybackPositionTicks: MsToTicks(v.PositionMS),
|
||||||
PlayedPercentage: v.Percent * 100,
|
PlayedPercentage: v.Percent * 100,
|
||||||
PlayCount: v.PlayCount,
|
PlayCount: v.PlayCount,
|
||||||
IsFavorite: v.Favorite,
|
IsFavorite: v.Favorite,
|
||||||
Played: v.Finished,
|
Played: v.Finished,
|
||||||
}
|
}
|
||||||
if includeMediaSources {
|
if includeMediaSources {
|
||||||
dto.MediaSources = []MediaSourceInfo{buildMediaSource(v.Item, v.File, apiKey)}
|
dto.MediaSources = []MediaSourceInfo{BuildMediaSource(v.Item, v.File, apiKey)}
|
||||||
}
|
}
|
||||||
return dto
|
return dto
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMediaSource renders the single direct-play source for an item. It is
|
// BuildMediaSource renders the single direct-play source for an item. It is
|
||||||
// shared by PlaybackInfo and by list responses that requested the
|
// shared by PlaybackInfo and by list responses that requested the MediaSources
|
||||||
// MediaSources field. Both inputs come from internal/catalog; a zero File is
|
// field. Both inputs come from internal/catalog; a zero File is an item with no
|
||||||
// an item with no file, and produces the same defaults a missing file always
|
// file, and produces the same defaults a missing file always did.
|
||||||
// did.
|
func BuildMediaSource(it catalog.Item, f catalog.File, apiKey string) MediaSourceInfo {
|
||||||
func buildMediaSource(it catalog.Item, f catalog.File, apiKey string) MediaSourceInfo {
|
|
||||||
ms := MediaSourceInfo{
|
ms := MediaSourceInfo{
|
||||||
Id: itemID(it.ID),
|
Id: ItemID(it.ID),
|
||||||
Name: it.Title,
|
Name: it.Title,
|
||||||
Path: f.Path,
|
Path: f.Path,
|
||||||
Container: f.Container,
|
Container: f.Container,
|
||||||
RunTimeTicks: msToTicks(f.DurationMS),
|
RunTimeTicks: MsToTicks(f.DurationMS),
|
||||||
Size: f.Size,
|
Size: f.Size,
|
||||||
SupportsDirectPlay: true,
|
SupportsDirectPlay: true,
|
||||||
SupportsDirectStream: true,
|
SupportsDirectStream: true,
|
||||||
|
|
@ -275,14 +337,60 @@ func buildMediaSource(it catalog.Item, f catalog.File, apiKey string) MediaSourc
|
||||||
return ms
|
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
|
// directStreamURL builds the URL a client should play. Video items stream from
|
||||||
// /Videos, everything else (audio, books, photos) from /Audio, which is the
|
// /Videos, everything else (audio, books, photos) from /Audio, which is the
|
||||||
// same file-serving path either way. The api_key is embedded because media
|
// same file-serving path either way. The api_key is embedded because media
|
||||||
// elements cannot set headers.
|
// elements cannot set headers.
|
||||||
func directStreamURL(it catalog.Item, apiKey string) string {
|
func directStreamURL(it catalog.Item, apiKey string) string {
|
||||||
base := "/Audio/" + itemID(it.ID) + "/stream"
|
base := "/Audio/" + ItemID(it.ID) + "/stream"
|
||||||
if it.Kind == "video" {
|
if it.Kind == "video" {
|
||||||
base = "/Videos/" + itemID(it.ID) + "/stream"
|
base = "/Videos/" + ItemID(it.ID) + "/stream"
|
||||||
}
|
}
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
return base
|
return base
|
||||||
|
|
@ -290,8 +398,8 @@ func directStreamURL(it catalog.Item, apiKey string) string {
|
||||||
return base + "?api_key=" + apiKey
|
return base + "?api_key=" + apiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// msToTicks converts Vellum's milliseconds to Jellyfin's 100ns ticks.
|
// MsToTicks converts Vellum's milliseconds to Jellyfin's 100ns ticks.
|
||||||
func msToTicks(ms int64) int64 { return ms * 10000 }
|
func MsToTicks(ms int64) int64 { return ms * 10000 }
|
||||||
|
|
||||||
// ticksToMS converts Jellyfin's 100ns ticks to Vellum's milliseconds.
|
// TicksToMS converts Jellyfin's 100ns ticks to Vellum's milliseconds.
|
||||||
func ticksToMS(ticks int64) int64 { return ticks / 10000 }
|
func TicksToMS(ticks int64) int64 { return ticks / 10000 }
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package jellyfin
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -17,17 +17,17 @@ import (
|
||||||
// The mapping is stable and deterministic: the same Vellum row always yields
|
// The mapping is stable and deterministic: the same Vellum row always yields
|
||||||
// the same Jellyfin id, which clients rely on for caching and favourites.
|
// the same Jellyfin id, which clients rely on for caching and favourites.
|
||||||
|
|
||||||
// itemID renders an item's Jellyfin id.
|
// ItemID renders an item's Jellyfin id.
|
||||||
func itemID(id int64) string { return fmt.Sprintf("%032x", uint64(id)) }
|
func ItemID(id int64) string { return fmt.Sprintf("%032x", uint64(id)) }
|
||||||
|
|
||||||
// libraryID renders a library's Jellyfin id. The leading 'f' namespaces it
|
// LibraryID renders a library's Jellyfin id. The leading 'f' namespaces it
|
||||||
// away from item ids.
|
// away from item ids.
|
||||||
func libraryID(id int64) string { return fmt.Sprintf("f%031x", uint64(id)) }
|
func LibraryID(id int64) string { return fmt.Sprintf("f%031x", uint64(id)) }
|
||||||
|
|
||||||
// decodeID reverses itemID/libraryID. isLibrary reports which namespace the 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.
|
// 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.
|
// Input is lowercased first, because clients sometimes normalise case.
|
||||||
func decodeID(s string) (id int64, isLibrary, ok bool) {
|
func DecodeID(s string) (id int64, isLibrary, ok bool) {
|
||||||
s = strings.ToLower(strings.TrimSpace(s))
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
if len(s) != 32 {
|
if len(s) != 32 {
|
||||||
return 0, false, false
|
return 0, false, false
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
"github.com/mithral/vellum/internal/catalog"
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
"github.com/mithral/vellum/internal/jellyfin/dto"
|
||||||
"github.com/mithral/vellum/internal/library"
|
"github.com/mithral/vellum/internal/library"
|
||||||
"github.com/mithral/vellum/internal/stream"
|
"github.com/mithral/vellum/internal/stream"
|
||||||
)
|
)
|
||||||
|
|
@ -66,12 +67,12 @@ func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.Us
|
||||||
offset: startIndex,
|
offset: startIndex,
|
||||||
searchTerm: search,
|
searchTerm: search,
|
||||||
filters: splitCSV(q.Get("Filters")),
|
filters: splitCSV(q.Get("Filters")),
|
||||||
kinds: kindsForTypes(splitCSV(q.Get("IncludeItemTypes"))),
|
kinds: dto.KindsForTypes(splitCSV(q.Get("IncludeItemTypes"))),
|
||||||
includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"),
|
includeSources: containsFold(splitCSV(q.Get("Fields")), "MediaSources"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if parentRaw != "" {
|
if parentRaw != "" {
|
||||||
pid, isLib, ok := decodeID(parentRaw)
|
pid, isLib, ok := dto.DecodeID(parentRaw)
|
||||||
if !ok {
|
if !ok {
|
||||||
// An unknown ParentId is an empty page, not an error: clients probe
|
// An unknown ParentId is an empty page, not an error: clients probe
|
||||||
// ids and expect to be told there is nothing there.
|
// ids and expect to be told there is nothing there.
|
||||||
|
|
@ -106,9 +107,9 @@ func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.Us
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey := tokenFromRequest(r)
|
apiKey := tokenFromRequest(r)
|
||||||
dtos := make([]BaseItemDto, 0, len(items))
|
dtos := make([]dto.BaseItemDto, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
dtos = append(dtos, toBaseItemDto(it, query.includeSources, apiKey))
|
dtos = append(dtos, dto.ToBaseItemDto(it, query.includeSources, apiKey))
|
||||||
}
|
}
|
||||||
writeItems(w, startIndex, total, dtos)
|
writeItems(w, startIndex, total, dtos)
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +118,7 @@ func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.Us
|
||||||
// authoritative; a mismatched path id is a 404 (cross-user reads are not
|
// authoritative; a mismatched path id is a 404 (cross-user reads are not
|
||||||
// implemented — see the package notes).
|
// implemented — see the package notes).
|
||||||
func (h *Handlers) handleUserItems(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleUserItems(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
id, _, ok := decodeID(r.PathValue("userId"))
|
id, _, ok := dto.DecodeID(r.PathValue("userId"))
|
||||||
if !ok || id != u.ID {
|
if !ok || id != u.ID {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "user not found")
|
httpx.WriteError(w, http.StatusNotFound, "user not found")
|
||||||
return
|
return
|
||||||
|
|
@ -128,7 +129,7 @@ func (h *Handlers) handleUserItems(w http.ResponseWriter, r *http.Request, u aut
|
||||||
// handleItem serves one BaseItemDto. A library id is answered as a folder so a
|
// handleItem serves one BaseItemDto. A library id is answered as a folder so a
|
||||||
// client that treats views as items still works.
|
// client that treats views as items still works.
|
||||||
func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
id, isLib, ok := decodeID(r.PathValue("itemId"))
|
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
|
||||||
if !ok {
|
if !ok {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
return
|
return
|
||||||
|
|
@ -152,7 +153,7 @@ func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.WriteJSON(w, http.StatusOK, libraryDTO(lib))
|
httpx.WriteJSON(w, http.StatusOK, dto.LibraryDto(lib))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,7 +161,7 @@ func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
v := itemView{Item: it}
|
v := dto.ItemView{Item: it}
|
||||||
if err := h.attachFile(r.Context(), &v); err != nil {
|
if err := h.attachFile(r.Context(), &v); err != nil {
|
||||||
h.fail(w, "load file", err)
|
h.fail(w, "load file", err)
|
||||||
return
|
return
|
||||||
|
|
@ -173,7 +174,7 @@ func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
h.fail(w, "load item state", err)
|
h.fail(w, "load item state", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.WriteJSON(w, http.StatusOK, toBaseItemDto(v, true, tokenFromRequest(r)))
|
httpx.WriteJSON(w, http.StatusOK, dto.ToBaseItemDto(v, true, tokenFromRequest(r)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleImagePrimary streams the cover bytes the scanner/web already extracted.
|
// handleImagePrimary streams the cover bytes the scanner/web already extracted.
|
||||||
|
|
@ -182,7 +183,7 @@ func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
// extraction path belongs to the web layer. An item whose cover has not been
|
// extraction path belongs to the web layer. An item whose cover has not been
|
||||||
// extracted yet simply 404s, which clients treat as "no artwork".
|
// extracted yet simply 404s, which clients treat as "no artwork".
|
||||||
func (h *Handlers) handleImagePrimary(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleImagePrimary(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
id, isLib, ok := decodeID(r.PathValue("itemId"))
|
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
|
||||||
if !ok || isLib {
|
if !ok || isLib {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "no cover available")
|
httpx.WriteError(w, http.StatusNotFound, "no cover available")
|
||||||
return
|
return
|
||||||
|
|
@ -243,47 +244,18 @@ func (h *Handlers) writeViews(w http.ResponseWriter, ctx context.Context, libs [
|
||||||
if end > total {
|
if end > total {
|
||||||
end = total
|
end = total
|
||||||
}
|
}
|
||||||
items := make([]BaseItemDto, 0, end-startIndex)
|
items := make([]dto.BaseItemDto, 0, end-startIndex)
|
||||||
for _, lib := range list[startIndex:end] {
|
for _, lib := range list[startIndex:end] {
|
||||||
items = append(items, libraryDTO(lib))
|
items = append(items, dto.LibraryDto(lib))
|
||||||
}
|
}
|
||||||
writeItems(w, startIndex, total, items)
|
writeItems(w, startIndex, total, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// libraryDTO renders a library as a Jellyfin folder.
|
func writeItems(w http.ResponseWriter, startIndex, total int, items []dto.BaseItemDto) {
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeItems(w http.ResponseWriter, startIndex, total int, items []BaseItemDto) {
|
|
||||||
if items == nil {
|
if items == nil {
|
||||||
items = []BaseItemDto{}
|
items = []dto.BaseItemDto{}
|
||||||
}
|
}
|
||||||
httpx.WriteJSON(w, http.StatusOK, itemQueryResult{
|
httpx.WriteJSON(w, http.StatusOK, dto.ItemQueryResult{
|
||||||
Items: items,
|
Items: items,
|
||||||
TotalRecordCount: total,
|
TotalRecordCount: total,
|
||||||
StartIndex: startIndex,
|
StartIndex: startIndex,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
"github.com/mithral/vellum/internal/catalog"
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
"github.com/mithral/vellum/internal/jellyfin/dto"
|
||||||
"github.com/mithral/vellum/internal/stream"
|
"github.com/mithral/vellum/internal/stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,7 +23,7 @@ import (
|
||||||
// ErrorCode "NoCompatibleStream" and a warning, rather than by handing back a
|
// ErrorCode "NoCompatibleStream" and a warning, rather than by handing back a
|
||||||
// TranscodingUrl that would not exist.
|
// TranscodingUrl that would not exist.
|
||||||
func (h *Handlers) handlePlaybackInfo(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handlePlaybackInfo(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
id, isLib, ok := decodeID(r.PathValue("itemId"))
|
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
|
||||||
if !ok || isLib {
|
if !ok || isLib {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
return
|
return
|
||||||
|
|
@ -41,8 +42,8 @@ func (h *Handlers) handlePlaybackInfo(w http.ResponseWriter, r *http.Request, u
|
||||||
directPlay := queryBoolDefault(q.Get("EnableDirectPlay"), true)
|
directPlay := queryBoolDefault(q.Get("EnableDirectPlay"), true)
|
||||||
directStream := queryBoolDefault(q.Get("EnableDirectStream"), true)
|
directStream := queryBoolDefault(q.Get("EnableDirectStream"), true)
|
||||||
|
|
||||||
src := buildMediaSource(it, f, tokenFromRequest(r))
|
src := dto.BuildMediaSource(it, f, tokenFromRequest(r))
|
||||||
resp := PlaybackInfoResponse{MediaSources: []MediaSourceInfo{src}}
|
resp := dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}}
|
||||||
if !directPlay && !directStream {
|
if !directPlay && !directStream {
|
||||||
resp.ErrorCode = "NoCompatibleStream"
|
resp.ErrorCode = "NoCompatibleStream"
|
||||||
h.log.Warn("jellyfin client requires transcoding; refusing",
|
h.log.Warn("jellyfin client requires transcoding; refusing",
|
||||||
|
|
@ -75,7 +76,7 @@ func (h *Handlers) serveStream(w http.ResponseWriter, r *http.Request, u auth.Us
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, isLib, ok := decodeID(r.PathValue("itemId"))
|
id, isLib, ok := dto.DecodeID(r.PathValue("itemId"))
|
||||||
if !ok || isLib {
|
if !ok || isLib {
|
||||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
"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
|
// defaultLimit caps an unbounded browse so a client that omits Limit cannot
|
||||||
|
|
@ -66,7 +67,7 @@ type listQuery struct {
|
||||||
// The browse rows are drained before the per-item file lookups run: catalog
|
// 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
|
// reads one file at a time, and issuing those queries against a still-open
|
||||||
// result set is the classic nested-query footgun.
|
// result set is the classic nested-query footgun.
|
||||||
func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]itemView, int, error) {
|
func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]dto.ItemView, int, error) {
|
||||||
where, whereArgs := buildWhere(q)
|
where, whereArgs := buildWhere(q)
|
||||||
|
|
||||||
var total int
|
var total int
|
||||||
|
|
@ -94,7 +95,7 @@ func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]itemView, int
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
out := []itemView{}
|
out := []dto.ItemView{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
v, err := scanItem(rows)
|
v, err := scanItem(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -118,7 +119,7 @@ func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]itemView, int
|
||||||
// attachFile fills the first-file facts from internal/catalog, the one place
|
// 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
|
// 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.
|
// exactly the defaults the old media_files LEFT JOIN produced.
|
||||||
func (h *Handlers) attachFile(ctx context.Context, v *itemView) error {
|
func (h *Handlers) attachFile(ctx context.Context, v *dto.ItemView) error {
|
||||||
f, err := h.firstFile(ctx, v.Item.ID)
|
f, err := h.firstFile(ctx, v.Item.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -141,7 +142,7 @@ func (h *Handlers) firstFile(ctx context.Context, itemID int64) (catalog.File, e
|
||||||
// loadOverlay fills the browse-only and per-user fields of an already-loaded
|
// 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
|
// item. It reads no file and performs no access check: catalog.LoadFor already
|
||||||
// did both.
|
// did both.
|
||||||
func (h *Handlers) loadOverlay(ctx context.Context, userID, itemID int64, v *itemView) error {
|
func (h *Handlers) loadOverlay(ctx context.Context, userID, itemID int64, v *dto.ItemView) error {
|
||||||
row := h.db.QueryRowContext(ctx,
|
row := h.db.QueryRowContext(ctx,
|
||||||
`SELECT `+overlayColumns+` `+overlayFrom+` WHERE i.id = ?`,
|
`SELECT `+overlayColumns+` `+overlayFrom+` WHERE i.id = ?`,
|
||||||
userID, userID, userID, itemID)
|
userID, userID, userID, itemID)
|
||||||
|
|
@ -158,7 +159,7 @@ func (h *Handlers) loadOverlay(ctx context.Context, userID, itemID int64, v *ite
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v.ParentID = parentID
|
v.ParentID = parentID.Int64
|
||||||
v.Finished = finished == 1
|
v.Finished = finished == 1
|
||||||
v.Favorite = favUser != 0
|
v.Favorite = favUser != 0
|
||||||
v.HasCover = hasCover == 1
|
v.HasCover = hasCover == 1
|
||||||
|
|
@ -180,9 +181,9 @@ func (h *Handlers) itemVisible(ctx context.Context, u auth.User, id int64) (bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanItem reads one row of the shared item column list.
|
// scanItem reads one row of the shared item column list.
|
||||||
func scanItem(row interface{ Scan(...any) error }) (itemView, error) {
|
func scanItem(row interface{ Scan(...any) error }) (dto.ItemView, error) {
|
||||||
var (
|
var (
|
||||||
v itemView
|
v dto.ItemView
|
||||||
parentID sql.NullInt64
|
parentID sql.NullInt64
|
||||||
finished int
|
finished int
|
||||||
favUser int64
|
favUser int64
|
||||||
|
|
@ -196,9 +197,9 @@ func scanItem(row interface{ Scan(...any) error }) (itemView, error) {
|
||||||
&v.PlayCount, &favUser, &hasCover,
|
&v.PlayCount, &favUser, &hasCover,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return itemView{}, err
|
return dto.ItemView{}, err
|
||||||
}
|
}
|
||||||
v.ParentID = parentID
|
v.ParentID = parentID.Int64
|
||||||
v.Finished = finished == 1
|
v.Finished = finished == 1
|
||||||
v.Favorite = favUser != 0
|
v.Favorite = favUser != 0
|
||||||
v.HasCover = hasCover == 1
|
v.HasCover = hasCover == 1
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,10 @@ import (
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
"github.com/mithral/vellum/internal/jellyfin/dto"
|
||||||
"github.com/mithral/vellum/internal/progress"
|
"github.com/mithral/vellum/internal/progress"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePlaying is POST /Sessions/Playing: the client has started (or resumed)
|
// handlePlaying is POST /Sessions/Playing: the client has started (or resumed)
|
||||||
// an item.
|
// an item.
|
||||||
func (h *Handlers) handlePlaying(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handlePlaying(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
|
|
@ -43,7 +34,7 @@ func (h *Handlers) handlePlayingStopped(w http.ResponseWriter, r *http.Request,
|
||||||
// how a Jellyfin client reports resume position, so it is written through the
|
// how a Jellyfin client reports resume position, so it is written through the
|
||||||
// existing progress store rather than kept anywhere new.
|
// existing progress store rather than kept anywhere new.
|
||||||
func (h *Handlers) writePlaybackProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) writePlaybackProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
var rep playReport
|
var rep dto.PlayReport
|
||||||
// Not httpx.DecodeJSON: real clients add fields we do not model, and a
|
// Not httpx.DecodeJSON: real clients add fields we do not model, and a
|
||||||
// rejected body would lose the user's position.
|
// rejected body would lose the user's position.
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&rep); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&rep); err != nil {
|
||||||
|
|
@ -55,7 +46,7 @@ func (h *Handlers) writePlaybackProgress(w http.ResponseWriter, r *http.Request,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, isLib, ok := decodeID(rep.ItemId)
|
id, isLib, ok := dto.DecodeID(rep.ItemId)
|
||||||
if !ok || isLib {
|
if !ok || isLib {
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "invalid ItemId")
|
httpx.WriteError(w, http.StatusBadRequest, "invalid ItemId")
|
||||||
return
|
return
|
||||||
|
|
@ -74,7 +65,7 @@ func (h *Handlers) writePlaybackProgress(w http.ResponseWriter, r *http.Request,
|
||||||
}
|
}
|
||||||
durationMS := f.DurationMS
|
durationMS := f.DurationMS
|
||||||
|
|
||||||
posMS := ticksToMS(rep.PositionTicks)
|
posMS := dto.TicksToMS(rep.PositionTicks)
|
||||||
if posMS < 0 {
|
if posMS < 0 {
|
||||||
posMS = 0
|
posMS = 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,44 +9,23 @@ import (
|
||||||
"github.com/mithral/vellum/internal/apitoken"
|
"github.com/mithral/vellum/internal/apitoken"
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/httpx"
|
"github.com/mithral/vellum/internal/httpx"
|
||||||
|
"github.com/mithral/vellum/internal/jellyfin/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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"
|
|
||||||
|
|
||||||
func (h *Handlers) handleSystemInfoPublic(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) handleSystemInfoPublic(w http.ResponseWriter, r *http.Request) {
|
||||||
httpx.WriteJSON(w, http.StatusOK, systemInfoPublic{
|
httpx.WriteJSON(w, http.StatusOK, dto.SystemInfoPublic{
|
||||||
Id: h.serverIDFor(r.Context()),
|
Id: h.serverIDFor(r.Context()),
|
||||||
ServerName: "Vellum",
|
ServerName: "Vellum",
|
||||||
Version: version,
|
Version: dto.Version,
|
||||||
ProductName: "Vellum",
|
ProductName: "Vellum",
|
||||||
OperatingSystem: "Linux",
|
OperatingSystem: "Linux",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// authByNameRequest is the AuthenticateByName body. Jellyfin spells the
|
|
||||||
// password "Pw".
|
|
||||||
type authByNameRequest struct {
|
|
||||||
Username string `json:"Username"`
|
|
||||||
Pw string `json:"Pw"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handlers) handleAuthenticateByName(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) handleAuthenticateByName(w http.ResponseWriter, r *http.Request) {
|
||||||
// Deliberately not httpx.DecodeJSON: that rejects unknown fields, and real
|
// Deliberately not httpx.DecodeJSON: that rejects unknown fields, and real
|
||||||
// clients add extras. We only read Username and Pw.
|
// clients add extras. We only read Username and Pw.
|
||||||
var req authByNameRequest
|
var req dto.AuthByNameRequest
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "invalid request body")
|
httpx.WriteError(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
|
|
@ -99,7 +78,7 @@ func (h *Handlers) handleUserMe(w http.ResponseWriter, r *http.Request, u auth.U
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) handleUserByID(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleUserByID(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
id, _, ok := decodeID(r.PathValue("userId"))
|
id, _, ok := dto.DecodeID(r.PathValue("userId"))
|
||||||
if !ok || id != u.ID {
|
if !ok || id != u.ID {
|
||||||
// A token may only address its own user. Cross-user reads (which
|
// A token may only address its own user. Cross-user reads (which
|
||||||
// Jellyfin permits for admins) are NOT implemented; a mismatched id is
|
// Jellyfin permits for admins) are NOT implemented; a mismatched id is
|
||||||
|
|
@ -117,39 +96,12 @@ func (h *Handlers) handleUserByID(w http.ResponseWriter, r *http.Request, u auth
|
||||||
|
|
||||||
// userDTO renders the Jellyfin user shape, including the visible library
|
// userDTO renders the Jellyfin user shape, including the visible library
|
||||||
// folders so a client can scope its views.
|
// folders so a client can scope its views.
|
||||||
func (h *Handlers) userDTO(ctx context.Context, u auth.User) (UserDto, error) {
|
func (h *Handlers) userDTO(ctx context.Context, u auth.User) (dto.UserDto, error) {
|
||||||
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
|
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return UserDto{}, err
|
return dto.UserDto{}, err
|
||||||
}
|
}
|
||||||
folders := make([]string, 0, len(libs))
|
return dto.User(u, h.serverIDFor(ctx), libs), nil
|
||||||
for _, id := range libs {
|
|
||||||
folders = append(folders, libraryID(id))
|
|
||||||
}
|
|
||||||
return UserDto{
|
|
||||||
Name: displayName(u),
|
|
||||||
ServerId: h.serverIDFor(ctx),
|
|
||||||
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{},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deviceLabel best-effort extracts a human device name from the
|
// deviceLabel best-effort extracts a human device name from the
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,12 @@ func PurgeMissing(ctx context.Context, db *sql.DB, libraryID int64) (int64, erro
|
||||||
return n, nil
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -109,194 +109,3 @@ func TestListVisibleNeverMeansAll(t *testing.T) {
|
||||||
t.Errorf("ListVisible(nil) returned %d libraries; empty set must mean none", len(libs))
|
t.Errorf("ListVisible(nil) returned %d libraries; empty set must mean none", len(libs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 := 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 := 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, _ := 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 := 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 := 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 := 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 := 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 := PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 {
|
|
||||||
t.Errorf("second purge = %d, %v; want 0, nil", n, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
// Package library owns library definitions and the scanner that turns files on
|
// Package library owns library definitions — create, list, delete — and the
|
||||||
// disk into rows in media_items / media_files.
|
// media-kind registry that says which file extensions each kind of library owns.
|
||||||
//
|
//
|
||||||
// Modules declare what file extensions they care about via RegisterType; this
|
// The registry lives here, not in internal/scanner, because it is not the
|
||||||
// package never imports a module.
|
// scanner's business: modules register their types, the admin HTTP surface
|
||||||
|
// serves them, and the metadata enricher reads the grouping mode. Only one of
|
||||||
|
// those three is the scanner, so the registry belongs in the lower layer that
|
||||||
|
// all three already depend on. The scanner imports it from here; nothing here
|
||||||
|
// imports the scanner.
|
||||||
package library
|
package library
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -134,8 +138,11 @@ func GroupModeFor(kind string) GroupMode {
|
||||||
return GroupByFile
|
return GroupByFile
|
||||||
}
|
}
|
||||||
|
|
||||||
// extensionsFor returns the extension set owned by a library kind.
|
// ExtensionsFor returns the extension set owned by a library kind, each entry
|
||||||
func extensionsFor(kind string) map[string]bool {
|
// lowercase with a leading dot. The scanner walks a tree by testing every file
|
||||||
|
// name against this set, which is why it lives in the registry rather than in
|
||||||
|
// the scanner: the answer is a property of the kind, not of the scan.
|
||||||
|
func ExtensionsFor(kind string) map[string]bool {
|
||||||
typesMu.RLock()
|
typesMu.RLock()
|
||||||
defer typesMu.RUnlock()
|
defer typesMu.RUnlock()
|
||||||
t, ok := types[kind]
|
t, ok := types[kind]
|
||||||
|
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
package library
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestScanLifecycle(t *testing.T) {
|
|
||||||
db := testDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
root := t.TempDir()
|
|
||||||
|
|
||||||
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
|
|
||||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
|
||||||
writeFile(t, filepath.Join(root, "sub", "c.epub"), "cccccccccccccccccccccccccccccc")
|
|
||||||
writeFile(t, filepath.Join(root, "notes.txt"), "not media")
|
|
||||||
writeFile(t, filepath.Join(root, ".hidden.epub"), "hidden")
|
|
||||||
writeFile(t, filepath.Join(root, ".git", "d.epub"), "in a hidden dir")
|
|
||||||
|
|
||||||
lib, err := Create(ctx, db, "Ebooks", "ebooks", root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Create: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First scan: three supported files, nothing else.
|
|
||||||
res, err := Scan(ctx, db, lib, testLogger())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Scan: %v", err)
|
|
||||||
}
|
|
||||||
if res.Added != 3 || res.Scanned != 3 {
|
|
||||||
t.Fatalf("first scan added=%d scanned=%d, want 3/3 (%+v)", res.Added, res.Scanned, res)
|
|
||||||
}
|
|
||||||
if n, _ := CountItems(ctx, db, lib.ID); n != 3 {
|
|
||||||
t.Errorf("item count = %d, want 3", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second scan: nothing changed.
|
|
||||||
res, err = Scan(ctx, db, lib, testLogger())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if res.Unchanged != 3 || res.Added != 0 || res.Updated != 0 {
|
|
||||||
t.Errorf("second scan unchanged=%d added=%d updated=%d, want 3/0/0", res.Unchanged, res.Added, res.Updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modify a.epub (different size).
|
|
||||||
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
|
||||||
res, err = Scan(ctx, db, lib, testLogger())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if res.Updated != 1 || res.Unchanged != 2 {
|
|
||||||
t.Errorf("after modify updated=%d unchanged=%d, want 1/2", res.Updated, res.Unchanged)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete b.epub: it goes missing, it is not deleted.
|
|
||||||
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
res, err = Scan(ctx, db, lib, testLogger())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if res.Missing != 1 {
|
|
||||||
t.Errorf("after delete missing=%d, want 1", res.Missing)
|
|
||||||
}
|
|
||||||
if n, _ := CountItems(ctx, db, lib.ID); n != 2 {
|
|
||||||
t.Errorf("item count (excluding missing) = %d, want 2", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The row still exists, flagged missing.
|
|
||||||
var missing int
|
|
||||||
if err := db.QueryRowContext(ctx,
|
|
||||||
`SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "b.epub").Scan(&missing); err != nil {
|
|
||||||
t.Fatalf("missing item row was deleted: %v", err)
|
|
||||||
}
|
|
||||||
if missing != 1 {
|
|
||||||
t.Errorf("missing flag = %d, want 1", missing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore b.epub.
|
|
||||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
|
||||||
res, err = Scan(ctx, db, lib, testLogger())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if res.Restored != 1 {
|
|
||||||
t.Errorf("after restore restored=%d, want 1 (%+v)", res.Restored, res)
|
|
||||||
}
|
|
||||||
if n, _ := CountItems(ctx, db, lib.ID); n != 3 {
|
|
||||||
t.Errorf("item count after restore = %d, want 3", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanGroupsDirectoryKinds(t *testing.T) {
|
|
||||||
db := testDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
root := t.TempDir()
|
|
||||||
|
|
||||||
// An audiobook is a folder of chapters.
|
|
||||||
writeFile(t, filepath.Join(root, "Author", "Book", "01 - Chapter One.mp3"), "a")
|
|
||||||
writeFile(t, filepath.Join(root, "Author", "Book", "02 - Chapter Two.mp3"), "b")
|
|
||||||
writeFile(t, filepath.Join(root, "Author", "Book", "10 - Chapter Ten.mp3"), "c")
|
|
||||||
// A loose file at the root groups under the library name.
|
|
||||||
writeFile(t, filepath.Join(root, "stray.mp3"), "d")
|
|
||||||
|
|
||||||
lib, err := Create(ctx, db, "Audiobooks", "audiobooks", root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Create: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := Scan(ctx, db, lib, testLogger())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Scan: %v", err)
|
|
||||||
}
|
|
||||||
if res.Added != 4 {
|
|
||||||
t.Errorf("added = %d, want 4 (four tracks)", res.Added)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two items: the directory, and the root group.
|
|
||||||
var items int
|
|
||||||
if err := db.QueryRowContext(ctx,
|
|
||||||
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if items != 2 {
|
|
||||||
t.Fatalf("items = %d, want 2", items)
|
|
||||||
}
|
|
||||||
|
|
||||||
var title string
|
|
||||||
if err := db.QueryRowContext(ctx,
|
|
||||||
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Author/Book").Scan(&title); err != nil {
|
|
||||||
t.Fatalf("grouped item not found: %v", err)
|
|
||||||
}
|
|
||||||
if title != "Book" {
|
|
||||||
t.Errorf("grouped title = %q, want Book", title)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracks must come back in natural order, not lexical.
|
|
||||||
rows, err := db.QueryContext(ctx, `
|
|
||||||
SELECT f.path FROM media_files f JOIN media_items i ON i.id = f.item_id
|
|
||||||
WHERE i.path = ? ORDER BY f.id`, "Author/Book")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var paths []string
|
|
||||||
for rows.Next() {
|
|
||||||
var p string
|
|
||||||
if err := rows.Scan(&p); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
paths = append(paths, p)
|
|
||||||
}
|
|
||||||
want := []string{
|
|
||||||
"Author/Book/01 - Chapter One.mp3",
|
|
||||||
"Author/Book/02 - Chapter Two.mp3",
|
|
||||||
"Author/Book/10 - Chapter Ten.mp3",
|
|
||||||
}
|
|
||||||
if len(paths) != len(want) {
|
|
||||||
t.Fatalf("track count = %d, want %d: %v", len(paths), len(want), paths)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if paths[i] != want[i] {
|
|
||||||
t.Errorf("track %d = %q, want %q", i, paths[i], want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanUnreadableRoot(t *testing.T) {
|
|
||||||
db := testDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
root := t.TempDir()
|
|
||||||
writeFile(t, filepath.Join(root, "a.epub"), "x")
|
|
||||||
|
|
||||||
lib, err := Create(ctx, db, "Ebooks", "ebooks", root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Index it once so there is a known file to go missing.
|
|
||||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the root out from under the library.
|
|
||||||
if err := os.RemoveAll(root); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := Scan(ctx, db, lib, testLogger())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("scan should not fail on a missing root: %v", err)
|
|
||||||
}
|
|
||||||
if res.Added != 0 {
|
|
||||||
t.Errorf("added=%d, want 0", res.Added)
|
|
||||||
}
|
|
||||||
if res.Missing != 1 {
|
|
||||||
t.Errorf("missing=%d, want 1 (existing item now vanished)", res.Missing)
|
|
||||||
}
|
|
||||||
if len(res.Errors) == 0 {
|
|
||||||
t.Error("expected the walk error to be recorded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
193
internal/media/fixtures_test.go
Normal file
193
internal/media/fixtures_test.go
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/core"
|
||||||
|
"github.com/mithral/vellum/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These fixtures are duplicated from internal/home. Go test helpers cannot be
|
||||||
|
// shared across packages without being exported from non-test code, and the home
|
||||||
|
// screen's version is a different shape: it seeds progress and views so the
|
||||||
|
// shelves have something to aggregate.
|
||||||
|
|
||||||
|
// ── shared helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
|
||||||
|
t.Helper()
|
||||||
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
return NewHandlers(db, log, nil, t.TempDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("query %q: %v", q, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
|
||||||
|
t.Fatalf("exec %q: %v", q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
return mustQueryID(t, db,
|
||||||
|
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`, name, kind, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUser(t *testing.T, db *sql.DB, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
return mustQueryID(t, db,
|
||||||
|
`INSERT INTO users (username, password_hash) VALUES (?, 'x') RETURNING id`, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantRead(t *testing.T, db *sql.DB, userID, libID int64) {
|
||||||
|
t.Helper()
|
||||||
|
mustExec(t, db,
|
||||||
|
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`,
|
||||||
|
userID, libID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newItem inserts a media item plus a backing file, because progress rows carry
|
||||||
|
// a NOT NULL file_id foreign key.
|
||||||
|
func newItem(t *testing.T, db *sql.DB, libID int64, kind, path, title, sortTitle string, missing bool, addedAt string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
m := 0
|
||||||
|
if missing {
|
||||||
|
m = 1
|
||||||
|
}
|
||||||
|
id := mustQueryID(t, db, `
|
||||||
|
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, added_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
|
libID, kind, path, title, sortTitle, m, addedAt)
|
||||||
|
mustExec(t, db,
|
||||||
|
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`,
|
||||||
|
id, "file-"+strconv.FormatInt(id, 10))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileIDFor(t *testing.T, db *sql.DB, itemID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
if err := db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("file for item %d: %v", itemID, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProgress(t *testing.T, db *sql.DB, userID, itemID int64, percent float64, finished bool, updatedAt string) {
|
||||||
|
t.Helper()
|
||||||
|
f := 0
|
||||||
|
if finished {
|
||||||
|
f = 1
|
||||||
|
}
|
||||||
|
mustExec(t, db, `
|
||||||
|
INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'ebooks', 1, ?, ?, ?)`,
|
||||||
|
userID, itemID, fileIDFor(t, db, itemID), percent, f, updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newView(t *testing.T, db *sql.DB, userID, itemID int64, views int, lastViewedAt string) {
|
||||||
|
t.Helper()
|
||||||
|
mustExec(t, db,
|
||||||
|
`INSERT INTO item_views (user_id, item_id, views, last_viewed_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
userID, itemID, views, lastViewedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupHome builds the two-library, three-user fixture the item-detail tests
|
||||||
|
// use: one item the caller may read, one in a library they may not, and one user
|
||||||
|
// with no grants at all.
|
||||||
|
func setupHome(t *testing.T) homeFixture {
|
||||||
|
t.Helper()
|
||||||
|
db := testDB(t)
|
||||||
|
f := homeFixture{db: db}
|
||||||
|
|
||||||
|
f.libA = newLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks")
|
||||||
|
f.libB = newLibrary(t, db, "Audio", "audiobooks", "/media/audio")
|
||||||
|
|
||||||
|
f.userA = newUser(t, db, "alice")
|
||||||
|
f.userB = newUser(t, db, "bob")
|
||||||
|
f.userNone = newUser(t, db, "carol")
|
||||||
|
|
||||||
|
grantRead(t, db, f.userA, f.libA)
|
||||||
|
grantRead(t, db, f.userB, f.libB)
|
||||||
|
|
||||||
|
f.a1 = newItem(t, db, f.libA, "ebooks", "a1.epub", "A One", "a one", false, "2026-01-01T00:00:01.000Z")
|
||||||
|
f.a2 = newItem(t, db, f.libA, "ebooks", "a2.epub", "A Two", "a two", false, "2026-01-01T00:00:02.000Z")
|
||||||
|
f.a3 = newItem(t, db, f.libA, "comics", "a3.cbz", "A Three", "a three", false, "2026-01-01T00:00:03.000Z")
|
||||||
|
f.a4 = newItem(t, db, f.libA, "ebooks", "a4.epub", "A Four", "a four", true, "2026-01-01T00:00:04.000Z")
|
||||||
|
f.a5 = newItem(t, db, f.libA, "ebooks", "a5.epub", "A Five", "a five", false, "2026-01-01T00:00:05.000Z")
|
||||||
|
f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "B One", "b one", false, "2026-01-01T00:00:06.000Z")
|
||||||
|
|
||||||
|
newProgress(t, db, f.userA, f.a1, 0.5, false, "2026-02-01T00:00:03.000Z")
|
||||||
|
newProgress(t, db, f.userA, f.a2, 1.0, true, "2026-02-01T00:00:02.000Z")
|
||||||
|
newProgress(t, db, f.userA, f.a4, 0.3, false, "2026-02-01T00:00:04.000Z")
|
||||||
|
newProgress(t, db, f.userB, f.b1, 0.2, false, "2026-02-02T00:00:01.000Z")
|
||||||
|
|
||||||
|
newView(t, db, f.userA, f.a1, 3, "2026-02-03T00:00:01.000Z")
|
||||||
|
newView(t, db, f.userA, f.a3, 1, "2026-02-03T00:00:02.000Z")
|
||||||
|
newView(t, db, f.userB, f.b1, 9, "2026-02-03T00:00:03.000Z")
|
||||||
|
|
||||||
|
ada := mustQueryID(t, db, `INSERT INTO people (name) VALUES ('Ada Author') RETURNING id`)
|
||||||
|
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a1, ada)
|
||||||
|
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a3, ada)
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int64) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b [20]byte
|
||||||
|
i := len(b)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
b[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
type homeFixture struct {
|
||||||
|
db *sql.DB
|
||||||
|
libA int64
|
||||||
|
libB int64
|
||||||
|
userA int64
|
||||||
|
userB int64
|
||||||
|
userNone int64
|
||||||
|
a1 int64
|
||||||
|
a2 int64
|
||||||
|
a3 int64
|
||||||
|
a4 int64
|
||||||
|
a5 int64
|
||||||
|
b1 int64
|
||||||
|
}
|
||||||
|
|
@ -8,9 +8,11 @@ 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/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"
|
||||||
|
|
@ -31,8 +33,10 @@ 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))
|
||||||
|
|
@ -271,8 +275,54 @@ 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.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
// Package media serves generic item endpoints: detail, the raw file, cover
|
// Package media serves generic item RESOURCES: the browse list, one item's
|
||||||
// art, and progress. It knows nothing about any specific media format; modules
|
// detail, its raw file, cover art, progress, credits and view counts. It knows
|
||||||
// register cover extractors and expose their own metadata endpoints.
|
// nothing about any specific media format; modules register cover extractors
|
||||||
|
// and expose their own metadata endpoints.
|
||||||
|
//
|
||||||
|
// What the landing screen shows is deliberately NOT here. The home shelves are
|
||||||
|
// AGGREGATES over many items rather than one resource, they answer a different
|
||||||
|
// question, and they have different callers, so they live in internal/home.
|
||||||
//
|
//
|
||||||
// Item and file ACCESS does not live here any more — it lives in
|
// 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
|
// internal/catalog, so that the web app, the modules, Jellyfin and Subsonic all
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
// Package metadata enriches catalogue items from external providers.
|
||||||
|
//
|
||||||
|
// It owns the policy: which provider to try, whether a fuzzy candidate is
|
||||||
|
// actually the item being asked about, and what gets written back. The HTTP
|
||||||
|
// clients live in internal/providers, which knows nothing about the database.
|
||||||
package metadata
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -19,6 +24,7 @@ import (
|
||||||
"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.
|
||||||
|
|
@ -41,7 +47,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) []Provider
|
providers func(kind string) []providers.Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEnricher builds the enricher.
|
// NewEnricher builds the enricher.
|
||||||
|
|
@ -51,7 +57,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: For,
|
providers: providers.For,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,13 +106,13 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
providers := e.providers(it.Kind)
|
provs := e.providers(it.Kind)
|
||||||
if len(providers) == 0 {
|
if len(provs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
q := Query{Title: searchTitle(it.Title), Author: artistHint(it), Kind: it.Kind}
|
q := providers.Query{Title: searchTitle(it.Title), Author: artistHint(it), Kind: it.Kind}
|
||||||
for _, p := range providers {
|
for _, p := range provs {
|
||||||
if e.triedRecently(ctx, itemID, p.Name()) {
|
if e.triedRecently(ctx, itemID, p.Name()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -201,10 +207,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 []Candidate, want string) (Candidate, bool) {
|
func pickBest(candidates []providers.Candidate, want string) (providers.Candidate, bool) {
|
||||||
w := normalizeTitle(want)
|
w := normalizeTitle(want)
|
||||||
if len(w) < 3 {
|
if len(w) < 3 {
|
||||||
return Candidate{}, false
|
return providers.Candidate{}, false
|
||||||
}
|
}
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
got := normalizeTitle(c.Title)
|
got := normalizeTitle(c.Title)
|
||||||
|
|
@ -215,7 +221,7 @@ func pickBest(candidates []Candidate, want string) (Candidate, bool) {
|
||||||
return c, true
|
return c, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Candidate{}, false
|
return providers.Candidate{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeTitle lowercases and strips punctuation so titles can be compared
|
// normalizeTitle lowercases and strips punctuation so titles can be compared
|
||||||
|
|
@ -243,7 +249,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 Candidate) error {
|
func (e *Enricher) apply(ctx context.Context, it catalog.Item, c providers.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
|
||||||
|
|
@ -307,7 +313,7 @@ func (e *Enricher) apply(ctx context.Context, it catalog.Item, c Candidate) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 Candidate) error {
|
func (e *Enricher) downloadCover(ctx context.Context, it catalog.Item, c providers.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,6 +11,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -56,22 +57,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 []Candidate
|
results []providers.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, Query) ([]Candidate, error) {
|
func (f *fakeProvider) Search(context.Context, providers.Query) ([]providers.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 ...Provider) *Enricher {
|
func enricherWith(t *testing.T, db *sql.DB, provs ...providers.Provider) *Enricher {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
e := NewEnricher(db, discard(), t.TempDir())
|
e := NewEnricher(db, discard(), t.TempDir())
|
||||||
e.providers = func(string) []Provider { return provs }
|
e.providers = func(string) []providers.Provider { return provs }
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +83,7 @@ func TestEnrichAppliesCandidate(t *testing.T) {
|
||||||
fake := &fakeProvider{
|
fake := &fakeProvider{
|
||||||
name: "fake",
|
name: "fake",
|
||||||
kinds: []string{"ebooks"},
|
kinds: []string{"ebooks"},
|
||||||
results: []Candidate{{
|
results: []providers.Candidate{{
|
||||||
Provider: "fake",
|
Provider: "fake",
|
||||||
ExternalID: "OL123W",
|
ExternalID: "OL123W",
|
||||||
Title: "The Hobbit",
|
Title: "The Hobbit",
|
||||||
|
|
@ -148,7 +149,7 @@ func TestEnrichFallsBackToSecondProvider(t *testing.T) {
|
||||||
good := &fakeProvider{
|
good := &fakeProvider{
|
||||||
name: "good",
|
name: "good",
|
||||||
kinds: []string{"ebooks"},
|
kinds: []string{"ebooks"},
|
||||||
results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
|
results: []providers.Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
|
||||||
}
|
}
|
||||||
e := enricherWith(t, db, empty, good)
|
e := enricherWith(t, db, empty, good)
|
||||||
|
|
||||||
|
|
@ -177,7 +178,7 @@ func TestEnrichContinuesAfterProviderError(t *testing.T) {
|
||||||
good := &fakeProvider{
|
good := &fakeProvider{
|
||||||
name: "good",
|
name: "good",
|
||||||
kinds: []string{"ebooks"},
|
kinds: []string{"ebooks"},
|
||||||
results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
|
results: []providers.Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}},
|
||||||
}
|
}
|
||||||
e := enricherWith(t, db, broken, good)
|
e := enricherWith(t, db, broken, good)
|
||||||
|
|
||||||
|
|
@ -203,7 +204,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: []Candidate{{Provider: "fake", ExternalID: "x", Title: "Book"}}}
|
results: []providers.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 {
|
||||||
|
|
@ -277,7 +278,7 @@ func TestSearchTitleStripsReleaseTags(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPickBestRejectsUnrelated(t *testing.T) {
|
func TestPickBestRejectsUnrelated(t *testing.T) {
|
||||||
candidates := []Candidate{
|
candidates := []providers.Candidate{
|
||||||
{Title: "Random Access Memories", ExternalID: "right"},
|
{Title: "Random Access Memories", ExternalID: "right"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,7 +288,7 @@ func TestPickBestRejectsUnrelated(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Containment in either direction is accepted.
|
// Containment in either direction is accepted.
|
||||||
long := []Candidate{{Title: "The Hobbit, or There and Back Again"}}
|
long := []providers.Candidate{{Title: "The Hobbit, or There and Back Again"}}
|
||||||
if _, ok := pickBest(long, "The Hobbit"); !ok {
|
if _, ok := pickBest(long, "The Hobbit"); !ok {
|
||||||
t.Error("containment should be accepted")
|
t.Error("containment should be accepted")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package metadata
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package metadata
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package metadata
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package metadata
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package metadata
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package metadata
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package metadata
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -13,12 +13,17 @@ 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{Client: &http.Client{Timeout: 10 * time.Second}}
|
return &OpenLibrary{
|
||||||
|
BaseURL: "https://openlibrary.org",
|
||||||
|
Client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *OpenLibrary) Name() string { return "openlibrary" }
|
func (p *OpenLibrary) Name() string { return "openlibrary" }
|
||||||
|
|
@ -57,7 +62,7 @@ func (p *OpenLibrary) Search(ctx context.Context, q Query) ([]Candidate, error)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := "https://openlibrary.org/search.json?" + params.Encode()
|
endpoint := strings.TrimRight(p.BaseURL, "/") + "/search.json?" + params.Encode()
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
100
internal/providers/openlibrary_test.go
Normal file
100
internal/providers/openlibrary_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOpenLibrarySearchDBFree is the point of the split: a provider is an HTTP
|
||||||
|
// client, so it is exercisable end to end against a stub server with no
|
||||||
|
// database anywhere in sight.
|
||||||
|
func TestOpenLibrarySearchDBFree(t *testing.T) {
|
||||||
|
var gotTitle, gotAuthor, gotLimit, gotUA string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/search.json" {
|
||||||
|
t.Errorf("path = %q, want /search.json", r.URL.Path)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
gotTitle, gotAuthor, gotLimit = q.Get("title"), q.Get("author"), q.Get("limit")
|
||||||
|
gotUA = r.Header.Get("User-Agent")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"docs":[
|
||||||
|
{"key":"/works/OL45804W","title":"The Hobbit","author_name":["J.R.R. Tolkien"],
|
||||||
|
"first_publish_year":1937,"publisher":["Allen & Unwin"],"language":["eng"],
|
||||||
|
"cover_i":8225261,"isbn":["9780261102217"]},
|
||||||
|
{"key":"/works/OL1W","title":"","author_name":["Nobody"]}
|
||||||
|
]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := NewOpenLibrary()
|
||||||
|
p.BaseURL = srv.URL
|
||||||
|
|
||||||
|
cands, err := p.Search(context.Background(), Query{Title: "The Hobbit", Author: "Tolkien"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
// The untitled doc is dropped rather than surfaced as an empty candidate.
|
||||||
|
if len(cands) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(cands))
|
||||||
|
}
|
||||||
|
|
||||||
|
c := cands[0]
|
||||||
|
if c.Provider != "openlibrary" || c.ExternalID != "/works/OL45804W" {
|
||||||
|
t.Errorf("provider id = %s/%s", c.Provider, c.ExternalID)
|
||||||
|
}
|
||||||
|
if c.Title != "The Hobbit" || c.Year != 1937 {
|
||||||
|
t.Errorf("title/year = %q/%d", c.Title, c.Year)
|
||||||
|
}
|
||||||
|
if len(c.Authors) != 1 || c.Authors[0] != "J.R.R. Tolkien" {
|
||||||
|
t.Errorf("Authors = %v", c.Authors)
|
||||||
|
}
|
||||||
|
if c.Publisher != "Allen & Unwin" || c.Language != "eng" {
|
||||||
|
t.Errorf("publisher/language = %q/%q", c.Publisher, c.Language)
|
||||||
|
}
|
||||||
|
if c.ISBN != "9780261102217" {
|
||||||
|
t.Errorf("ISBN = %q", c.ISBN)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.CoverURL, "covers.openlibrary.org/b/id/8225261") {
|
||||||
|
t.Errorf("CoverURL = %q", c.CoverURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotTitle != "The Hobbit" || gotAuthor != "Tolkien" {
|
||||||
|
t.Errorf("query title/author = %q/%q", gotTitle, gotAuthor)
|
||||||
|
}
|
||||||
|
if gotLimit != "5" {
|
||||||
|
t.Errorf("limit = %q, want 5", gotLimit)
|
||||||
|
}
|
||||||
|
if gotUA == "" {
|
||||||
|
t.Error("no User-Agent sent; Open Library asks callers to identify themselves")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenLibrarySkipsRequestWithoutATerm(t *testing.T) {
|
||||||
|
p := NewOpenLibrary()
|
||||||
|
cands, err := p.Search(context.Background(), Query{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if len(cands) != 0 {
|
||||||
|
t.Errorf("got %d candidates for an empty query, want 0", len(cands))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenLibraryReportsHTTPFailure(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := NewOpenLibrary()
|
||||||
|
p.BaseURL = srv.URL
|
||||||
|
|
||||||
|
if _, err := p.Search(context.Background(), Query{Title: "The Hobbit"}); err == nil {
|
||||||
|
t.Fatal("expected an error for a 429 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
// Package metadata resolves media items against external catalogues.
|
// Package providers talks to external metadata catalogues.
|
||||||
|
//
|
||||||
|
// One client per service, no database, no policy: a provider turns a Query into
|
||||||
|
// candidates and knows nothing about what the caller does with them. Matching
|
||||||
|
// policy and persistence belong to the caller (internal/metadata), which is
|
||||||
|
// what keeps these clients testable against an httptest server alone.
|
||||||
//
|
//
|
||||||
// Providers are optional by design: a library with no network still browses,
|
// 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 metadata
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
package library
|
// Package scanner walks a library's root and reconciles it with the database:
|
||||||
|
// files that appeared become items, files that changed are re-upserted with
|
||||||
|
// fresh codec facts, and files that vanished are flagged missing rather than
|
||||||
|
// deleted.
|
||||||
|
//
|
||||||
|
// It is separate from internal/library because the two have different callers.
|
||||||
|
// A scan runs in a background job with no user and no HTTP request, tolerates a
|
||||||
|
// partially readable tree, and must never delete; library CRUD is an admin
|
||||||
|
// surface where a bad request is a 400 and a bad path is a 404. Scanner imports
|
||||||
|
// library (for Library and the media-kind registry), never the other way round.
|
||||||
|
package scanner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -6,7 +16,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
@ -16,6 +25,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"
|
"github.com/mithral/vellum/internal/probe"
|
||||||
)
|
)
|
||||||
|
|
@ -87,11 +97,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, log *slog.Logger) (ScanResult, error) {
|
func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger) (ScanResult, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
res := ScanResult{LibraryID: lib.ID}
|
res := ScanResult{LibraryID: lib.ID}
|
||||||
|
|
||||||
exts := extensionsFor(lib.Kind)
|
exts := library.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)
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +261,7 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR
|
||||||
|
|
||||||
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", GroupModeFor(lib.Kind),
|
"library", lib.Name, "kind", lib.Kind, "grouping", library.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),
|
||||||
|
|
@ -280,23 +290,23 @@ func commitBatch(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) err
|
||||||
// buildGroups turns found files into items. Directory-grouped kinds (audiobooks,
|
// 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, found map[string]candidate) map[string]group {
|
func buildGroups(lib library.Library, found map[string]candidate) map[string]group {
|
||||||
mode := GroupModeFor(lib.Kind)
|
mode := library.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 == GroupByDirectory {
|
if mode == library.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, CleanTitle(path.Base(dir))
|
key, title = dir, library.CleanTitle(path.Base(dir))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
key, title = rel, CleanTitle(rel)
|
key, title = rel, library.CleanTitle(rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
g := groups[key]
|
g := groups[key]
|
||||||
|
|
@ -428,7 +438,7 @@ 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, key, title string) (int64, error) {
|
func upsertItem(ctx context.Context, tx *sql.Tx, lib library.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, missing_since)
|
||||||
|
|
@ -498,7 +508,7 @@ func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info
|
||||||
// never held across an ffprobe. A probe failure is recorded through the bounded
|
// 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
|
// error mechanism and the file is written with empty codec fields rather than
|
||||||
// failing the scan.
|
// failing the scan.
|
||||||
func probeBatch(ctx context.Context, lib Library, groups map[string]group, batch []string, known map[string]knownFile, res *ScanResult) map[string]probe.Info {
|
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() {
|
if !probeable(lib.Kind) || !probe.Available() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -590,9 +600,3 @@ 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()
|
|
||||||
}
|
|
||||||
430
internal/scanner/scan_test.go
Normal file
430
internal/scanner/scan_test.go
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/core"
|
||||||
|
"github.com/mithral/vellum/internal/library"
|
||||||
|
"github.com/mithral/vellum/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "scan.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanLifecycle(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
|
||||||
|
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||||
|
writeFile(t, filepath.Join(root, "sub", "c.epub"), "cccccccccccccccccccccccccccccc")
|
||||||
|
writeFile(t, filepath.Join(root, "notes.txt"), "not media")
|
||||||
|
writeFile(t, filepath.Join(root, ".hidden.epub"), "hidden")
|
||||||
|
writeFile(t, filepath.Join(root, ".git", "d.epub"), "in a hidden dir")
|
||||||
|
|
||||||
|
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First scan: three supported files, nothing else.
|
||||||
|
res, err := Scan(ctx, db, lib, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if res.Added != 3 || res.Scanned != 3 {
|
||||||
|
t.Fatalf("first scan added=%d scanned=%d, want 3/3 (%+v)", res.Added, res.Scanned, res)
|
||||||
|
}
|
||||||
|
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
|
||||||
|
t.Errorf("item count = %d, want 3", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second scan: nothing changed.
|
||||||
|
res, err = Scan(ctx, db, lib, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Unchanged != 3 || res.Added != 0 || res.Updated != 0 {
|
||||||
|
t.Errorf("second scan unchanged=%d added=%d updated=%d, want 3/0/0", res.Unchanged, res.Added, res.Updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify a.epub (different size).
|
||||||
|
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||||
|
res, err = Scan(ctx, db, lib, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Updated != 1 || res.Unchanged != 2 {
|
||||||
|
t.Errorf("after modify updated=%d unchanged=%d, want 1/2", res.Updated, res.Unchanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete b.epub: it goes missing, it is not deleted.
|
||||||
|
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err = Scan(ctx, db, lib, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Missing != 1 {
|
||||||
|
t.Errorf("after delete missing=%d, want 1", res.Missing)
|
||||||
|
}
|
||||||
|
if n, _ := library.CountItems(ctx, db, lib.ID); n != 2 {
|
||||||
|
t.Errorf("item count (excluding missing) = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row still exists, flagged missing.
|
||||||
|
var missing int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "b.epub").Scan(&missing); err != nil {
|
||||||
|
t.Fatalf("missing item row was deleted: %v", err)
|
||||||
|
}
|
||||||
|
if missing != 1 {
|
||||||
|
t.Errorf("missing flag = %d, want 1", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore b.epub.
|
||||||
|
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||||
|
res, err = Scan(ctx, db, lib, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Restored != 1 {
|
||||||
|
t.Errorf("after restore restored=%d, want 1 (%+v)", res.Restored, res)
|
||||||
|
}
|
||||||
|
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
|
||||||
|
t.Errorf("item count after restore = %d, want 3", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanGroupsDirectoryKinds(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
// An audiobook is a folder of chapters.
|
||||||
|
writeFile(t, filepath.Join(root, "Author", "Book", "01 - Chapter One.mp3"), "a")
|
||||||
|
writeFile(t, filepath.Join(root, "Author", "Book", "02 - Chapter Two.mp3"), "b")
|
||||||
|
writeFile(t, filepath.Join(root, "Author", "Book", "10 - Chapter Ten.mp3"), "c")
|
||||||
|
// A loose file at the root groups under the library name.
|
||||||
|
writeFile(t, filepath.Join(root, "stray.mp3"), "d")
|
||||||
|
|
||||||
|
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := Scan(ctx, db, lib, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if res.Added != 4 {
|
||||||
|
t.Errorf("added = %d, want 4 (four tracks)", res.Added)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two items: the directory, and the root group.
|
||||||
|
var items int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if items != 2 {
|
||||||
|
t.Fatalf("items = %d, want 2", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
var title string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Author/Book").Scan(&title); err != nil {
|
||||||
|
t.Fatalf("grouped item not found: %v", err)
|
||||||
|
}
|
||||||
|
if title != "Book" {
|
||||||
|
t.Errorf("grouped title = %q, want Book", title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracks must come back in natural order, not lexical.
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT f.path FROM media_files f JOIN media_items i ON i.id = f.item_id
|
||||||
|
WHERE i.path = ? ORDER BY f.id`, "Author/Book")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var paths []string
|
||||||
|
for rows.Next() {
|
||||||
|
var p string
|
||||||
|
if err := rows.Scan(&p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paths = append(paths, p)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"Author/Book/01 - Chapter One.mp3",
|
||||||
|
"Author/Book/02 - Chapter Two.mp3",
|
||||||
|
"Author/Book/10 - Chapter Ten.mp3",
|
||||||
|
}
|
||||||
|
if len(paths) != len(want) {
|
||||||
|
t.Fatalf("track count = %d, want %d: %v", len(paths), len(want), paths)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if paths[i] != want[i] {
|
||||||
|
t.Errorf("track %d = %q, want %q", i, paths[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanUnreadableRoot(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "a.epub"), "x")
|
||||||
|
|
||||||
|
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Index it once so there is a known file to go missing.
|
||||||
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the root out from under the library.
|
||||||
|
if err := os.RemoveAll(root); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := Scan(ctx, db, lib, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan should not fail on a missing root: %v", err)
|
||||||
|
}
|
||||||
|
if res.Added != 0 {
|
||||||
|
t.Errorf("added=%d, want 0", res.Added)
|
||||||
|
}
|
||||||
|
if res.Missing != 1 {
|
||||||
|
t.Errorf("missing=%d, want 1 (existing item now vanished)", res.Missing)
|
||||||
|
}
|
||||||
|
if len(res.Errors) == 0 {
|
||||||
|
t.Error("expected the walk error to be recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestMissingSinceLifecycle(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
|
||||||
|
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||||
|
|
||||||
|
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||||
|
t.Fatalf("first Scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A present file has no missing timestamp.
|
||||||
|
var since string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if since != "" {
|
||||||
|
t.Errorf("present file missing_since = %q, want empty", since)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete b.epub: it goes missing and the timestamp is recorded.
|
||||||
|
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||||
|
t.Fatalf("missing Scan: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if since == "" {
|
||||||
|
t.Fatal("missing_since was not set on the transition into missing")
|
||||||
|
}
|
||||||
|
first := since
|
||||||
|
|
||||||
|
// A rescan must not refresh the age.
|
||||||
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||||
|
t.Fatalf("rescan: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if since != first {
|
||||||
|
t.Errorf("missing_since reset on rescan: %q -> %q", first, since)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMissing surfaces the file with its age.
|
||||||
|
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListMissing: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("ListMissing returned %d entries, want 1", len(entries))
|
||||||
|
}
|
||||||
|
e := entries[0]
|
||||||
|
if e.Title != "b" || e.Path != "b.epub" || e.Kind != "ebooks" || e.LibraryID != lib.ID {
|
||||||
|
t.Errorf("unexpected missing entry: %+v", e)
|
||||||
|
}
|
||||||
|
if e.MissingSince != first {
|
||||||
|
t.Errorf("entry missing_since = %q, want %q", e.MissingSince, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the file: missing_since must be cleared, not left behind.
|
||||||
|
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||||
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||||
|
t.Fatalf("restore Scan: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if since != "" {
|
||||||
|
t.Errorf("missing_since after restore = %q, want empty", since)
|
||||||
|
}
|
||||||
|
if got, _ := library.ListMissing(ctx, db, []int64{lib.ID}); len(got) != 0 {
|
||||||
|
t.Errorf("ListMissing after restore returned %d entries, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
// Two audiobooks, each a folder of tracks.
|
||||||
|
writeFile(t, filepath.Join(root, "Book A", "01.mp3"), "a1")
|
||||||
|
writeFile(t, filepath.Join(root, "Book A", "02.mp3"), "a2")
|
||||||
|
writeFile(t, filepath.Join(root, "Book B", "01.mp3"), "b1")
|
||||||
|
writeFile(t, filepath.Join(root, "Book B", "02.mp3"), "b2")
|
||||||
|
|
||||||
|
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second library with its own missing item, to prove purge is scoped.
|
||||||
|
otherRoot := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(otherRoot, "gone.epub"), "x")
|
||||||
|
other, err := library.Create(ctx, db, "Other", "ebooks", otherRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create other: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
|
||||||
|
t.Fatalf("Scan other: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(otherRoot, "gone.epub")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
|
||||||
|
t.Fatalf("Scan other (missing): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove one track from Book A: the item is not missing, only that file.
|
||||||
|
// Remove Book B entirely: the item and both its files are missing.
|
||||||
|
if err := os.Remove(filepath.Join(root, "Book A", "02.mp3")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(filepath.Join(root, "Book B")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||||
|
t.Fatalf("second Scan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListMissing: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Fatalf("ListMissing returned %d entries, want 3 (Book A/02 + Book B/01 + Book B/02)", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge must remove only Book B.
|
||||||
|
n, err := library.PurgeMissing(ctx, db, lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PurgeMissing: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("purged = %d, want 1 (only the fully missing item)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Book A survives, with its present and missing files intact.
|
||||||
|
var items int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if items != 1 {
|
||||||
|
t.Errorf("media_items after purge = %d, want 1", items)
|
||||||
|
}
|
||||||
|
var bookATitle string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Book A").Scan(&bookATitle); err != nil {
|
||||||
|
t.Fatalf("a non-missing item was purged: %v", err)
|
||||||
|
}
|
||||||
|
var files int
|
||||||
|
if err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT count(*) FROM media_files f JOIN media_items i ON i.id = f.item_id
|
||||||
|
WHERE i.library_id = ? AND i.path = ?`, lib.ID, "Book A").Scan(&files); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if files != 2 {
|
||||||
|
t.Errorf("Book A files after purge = %d, want 2", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other library's missing item is untouched.
|
||||||
|
var otherItems int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM media_items WHERE library_id = ?`, other.ID).Scan(&otherItems); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if otherItems != 1 {
|
||||||
|
t.Errorf("other library items after purge = %d, want 1 (purge must be scoped)", otherItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second purge has nothing left to remove.
|
||||||
|
if n, err := library.PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 {
|
||||||
|
t.Errorf("second purge = %d, %v; want 0, nil", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
|
"github.com/mithral/vellum/internal/subsonic/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// directoryForLibrary renders the root of one audio library.
|
// directoryForLibrary renders the root of one audio library.
|
||||||
|
|
@ -25,7 +26,7 @@ func (h *Handlers) directoryForLibrary(w http.ResponseWriter, r *http.Request, u
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if name == "" {
|
if name == "" {
|
||||||
h.fail(w, r, errDataNotFound, "directory not found")
|
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.writeDirectory(w, r, u, libID, "", name)
|
h.writeDirectory(w, r, u, libID, "", name)
|
||||||
|
|
@ -40,7 +41,7 @@ func (h *Handlers) directoryForPath(w http.ResponseWriter, r *http.Request, u au
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !containsID(libs, libID) {
|
if !containsID(libs, libID) {
|
||||||
h.fail(w, r, errDataNotFound, "directory not found")
|
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rel = normalizeRel(rel)
|
rel = normalizeRel(rel)
|
||||||
|
|
@ -118,10 +119,10 @@ func (h *Handlers) writeDirectory(w http.ResponseWriter, r *http.Request, u auth
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dirNode := el("directory",
|
dirNode := dto.El("directory",
|
||||||
at("id", dirID(libID, rel)),
|
dto.At("id", dto.DirID(libID, rel)),
|
||||||
at("name", name),
|
dto.At("name", name),
|
||||||
).arrays("child")
|
).Arrays("child")
|
||||||
|
|
||||||
subNames := make([]string, 0, len(subdirs))
|
subNames := make([]string, 0, len(subdirs))
|
||||||
for s := range subdirs {
|
for s := range subdirs {
|
||||||
|
|
@ -133,13 +134,13 @@ func (h *Handlers) writeDirectory(w http.ResponseWriter, r *http.Request, u auth
|
||||||
if rel != "." {
|
if rel != "." {
|
||||||
sub = rel + "/" + seg
|
sub = rel + "/" + seg
|
||||||
}
|
}
|
||||||
dirNode.add(el("child",
|
dirNode.Add(dto.El("child",
|
||||||
at("id", dirID(libID, sub)),
|
dto.At("id", dto.DirID(libID, sub)),
|
||||||
at("parent", dirID(libID, rel)),
|
dto.At("parent", dto.DirID(libID, rel)),
|
||||||
at("isDir", true),
|
dto.At("isDir", true),
|
||||||
at("title", path.Base(seg)),
|
dto.At("title", path.Base(seg)),
|
||||||
at("album", path.Base(seg)),
|
dto.At("album", path.Base(seg)),
|
||||||
at("artist", ""),
|
dto.At("artist", ""),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,11 +150,11 @@ func (h *Handlers) writeDirectory(w http.ResponseWriter, r *http.Request, u auth
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, a := range albums {
|
for _, a := range albums {
|
||||||
dirNode.add(childNode(a))
|
dirNode.Add(dto.ChildNode(a))
|
||||||
}
|
}
|
||||||
|
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(dirNode)
|
root.Add(dirNode)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +166,7 @@ func (h *Handlers) directoryForAlbum(w http.ResponseWriter, r *http.Request, u a
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "directory not found")
|
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tracks, err := h.tracksForItem(r.Context(), itemID)
|
tracks, err := h.tracksForItem(r.Context(), itemID)
|
||||||
|
|
@ -173,18 +174,18 @@ func (h *Handlers) directoryForAlbum(w http.ResponseWriter, r *http.Request, u a
|
||||||
h.internal(w, r, "directory songs", err)
|
h.internal(w, r, "directory songs", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dirNode := el("directory",
|
dirNode := dto.El("directory",
|
||||||
at("id", albumID(a.ID)),
|
dto.At("id", dto.AlbumID(a.ID)),
|
||||||
at("parent", dirID(a.LibraryID, parentDirOf(a.Path))),
|
dto.At("parent", dto.DirID(a.LibraryID, parentDirOf(a.Path))),
|
||||||
at("name", a.Title),
|
dto.At("name", a.Title),
|
||||||
).arrays("child")
|
).Arrays("child")
|
||||||
for _, t := range tracks {
|
for _, t := range tracks {
|
||||||
child := songNode(t)
|
child := dto.SongNode(t)
|
||||||
child.name = "child"
|
child.Name = "child"
|
||||||
dirNode.add(child)
|
dirNode.Add(child)
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(dirNode)
|
root.Add(dirNode)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,28 +204,28 @@ func (h *Handlers) directoryForArtist(w http.ResponseWriter, r *http.Request, u
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(albums) == 0 {
|
if len(albums) == 0 {
|
||||||
h.fail(w, r, errDataNotFound, "directory not found")
|
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name, _ := h.artistName(r.Context(), personID)
|
name, _ := h.artistName(r.Context(), personID)
|
||||||
|
|
||||||
dirNode := el("directory",
|
dirNode := dto.El("directory",
|
||||||
at("id", artistID(personID)),
|
dto.At("id", dto.ArtistID(personID)),
|
||||||
at("name", name),
|
dto.At("name", name),
|
||||||
).arrays("child")
|
).Arrays("child")
|
||||||
for _, a := range albums {
|
for _, a := range albums {
|
||||||
dirNode.add(childNode(a))
|
dirNode.Add(dto.ChildNode(a))
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(dirNode)
|
root.Add(dirNode)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
// albumsByIDs loads the album projection for a specific id set within one
|
// albumsByIDs loads the album projection for a specific id set within one
|
||||||
// library, preserving album-title order.
|
// library, preserving album-title order.
|
||||||
func (h *Handlers) albumsByIDs(ctx context.Context, libID int64, ids []int64) ([]albumInfo, error) {
|
func (h *Handlers) albumsByIDs(ctx context.Context, libID int64, ids []int64) ([]dto.AlbumInfo, error) {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return []albumInfo{}, nil
|
return []dto.AlbumInfo{}, nil
|
||||||
}
|
}
|
||||||
in, args := inClause(ids)
|
in, args := inClause(ids)
|
||||||
return h.albumsWhere(ctx, []int64{libID}, " AND i.id IN ("+in+")", args, "i.sort_title", 500, 0)
|
return h.albumsWhere(ctx, []int64{libID}, " AND i.id IN ("+in+")", args, "i.sort_title", 500, 0)
|
||||||
|
|
|
||||||
215
internal/subsonic/dto/ids.go
Normal file
215
internal/subsonic/dto/ids.go
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/library"
|
||||||
|
"github.com/mithral/vellum/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vellum has two id spaces — media_items and media_files — and Subsonic has
|
||||||
|
// one opaque string id, so every id this layer emits is prefixed to say what it
|
||||||
|
// names. The prefix also lets stream/getCoverArt resolve an id without a
|
||||||
|
// separate lookup table.
|
||||||
|
//
|
||||||
|
// al-<itemID> an album / directory-grouped item
|
||||||
|
// mf-<fileID> a song, i.e. one physical media_files row
|
||||||
|
// ar-<personID> an artist, from people/item_people
|
||||||
|
// fd-<libID> a music folder (a whole audio library)
|
||||||
|
// d-<libID>:<relpath> a browsable directory within a library
|
||||||
|
const (
|
||||||
|
PrefixAlbum = "al-"
|
||||||
|
PrefixFile = "mf-"
|
||||||
|
PrefixArtist = "ar-"
|
||||||
|
PrefixFolder = "fd-"
|
||||||
|
PrefixDir = "d-"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlbumID renders an album id.
|
||||||
|
func AlbumID(itemID int64) string { return PrefixAlbum + strconv.FormatInt(itemID, 10) }
|
||||||
|
|
||||||
|
// FileID renders a song id.
|
||||||
|
func FileID(fileID int64) string { return PrefixFile + strconv.FormatInt(fileID, 10) }
|
||||||
|
|
||||||
|
// ArtistID renders an artist id.
|
||||||
|
func ArtistID(personID int64) string { return PrefixArtist + strconv.FormatInt(personID, 10) }
|
||||||
|
|
||||||
|
// FolderID renders a music folder id.
|
||||||
|
func FolderID(libID int64) string { return PrefixFolder + strconv.FormatInt(libID, 10) }
|
||||||
|
|
||||||
|
// DirID renders a browsable directory id.
|
||||||
|
func DirID(libID int64, rel string) string {
|
||||||
|
return PrefixDir + strconv.FormatInt(libID, 10) + ":" + rel
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseID splits a prefixed id, returning the numeric part.
|
||||||
|
func ParseID(id, prefix string) (int64, bool) {
|
||||||
|
rest, ok := strings.CutPrefix(id, prefix)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(rest, 10, 64)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDirID splits "d-<libID>:<relpath>". An empty relpath is the library root.
|
||||||
|
func ParseDirID(id string) (libID int64, rel string, ok bool) {
|
||||||
|
rest, ok := strings.CutPrefix(id, PrefixDir)
|
||||||
|
if !ok {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
lib, rel, found := strings.Cut(rest, ":")
|
||||||
|
if !found {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(lib, 10, 64)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
return n, rel, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── entities ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// AlbumInfo is one album: a directory-grouped media_items row of an audio kind.
|
||||||
|
type AlbumInfo struct {
|
||||||
|
ID int64
|
||||||
|
LibraryID int64
|
||||||
|
Kind string
|
||||||
|
Path string
|
||||||
|
Title string
|
||||||
|
Year int
|
||||||
|
AddedAt string
|
||||||
|
ArtistID int64
|
||||||
|
Artist string
|
||||||
|
SongCount int
|
||||||
|
DurationMS int64
|
||||||
|
HasCover bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track is one song: a media_files row together with its album's context.
|
||||||
|
type Track struct {
|
||||||
|
FileID int64
|
||||||
|
ItemID int64
|
||||||
|
LibraryID int64
|
||||||
|
Kind string
|
||||||
|
AlbumPath string
|
||||||
|
AlbumTitle string
|
||||||
|
Year int
|
||||||
|
AddedAt string
|
||||||
|
RelPath string
|
||||||
|
Size int64
|
||||||
|
DurationMS int64
|
||||||
|
Container string
|
||||||
|
ArtistID int64
|
||||||
|
Artist string
|
||||||
|
Ordinal int
|
||||||
|
HasCover bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SongNode renders a track as the Subsonic Child/Song element. getSong returns
|
||||||
|
// it bare; getAlbum nests it under <album>.
|
||||||
|
func SongNode(t Track) *Node {
|
||||||
|
ct := stream.ContentType(t.RelPath)
|
||||||
|
suffix := strings.TrimPrefix(strings.ToLower(filepath.Ext(t.RelPath)), ".")
|
||||||
|
|
||||||
|
n := El("song",
|
||||||
|
At("id", FileID(t.FileID)),
|
||||||
|
At("parent", AlbumID(t.ItemID)),
|
||||||
|
At("isDir", false),
|
||||||
|
At("title", library.CleanTitle(filepath.Base(t.RelPath))),
|
||||||
|
At("album", t.AlbumTitle),
|
||||||
|
At("albumId", AlbumID(t.ItemID)),
|
||||||
|
At("artist", t.Artist),
|
||||||
|
At("type", "music"),
|
||||||
|
At("isVideo", false),
|
||||||
|
)
|
||||||
|
if t.ArtistID > 0 {
|
||||||
|
n.Attrs = append(n.Attrs, At("artistId", ArtistID(t.ArtistID)))
|
||||||
|
}
|
||||||
|
if t.HasCover {
|
||||||
|
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(t.ItemID)))
|
||||||
|
}
|
||||||
|
if t.Ordinal > 0 {
|
||||||
|
n.Attrs = append(n.Attrs, At("track", int64(t.Ordinal)))
|
||||||
|
}
|
||||||
|
if t.Year > 0 {
|
||||||
|
n.Attrs = append(n.Attrs, At("year", int64(t.Year)))
|
||||||
|
}
|
||||||
|
n.Attrs = append(n.Attrs,
|
||||||
|
At("size", t.Size),
|
||||||
|
At("contentType", ct),
|
||||||
|
At("suffix", suffix),
|
||||||
|
At("duration", t.DurationMS/1000),
|
||||||
|
At("path", t.RelPath),
|
||||||
|
)
|
||||||
|
if t.AddedAt != "" {
|
||||||
|
n.Attrs = append(n.Attrs, At("created", t.AddedAt))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumNode renders an album as the Subsonic AlbumID3 element. Extra legacy
|
||||||
|
// fields (title, album, isDir) are included because older clients read them and
|
||||||
|
// they cost nothing.
|
||||||
|
func AlbumNode(a AlbumInfo) *Node {
|
||||||
|
parent := FolderID(a.LibraryID)
|
||||||
|
if a.ArtistID > 0 {
|
||||||
|
parent = ArtistID(a.ArtistID)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := El("album",
|
||||||
|
At("id", AlbumID(a.ID)),
|
||||||
|
At("parent", parent),
|
||||||
|
At("name", a.Title),
|
||||||
|
At("title", a.Title),
|
||||||
|
At("album", a.Title),
|
||||||
|
At("isDir", true),
|
||||||
|
At("artist", a.Artist),
|
||||||
|
At("songCount", int64(a.SongCount)),
|
||||||
|
At("duration", a.DurationMS/1000),
|
||||||
|
At("playCount", int64(0)),
|
||||||
|
)
|
||||||
|
if a.ArtistID > 0 {
|
||||||
|
n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID)))
|
||||||
|
}
|
||||||
|
if a.HasCover {
|
||||||
|
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID)))
|
||||||
|
}
|
||||||
|
if a.Year > 0 {
|
||||||
|
n.Attrs = append(n.Attrs, At("year", int64(a.Year)))
|
||||||
|
}
|
||||||
|
if a.AddedAt != "" {
|
||||||
|
n.Attrs = append(n.Attrs, At("created", a.AddedAt))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildNode renders an album or directory entry for getMusicDirectory /
|
||||||
|
// getIndexes, where the element is a generic Child rather than AlbumID3.
|
||||||
|
func ChildNode(a AlbumInfo) *Node {
|
||||||
|
n := El("child",
|
||||||
|
At("id", AlbumID(a.ID)),
|
||||||
|
At("parent", FolderID(a.LibraryID)),
|
||||||
|
At("isDir", true),
|
||||||
|
At("title", a.Title),
|
||||||
|
At("album", a.Title),
|
||||||
|
At("artist", a.Artist),
|
||||||
|
At("isVideo", false),
|
||||||
|
)
|
||||||
|
if a.ArtistID > 0 {
|
||||||
|
n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID)))
|
||||||
|
}
|
||||||
|
if a.HasCover {
|
||||||
|
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID)))
|
||||||
|
}
|
||||||
|
if a.Year > 0 {
|
||||||
|
n.Attrs = append(n.Attrs, At("year", int64(a.Year)))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
243
internal/subsonic/dto/node.go
Normal file
243
internal/subsonic/dto/node.go
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
// Package dto is the Subsonic wire contract: the response envelope, the
|
||||||
|
// element tree it carries, the protocol's error codes and the opaque id scheme.
|
||||||
|
//
|
||||||
|
// It is deliberately separate from internal/subsonic, which owns the HTTP
|
||||||
|
// handlers and the queries. A node here knows nothing about a request, a
|
||||||
|
// response writer or a database row: it maps data structures into the two
|
||||||
|
// serialisations Subsonic clients speak (XML attributes and flattened JSON).
|
||||||
|
//
|
||||||
|
// The node names and attribute names below are load-bearing. Symfonium,
|
||||||
|
// Ultrasonic, play:Sub and every other client branch on them, and a rename here
|
||||||
|
// fails silently on devices nobody in this repository can see. Likewise error
|
||||||
|
// code 40: a client must read "wrong credentials" out of the envelope, never an
|
||||||
|
// HTTP 401.
|
||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ProtocolVersion is the Subsonic API version this layer implements. It is
|
||||||
|
// advertised in every envelope.
|
||||||
|
ProtocolVersion = "1.16.1"
|
||||||
|
// ServerType identifies the server implementation in the envelope.
|
||||||
|
ServerType = "vellum"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerVersion is advertised as serverVersion in the envelope. The main
|
||||||
|
// binary may set it to its build version; it defaults to "dev".
|
||||||
|
var ServerVersion = "dev"
|
||||||
|
|
||||||
|
// Subsonic error codes. The protocol defines these; clients branch on them.
|
||||||
|
const (
|
||||||
|
ErrGeneric = 0 // a generic error
|
||||||
|
ErrMissingParameter = 10 // required parameter is missing
|
||||||
|
ErrWrongCredentials = 40 // wrong username or password
|
||||||
|
ErrNotAuthorized = 50 // user is not authorized for the operation
|
||||||
|
ErrDataNotFound = 70 // the requested data was not found
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusOK / StatusFailed are the two envelope `status` values.
|
||||||
|
const (
|
||||||
|
StatusOK = "ok"
|
||||||
|
StatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attr is one attribute of an element. The value is typed — string, int64 or
|
||||||
|
// bool — rather than a string so the JSON form can emit a real number or
|
||||||
|
// boolean where the protocol expects one, while XML always stringifies it.
|
||||||
|
type Attr struct {
|
||||||
|
Name string
|
||||||
|
Value any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node is one element in a response tree. Attributes and children are kept in
|
||||||
|
// insertion order, which XML needs (attribute order is cosmetic but stable
|
||||||
|
// output is easier to test) and which lets JSON grouping stay deterministic.
|
||||||
|
//
|
||||||
|
// The tree exists instead of Go structs because Subsonic's XML and JSON forms
|
||||||
|
// are not the same shape: XML uses attributes, while JSON flattens attributes
|
||||||
|
// and child elements into one object and turns repeated child names into
|
||||||
|
// arrays. A single tree with two serializers keeps the two in lockstep.
|
||||||
|
type Node struct {
|
||||||
|
Name string
|
||||||
|
Attrs []Attr
|
||||||
|
Kids []*Node
|
||||||
|
// arrayKids names child elements to emit as JSON arrays even when there is
|
||||||
|
// exactly one (or none). Whether "song" is an object or an array depends on
|
||||||
|
// the parent: getSong returns one object, but album/searchResult3 contain
|
||||||
|
// arrays. That is a property of the container, so it is recorded here rather
|
||||||
|
// than inferred from the element name.
|
||||||
|
arrayKids map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// El starts an element.
|
||||||
|
func El(name string, attrs ...Attr) *Node {
|
||||||
|
return &Node{Name: name, Attrs: attrs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrays marks child names that should always be JSON arrays under this node,
|
||||||
|
// and that should appear as [] when there are none, so a client that indexes
|
||||||
|
// the array never sees a missing key.
|
||||||
|
func (n *Node) Arrays(names ...string) *Node {
|
||||||
|
if n.arrayKids == nil {
|
||||||
|
n.arrayKids = make(map[string]bool, len(names))
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
n.arrayKids[name] = true
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// At is one attribute.
|
||||||
|
func At(name string, value any) Attr { return Attr{Name: name, Value: value} }
|
||||||
|
|
||||||
|
// Add appends child elements and returns the parent, so trees can be built
|
||||||
|
// inline.
|
||||||
|
func (n *Node) Add(kids ...*Node) *Node {
|
||||||
|
n.Kids = append(n.Kids, kids...)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envelope starts a response. XML needs the namespace; nodeJSON drops it.
|
||||||
|
func Envelope(status string) *Node {
|
||||||
|
return El("subsonic-response",
|
||||||
|
At("xmlns", "http://subsonic.org/restapi"),
|
||||||
|
At("status", status),
|
||||||
|
At("version", ProtocolVersion),
|
||||||
|
At("type", ServerType),
|
||||||
|
At("serverVersion", ServerVersion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorNode is the <error> element clients read out of a failed envelope.
|
||||||
|
func ErrorNode(code int, msg string) *Node {
|
||||||
|
return El("error", At("code", code), At("message", msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// NodeJSON converts a node into the JSON value Subsonic clients expect. A
|
||||||
|
// "folder" leaf whose only attribute is id is flattened to the bare id, which
|
||||||
|
// is how getuser's folder list is specified ([1, 3], not [{"id":1}]).
|
||||||
|
func NodeJSON(n *Node) any {
|
||||||
|
if n.Name == "folder" && len(n.Attrs) == 1 && len(n.Kids) == 0 && n.Attrs[0].Name == "id" {
|
||||||
|
return n.Attrs[0].Value
|
||||||
|
}
|
||||||
|
|
||||||
|
m := make(map[string]any, len(n.Attrs)+len(n.Kids))
|
||||||
|
for _, a := range n.Attrs {
|
||||||
|
if a.Name == "xmlns" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[a.Name] = a.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
order := make([]string, 0, len(n.Kids))
|
||||||
|
groups := make(map[string][]*Node, len(n.Kids))
|
||||||
|
for _, k := range n.Kids {
|
||||||
|
if _, ok := groups[k.Name]; !ok {
|
||||||
|
order = append(order, k.Name)
|
||||||
|
}
|
||||||
|
groups[k.Name] = append(groups[k.Name], k)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range order {
|
||||||
|
kids := groups[name]
|
||||||
|
if len(kids) == 1 && !n.arrayKids[name] {
|
||||||
|
m[name] = NodeJSON(kids[0])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
arr := make([]any, 0, len(kids))
|
||||||
|
for _, k := range kids {
|
||||||
|
arr = append(arr, NodeJSON(k))
|
||||||
|
}
|
||||||
|
m[name] = arr
|
||||||
|
}
|
||||||
|
|
||||||
|
for name := range n.arrayKids {
|
||||||
|
if _, ok := m[name]; !ok {
|
||||||
|
m[name] = []any{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONBody encodes the response envelope. HTML escaping is disabled so a path
|
||||||
|
// containing "&" or "<" stays readable rather than becoming &.
|
||||||
|
func JSONBody(root *Node) ([]byte, error) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&b)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(map[string]any{"subsonic-response": NodeJSON(root)}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XML ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// XMLBody renders the response envelope including the XSD namespace and the XML
|
||||||
|
// declaration. It writes to a buffer rather than straight to the response
|
||||||
|
// because the envelope's status and content type are decided by the handler,
|
||||||
|
// not by the tree.
|
||||||
|
func XMLBody(root *Node) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteString(xml.Header)
|
||||||
|
_ = writeXML(&b, root)
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeXML(w io.Writer, n *Node) error {
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteByte('<')
|
||||||
|
b.WriteString(n.Name)
|
||||||
|
for _, a := range n.Attrs {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(a.Name)
|
||||||
|
b.WriteString(`="`)
|
||||||
|
_ = xml.EscapeText(&b, []byte(xmlString(a.Value)))
|
||||||
|
b.WriteByte('"')
|
||||||
|
}
|
||||||
|
if len(n.Kids) == 0 {
|
||||||
|
b.WriteString("/>")
|
||||||
|
_, err := w.Write(b.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.WriteByte('>')
|
||||||
|
if _, err := w.Write(b.Bytes()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, k := range n.Kids {
|
||||||
|
if err := writeXML(w, k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := io.WriteString(w, "</"+n.Name+">")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// xmlString renders a typed attribute value for XML. Booleans are lower-case,
|
||||||
|
// which is what the Subsonic schema uses.
|
||||||
|
func xmlString(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case bool:
|
||||||
|
if t {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(t)
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(t, 10)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/progress"
|
"github.com/mithral/vellum/internal/progress"
|
||||||
"github.com/mithral/vellum/internal/stream"
|
"github.com/mithral/vellum/internal/stream"
|
||||||
|
"github.com/mithral/vellum/internal/subsonic/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ignoredArticles is advertised in getIndexes/getArtists. It is the list the
|
// ignoredArticles is advertised in getIndexes/getArtists. It is the list the
|
||||||
|
|
@ -54,7 +55,7 @@ func (h *Handlers) Register(mux *http.ServeMux) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) handleUnknownRest(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) handleUnknownRest(w http.ResponseWriter, r *http.Request) {
|
||||||
h.fail(w, r, errGeneric, "unknown Subsonic method")
|
h.fail(w, r, dto.ErrGeneric, "unknown Subsonic method")
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatch authenticates and routes one /rest/<method>.view call. Every method
|
// dispatch authenticates and routes one /rest/<method>.view call. Every method
|
||||||
|
|
@ -66,7 +67,7 @@ func (h *Handlers) dispatch(w http.ResponseWriter, r *http.Request) {
|
||||||
if !ok {
|
if !ok {
|
||||||
// The single most common integration failure: a bad credential must be
|
// The single most common integration failure: a bad credential must be
|
||||||
// an envelope with code 40, not an HTTP 401. Clients read the envelope.
|
// an envelope with code 40, not an HTTP 401. Clients read the envelope.
|
||||||
h.fail(w, r, errWrongCredentials, "Wrong username or password")
|
h.fail(w, r, dto.ErrWrongCredentials, "Wrong username or password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +107,7 @@ func (h *Handlers) dispatch(w http.ResponseWriter, r *http.Request) {
|
||||||
case "getplaylists":
|
case "getplaylists":
|
||||||
h.handleGetPlaylists(w, r)
|
h.handleGetPlaylists(w, r)
|
||||||
default:
|
default:
|
||||||
h.fail(w, r, errGeneric, "unknown method: "+method)
|
h.fail(w, r, dto.ErrGeneric, "unknown method: "+method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +121,7 @@ func (h *Handlers) handleGetLicense(w http.ResponseWriter, r *http.Request) {
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
// Vellum is not Subsonic and has no licence or trial. Clients only check
|
// Vellum is not Subsonic and has no licence or trial. Clients only check
|
||||||
// `valid`; a permanent valid licence is the honest answer.
|
// `valid`; a permanent valid licence is the honest answer.
|
||||||
root.add(el("license", at("valid", true)))
|
root.Add(dto.El("license", dto.At("valid", true)))
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,12 +133,12 @@ func (h *Handlers) handleGetMusicFolders(w http.ResponseWriter, r *http.Request,
|
||||||
h.internal(w, r, "music folders", err)
|
h.internal(w, r, "music folders", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folders := el("musicFolders").arrays("musicFolder")
|
folders := dto.El("musicFolders").Arrays("musicFolder")
|
||||||
for _, lib := range libs {
|
for _, lib := range libs {
|
||||||
folders.add(el("musicFolder", at("id", lib.id), at("name", lib.name)))
|
folders.Add(dto.El("musicFolder", dto.At("id", lib.id), dto.At("name", lib.name)))
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(folders)
|
root.Add(folders)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,10 +148,10 @@ func (h *Handlers) handleGetIndexes(w http.ResponseWriter, r *http.Request, u au
|
||||||
h.internal(w, r, "indexes", err)
|
h.internal(w, r, "indexes", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
indexes := el("indexes", at("ignoredArticles", ignoredArticles)).arrays("index")
|
indexes := dto.El("indexes", dto.At("ignoredArticles", ignoredArticles)).Arrays("index")
|
||||||
addIndexes(indexes, artists, false)
|
addIndexes(indexes, artists, false)
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(indexes)
|
root.Add(indexes)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,19 +161,19 @@ func (h *Handlers) handleGetArtists(w http.ResponseWriter, r *http.Request, u au
|
||||||
h.internal(w, r, "artists", err)
|
h.internal(w, r, "artists", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
node := el("artists", at("ignoredArticles", ignoredArticles)).arrays("index")
|
node := dto.El("artists", dto.At("ignoredArticles", ignoredArticles)).Arrays("index")
|
||||||
addIndexes(node, artists, true)
|
addIndexes(node, artists, true)
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(node)
|
root.Add(node)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) handleGetArtist(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleGetArtist(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
personID, ok := parseID(r.FormValue("id"), prefixArtist)
|
personID, ok := dto.ParseID(r.FormValue("id"), dto.PrefixArtist)
|
||||||
if !ok {
|
if !ok {
|
||||||
// The synthetic "unknown artist" bucket uses ar-0.
|
// The synthetic "unknown artist" bucket uses ar-0.
|
||||||
if r.FormValue("id") != prefixArtist+"0" {
|
if r.FormValue("id") != dto.PrefixArtist+"0" {
|
||||||
h.fail(w, r, errDataNotFound, "artist not found")
|
h.fail(w, r, dto.ErrDataNotFound, "artist not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
personID = 0
|
personID = 0
|
||||||
|
|
@ -189,31 +190,31 @@ func (h *Handlers) handleGetArtist(w http.ResponseWriter, r *http.Request, u aut
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(albums) == 0 {
|
if len(albums) == 0 {
|
||||||
h.fail(w, r, errDataNotFound, "artist not found")
|
h.fail(w, r, dto.ErrDataNotFound, "artist not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
name, _ := h.artistName(r.Context(), personID)
|
name, _ := h.artistName(r.Context(), personID)
|
||||||
artist := el("artist",
|
artist := dto.El("artist",
|
||||||
at("id", artistID(personID)),
|
dto.At("id", dto.ArtistID(personID)),
|
||||||
at("name", name),
|
dto.At("name", name),
|
||||||
at("albumCount", int64(len(albums))),
|
dto.At("albumCount", int64(len(albums))),
|
||||||
).arrays("album")
|
).Arrays("album")
|
||||||
if cover := firstCover(albums); cover != 0 {
|
if cover := firstCover(albums); cover != 0 {
|
||||||
artist.attrs = append(artist.attrs, at("coverArt", albumID(cover)))
|
artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.AlbumID(cover)))
|
||||||
}
|
}
|
||||||
for _, a := range albums {
|
for _, a := range albums {
|
||||||
artist.add(albumNode(a))
|
artist.Add(dto.AlbumNode(a))
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(artist)
|
root.Add(artist)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
itemID, ok := parseID(r.FormValue("id"), prefixAlbum)
|
itemID, ok := dto.ParseID(r.FormValue("id"), dto.PrefixAlbum)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "album not found")
|
h.fail(w, r, dto.ErrDataNotFound, "album not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a, ok, err := h.loadAlbum(r.Context(), u, itemID)
|
a, ok, err := h.loadAlbum(r.Context(), u, itemID)
|
||||||
|
|
@ -222,7 +223,7 @@ func (h *Handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request, u auth
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "album not found")
|
h.fail(w, r, dto.ErrDataNotFound, "album not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tracks, err := h.tracksForItem(r.Context(), itemID)
|
tracks, err := h.tracksForItem(r.Context(), itemID)
|
||||||
|
|
@ -230,19 +231,19 @@ func (h *Handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request, u auth
|
||||||
h.internal(w, r, "album songs", err)
|
h.internal(w, r, "album songs", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
node := albumNode(a).arrays("song")
|
node := dto.AlbumNode(a).Arrays("song")
|
||||||
for _, t := range tracks {
|
for _, t := range tracks {
|
||||||
node.add(songNode(t))
|
node.Add(dto.SongNode(t))
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(node)
|
root.Add(node)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) handleGetSong(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleGetSong(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
fileIDVal, ok := parseID(r.FormValue("id"), prefixFile)
|
fileIDVal, ok := dto.ParseID(r.FormValue("id"), dto.PrefixFile)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "song not found")
|
h.fail(w, r, dto.ErrDataNotFound, "song not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t, ok, err := h.loadTrack(r.Context(), u, fileIDVal)
|
t, ok, err := h.loadTrack(r.Context(), u, fileIDVal)
|
||||||
|
|
@ -251,11 +252,11 @@ func (h *Handlers) handleGetSong(w http.ResponseWriter, r *http.Request, u auth.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "song not found")
|
h.fail(w, r, dto.ErrDataNotFound, "song not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(songNode(t))
|
root.Add(dto.SongNode(t))
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,36 +269,36 @@ func (h *Handlers) handleGetMusicDirectory(w http.ResponseWriter, r *http.Reques
|
||||||
id := r.FormValue("id")
|
id := r.FormValue("id")
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(id, prefixFolder):
|
case strings.HasPrefix(id, dto.PrefixFolder):
|
||||||
libID, ok := parseID(id, prefixFolder)
|
libID, ok := dto.ParseID(id, dto.PrefixFolder)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "directory not found")
|
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.directoryForLibrary(w, r, u, libID)
|
h.directoryForLibrary(w, r, u, libID)
|
||||||
case strings.HasPrefix(id, prefixDir):
|
case strings.HasPrefix(id, dto.PrefixDir):
|
||||||
libID, rel, ok := parseDirID(id)
|
libID, rel, ok := dto.ParseDirID(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "directory not found")
|
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.directoryForPath(w, r, u, libID, rel)
|
h.directoryForPath(w, r, u, libID, rel)
|
||||||
case strings.HasPrefix(id, prefixAlbum):
|
case strings.HasPrefix(id, dto.PrefixAlbum):
|
||||||
itemID, ok := parseID(id, prefixAlbum)
|
itemID, ok := dto.ParseID(id, dto.PrefixAlbum)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "directory not found")
|
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.directoryForAlbum(w, r, u, itemID)
|
h.directoryForAlbum(w, r, u, itemID)
|
||||||
case strings.HasPrefix(id, prefixArtist):
|
case strings.HasPrefix(id, dto.PrefixArtist):
|
||||||
personID, ok := parseID(id, prefixArtist)
|
personID, ok := dto.ParseID(id, dto.PrefixArtist)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "directory not found")
|
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.directoryForArtist(w, r, u, personID)
|
h.directoryForArtist(w, r, u, personID)
|
||||||
default:
|
default:
|
||||||
h.fail(w, r, errDataNotFound, "directory not found")
|
h.fail(w, r, dto.ErrDataNotFound, "directory not found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,22 +337,22 @@ func (h *Handlers) handleSearch3(w http.ResponseWriter, r *http.Request, u auth.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result := el("searchResult3").arrays("artist", "album", "song")
|
result := dto.El("searchResult3").Arrays("artist", "album", "song")
|
||||||
for _, a := range artists {
|
for _, a := range artists {
|
||||||
artist := el("artist", at("id", artistID(a.id)), at("name", a.name), at("albumCount", int64(a.albumCount)))
|
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 {
|
if a.coverAlbum != 0 {
|
||||||
artist.attrs = append(artist.attrs, at("coverArt", albumID(a.coverAlbum)))
|
artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.AlbumID(a.coverAlbum)))
|
||||||
}
|
}
|
||||||
result.add(artist)
|
result.Add(artist)
|
||||||
}
|
}
|
||||||
for _, a := range albums {
|
for _, a := range albums {
|
||||||
result.add(albumNode(a))
|
result.Add(dto.AlbumNode(a))
|
||||||
}
|
}
|
||||||
for _, s := range songs {
|
for _, s := range songs {
|
||||||
result.add(songNode(s))
|
result.Add(dto.SongNode(s))
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(result)
|
root.Add(result)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,12 +398,12 @@ func (h *Handlers) handleGetAlbumList2(w http.ResponseWriter, r *http.Request, u
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
list := el("albumList2").arrays("album")
|
list := dto.El("albumList2").Arrays("album")
|
||||||
for _, a := range albums {
|
for _, a := range albums {
|
||||||
list.add(albumNode(a))
|
list.Add(dto.AlbumNode(a))
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(list)
|
root.Add(list)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,12 +419,12 @@ func (h *Handlers) handleGetRandomSongs(w http.ResponseWriter, r *http.Request,
|
||||||
h.internal(w, r, "random songs", err)
|
h.internal(w, r, "random songs", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
node := el("randomSongs").arrays("song")
|
node := dto.El("randomSongs").Arrays("song")
|
||||||
for _, s := range songs {
|
for _, s := range songs {
|
||||||
node.add(songNode(s))
|
node.Add(dto.SongNode(s))
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(node)
|
root.Add(node)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,7 +438,7 @@ func (h *Handlers) handleGetRandomSongs(w http.ResponseWriter, r *http.Request,
|
||||||
func (h *Handlers) handleStream(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleStream(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
|
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
|
||||||
if format != "" && format != "raw" {
|
if format != "" && format != "raw" {
|
||||||
h.fail(w, r, errGeneric, "transcoding is not supported by this server; request format=raw")
|
h.fail(w, r, dto.ErrGeneric, "transcoding is not supported by this server; request format=raw")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// maxBitRate and estimateContentLength are accepted and honestly ignored:
|
// maxBitRate and estimateContentLength are accepted and honestly ignored:
|
||||||
|
|
@ -449,13 +450,13 @@ func (h *Handlers) handleStream(w http.ResponseWriter, r *http.Request, u auth.U
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "song not found")
|
h.fail(w, r, dto.ErrDataNotFound, "song not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
h.fail(w, r, errDataNotFound, "file is not available")
|
h.fail(w, r, dto.ErrDataNotFound, "file is not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.log.Error("subsonic: stream file", "err", err)
|
h.log.Error("subsonic: stream file", "err", err)
|
||||||
|
|
@ -469,7 +470,7 @@ func (h *Handlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request, u a
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
h.fail(w, r, errDataNotFound, "cover art not found")
|
h.fail(w, r, dto.ErrDataNotFound, "cover art not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,7 +478,7 @@ func (h *Handlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request, u a
|
||||||
err = h.db.QueryRowContext(r.Context(),
|
err = h.db.QueryRowContext(r.Context(),
|
||||||
`SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, itemID).Scan(&coverPath)
|
`SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, itemID).Scan(&coverPath)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
h.fail(w, r, errDataNotFound, "cover art not found")
|
h.fail(w, r, dto.ErrDataNotFound, "cover art not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -485,7 +486,7 @@ func (h *Handlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request, u a
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, statErr := os.Stat(coverPath); statErr != nil {
|
if _, statErr := os.Stat(coverPath); statErr != nil {
|
||||||
h.fail(w, r, errDataNotFound, "cover art not available")
|
h.fail(w, r, dto.ErrDataNotFound, "cover art not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
|
@ -504,13 +505,13 @@ func (h *Handlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request, u a
|
||||||
func (h *Handlers) handleScrobble(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleScrobble(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
ids := r.Form["id"]
|
ids := r.Form["id"]
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
h.fail(w, r, errMissingParameter, "Required parameter is missing")
|
h.fail(w, r, dto.ErrMissingParameter, "Required parameter is missing")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
submission := strings.ToLower(r.FormValue("submission")) != "false"
|
submission := strings.ToLower(r.FormValue("submission")) != "false"
|
||||||
|
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
fileIDVal, ok := parseID(id, prefixFile)
|
fileIDVal, ok := dto.ParseID(id, dto.PrefixFile)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -560,12 +561,12 @@ func (h *Handlers) handleGetUser(w http.ResponseWriter, r *http.Request, u auth.
|
||||||
// behaviour clients expect when they omit it.
|
// behaviour clients expect when they omit it.
|
||||||
if target != "" && !strings.EqualFold(target, u.Username) {
|
if target != "" && !strings.EqualFold(target, u.Username) {
|
||||||
if !u.IsAdmin {
|
if !u.IsAdmin {
|
||||||
h.fail(w, r, errNotAuthorized, "User is not authorized for the given operation")
|
h.fail(w, r, dto.ErrNotAuthorized, "User is not authorized for the given operation")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
other, err := auth.GetUserByUsername(r.Context(), h.db, target)
|
other, err := auth.GetUserByUsername(r.Context(), h.db, target)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
h.fail(w, r, errDataNotFound, "user not found")
|
h.fail(w, r, dto.ErrDataNotFound, "user not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -581,28 +582,28 @@ func (h *Handlers) handleGetUser(w http.ResponseWriter, r *http.Request, u auth.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := el("user",
|
user := dto.El("user",
|
||||||
at("username", u.Username),
|
dto.At("username", u.Username),
|
||||||
at("email", ""),
|
dto.At("email", ""),
|
||||||
at("scrobblingEnabled", true),
|
dto.At("scrobblingEnabled", true),
|
||||||
at("adminRole", u.IsAdmin),
|
dto.At("adminRole", u.IsAdmin),
|
||||||
at("settingsRole", u.IsAdmin),
|
dto.At("settingsRole", u.IsAdmin),
|
||||||
at("downloadRole", true),
|
dto.At("downloadRole", true),
|
||||||
at("uploadRole", false),
|
dto.At("uploadRole", false),
|
||||||
at("playlistRole", true),
|
dto.At("playlistRole", true),
|
||||||
at("coverArtRole", true),
|
dto.At("coverArtRole", true),
|
||||||
at("commentRole", false),
|
dto.At("commentRole", false),
|
||||||
at("podcastRole", false),
|
dto.At("podcastRole", false),
|
||||||
at("streamRole", true),
|
dto.At("streamRole", true),
|
||||||
at("jukeboxRole", false),
|
dto.At("jukeboxRole", false),
|
||||||
at("shareRole", false),
|
dto.At("shareRole", false),
|
||||||
at("videoConversionRole", false),
|
dto.At("videoConversionRole", false),
|
||||||
).arrays("folder")
|
).Arrays("folder")
|
||||||
for _, id := range libIDs {
|
for _, id := range libIDs {
|
||||||
user.add(el("folder", at("id", id)))
|
user.Add(dto.El("folder", dto.At("id", id)))
|
||||||
}
|
}
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(user)
|
root.Add(user)
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -611,7 +612,7 @@ func (h *Handlers) handleGetUser(w http.ResponseWriter, r *http.Request, u auth.
|
||||||
// client from erroring on login.
|
// client from erroring on login.
|
||||||
func (h *Handlers) handleGetPlaylists(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) handleGetPlaylists(w http.ResponseWriter, r *http.Request) {
|
||||||
root := envelope("ok")
|
root := envelope("ok")
|
||||||
root.add(el("playlists").arrays("playlist"))
|
root.Add(dto.El("playlists").Arrays("playlist"))
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -619,5 +620,5 @@ func (h *Handlers) handleGetPlaylists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func (h *Handlers) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
func (h *Handlers) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||||
h.log.Error("subsonic: "+what, "err", err)
|
h.log.Error("subsonic: "+what, "err", err)
|
||||||
h.fail(w, r, errGeneric, "internal server error")
|
h.fail(w, r, dto.ErrGeneric, "internal server error")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
package subsonic
|
|
||||||
|
|
||||||
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-"
|
|
||||||
)
|
|
||||||
|
|
||||||
func albumID(itemID int64) string { return prefixAlbum + strconv.FormatInt(itemID, 10) }
|
|
||||||
func fileID(fileID int64) string { return prefixFile + strconv.FormatInt(fileID, 10) }
|
|
||||||
func artistID(personID int64) string { return prefixArtist + strconv.FormatInt(personID, 10) }
|
|
||||||
func folderID(libID int64) string { return prefixFolder + strconv.FormatInt(libID, 10) }
|
|
||||||
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,182 +1,24 @@
|
||||||
package subsonic
|
package subsonic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"encoding/xml"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
"github.com/mithral/vellum/internal/subsonic/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// attr is one attribute of an element. The value is typed — string, int64 or
|
// writeJSONResponse writes the JSON envelope.
|
||||||
// bool — rather than a string so the JSON form can emit a real number or
|
func writeJSONResponse(w http.ResponseWriter, root *dto.Node) {
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeJSONResponse writes the JSON envelope. HTML escaping is disabled so a
|
|
||||||
// path containing "&" or "<" stays readable rather than becoming \u0026.
|
|
||||||
func writeJSONResponse(w http.ResponseWriter, root *node) {
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
enc := json.NewEncoder(w)
|
body, err := dto.JSONBody(root)
|
||||||
enc.SetEscapeHTML(false)
|
if err != nil {
|
||||||
_ = enc.Encode(map[string]any{"subsonic-response": nodeJSON(root)})
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── XML ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// writeXMLResponse writes the XML envelope, including the namespace the XSD
|
// writeXMLResponse writes the XML envelope, including the namespace the XSD
|
||||||
// declares.
|
// declares.
|
||||||
func writeXMLResponse(w http.ResponseWriter, root *node) {
|
func writeXMLResponse(w http.ResponseWriter, root *dto.Node) {
|
||||||
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||||
_, _ = io.WriteString(w, xml.Header)
|
_, _ = w.Write(dto.XMLBody(root))
|
||||||
_ = writeXML(w, root)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/catalog"
|
"github.com/mithral/vellum/internal/catalog"
|
||||||
|
"github.com/mithral/vellum/internal/subsonic/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// libraryRef is an audio library as getMusicFolders shows it.
|
// libraryRef is an audio library as getMusicFolders shows it.
|
||||||
|
|
@ -88,7 +89,7 @@ FROM media_items i`
|
||||||
// and order. order MUST come from a fixed set, never from user input; the
|
// 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
|
// callers below pass only literals. The final projected column is aliased
|
||||||
// artist_name so alphabeticalByArtist can order by it without a second join.
|
// 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) ([]albumInfo, error) {
|
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 {
|
if limit <= 0 || limit > 500 {
|
||||||
limit = 500
|
limit = 500
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +110,7 @@ func (h *Handlers) albumsWhere(ctx context.Context, libs []int64, extra string,
|
||||||
return scanAlbums(rows)
|
return scanAlbums(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) albumsByArtist(ctx context.Context, libs []int64, personID int64) ([]albumInfo, error) {
|
func (h *Handlers) albumsByArtist(ctx context.Context, libs []int64, personID int64) ([]dto.AlbumInfo, error) {
|
||||||
if personID == 0 {
|
if personID == 0 {
|
||||||
return h.albumsWhere(ctx, libs,
|
return h.albumsWhere(ctx, libs,
|
||||||
` AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`,
|
` AND NOT EXISTS (SELECT 1 FROM item_people ip WHERE ip.item_id = i.id AND ip.role = 'author')`,
|
||||||
|
|
@ -127,28 +128,28 @@ func (h *Handlers) albumsByArtist(ctx context.Context, libs []int64, personID in
|
||||||
// item and a forbidden one. The album projection below is Subsonic's own
|
// item and a forbidden one. The album projection below is Subsonic's own
|
||||||
// presentation concern — song count, duration, artist credit and cover are not
|
// presentation concern — song count, duration, artist credit and cover are not
|
||||||
// part of catalog's item model — and no longer decides access.
|
// part of catalog's item model — and no longer decides access.
|
||||||
func (h *Handlers) loadAlbum(ctx context.Context, u auth.User, itemID int64) (albumInfo, bool, error) {
|
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)
|
it, err := catalog.LoadFor(ctx, h.db, u, itemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, catalog.ErrNotFound) {
|
if errors.Is(err, catalog.ErrNotFound) {
|
||||||
return albumInfo{}, false, nil
|
return dto.AlbumInfo{}, false, nil
|
||||||
}
|
}
|
||||||
return albumInfo{}, false, err
|
return dto.AlbumInfo{}, false, err
|
||||||
}
|
}
|
||||||
albums, err := h.albumsWhere(ctx, []int64{it.LibraryID}, ` AND i.id = ?`, []any{itemID}, "i.id", 1, 0)
|
albums, err := h.albumsWhere(ctx, []int64{it.LibraryID}, ` AND i.id = ?`, []any{itemID}, "i.id", 1, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return albumInfo{}, false, err
|
return dto.AlbumInfo{}, false, err
|
||||||
}
|
}
|
||||||
if len(albums) == 0 {
|
if len(albums) == 0 {
|
||||||
return albumInfo{}, false, nil
|
return dto.AlbumInfo{}, false, nil
|
||||||
}
|
}
|
||||||
return albums[0], true, nil
|
return albums[0], true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanAlbums(rows *sql.Rows) ([]albumInfo, error) {
|
func scanAlbums(rows *sql.Rows) ([]dto.AlbumInfo, error) {
|
||||||
out := []albumInfo{}
|
out := []dto.AlbumInfo{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a albumInfo
|
var a dto.AlbumInfo
|
||||||
var hasCover int
|
var hasCover int
|
||||||
if err := rows.Scan(&a.ID, &a.LibraryID, &a.Kind, &a.Path, &a.Title, &a.Year, &a.AddedAt,
|
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 {
|
&a.ArtistID, &a.SongCount, &a.DurationMS, &hasCover, &a.Artist); err != nil {
|
||||||
|
|
@ -173,7 +174,7 @@ SELECT f.id, f.item_id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year,0
|
||||||
FROM media_files f
|
FROM media_files f
|
||||||
JOIN media_items i ON i.id = f.item_id`
|
JOIN media_items i ON i.id = f.item_id`
|
||||||
|
|
||||||
func (h *Handlers) tracksForItem(ctx context.Context, itemID int64) ([]track, error) {
|
func (h *Handlers) tracksForItem(ctx context.Context, itemID int64) ([]dto.Track, error) {
|
||||||
rows, err := h.db.QueryContext(ctx, trackSelect+`
|
rows, err := h.db.QueryContext(ctx, trackSelect+`
|
||||||
WHERE f.item_id = ? AND f.missing = 0 AND i.missing = 0
|
WHERE f.item_id = ? AND f.missing = 0 AND i.missing = 0
|
||||||
ORDER BY f.path`, itemID)
|
ORDER BY f.path`, itemID)
|
||||||
|
|
@ -197,30 +198,30 @@ func (h *Handlers) tracksForItem(ctx context.Context, itemID int64) ([]track, er
|
||||||
// context, artist credit, cover — so it stays here. Access is not its job:
|
// 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
|
// 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.
|
// missing item and a forbidden one so a 404 cannot leak existence.
|
||||||
func (h *Handlers) loadTrack(ctx context.Context, u auth.User, fileIDVal int64) (track, bool, error) {
|
func (h *Handlers) loadTrack(ctx context.Context, u auth.User, fileIDVal int64) (dto.Track, bool, error) {
|
||||||
rows, err := h.db.QueryContext(ctx, trackSelect+`
|
rows, err := h.db.QueryContext(ctx, trackSelect+`
|
||||||
WHERE f.id = ? AND f.missing = 0 AND i.missing = 0`, fileIDVal)
|
WHERE f.id = ? AND f.missing = 0 AND i.missing = 0`, fileIDVal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return track{}, false, err
|
return dto.Track{}, false, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
tracks, err := scanTracks(rows)
|
tracks, err := scanTracks(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return track{}, false, err
|
return dto.Track{}, false, err
|
||||||
}
|
}
|
||||||
if len(tracks) == 0 {
|
if len(tracks) == 0 {
|
||||||
return track{}, false, nil
|
return dto.Track{}, false, nil
|
||||||
}
|
}
|
||||||
if _, err := catalog.LoadFor(ctx, h.db, u, tracks[0].ItemID); err != nil {
|
if _, err := catalog.LoadFor(ctx, h.db, u, tracks[0].ItemID); err != nil {
|
||||||
if errors.Is(err, catalog.ErrNotFound) {
|
if errors.Is(err, catalog.ErrNotFound) {
|
||||||
return track{}, false, nil
|
return dto.Track{}, false, nil
|
||||||
}
|
}
|
||||||
return track{}, false, err
|
return dto.Track{}, false, err
|
||||||
}
|
}
|
||||||
return tracks[0], true, nil
|
return tracks[0], true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) songsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]track, error) {
|
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 {
|
if limit <= 0 || limit > 500 {
|
||||||
limit = 500
|
limit = 500
|
||||||
}
|
}
|
||||||
|
|
@ -242,10 +243,10 @@ func (h *Handlers) songsWhere(ctx context.Context, libs []int64, extra string, e
|
||||||
return scanTracks(rows)
|
return scanTracks(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanTracks(rows *sql.Rows) ([]track, error) {
|
func scanTracks(rows *sql.Rows) ([]dto.Track, error) {
|
||||||
out := []track{}
|
out := []dto.Track{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t track
|
var t dto.Track
|
||||||
var hasCover int
|
var hasCover int
|
||||||
if err := rows.Scan(&t.FileID, &t.ItemID, &t.LibraryID, &t.Kind, &t.AlbumPath, &t.AlbumTitle,
|
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.Year, &t.AddedAt, &t.RelPath, &t.Size, &t.DurationMS, &t.Container,
|
||||||
|
|
@ -358,14 +359,14 @@ func (h *Handlers) artistName(ctx context.Context, personID int64) (string, erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchAlbums filters the album projection by title or credited artist.
|
// searchAlbums filters the album projection by title or credited artist.
|
||||||
func (h *Handlers) searchAlbums(ctx context.Context, libs []int64, q string, limit, offset int) ([]albumInfo, error) {
|
func (h *Handlers) searchAlbums(ctx context.Context, libs []int64, q string, limit, offset int) ([]dto.AlbumInfo, error) {
|
||||||
extra := ` AND (? = '' OR i.title LIKE '%' || ? || '%'
|
extra := ` AND (? = '' OR i.title LIKE '%' || ? || '%'
|
||||||
OR EXISTS (SELECT 1 FROM item_people ip JOIN people p ON p.id = ip.person_id
|
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 '%' || ? || '%'))`
|
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)
|
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) ([]track, error) {
|
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 '%' || ? || '%')`
|
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)
|
return h.songsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title, f.path", limit, offset)
|
||||||
}
|
}
|
||||||
|
|
@ -373,7 +374,7 @@ func (h *Handlers) searchSongs(ctx context.Context, libs []int64, q string, limi
|
||||||
// addIndexes groups artists by their index letter and appends <index> children
|
// addIndexes groups artists by their index letter and appends <index> children
|
||||||
// to parent. withCount controls whether the ArtistID3 albumCount is emitted
|
// to parent. withCount controls whether the ArtistID3 albumCount is emitted
|
||||||
// (getArtists) or the leaner Child shape is used (getIndexes).
|
// (getArtists) or the leaner Child shape is used (getIndexes).
|
||||||
func addIndexes(parent *node, artists []artistInfo, withCount bool) {
|
func addIndexes(parent *dto.Node, artists []artistInfo, withCount bool) {
|
||||||
sorted := append([]artistInfo(nil), artists...)
|
sorted := append([]artistInfo(nil), artists...)
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
li, lj := indexLetter(sorted[i].name), indexLetter(sorted[j].name)
|
li, lj := indexLetter(sorted[i].name), indexLetter(sorted[j].name)
|
||||||
|
|
@ -383,23 +384,23 @@ func addIndexes(parent *node, artists []artistInfo, withCount bool) {
|
||||||
return sorted[i].name < sorted[j].name
|
return sorted[i].name < sorted[j].name
|
||||||
})
|
})
|
||||||
|
|
||||||
var current *node
|
var current *dto.Node
|
||||||
currentLetter := ""
|
currentLetter := ""
|
||||||
for _, a := range sorted {
|
for _, a := range sorted {
|
||||||
letter := indexLetter(a.name)
|
letter := indexLetter(a.name)
|
||||||
if current == nil || letter != currentLetter {
|
if current == nil || letter != currentLetter {
|
||||||
current = el("index", at("name", letter)).arrays("artist")
|
current = dto.El("index", dto.At("name", letter)).Arrays("artist")
|
||||||
parent.add(current)
|
parent.Add(current)
|
||||||
currentLetter = letter
|
currentLetter = letter
|
||||||
}
|
}
|
||||||
artist := el("artist", at("id", artistID(a.id)), at("name", a.name))
|
artist := dto.El("artist", dto.At("id", dto.ArtistID(a.id)), dto.At("name", a.name))
|
||||||
if withCount {
|
if withCount {
|
||||||
artist.attrs = append(artist.attrs, at("albumCount", int64(a.albumCount)))
|
artist.Attrs = append(artist.Attrs, dto.At("albumCount", int64(a.albumCount)))
|
||||||
}
|
}
|
||||||
if a.coverAlbum != 0 {
|
if a.coverAlbum != 0 {
|
||||||
artist.attrs = append(artist.attrs, at("coverArt", albumID(a.coverAlbum)))
|
artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.AlbumID(a.coverAlbum)))
|
||||||
}
|
}
|
||||||
current.add(artist)
|
current.Add(artist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,7 +426,7 @@ func indexLetter(name string) string {
|
||||||
return "#"
|
return "#"
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstCover(albums []albumInfo) int64 {
|
func firstCover(albums []dto.AlbumInfo) int64 {
|
||||||
for _, a := range albums {
|
for _, a := range albums {
|
||||||
if a.HasCover {
|
if a.HasCover {
|
||||||
return a.ID
|
return a.ID
|
||||||
|
|
@ -444,8 +445,8 @@ func (h *Handlers) resolveAudioFile(ctx context.Context, u auth.User, id string)
|
||||||
lib int64
|
lib int64
|
||||||
)
|
)
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(id, prefixFile):
|
case strings.HasPrefix(id, dto.PrefixFile):
|
||||||
fileIDVal, ok := parseID(id, prefixFile)
|
fileIDVal, ok := dto.ParseID(id, dto.PrefixFile)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -454,8 +455,8 @@ func (h *Handlers) resolveAudioFile(ctx context.Context, u auth.User, id string)
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
rel, lib = t.RelPath, t.LibraryID
|
rel, lib = t.RelPath, t.LibraryID
|
||||||
case strings.HasPrefix(id, prefixAlbum):
|
case strings.HasPrefix(id, dto.PrefixAlbum):
|
||||||
itemID, ok := parseID(id, prefixAlbum)
|
itemID, ok := dto.ParseID(id, dto.PrefixAlbum)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -484,8 +485,8 @@ func (h *Handlers) resolveAudioFile(ctx context.Context, u auth.User, id string)
|
||||||
// nothing rather than an arbitrary album.
|
// nothing rather than an arbitrary album.
|
||||||
func (h *Handlers) coverItemID(ctx context.Context, u auth.User, id string) (int64, bool, error) {
|
func (h *Handlers) coverItemID(ctx context.Context, u auth.User, id string) (int64, bool, error) {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(id, prefixAlbum):
|
case strings.HasPrefix(id, dto.PrefixAlbum):
|
||||||
itemID, ok := parseID(id, prefixAlbum)
|
itemID, ok := dto.ParseID(id, dto.PrefixAlbum)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false, nil
|
return 0, false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -493,8 +494,8 @@ func (h *Handlers) coverItemID(ctx context.Context, u auth.User, id string) (int
|
||||||
return 0, false, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
return itemID, true, nil
|
return itemID, true, nil
|
||||||
case strings.HasPrefix(id, prefixFile):
|
case strings.HasPrefix(id, dto.PrefixFile):
|
||||||
fileIDVal, ok := parseID(id, prefixFile)
|
fileIDVal, ok := dto.ParseID(id, dto.PrefixFile)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false, nil
|
return 0, false, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@
|
||||||
// apitoken.KindSubsonic tokens, so revoking one in the web UI revokes the
|
// apitoken.KindSubsonic tokens, so revoking one in the web UI revokes the
|
||||||
// client too.
|
// 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
|
// # Authentication
|
||||||
//
|
//
|
||||||
// Subsonic carries its credentials on every request, not in a cookie: u names
|
// Subsonic carries its credentials on every request, not in a cookie: u names
|
||||||
|
|
@ -36,38 +40,13 @@ package subsonic
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
"github.com/mithral/vellum/internal/subsonic/dto"
|
||||||
// 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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// envelope starts a response. XML needs the namespace; nodeJSON drops it.
|
// envelope starts a response. XML needs the namespace; nodeJSON drops it.
|
||||||
func envelope(status string) *node {
|
func envelope(status string) *dto.Node {
|
||||||
return el("subsonic-response",
|
return dto.Envelope(status)
|
||||||
at("xmlns", "http://subsonic.org/restapi"),
|
|
||||||
at("status", status),
|
|
||||||
at("version", protocolVersion),
|
|
||||||
at("type", serverType),
|
|
||||||
at("serverVersion", ServerVersion),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantsJSON reports whether the caller asked for the JSON form. The default,
|
// wantsJSON reports whether the caller asked for the JSON form. The default,
|
||||||
|
|
@ -84,7 +63,7 @@ func wantsJSON(r *http.Request) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// write serialises a response in the requested format.
|
// write serialises a response in the requested format.
|
||||||
func (h *Handlers) write(w http.ResponseWriter, r *http.Request, root *node) {
|
func (h *Handlers) write(w http.ResponseWriter, r *http.Request, root *dto.Node) {
|
||||||
if wantsJSON(r) {
|
if wantsJSON(r) {
|
||||||
writeJSONResponse(w, root)
|
writeJSONResponse(w, root)
|
||||||
return
|
return
|
||||||
|
|
@ -96,7 +75,7 @@ func (h *Handlers) write(w http.ResponseWriter, r *http.Request, root *node) {
|
||||||
// Subsonic clients read the envelope's status/error, and a non-200 is often
|
// Subsonic clients read the envelope's status/error, and a non-200 is often
|
||||||
// surfaced as a transport error rather than "wrong password".
|
// surfaced as a transport error rather than "wrong password".
|
||||||
func (h *Handlers) fail(w http.ResponseWriter, r *http.Request, code int, msg string) {
|
func (h *Handlers) fail(w http.ResponseWriter, r *http.Request, code int, msg string) {
|
||||||
root := envelope("failed")
|
root := envelope(dto.StatusFailed)
|
||||||
root.add(el("error", at("code", code), at("message", msg)))
|
root.Add(dto.ErrorNode(code, msg))
|
||||||
h.write(w, r, root)
|
h.write(w, r, root)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"github.com/mithral/vellum/internal/apitoken"
|
"github.com/mithral/vellum/internal/apitoken"
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"github.com/mithral/vellum/internal/auth"
|
||||||
"github.com/mithral/vellum/internal/core"
|
"github.com/mithral/vellum/internal/core"
|
||||||
|
"github.com/mithral/vellum/internal/subsonic/dto"
|
||||||
"github.com/mithral/vellum/migrations"
|
"github.com/mithral/vellum/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -250,7 +251,7 @@ func TestPingJSONAndXML(t *testing.T) {
|
||||||
srv, f := newFixture(t)
|
srv, f := newFixture(t)
|
||||||
|
|
||||||
jroot := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
|
jroot := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
|
||||||
if jroot["status"] != "ok" || jroot["version"] != protocolVersion || jroot["type"] != serverType {
|
if jroot["status"] != "ok" || jroot["version"] != dto.ProtocolVersion || jroot["type"] != dto.ServerType {
|
||||||
t.Errorf("json envelope = %v", jroot)
|
t.Errorf("json envelope = %v", jroot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,7 +264,7 @@ func TestPingJSONAndXML(t *testing.T) {
|
||||||
if err := xml.Unmarshal([]byte(xmlBody), &parsed); err != nil {
|
if err := xml.Unmarshal([]byte(xmlBody), &parsed); err != nil {
|
||||||
t.Fatalf("xml parse: %v; body=%s", err, xmlBody)
|
t.Fatalf("xml parse: %v; body=%s", err, xmlBody)
|
||||||
}
|
}
|
||||||
if parsed.Status != "ok" || parsed.Version != protocolVersion {
|
if parsed.Status != "ok" || parsed.Version != dto.ProtocolVersion {
|
||||||
t.Errorf("xml envelope = %+v", parsed)
|
t.Errorf("xml envelope = %+v", parsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -349,14 +350,14 @@ func TestGetAlbumList2Newest(t *testing.T) {
|
||||||
func TestGetAlbumAndSong(t *testing.T) {
|
func TestGetAlbumAndSong(t *testing.T) {
|
||||||
srv, f := newFixture(t)
|
srv, f := newFixture(t)
|
||||||
|
|
||||||
body := readBody(t, get(t, srv.URL+"/rest/getAlbum.view?"+f.authQuery()+"&id="+albumID(f.album)+"&f=json"))
|
body := readBody(t, get(t, srv.URL+"/rest/getAlbum.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album)+"&f=json"))
|
||||||
root := jsonEnvelope(t, body)
|
root := jsonEnvelope(t, body)
|
||||||
album := root["album"].(map[string]any)
|
album := root["album"].(map[string]any)
|
||||||
if len(album["song"].([]any)) != 3 {
|
if len(album["song"].([]any)) != 3 {
|
||||||
t.Errorf("getAlbum songs = %v", album["song"])
|
t.Errorf("getAlbum songs = %v", album["song"])
|
||||||
}
|
}
|
||||||
|
|
||||||
body = readBody(t, get(t, srv.URL+"/rest/getSong.view?"+f.authQuery()+"&id="+fileID(f.file)+"&f=json"))
|
body = readBody(t, get(t, srv.URL+"/rest/getSong.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&f=json"))
|
||||||
root = jsonEnvelope(t, body)
|
root = jsonEnvelope(t, body)
|
||||||
song := root["song"].(map[string]any)
|
song := root["song"].(map[string]any)
|
||||||
if song["album"] != "Album" || song["artist"] != "The Artist" {
|
if song["album"] != "Album" || song["artist"] != "The Artist" {
|
||||||
|
|
@ -366,7 +367,7 @@ func TestGetAlbumAndSong(t *testing.T) {
|
||||||
|
|
||||||
func TestGetMusicDirectory(t *testing.T) {
|
func TestGetMusicDirectory(t *testing.T) {
|
||||||
srv, f := newFixture(t)
|
srv, f := newFixture(t)
|
||||||
body := readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+folderID(f.libID)+"&f=json"))
|
body := readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+dto.FolderID(f.libID)+"&f=json"))
|
||||||
root := jsonEnvelope(t, body)
|
root := jsonEnvelope(t, body)
|
||||||
dir := root["directory"].(map[string]any)
|
dir := root["directory"].(map[string]any)
|
||||||
children := dir["child"].([]any)
|
children := dir["child"].([]any)
|
||||||
|
|
@ -374,7 +375,7 @@ func TestGetMusicDirectory(t *testing.T) {
|
||||||
t.Fatalf("root children = %v; body=%s", children, body)
|
t.Fatalf("root children = %v; body=%s", children, body)
|
||||||
}
|
}
|
||||||
// Drill into the album directory and confirm its songs appear.
|
// Drill into the album directory and confirm its songs appear.
|
||||||
body = readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+albumID(f.album)+"&f=json"))
|
body = readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album)+"&f=json"))
|
||||||
root = jsonEnvelope(t, body)
|
root = jsonEnvelope(t, body)
|
||||||
dir = root["directory"].(map[string]any)
|
dir = root["directory"].(map[string]any)
|
||||||
if len(dir["child"].([]any)) != 3 {
|
if len(dir["child"].([]any)) != 3 {
|
||||||
|
|
@ -413,7 +414,7 @@ func TestGetPlaylistsEmpty(t *testing.T) {
|
||||||
func TestStreamRawAndRange(t *testing.T) {
|
func TestStreamRawAndRange(t *testing.T) {
|
||||||
srv, f := newFixture(t)
|
srv, f := newFixture(t)
|
||||||
|
|
||||||
resp := get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=raw")
|
resp := get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw")
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("stream status = %d, want 200", resp.StatusCode)
|
t.Fatalf("stream status = %d, want 200", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
@ -422,7 +423,7 @@ func TestStreamRawAndRange(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet,
|
req, _ := http.NewRequest(http.MethodGet,
|
||||||
srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=raw", nil)
|
srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw", nil)
|
||||||
req.Header.Set("Range", "bytes=0-9")
|
req.Header.Set("Range", "bytes=0-9")
|
||||||
rangeResp, err := http.DefaultClient.Do(req)
|
rangeResp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -445,7 +446,7 @@ func TestStreamRawAndRange(t *testing.T) {
|
||||||
// than silently served untranscoded.
|
// than silently served untranscoded.
|
||||||
func TestStreamRejectsTranscode(t *testing.T) {
|
func TestStreamRejectsTranscode(t *testing.T) {
|
||||||
srv, f := newFixture(t)
|
srv, f := newFixture(t)
|
||||||
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=mp3&f=json"))
|
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)
|
root := jsonEnvelope(t, body)
|
||||||
if root["status"] != "failed" {
|
if root["status"] != "failed" {
|
||||||
t.Fatalf("format=mp3 status = %v, want failed; body=%s", root["status"], body)
|
t.Fatalf("format=mp3 status = %v, want failed; body=%s", root["status"], body)
|
||||||
|
|
@ -457,7 +458,7 @@ func TestStreamRejectsTranscode(t *testing.T) {
|
||||||
|
|
||||||
func TestGetCoverArt(t *testing.T) {
|
func TestGetCoverArt(t *testing.T) {
|
||||||
srv, f := newFixture(t)
|
srv, f := newFixture(t)
|
||||||
resp := get(t, srv.URL+"/rest/getCoverArt.view?"+f.authQuery()+"&id="+albumID(f.album))
|
resp := get(t, srv.URL+"/rest/getCoverArt.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album))
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("cover status = %d, want 200", resp.StatusCode)
|
t.Fatalf("cover status = %d, want 200", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
@ -470,7 +471,7 @@ func TestGetCoverArt(t *testing.T) {
|
||||||
func TestScrobbleWritesProgress(t *testing.T) {
|
func TestScrobbleWritesProgress(t *testing.T) {
|
||||||
srv, f := newFixture(t)
|
srv, f := newFixture(t)
|
||||||
body := readBody(t, get(t, srv.URL+"/rest/scrobble.view?"+f.authQuery()+
|
body := readBody(t, get(t, srv.URL+"/rest/scrobble.view?"+f.authQuery()+
|
||||||
"&id="+fileID(f.file)+"&submission=true&position=30000&f=json"))
|
"&id="+dto.FileID(f.file)+"&submission=true&position=30000&f=json"))
|
||||||
if root := jsonEnvelope(t, body); root["status"] != "ok" {
|
if root := jsonEnvelope(t, body); root["status"] != "ok" {
|
||||||
t.Fatalf("scrobble status = %v; body=%s", root["status"], body)
|
t.Fatalf("scrobble status = %v; body=%s", root["status"], body)
|
||||||
}
|
}
|
||||||
|
|
@ -493,7 +494,7 @@ func TestAccessScopedToGrants(t *testing.T) {
|
||||||
if _, err := f.db.Exec(`DELETE FROM library_grants WHERE user_id = ?`, f.userID); err != nil {
|
if _, err := f.db.Exec(`DELETE FROM library_grants WHERE user_id = ?`, f.userID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=raw&f=json"))
|
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)
|
root := jsonEnvelope(t, body)
|
||||||
if root["status"] != "failed" {
|
if root["status"] != "failed" {
|
||||||
t.Fatalf("revoked grant stream status = %v, want failed; body=%s", root["status"], body)
|
t.Fatalf("revoked grant stream status = %v, want failed; body=%s", root["status"], body)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue