From 35442e4afff0d2a665ebc93b904604b81386ee43 Mon Sep 17 00:00:00 2001 From: mithral Date: Sun, 11 Oct 2026 16:03:10 -0400 Subject: [PATCH] Phase 2: split the fat packages along real boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 1 + cmd/vellum/main.go | 18 +- internal/arch/arch_test.go | 19 +- internal/{media => home}/discover.go | 2 +- internal/{media => home}/discover_test.go | 22 +- internal/{media => home}/home.go | 62 +-- internal/{media => home}/home_test.go | 29 +- internal/jellyfin/{ => dto}/dto.go | 192 ++++++-- internal/jellyfin/{ => dto}/ids.go | 14 +- internal/jellyfin/items.go | 60 +-- internal/jellyfin/playback.go | 9 +- internal/jellyfin/query.go | 19 +- internal/jellyfin/sessions.go | 17 +- internal/jellyfin/system.go | 64 +-- internal/library/library.go | 6 + internal/library/library_test.go | 191 -------- internal/library/registry.go | 19 +- internal/library/scan_test.go | 205 --------- internal/media/fixtures_test.go | 193 ++++++++ internal/media/handlers.go | 52 ++- internal/media/media.go | 11 +- internal/metadata/enrich.go | 28 +- internal/metadata/enrich_test.go | 21 +- internal/{metadata => providers}/comicvine.go | 2 +- .../{metadata => providers}/comicvine_test.go | 2 +- .../{metadata => providers}/googlebooks.go | 2 +- internal/{metadata => providers}/igdb.go | 2 +- internal/{metadata => providers}/igdb_test.go | 2 +- .../{metadata => providers}/musicbrainz.go | 2 +- .../{metadata => providers}/openlibrary.go | 13 +- internal/providers/openlibrary_test.go | 100 ++++ internal/{metadata => providers}/provider.go | 9 +- internal/{library => scanner}/scan.go | 40 +- internal/scanner/scan_test.go | 430 ++++++++++++++++++ internal/subsonic/browse.go | 69 +-- internal/subsonic/dto/ids.go | 215 +++++++++ internal/subsonic/dto/node.go | 243 ++++++++++ internal/subsonic/handlers.go | 181 ++++---- internal/subsonic/ids.go | 206 --------- internal/subsonic/node.go | 180 +------- internal/subsonic/store.go | 81 ++-- internal/subsonic/subsonic.go | 41 +- internal/subsonic/subsonic_test.go | 25 +- 43 files changed, 1818 insertions(+), 1281 deletions(-) rename internal/{media => home}/discover.go (99%) rename internal/{media => home}/discover_test.go (92%) rename internal/{media => home}/home.go (80%) rename internal/{media => home}/home_test.go (97%) rename internal/jellyfin/{ => dto}/dto.go (60%) rename internal/jellyfin/{ => dto}/ids.go (79%) delete mode 100644 internal/library/scan_test.go create mode 100644 internal/media/fixtures_test.go rename internal/{metadata => providers}/comicvine.go (99%) rename internal/{metadata => providers}/comicvine_test.go (99%) rename internal/{metadata => providers}/googlebooks.go (99%) rename internal/{metadata => providers}/igdb.go (99%) rename internal/{metadata => providers}/igdb_test.go (99%) rename internal/{metadata => providers}/musicbrainz.go (99%) rename internal/{metadata => providers}/openlibrary.go (89%) create mode 100644 internal/providers/openlibrary_test.go rename internal/{metadata => providers}/provider.go (82%) rename internal/{library => scanner}/scan.go (91%) create mode 100644 internal/scanner/scan_test.go create mode 100644 internal/subsonic/dto/ids.go create mode 100644 internal/subsonic/dto/node.go delete mode 100644 internal/subsonic/ids.go diff --git a/.gitignore b/.gitignore index 4331fd3..f66e107 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ # local preview instance demo/ +.archify/ diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 21b54e9..db223e2 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -17,6 +17,7 @@ import ( "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/cli" "github.com/mithral/vellum/internal/core" + "github.com/mithral/vellum/internal/home" "github.com/mithral/vellum/internal/httpapi" "github.com/mithral/vellum/internal/jellyfin" "github.com/mithral/vellum/internal/jobs" @@ -28,6 +29,8 @@ import ( "github.com/mithral/vellum/internal/modules/games" _ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor "github.com/mithral/vellum/internal/playback" + "github.com/mithral/vellum/internal/providers" + "github.com/mithral/vellum/internal/scanner" "github.com/mithral/vellum/internal/search" "github.com/mithral/vellum/internal/subsonic" "github.com/mithral/vellum/migrations" @@ -103,6 +106,9 @@ func run(cfg core.Config, log *slog.Logger) error { library.NewHandlers(db, log, authenticator, queue).Register(api.Mux()) media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux()) + // The landing screen is an aggregate over items, not an item resource, so it + // is mounted by its own package. + home.NewHandlers(db, log, authenticator).Register(api.Mux()) ebooks.NewHandlers(db, log, authenticator).Register(api.Mux()) comics.NewHandlers(db, log, authenticator).Register(api.Mux()) games.NewHandlers(db, log, authenticator).Register(api.Mux()) @@ -134,7 +140,7 @@ func run(cfg core.Config, log *slog.Logger) error { if err != nil { return nil // deleted since the job was queued } - res, err := library.Scan(ctx, db, lib, log) + res, err := scanner.Scan(ctx, db, lib, log) if jobID, ok := jobs.JobID(ctx); ok { if serr := jobs.SetResult(ctx, db, jobID, res); serr != nil { log.Warn("record scan result", "err", serr) @@ -145,16 +151,16 @@ func run(cfg core.Config, log *slog.Logger) error { // Providers are registered primary-first; a fallback is only consulted // when the primary returns nothing. - metadata.Register(metadata.NewOpenLibrary()) - metadata.Register(metadata.NewGoogleBooks()) - metadata.Register(metadata.NewMusicBrainz()) + providers.Register(providers.NewOpenLibrary()) + providers.Register(providers.NewGoogleBooks()) + providers.Register(providers.NewMusicBrainz()) if cfg.ComicVineAPIKey != "" { - metadata.Register(metadata.NewComicVine(cfg.ComicVineAPIKey)) + providers.Register(providers.NewComicVine(cfg.ComicVineAPIKey)) } else { log.Info("comic metadata disabled: set VELLUM_COMICVINE_API_KEY to enable ComicVine lookups") } if cfg.IGDBClientID != "" && cfg.IGDBClientSecret != "" { - metadata.Register(metadata.NewIGDB(cfg.IGDBClientID, cfg.IGDBClientSecret)) + providers.Register(providers.NewIGDB(cfg.IGDBClientID, cfg.IGDBClientSecret)) } else { log.Info("game metadata disabled: set VELLUM_IGDB_CLIENT_ID and VELLUM_IGDB_CLIENT_SECRET to enable IGDB lookups") } diff --git a/internal/arch/arch_test.go b/internal/arch/arch_test.go index 3c0e564..845858c 100644 --- a/internal/arch/arch_test.go +++ b/internal/arch/arch_test.go @@ -41,12 +41,17 @@ var layers = map[string]int{ "internal/engine": 1, "internal/apitoken": 1, - // 2 — the data seam: items and access. - "internal/catalog": 2, + // 2 — leaves that the application is built from: the data seam, and the + // external catalogues. providers sits here so it stays a leaf — a + // metadata client must be usable from a test with no database. + "internal/catalog": 2, + "internal/providers": 2, // 3 — the application. "internal/library": 3, + "internal/scanner": 3, "internal/media": 3, + "internal/home": 3, "internal/search": 3, "internal/metadata": 3, "internal/playback": 3, @@ -56,10 +61,12 @@ var layers = map[string]int{ "internal/modules/photos": 3, // 4 — adapters: HTTP surfaces and foreign protocols. - "internal/httpapi": 4, - "internal/jellyfin": 4, - "internal/subsonic": 4, - "internal/cli": 4, + "internal/httpapi": 4, + "internal/jellyfin": 4, + "internal/jellyfin/dto": 3, + "internal/subsonic": 4, + "internal/subsonic/dto": 3, + "internal/cli": 4, // 5 — the only place everything is wired together. "cmd/vellum": 5, diff --git a/internal/media/discover.go b/internal/home/discover.go similarity index 99% rename from internal/media/discover.go rename to internal/home/discover.go index 3c56cc9..d929db9 100644 --- a/internal/media/discover.go +++ b/internal/home/discover.go @@ -1,4 +1,4 @@ -package media +package home import ( "context" diff --git a/internal/media/discover_test.go b/internal/home/discover_test.go similarity index 92% rename from internal/media/discover_test.go rename to internal/home/discover_test.go index 3fa592a..0565864 100644 --- a/internal/media/discover_test.go +++ b/internal/home/discover_test.go @@ -1,31 +1,11 @@ -package media +package home import ( "context" "database/sql" - "io" - "log/slog" - "path/filepath" "testing" - - "github.com/mithral/vellum/internal/core" - "github.com/mithral/vellum/migrations" ) -func testDB(t *testing.T) *sql.DB { - t.Helper() - db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.db")) - if err != nil { - t.Fatalf("OpenDB: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - log := slog.New(slog.NewTextHandler(io.Discard, nil)) - if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil { - t.Fatalf("Migrate: %v", err) - } - return db -} - type fixture struct { db *sql.DB libA int64 diff --git a/internal/media/home.go b/internal/home/home.go similarity index 80% rename from internal/media/home.go rename to internal/home/home.go index e55d6d2..515a6fb 100644 --- a/internal/media/home.go +++ b/internal/home/home.go @@ -1,7 +1,21 @@ -package media +// Package home computes what the landing screen shows: the five shelves and the +// counts the sidebar needs. +// +// It is separated from internal/media because the two answer different +// questions. Media serves item RESOURCES — one item, its file, its cover, its +// progress. Home computes AGGREGATES over many items: what you were in the +// middle of, what you keep opening, what to try next. Neither can be built from +// the other, and a change to a shelf query has no business touching the endpoint +// that streams a file. +// +// It depends on internal/catalog only for the shape of an item; the SQL is its +// own because the shelves are the only queries in the server that aggregate +// across libraries, kinds and users at once. +package home import ( "database/sql" + "log/slog" "net/http" "strings" @@ -10,6 +24,23 @@ import ( "github.com/mithral/vellum/internal/httpx" ) +// Handlers serves the home screen. +type Handlers struct { + db *sql.DB + log *slog.Logger + auth *auth.Authenticator +} + +// NewHandlers builds the home-screen HTTP layer. +func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers { + return &Handlers{db: db, log: log, auth: a} +} + +// Register mounts the routes. +func (h *Handlers) Register(mux *http.ServeMux) { + mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome)) +} + // shelfItem is one card on the home screen. type shelfItem struct { ID int64 `json:"id"` @@ -159,32 +190,9 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.Use httpx.WriteJSON(w, http.StatusOK, resp) } -// handleRecordView increments the user's view count for an item. -func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) { - it, ok := h.authorized(w, r, u) - if !ok { - return - } - views, err := RecordView(r.Context(), h.db, u.ID, it.ID) - if err != nil { - h.fail(w, "record view", err) - return - } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"item_id": it.ID, "views": views}) -} - -// handleAuthors returns the people credited on an item. -func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) { - it, ok := h.authorized(w, r, u) - if !ok { - return - } - authors, err := h.authors(r, it.ID) - if err != nil { - h.fail(w, "authors", err) - return - } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors}) +func (h *Handlers) fail(w http.ResponseWriter, what string, err error) { + h.log.Error(what, "err", err) + httpx.WriteError(w, http.StatusInternalServerError, "internal server error") } func scanShelf(rows *sql.Rows) ([]shelfItem, error) { diff --git a/internal/media/home_test.go b/internal/home/home_test.go similarity index 97% rename from internal/media/home_test.go rename to internal/home/home_test.go index 4b9b946..c22a1e1 100644 --- a/internal/media/home_test.go +++ b/internal/home/home_test.go @@ -1,4 +1,4 @@ -package media +package home import ( "context" @@ -9,20 +9,37 @@ import ( "log/slog" "net/http" "net/http/httptest" + "path/filepath" "sort" "strconv" "strings" "testing" "github.com/mithral/vellum/internal/auth" + "github.com/mithral/vellum/internal/core" + "github.com/mithral/vellum/migrations" ) // ── shared helpers ───────────────────────────────────────────────────── -func mediaHandlers(t *testing.T, db *sql.DB) *Handlers { +func homeHandlers(t *testing.T, db *sql.DB) *Handlers { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) - return NewHandlers(db, log, nil, t.TempDir()) + return NewHandlers(db, log, nil) +} + +func testDB(t *testing.T) *sql.DB { + t.Helper() + db, err := core.OpenDB(filepath.Join(t.TempDir(), "home.db")) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil { + t.Fatalf("Migrate: %v", err) + } + return db } func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 { @@ -205,7 +222,7 @@ func getHome(t *testing.T, h *Handlers, u auth.User) (homeResponse, []byte) { func TestHandleHomeShelvesAndCounts(t *testing.T) { f := setupHome(t) - h := mediaHandlers(t, f.db) + h := homeHandlers(t, f.db) resp, body := getHome(t, h, auth.User{ID: f.userA}) // The landing screen contract: all five shelves plus the sidebar counts. @@ -266,7 +283,7 @@ func TestHandleHomeShelvesAndCounts(t *testing.T) { func TestHandleHomeGrantScoping(t *testing.T) { f := setupHome(t) - h := mediaHandlers(t, f.db) + h := homeHandlers(t, f.db) // userA holds a grant to libA only. Nothing from libB may surface anywhere. resp, _ := getHome(t, h, auth.User{ID: f.userA}) @@ -304,7 +321,7 @@ func TestHandleHomeGrantScoping(t *testing.T) { func TestHandleHomeIsolatesUsers(t *testing.T) { f := setupHome(t) - h := mediaHandlers(t, f.db) + h := homeHandlers(t, f.db) resp, _ := getHome(t, h, auth.User{ID: f.userB}) if got := ids(resp.Continue); len(got) != 1 || got[0] != f.b1 { diff --git a/internal/jellyfin/dto.go b/internal/jellyfin/dto/dto.go similarity index 60% rename from internal/jellyfin/dto.go rename to internal/jellyfin/dto/dto.go index 7f64b6b..37e6a2c 100644 --- a/internal/jellyfin/dto.go +++ b/internal/jellyfin/dto/dto.go @@ -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 ( - "database/sql" - + "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" + "github.com/mithral/vellum/internal/library" ) // This file defines a documented SUBSET of the Jellyfin DTOs. Jellyfin's @@ -25,7 +38,7 @@ type UserDto struct { // UserPolicy carries the two things a client needs from a user's policy: the // admin flag and which library folders the account can see. EnabledFolders is -// the user's 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. type UserPolicy struct { IsAdministrator bool `json:"IsAdministrator"` @@ -112,14 +125,46 @@ type PlaybackInfoResponse struct { ErrorCode string `json:"ErrorCode,omitempty"` } -// itemQueryResult is the browse envelope. -type itemQueryResult struct { +// ItemQueryResult is the browse envelope. +type ItemQueryResult struct { Items []BaseItemDto `json:"Items"` TotalRecordCount int `json:"TotalRecordCount"` 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 // 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 // is exactly one implementation of "may this user read this item" and exactly // one place item and file rows are read. -type itemView struct { +type ItemView struct { Item catalog.Item File catalog.File - // SortTitle and ParentID are browse-only columns; the DTO maps them to - // SortName and ParentId. + // SortTitle is a browse-only column; the DTO maps it to SortName. 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 bool @@ -148,10 +194,10 @@ type itemView struct { 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 // later (games) degrades to Folder so a client can still show it. -func itemType(kind string) string { +func ItemType(kind string) string { switch kind { case "video": 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"). -func mediaType(kind string) string { +func MediaType(kind string) string { switch kind { case "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 // to nothing rather than to everything. -func kindsForTypes(types []string) []string { +func KindsForTypes(types []string) []string { var out []string for _, t := range types { switch t { @@ -203,53 +266,52 @@ func kindsForTypes(types []string) []string { 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. -func toBaseItemDto(v itemView, includeMediaSources bool, apiKey string) BaseItemDto { +func ToBaseItemDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto { dto := BaseItemDto{ - Id: itemID(v.Item.ID), + Id: ItemID(v.Item.ID), Name: v.Item.Title, - Type: itemType(v.Item.Kind), - MediaType: mediaType(v.Item.Kind), + Type: ItemType(v.Item.Kind), + MediaType: MediaType(v.Item.Kind), Container: v.File.Container, - RunTimeTicks: msToTicks(v.File.DurationMS), + RunTimeTicks: MsToTicks(v.File.DurationMS), ProductionYear: v.Item.Year, IsFolder: false, SortName: v.SortTitle, Path: v.File.Path, } - if v.ParentID.Valid { - dto.ParentId = itemID(v.ParentID.Int64) + if v.ParentID > 0 { + dto.ParentId = ItemID(v.ParentID) } if v.HasCover { // The tag is the item id: stable, and it changes when the item changes. - dto.ImageTags = map[string]string{"Primary": itemID(v.Item.ID)} + dto.ImageTags = map[string]string{"Primary": ItemID(v.Item.ID)} } dto.UserData = &UserItemDataDto{ - PlaybackPositionTicks: msToTicks(v.PositionMS), + PlaybackPositionTicks: MsToTicks(v.PositionMS), PlayedPercentage: v.Percent * 100, PlayCount: v.PlayCount, IsFavorite: v.Favorite, Played: v.Finished, } if includeMediaSources { - dto.MediaSources = []MediaSourceInfo{buildMediaSource(v.Item, v.File, apiKey)} + dto.MediaSources = []MediaSourceInfo{BuildMediaSource(v.Item, v.File, apiKey)} } return dto } -// buildMediaSource renders the single direct-play source for an item. It is -// shared by PlaybackInfo and by list responses that requested the -// MediaSources field. Both inputs come from internal/catalog; a zero File is -// an item with no file, and produces the same defaults a missing file always -// did. -func buildMediaSource(it catalog.Item, f catalog.File, apiKey string) MediaSourceInfo { +// BuildMediaSource renders the single direct-play source for an item. It is +// shared by PlaybackInfo and by list responses that requested the MediaSources +// field. Both inputs come from internal/catalog; a zero File is an item with no +// file, and produces the same defaults a missing file always did. +func BuildMediaSource(it catalog.Item, f catalog.File, apiKey string) MediaSourceInfo { ms := MediaSourceInfo{ - Id: itemID(it.ID), + Id: ItemID(it.ID), Name: it.Title, Path: f.Path, Container: f.Container, - RunTimeTicks: msToTicks(f.DurationMS), + RunTimeTicks: MsToTicks(f.DurationMS), Size: f.Size, SupportsDirectPlay: true, SupportsDirectStream: true, @@ -275,14 +337,60 @@ func buildMediaSource(it catalog.Item, f catalog.File, apiKey string) MediaSourc return ms } +// LibraryDto renders a library as a Jellyfin folder. +func LibraryDto(lib library.Library) BaseItemDto { + return BaseItemDto{ + Id: LibraryID(lib.ID), + Name: lib.Name, + Type: "CollectionFolder", + CollectionType: CollectionType(lib.Kind), + IsFolder: true, + SortName: lib.Name, + } +} + +// User renders the Jellyfin user shape, including the visible library folders +// so a client can scope its views. visibleLibIDs are the caller's granted +// library ids; this function only renders them, it does not look them up. +func User(u auth.User, serverID string, visibleLibIDs []int64) UserDto { + folders := make([]string, 0, len(visibleLibIDs)) + for _, id := range visibleLibIDs { + folders = append(folders, LibraryID(id)) + } + return UserDto{ + Name: displayName(u), + ServerId: serverID, + Id: ItemID(u.ID), + HasPassword: u.PasswordHash != "", + HasConfiguredPassword: u.PasswordHash != "", + Policy: &UserPolicy{ + IsAdministrator: u.IsAdmin, + IsDisabled: false, + EnableAllFolders: u.IsAdmin, + EnabledFolders: folders, + EnableMediaPlayback: true, + EnableContentDownloading: true, + }, + Configuration: &UserConfig{}, + } +} + +// displayName falls back to the username when no display name is set. +func displayName(u auth.User) string { + if u.DisplayName != "" { + return u.DisplayName + } + return u.Username +} + // directStreamURL builds the URL a client should play. Video items stream from // /Videos, everything else (audio, books, photos) from /Audio, which is the // same file-serving path either way. The api_key is embedded because media // elements cannot set headers. func directStreamURL(it catalog.Item, apiKey string) string { - base := "/Audio/" + itemID(it.ID) + "/stream" + base := "/Audio/" + ItemID(it.ID) + "/stream" if it.Kind == "video" { - base = "/Videos/" + itemID(it.ID) + "/stream" + base = "/Videos/" + ItemID(it.ID) + "/stream" } if apiKey == "" { return base @@ -290,8 +398,8 @@ func directStreamURL(it catalog.Item, apiKey string) string { return base + "?api_key=" + apiKey } -// msToTicks converts Vellum's milliseconds to Jellyfin's 100ns ticks. -func msToTicks(ms int64) int64 { return ms * 10000 } +// MsToTicks converts Vellum's milliseconds to Jellyfin's 100ns ticks. +func MsToTicks(ms int64) int64 { return ms * 10000 } -// ticksToMS converts Jellyfin's 100ns ticks to Vellum's milliseconds. -func ticksToMS(ticks int64) int64 { return ticks / 10000 } +// TicksToMS converts Jellyfin's 100ns ticks to Vellum's milliseconds. +func TicksToMS(ticks int64) int64 { return ticks / 10000 } diff --git a/internal/jellyfin/ids.go b/internal/jellyfin/dto/ids.go similarity index 79% rename from internal/jellyfin/ids.go rename to internal/jellyfin/dto/ids.go index 4768704..75ca6ca 100644 --- a/internal/jellyfin/ids.go +++ b/internal/jellyfin/dto/ids.go @@ -1,4 +1,4 @@ -package jellyfin +package dto import ( "fmt" @@ -17,17 +17,17 @@ import ( // The mapping is stable and deterministic: the same Vellum row always yields // the same Jellyfin id, which clients rely on for caching and favourites. -// itemID renders an item's Jellyfin id. -func itemID(id int64) string { return fmt.Sprintf("%032x", uint64(id)) } +// ItemID renders an item's Jellyfin id. +func ItemID(id int64) string { return fmt.Sprintf("%032x", uint64(id)) } -// libraryID renders a library's Jellyfin id. The leading 'f' namespaces it +// LibraryID renders a library's Jellyfin id. The leading 'f' namespaces it // away from item ids. -func libraryID(id int64) string { return fmt.Sprintf("f%031x", uint64(id)) } +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. // 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)) if len(s) != 32 { return 0, false, false diff --git a/internal/jellyfin/items.go b/internal/jellyfin/items.go index f7867c8..4d77d5a 100644 --- a/internal/jellyfin/items.go +++ b/internal/jellyfin/items.go @@ -10,6 +10,7 @@ import ( "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" "github.com/mithral/vellum/internal/httpx" + "github.com/mithral/vellum/internal/jellyfin/dto" "github.com/mithral/vellum/internal/library" "github.com/mithral/vellum/internal/stream" ) @@ -66,12 +67,12 @@ func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.Us offset: startIndex, searchTerm: search, 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"), } if parentRaw != "" { - pid, isLib, ok := decodeID(parentRaw) + pid, isLib, ok := dto.DecodeID(parentRaw) if !ok { // An unknown ParentId is an empty page, not an error: clients probe // ids and expect to be told there is nothing there. @@ -106,9 +107,9 @@ func (h *Handlers) handleItems(w http.ResponseWriter, r *http.Request, u auth.Us } apiKey := tokenFromRequest(r) - dtos := make([]BaseItemDto, 0, len(items)) + dtos := make([]dto.BaseItemDto, 0, len(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) } @@ -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 // implemented — see the package notes). 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 { httpx.WriteError(w, http.StatusNotFound, "user not found") 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 // client that treats views as items still works. 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 { httpx.WriteError(w, http.StatusNotFound, "item not found") 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") return } - httpx.WriteJSON(w, http.StatusOK, libraryDTO(lib)) + httpx.WriteJSON(w, http.StatusOK, dto.LibraryDto(lib)) return } @@ -160,7 +161,7 @@ func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.Use if !ok { return } - v := itemView{Item: it} + v := dto.ItemView{Item: it} if err := h.attachFile(r.Context(), &v); err != nil { h.fail(w, "load file", err) 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) 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. @@ -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 // extracted yet simply 404s, which clients treat as "no artwork". func (h *Handlers) handleImagePrimary(w http.ResponseWriter, r *http.Request, u auth.User) { - id, isLib, ok := decodeID(r.PathValue("itemId")) + id, isLib, ok := dto.DecodeID(r.PathValue("itemId")) if !ok || isLib { httpx.WriteError(w, http.StatusNotFound, "no cover available") return @@ -243,47 +244,18 @@ func (h *Handlers) writeViews(w http.ResponseWriter, ctx context.Context, libs [ if end > total { end = total } - items := make([]BaseItemDto, 0, end-startIndex) + items := make([]dto.BaseItemDto, 0, end-startIndex) for _, lib := range list[startIndex:end] { - items = append(items, libraryDTO(lib)) + items = append(items, dto.LibraryDto(lib)) } writeItems(w, startIndex, total, items) } -// 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, - } -} - -// 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) { +func writeItems(w http.ResponseWriter, startIndex, total int, items []dto.BaseItemDto) { if items == nil { - items = []BaseItemDto{} + items = []dto.BaseItemDto{} } - httpx.WriteJSON(w, http.StatusOK, itemQueryResult{ + httpx.WriteJSON(w, http.StatusOK, dto.ItemQueryResult{ Items: items, TotalRecordCount: total, StartIndex: startIndex, diff --git a/internal/jellyfin/playback.go b/internal/jellyfin/playback.go index b65e72e..8d2258d 100644 --- a/internal/jellyfin/playback.go +++ b/internal/jellyfin/playback.go @@ -11,6 +11,7 @@ import ( "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" "github.com/mithral/vellum/internal/httpx" + "github.com/mithral/vellum/internal/jellyfin/dto" "github.com/mithral/vellum/internal/stream" ) @@ -22,7 +23,7 @@ import ( // ErrorCode "NoCompatibleStream" and a warning, rather than by handing back a // TranscodingUrl that would not exist. func (h *Handlers) handlePlaybackInfo(w http.ResponseWriter, r *http.Request, u auth.User) { - id, isLib, ok := decodeID(r.PathValue("itemId")) + id, isLib, ok := dto.DecodeID(r.PathValue("itemId")) if !ok || isLib { httpx.WriteError(w, http.StatusNotFound, "item not found") return @@ -41,8 +42,8 @@ func (h *Handlers) handlePlaybackInfo(w http.ResponseWriter, r *http.Request, u directPlay := queryBoolDefault(q.Get("EnableDirectPlay"), true) directStream := queryBoolDefault(q.Get("EnableDirectStream"), true) - src := buildMediaSource(it, f, tokenFromRequest(r)) - resp := PlaybackInfoResponse{MediaSources: []MediaSourceInfo{src}} + src := dto.BuildMediaSource(it, f, tokenFromRequest(r)) + resp := dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}} if !directPlay && !directStream { resp.ErrorCode = "NoCompatibleStream" h.log.Warn("jellyfin client requires transcoding; refusing", @@ -75,7 +76,7 @@ func (h *Handlers) serveStream(w http.ResponseWriter, r *http.Request, u auth.Us return } - id, isLib, ok := decodeID(r.PathValue("itemId")) + id, isLib, ok := dto.DecodeID(r.PathValue("itemId")) if !ok || isLib { httpx.WriteError(w, http.StatusNotFound, "item not found") return diff --git a/internal/jellyfin/query.go b/internal/jellyfin/query.go index 705470d..7bb36f6 100644 --- a/internal/jellyfin/query.go +++ b/internal/jellyfin/query.go @@ -10,6 +10,7 @@ import ( "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" + "github.com/mithral/vellum/internal/jellyfin/dto" ) // defaultLimit caps an unbounded browse so a client that omits Limit cannot @@ -66,7 +67,7 @@ type listQuery struct { // The browse rows are drained before the per-item file lookups run: catalog // reads one file at a time, and issuing those queries against a still-open // result set is the classic nested-query footgun. -func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]itemView, int, error) { +func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]dto.ItemView, int, error) { where, whereArgs := buildWhere(q) var total int @@ -94,7 +95,7 @@ func (h *Handlers) queryItems(ctx context.Context, q listQuery) ([]itemView, int } defer rows.Close() - out := []itemView{} + out := []dto.ItemView{} for rows.Next() { v, err := scanItem(rows) 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 // that reads media_files. An item with no file keeps a zero File, which is // exactly the defaults the old media_files LEFT JOIN produced. -func (h *Handlers) attachFile(ctx context.Context, v *itemView) error { +func (h *Handlers) attachFile(ctx context.Context, v *dto.ItemView) error { f, err := h.firstFile(ctx, v.Item.ID) if err != nil { 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 // item. It reads no file and performs no access check: catalog.LoadFor already // did both. -func (h *Handlers) loadOverlay(ctx context.Context, userID, itemID int64, v *itemView) error { +func (h *Handlers) loadOverlay(ctx context.Context, userID, itemID int64, v *dto.ItemView) error { row := h.db.QueryRowContext(ctx, `SELECT `+overlayColumns+` `+overlayFrom+` WHERE i.id = ?`, userID, userID, userID, itemID) @@ -158,7 +159,7 @@ func (h *Handlers) loadOverlay(ctx context.Context, userID, itemID int64, v *ite ); err != nil { return err } - v.ParentID = parentID + v.ParentID = parentID.Int64 v.Finished = finished == 1 v.Favorite = favUser != 0 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. -func scanItem(row interface{ Scan(...any) error }) (itemView, error) { +func scanItem(row interface{ Scan(...any) error }) (dto.ItemView, error) { var ( - v itemView + v dto.ItemView parentID sql.NullInt64 finished int favUser int64 @@ -196,9 +197,9 @@ func scanItem(row interface{ Scan(...any) error }) (itemView, error) { &v.PlayCount, &favUser, &hasCover, ) if err != nil { - return itemView{}, err + return dto.ItemView{}, err } - v.ParentID = parentID + v.ParentID = parentID.Int64 v.Finished = finished == 1 v.Favorite = favUser != 0 v.HasCover = hasCover == 1 diff --git a/internal/jellyfin/sessions.go b/internal/jellyfin/sessions.go index 5267bf8..c25f708 100644 --- a/internal/jellyfin/sessions.go +++ b/internal/jellyfin/sessions.go @@ -8,19 +8,10 @@ import ( "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/httpx" + "github.com/mithral/vellum/internal/jellyfin/dto" "github.com/mithral/vellum/internal/progress" ) -// 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) // an item. 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 // existing progress store rather than kept anywhere new. 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 // rejected body would lose the user's position. 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 } - id, isLib, ok := decodeID(rep.ItemId) + id, isLib, ok := dto.DecodeID(rep.ItemId) if !ok || isLib { httpx.WriteError(w, http.StatusBadRequest, "invalid ItemId") return @@ -74,7 +65,7 @@ func (h *Handlers) writePlaybackProgress(w http.ResponseWriter, r *http.Request, } durationMS := f.DurationMS - posMS := ticksToMS(rep.PositionTicks) + posMS := dto.TicksToMS(rep.PositionTicks) if posMS < 0 { posMS = 0 } diff --git a/internal/jellyfin/system.go b/internal/jellyfin/system.go index 53cc736..d909a45 100644 --- a/internal/jellyfin/system.go +++ b/internal/jellyfin/system.go @@ -9,44 +9,23 @@ import ( "github.com/mithral/vellum/internal/apitoken" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/httpx" + "github.com/mithral/vellum/internal/jellyfin/dto" ) -// 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) { - httpx.WriteJSON(w, http.StatusOK, systemInfoPublic{ + httpx.WriteJSON(w, http.StatusOK, dto.SystemInfoPublic{ Id: h.serverIDFor(r.Context()), ServerName: "Vellum", - Version: version, + Version: dto.Version, ProductName: "Vellum", 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) { // Deliberately not httpx.DecodeJSON: that rejects unknown fields, and real // 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 { httpx.WriteError(w, http.StatusBadRequest, "invalid request body") 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) { - id, _, ok := decodeID(r.PathValue("userId")) + id, _, ok := dto.DecodeID(r.PathValue("userId")) if !ok || id != u.ID { // A token may only address its own user. Cross-user reads (which // Jellyfin permits for admins) are NOT implemented; a mismatched id is @@ -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 // 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) if err != nil { - return UserDto{}, err + return dto.UserDto{}, err } - folders := make([]string, 0, len(libs)) - 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 + return dto.User(u, h.serverIDFor(ctx), libs), nil } // deviceLabel best-effort extracts a human device name from the diff --git a/internal/library/library.go b/internal/library/library.go index 2ef06ad..9c9a5c5 100644 --- a/internal/library/library.go +++ b/internal/library/library.go @@ -219,6 +219,12 @@ func PurgeMissing(ctx context.Context, db *sql.DB, libraryID int64) (int64, erro return n, nil } +// RootExists reports whether a library's root is currently reachable. +func RootExists(lib Library) bool { + info, err := os.Stat(lib.RootPath) + return err == nil && info.IsDir() +} + // ── helpers ──────────────────────────────────────────────────────────── func resolveRoot(rootPath string) (string, error) { diff --git a/internal/library/library_test.go b/internal/library/library_test.go index 8754f49..2ab7f99 100644 --- a/internal/library/library_test.go +++ b/internal/library/library_test.go @@ -109,194 +109,3 @@ func TestListVisibleNeverMeansAll(t *testing.T) { 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) - } -} diff --git a/internal/library/registry.go b/internal/library/registry.go index b6702a0..003cdf9 100644 --- a/internal/library/registry.go +++ b/internal/library/registry.go @@ -1,8 +1,12 @@ -// Package library owns library definitions and the scanner that turns files on -// disk into rows in media_items / media_files. +// Package library owns library definitions — create, list, delete — and the +// media-kind registry that says which file extensions each kind of library owns. // -// Modules declare what file extensions they care about via RegisterType; this -// package never imports a module. +// The registry lives here, not in internal/scanner, because it is not the +// scanner's business: modules register their types, the admin HTTP surface +// serves them, and the metadata enricher reads the grouping mode. Only one of +// those three is the scanner, so the registry belongs in the lower layer that +// all three already depend on. The scanner imports it from here; nothing here +// imports the scanner. package library import ( @@ -134,8 +138,11 @@ func GroupModeFor(kind string) GroupMode { return GroupByFile } -// extensionsFor returns the extension set owned by a library kind. -func extensionsFor(kind string) map[string]bool { +// ExtensionsFor returns the extension set owned by a library kind, each entry +// lowercase with a leading dot. The scanner walks a tree by testing every file +// name against this set, which is why it lives in the registry rather than in +// the scanner: the answer is a property of the kind, not of the scan. +func ExtensionsFor(kind string) map[string]bool { typesMu.RLock() defer typesMu.RUnlock() t, ok := types[kind] diff --git a/internal/library/scan_test.go b/internal/library/scan_test.go deleted file mode 100644 index be37c2b..0000000 --- a/internal/library/scan_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/media/fixtures_test.go b/internal/media/fixtures_test.go new file mode 100644 index 0000000..d599ec6 --- /dev/null +++ b/internal/media/fixtures_test.go @@ -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 +} diff --git a/internal/media/handlers.go b/internal/media/handlers.go index d2c22f2..4eb199e 100644 --- a/internal/media/handlers.go +++ b/internal/media/handlers.go @@ -8,9 +8,11 @@ import ( "os" "path/filepath" "strconv" + "strings" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" + "github.com/mithral/vellum/internal/home" "github.com/mithral/vellum/internal/httpx" "github.com/mithral/vellum/internal/progress" "github.com/mithral/vellum/internal/stream" @@ -31,8 +33,10 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir s } // Register mounts the routes. +// +// The home screen is not registered here: it is an aggregate over items, not an +// item resource, and it lives in internal/home. func (h *Handlers) Register(mux *http.ServeMux) { - mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome)) mux.Handle("GET /api/v1/items", h.auth.Require(h.handleListItems)) mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem)) mux.Handle("POST /api/v1/items/{id}/view", h.auth.Require(h.handleRecordView)) @@ -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}) } +// handleRecordView increments the user's view count for an item. +// +// The counting SQL lives in internal/home, which owns the shelves that read +// these numbers back; only the item-scoped route stays here. +func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + views, err := home.RecordView(r.Context(), h.db, u.ID, it.ID) + if err != nil { + h.fail(w, "record view", err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"item_id": it.ID, "views": views}) +} + +// handleAuthors returns the people credited on an item. +func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) { + it, ok := h.authorized(w, r, u) + if !ok { + return + } + authors, err := h.authors(r, it.ID) + if err != nil { + h.fail(w, "authors", err) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors}) +} + // ── helpers ──────────────────────────────────────────────────────────── +// inClause builds "?,?,?" plus the matching args for an IN list. +// +// Duplicated from internal/home on purpose: a browse list is an item resource, +// and importing the home-screen package to borrow a query-string helper would +// invert the dependency for three lines. +func inClause(ids []int64) (string, []any) { + placeholders := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + args[i] = id + } + return strings.Join(placeholders, ","), args +} + // authorized loads an item and confirms the caller may read its library. // On failure it has already written the response. // diff --git a/internal/media/media.go b/internal/media/media.go index fda97f0..a2bac72 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -1,6 +1,11 @@ -// Package media serves generic item endpoints: detail, the raw file, cover -// art, and progress. It knows nothing about any specific media format; modules -// register cover extractors and expose their own metadata endpoints. +// Package media serves generic item RESOURCES: the browse list, one item's +// detail, its raw file, cover art, progress, credits and view counts. It knows +// nothing about any specific media format; modules register cover extractors +// and expose their own metadata endpoints. +// +// What the landing screen shows is deliberately NOT here. The home shelves are +// AGGREGATES over many items rather than one resource, they answer a different +// question, and they have different callers, so they live in internal/home. // // Item and file ACCESS does not live here any more — it lives in // internal/catalog, so that the web app, the modules, Jellyfin and Subsonic all diff --git a/internal/metadata/enrich.go b/internal/metadata/enrich.go index 6b65754..81c7514 100644 --- a/internal/metadata/enrich.go +++ b/internal/metadata/enrich.go @@ -1,3 +1,8 @@ +// Package metadata enriches catalogue items from external providers. +// +// It owns the policy: which provider to try, whether a fuzzy candidate is +// actually the item being asked about, and what gets written back. The HTTP +// clients live in internal/providers, which knows nothing about the database. package metadata import ( @@ -19,6 +24,7 @@ import ( "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/library" "github.com/mithral/vellum/internal/media" + "github.com/mithral/vellum/internal/providers" ) // JobKind is the queue job that enriches a single item. @@ -41,7 +47,7 @@ type Enricher struct { // providers resolves the provider list for a media kind. It defaults to // the global registry; tests override it to avoid network and global state. - providers func(kind string) []Provider + providers func(kind string) []providers.Provider } // NewEnricher builds the enricher. @@ -51,7 +57,7 @@ func NewEnricher(db *sql.DB, log *slog.Logger, cacheDir string) *Enricher { log: log, cacheDir: cacheDir, client: &http.Client{Timeout: 20 * time.Second}, - providers: For, + providers: providers.For, } } @@ -100,13 +106,13 @@ func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error { return nil } - providers := e.providers(it.Kind) - if len(providers) == 0 { + provs := e.providers(it.Kind) + if len(provs) == 0 { return nil } - q := Query{Title: searchTitle(it.Title), Author: artistHint(it), Kind: it.Kind} - for _, p := range providers { + q := providers.Query{Title: searchTitle(it.Title), Author: artistHint(it), Kind: it.Kind} + for _, p := range provs { if e.triedRecently(ctx, itemID, p.Name()) { continue } @@ -201,10 +207,10 @@ func searchTitle(s string) string { // direction) but not absent: it accepts "The Hobbit" against "The Hobbit, or // There and Back Again" and rejects "Random Access Memories" against an // unrelated album that merely shares a word. -func pickBest(candidates []Candidate, want string) (Candidate, bool) { +func pickBest(candidates []providers.Candidate, want string) (providers.Candidate, bool) { w := normalizeTitle(want) if len(w) < 3 { - return Candidate{}, false + return providers.Candidate{}, false } for _, c := range candidates { got := normalizeTitle(c.Title) @@ -215,7 +221,7 @@ func pickBest(candidates []Candidate, want string) (Candidate, bool) { return c, true } } - return Candidate{}, false + return providers.Candidate{}, false } // normalizeTitle lowercases and strips punctuation so titles can be compared @@ -243,7 +249,7 @@ func normalizeTitle(s string) string { } // apply writes a chosen candidate back to the database. -func (e *Enricher) apply(ctx context.Context, it 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) if err != nil { 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. -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 if err := e.db.QueryRowContext(ctx, `SELECT count(*) FROM assets WHERE item_id = ? AND role = 'cover'`, it.ID).Scan(&existing); err != nil { diff --git a/internal/metadata/enrich_test.go b/internal/metadata/enrich_test.go index f9bb8c9..f230ea9 100644 --- a/internal/metadata/enrich_test.go +++ b/internal/metadata/enrich_test.go @@ -11,6 +11,7 @@ import ( "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/media" + "github.com/mithral/vellum/internal/providers" "github.com/mithral/vellum/migrations" ) @@ -56,22 +57,22 @@ func seedItem(t *testing.T, db *sql.DB, title string) int64 { type fakeProvider struct { name string kinds []string - results []Candidate + results []providers.Candidate err error calls int } func (f *fakeProvider) Name() string { return f.name } func (f *fakeProvider) Kinds() []string { return f.kinds } -func (f *fakeProvider) Search(context.Context, Query) ([]Candidate, error) { +func (f *fakeProvider) Search(context.Context, providers.Query) ([]providers.Candidate, error) { f.calls++ return f.results, f.err } -func enricherWith(t *testing.T, db *sql.DB, provs ...Provider) *Enricher { +func enricherWith(t *testing.T, db *sql.DB, provs ...providers.Provider) *Enricher { t.Helper() e := NewEnricher(db, discard(), t.TempDir()) - e.providers = func(string) []Provider { return provs } + e.providers = func(string) []providers.Provider { return provs } return e } @@ -82,7 +83,7 @@ func TestEnrichAppliesCandidate(t *testing.T) { fake := &fakeProvider{ name: "fake", kinds: []string{"ebooks"}, - results: []Candidate{{ + results: []providers.Candidate{{ Provider: "fake", ExternalID: "OL123W", Title: "The Hobbit", @@ -148,7 +149,7 @@ func TestEnrichFallsBackToSecondProvider(t *testing.T) { good := &fakeProvider{ name: "good", kinds: []string{"ebooks"}, - results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}}, + results: []providers.Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}}, } e := enricherWith(t, db, empty, good) @@ -177,7 +178,7 @@ func TestEnrichContinuesAfterProviderError(t *testing.T) { good := &fakeProvider{ name: "good", kinds: []string{"ebooks"}, - results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}}, + results: []providers.Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}}, } e := enricherWith(t, db, broken, good) @@ -203,7 +204,7 @@ func TestEnrichSkipsAlreadyMatched(t *testing.T) { itemID := seedItem(t, db, "book") fake := &fakeProvider{name: "fake", kinds: []string{"ebooks"}, - results: []Candidate{{Provider: "fake", ExternalID: "x", Title: "Book"}}} + results: []providers.Candidate{{Provider: "fake", ExternalID: "x", Title: "Book"}}} e := enricherWith(t, db, fake) if err := e.EnrichItem(context.Background(), itemID); err != nil { @@ -277,7 +278,7 @@ func TestSearchTitleStripsReleaseTags(t *testing.T) { } func TestPickBestRejectsUnrelated(t *testing.T) { - candidates := []Candidate{ + candidates := []providers.Candidate{ {Title: "Random Access Memories", ExternalID: "right"}, } @@ -287,7 +288,7 @@ func TestPickBestRejectsUnrelated(t *testing.T) { } // Containment in either direction is accepted. - long := []Candidate{{Title: "The Hobbit, or There and Back Again"}} + long := []providers.Candidate{{Title: "The Hobbit, or There and Back Again"}} if _, ok := pickBest(long, "The Hobbit"); !ok { t.Error("containment should be accepted") } diff --git a/internal/metadata/comicvine.go b/internal/providers/comicvine.go similarity index 99% rename from internal/metadata/comicvine.go rename to internal/providers/comicvine.go index 25ef847..0df0af7 100644 --- a/internal/metadata/comicvine.go +++ b/internal/providers/comicvine.go @@ -1,4 +1,4 @@ -package metadata +package providers import ( "context" diff --git a/internal/metadata/comicvine_test.go b/internal/providers/comicvine_test.go similarity index 99% rename from internal/metadata/comicvine_test.go rename to internal/providers/comicvine_test.go index 7671b37..d2b4916 100644 --- a/internal/metadata/comicvine_test.go +++ b/internal/providers/comicvine_test.go @@ -1,4 +1,4 @@ -package metadata +package providers import ( "context" diff --git a/internal/metadata/googlebooks.go b/internal/providers/googlebooks.go similarity index 99% rename from internal/metadata/googlebooks.go rename to internal/providers/googlebooks.go index fdd19f4..2ff605c 100644 --- a/internal/metadata/googlebooks.go +++ b/internal/providers/googlebooks.go @@ -1,4 +1,4 @@ -package metadata +package providers import ( "context" diff --git a/internal/metadata/igdb.go b/internal/providers/igdb.go similarity index 99% rename from internal/metadata/igdb.go rename to internal/providers/igdb.go index 87ac46e..3477755 100644 --- a/internal/metadata/igdb.go +++ b/internal/providers/igdb.go @@ -1,4 +1,4 @@ -package metadata +package providers import ( "context" diff --git a/internal/metadata/igdb_test.go b/internal/providers/igdb_test.go similarity index 99% rename from internal/metadata/igdb_test.go rename to internal/providers/igdb_test.go index dbbb2b9..e68fd47 100644 --- a/internal/metadata/igdb_test.go +++ b/internal/providers/igdb_test.go @@ -1,4 +1,4 @@ -package metadata +package providers import ( "context" diff --git a/internal/metadata/musicbrainz.go b/internal/providers/musicbrainz.go similarity index 99% rename from internal/metadata/musicbrainz.go rename to internal/providers/musicbrainz.go index c284558..3cef549 100644 --- a/internal/metadata/musicbrainz.go +++ b/internal/providers/musicbrainz.go @@ -1,4 +1,4 @@ -package metadata +package providers import ( "context" diff --git a/internal/metadata/openlibrary.go b/internal/providers/openlibrary.go similarity index 89% rename from internal/metadata/openlibrary.go rename to internal/providers/openlibrary.go index 28b9568..94616ee 100644 --- a/internal/metadata/openlibrary.go +++ b/internal/providers/openlibrary.go @@ -1,4 +1,4 @@ -package metadata +package providers import ( "context" @@ -13,12 +13,17 @@ import ( // OpenLibrary matches books by title and author. No API key, no registration. type OpenLibrary struct { - Client *http.Client + // BaseURL is overridable so tests can point at a local server. + BaseURL string + Client *http.Client } // NewOpenLibrary builds the provider with a bounded HTTP client. func NewOpenLibrary() *OpenLibrary { - return &OpenLibrary{Client: &http.Client{Timeout: 10 * time.Second}} + return &OpenLibrary{ + BaseURL: "https://openlibrary.org", + Client: &http.Client{Timeout: 10 * time.Second}, + } } func (p *OpenLibrary) Name() string { return "openlibrary" } @@ -57,7 +62,7 @@ func (p *OpenLibrary) Search(ctx context.Context, q Query) ([]Candidate, error) return nil, nil } - endpoint := "https://openlibrary.org/search.json?" + params.Encode() + endpoint := strings.TrimRight(p.BaseURL, "/") + "/search.json?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err diff --git a/internal/providers/openlibrary_test.go b/internal/providers/openlibrary_test.go new file mode 100644 index 0000000..66b5fcb --- /dev/null +++ b/internal/providers/openlibrary_test.go @@ -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") + } +} diff --git a/internal/metadata/provider.go b/internal/providers/provider.go similarity index 82% rename from internal/metadata/provider.go rename to internal/providers/provider.go index acb7c6c..e18ac9b 100644 --- a/internal/metadata/provider.go +++ b/internal/providers/provider.go @@ -1,9 +1,14 @@ -// Package metadata resolves media items against external catalogues. +// Package providers talks to external metadata catalogues. +// +// One client per service, no database, no policy: a provider turns a Query into +// candidates and knows nothing about what the caller does with them. Matching +// policy and persistence belong to the caller (internal/metadata), which is +// what keeps these clients testable against an httptest server alone. // // Providers are optional by design: a library with no network still browses, // because a failed lookup is recorded and skipped, never surfaced as a scan or // request failure. -package metadata +package providers import ( "context" diff --git a/internal/library/scan.go b/internal/scanner/scan.go similarity index 91% rename from internal/library/scan.go rename to internal/scanner/scan.go index 7331e71..3dfaa29 100644 --- a/internal/library/scan.go +++ b/internal/scanner/scan.go @@ -1,4 +1,14 @@ -package library +// Package scanner walks a library's root and reconciles it with the database: +// files that appeared become items, files that changed are re-upserted with +// fresh codec facts, and files that vanished are flagged missing rather than +// deleted. +// +// It is separate from internal/library because the two have different callers. +// A scan runs in a background job with no user and no HTTP request, tolerates a +// partially readable tree, and must never delete; library CRUD is an admin +// surface where a bad request is a 400 and a bad path is a 404. Scanner imports +// library (for Library and the media-kind registry), never the other way round. +package scanner import ( "context" @@ -6,7 +16,6 @@ import ( "fmt" "io/fs" "log/slog" - "os" "path" "path/filepath" "sort" @@ -16,6 +25,7 @@ import ( "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/jobs" + "github.com/mithral/vellum/internal/library" "github.com/mithral/vellum/internal/natsort" "github.com/mithral/vellum/internal/probe" ) @@ -87,11 +97,11 @@ type group struct { // rather than deleted, so their progress survives a temporarily unavailable // mount. A scan never fails because of a single unreadable file — it records // the error and continues, because a partial library is better than none. -func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanResult, error) { +func Scan(ctx context.Context, db *sql.DB, lib library.Library, log *slog.Logger) (ScanResult, error) { start := time.Now() res := ScanResult{LibraryID: lib.ID} - exts := extensionsFor(lib.Kind) + exts := library.ExtensionsFor(lib.Kind) if len(exts) == 0 { return res, fmt.Errorf("library %d: no extensions registered for kind %q", lib.ID, lib.Kind) } @@ -251,7 +261,7 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR res.Duration = time.Since(start).Milliseconds() log.Info("scan complete", - "library", lib.Name, "kind", lib.Kind, "grouping", GroupModeFor(lib.Kind), + "library", lib.Name, "kind", lib.Kind, "grouping", library.GroupModeFor(lib.Kind), "scanned", res.Scanned, "added", res.Added, "updated", res.Updated, "restored", res.Restored, "missing", res.Missing, "unchanged", res.Unchanged, "duration_ms", res.Duration, "errors", len(res.Errors), @@ -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, // music) collapse a folder of tracks into one item; everything else is one item // per file. -func buildGroups(lib Library, found map[string]candidate) map[string]group { - mode := GroupModeFor(lib.Kind) +func buildGroups(lib library.Library, found map[string]candidate) map[string]group { + mode := library.GroupModeFor(lib.Kind) groups := make(map[string]group) for _, rel := range sortedCandidateKeys(found) { c := found[rel] var key, title string - if mode == GroupByDirectory { + if mode == library.GroupByDirectory { dir := path.Dir(rel) if dir == "." { key, title = ".", lib.Name } else { - key, title = dir, CleanTitle(path.Base(dir)) + key, title = dir, library.CleanTitle(path.Base(dir)) } } else { - key, title = rel, CleanTitle(rel) + key, title = rel, library.CleanTitle(rel) } g := groups[key] @@ -428,7 +438,7 @@ func loadKnownItems(ctx context.Context, db *sql.DB, libraryID int64) (map[strin return out, rows.Err() } -func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, key, title string) (int64, error) { +func upsertItem(ctx context.Context, tx *sql.Tx, lib library.Library, key, title string) (int64, error) { var id int64 err := tx.QueryRowContext(ctx, ` INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, 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 // error mechanism and the file is written with empty codec fields rather than // 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() { return nil } @@ -590,9 +600,3 @@ func parseUnixNano(s string) int64 { } return n } - -// RootExists reports whether a library's root is currently reachable. -func RootExists(lib Library) bool { - info, err := os.Stat(lib.RootPath) - return err == nil && info.IsDir() -} diff --git a/internal/scanner/scan_test.go b/internal/scanner/scan_test.go new file mode 100644 index 0000000..a71afe9 --- /dev/null +++ b/internal/scanner/scan_test.go @@ -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) + } +} diff --git a/internal/subsonic/browse.go b/internal/subsonic/browse.go index b6aa423..d94acc3 100644 --- a/internal/subsonic/browse.go +++ b/internal/subsonic/browse.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/mithral/vellum/internal/auth" + "github.com/mithral/vellum/internal/subsonic/dto" ) // 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 == "" { - h.fail(w, r, errDataNotFound, "directory not found") + h.fail(w, r, dto.ErrDataNotFound, "directory not found") return } h.writeDirectory(w, r, u, libID, "", name) @@ -40,7 +41,7 @@ func (h *Handlers) directoryForPath(w http.ResponseWriter, r *http.Request, u au return } if !containsID(libs, libID) { - h.fail(w, r, errDataNotFound, "directory not found") + h.fail(w, r, dto.ErrDataNotFound, "directory not found") return } rel = normalizeRel(rel) @@ -118,10 +119,10 @@ func (h *Handlers) writeDirectory(w http.ResponseWriter, r *http.Request, u auth return } - dirNode := el("directory", - at("id", dirID(libID, rel)), - at("name", name), - ).arrays("child") + dirNode := dto.El("directory", + dto.At("id", dto.DirID(libID, rel)), + dto.At("name", name), + ).Arrays("child") subNames := make([]string, 0, len(subdirs)) for s := range subdirs { @@ -133,13 +134,13 @@ func (h *Handlers) writeDirectory(w http.ResponseWriter, r *http.Request, u auth if rel != "." { sub = rel + "/" + seg } - dirNode.add(el("child", - at("id", dirID(libID, sub)), - at("parent", dirID(libID, rel)), - at("isDir", true), - at("title", path.Base(seg)), - at("album", path.Base(seg)), - at("artist", ""), + dirNode.Add(dto.El("child", + dto.At("id", dto.DirID(libID, sub)), + dto.At("parent", dto.DirID(libID, rel)), + dto.At("isDir", true), + dto.At("title", path.Base(seg)), + dto.At("album", path.Base(seg)), + dto.At("artist", ""), )) } @@ -149,11 +150,11 @@ func (h *Handlers) writeDirectory(w http.ResponseWriter, r *http.Request, u auth return } for _, a := range albums { - dirNode.add(childNode(a)) + dirNode.Add(dto.ChildNode(a)) } root := envelope("ok") - root.add(dirNode) + root.Add(dirNode) h.write(w, r, root) } @@ -165,7 +166,7 @@ func (h *Handlers) directoryForAlbum(w http.ResponseWriter, r *http.Request, u a return } if !ok { - h.fail(w, r, errDataNotFound, "directory not found") + h.fail(w, r, dto.ErrDataNotFound, "directory not found") return } 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) return } - dirNode := el("directory", - at("id", albumID(a.ID)), - at("parent", dirID(a.LibraryID, parentDirOf(a.Path))), - at("name", a.Title), - ).arrays("child") + dirNode := dto.El("directory", + dto.At("id", dto.AlbumID(a.ID)), + dto.At("parent", dto.DirID(a.LibraryID, parentDirOf(a.Path))), + dto.At("name", a.Title), + ).Arrays("child") for _, t := range tracks { - child := songNode(t) - child.name = "child" - dirNode.add(child) + child := dto.SongNode(t) + child.Name = "child" + dirNode.Add(child) } root := envelope("ok") - root.add(dirNode) + root.Add(dirNode) h.write(w, r, root) } @@ -203,28 +204,28 @@ func (h *Handlers) directoryForArtist(w http.ResponseWriter, r *http.Request, u return } if len(albums) == 0 { - h.fail(w, r, errDataNotFound, "directory not found") + h.fail(w, r, dto.ErrDataNotFound, "directory not found") return } name, _ := h.artistName(r.Context(), personID) - dirNode := el("directory", - at("id", artistID(personID)), - at("name", name), - ).arrays("child") + dirNode := dto.El("directory", + dto.At("id", dto.ArtistID(personID)), + dto.At("name", name), + ).Arrays("child") for _, a := range albums { - dirNode.add(childNode(a)) + dirNode.Add(dto.ChildNode(a)) } root := envelope("ok") - root.add(dirNode) + root.Add(dirNode) h.write(w, r, root) } // albumsByIDs loads the album projection for a specific id set within one // library, preserving album-title order. -func (h *Handlers) albumsByIDs(ctx context.Context, libID int64, ids []int64) ([]albumInfo, error) { +func (h *Handlers) albumsByIDs(ctx context.Context, libID int64, ids []int64) ([]dto.AlbumInfo, error) { if len(ids) == 0 { - return []albumInfo{}, nil + return []dto.AlbumInfo{}, nil } in, args := inClause(ids) return h.albumsWhere(ctx, []int64{libID}, " AND i.id IN ("+in+")", args, "i.sort_title", 500, 0) diff --git a/internal/subsonic/dto/ids.go b/internal/subsonic/dto/ids.go new file mode 100644 index 0000000..0824972 --- /dev/null +++ b/internal/subsonic/dto/ids.go @@ -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- an album / directory-grouped item +// mf- a song, i.e. one physical media_files row +// ar- an artist, from people/item_people +// fd- a music folder (a whole audio library) +// d-: 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-:". 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 . +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 +} diff --git a/internal/subsonic/dto/node.go b/internal/subsonic/dto/node.go new file mode 100644 index 0000000..c5bc308 --- /dev/null +++ b/internal/subsonic/dto/node.go @@ -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 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, "") + 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 "" + } +} diff --git a/internal/subsonic/handlers.go b/internal/subsonic/handlers.go index a1f16f7..69bfa6d 100644 --- a/internal/subsonic/handlers.go +++ b/internal/subsonic/handlers.go @@ -12,6 +12,7 @@ import ( "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/progress" "github.com/mithral/vellum/internal/stream" + "github.com/mithral/vellum/internal/subsonic/dto" ) // ignoredArticles is advertised in getIndexes/getArtists. It is the list the @@ -54,7 +55,7 @@ func (h *Handlers) Register(mux *http.ServeMux) { } 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/.view call. Every method @@ -66,7 +67,7 @@ func (h *Handlers) dispatch(w http.ResponseWriter, r *http.Request) { if !ok { // The single most common integration failure: a bad credential must be // an envelope with code 40, not an HTTP 401. Clients read the envelope. - h.fail(w, r, errWrongCredentials, "Wrong username or password") + h.fail(w, r, dto.ErrWrongCredentials, "Wrong username or password") return } @@ -106,7 +107,7 @@ func (h *Handlers) dispatch(w http.ResponseWriter, r *http.Request) { case "getplaylists": h.handleGetPlaylists(w, r) 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") // Vellum is not Subsonic and has no licence or trial. Clients only check // `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) } @@ -132,12 +133,12 @@ func (h *Handlers) handleGetMusicFolders(w http.ResponseWriter, r *http.Request, h.internal(w, r, "music folders", err) return } - folders := el("musicFolders").arrays("musicFolder") + folders := dto.El("musicFolders").Arrays("musicFolder") 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.add(folders) + root.Add(folders) 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) return } - indexes := el("indexes", at("ignoredArticles", ignoredArticles)).arrays("index") + indexes := dto.El("indexes", dto.At("ignoredArticles", ignoredArticles)).Arrays("index") addIndexes(indexes, artists, false) root := envelope("ok") - root.add(indexes) + root.Add(indexes) 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) return } - node := el("artists", at("ignoredArticles", ignoredArticles)).arrays("index") + node := dto.El("artists", dto.At("ignoredArticles", ignoredArticles)).Arrays("index") addIndexes(node, artists, true) root := envelope("ok") - root.add(node) + root.Add(node) h.write(w, r, root) } 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 { // The synthetic "unknown artist" bucket uses ar-0. - if r.FormValue("id") != prefixArtist+"0" { - h.fail(w, r, errDataNotFound, "artist not found") + if r.FormValue("id") != dto.PrefixArtist+"0" { + h.fail(w, r, dto.ErrDataNotFound, "artist not found") return } personID = 0 @@ -189,31 +190,31 @@ func (h *Handlers) handleGetArtist(w http.ResponseWriter, r *http.Request, u aut return } if len(albums) == 0 { - h.fail(w, r, errDataNotFound, "artist not found") + h.fail(w, r, dto.ErrDataNotFound, "artist not found") return } name, _ := h.artistName(r.Context(), personID) - artist := el("artist", - at("id", artistID(personID)), - at("name", name), - at("albumCount", int64(len(albums))), - ).arrays("album") + artist := dto.El("artist", + dto.At("id", dto.ArtistID(personID)), + dto.At("name", name), + dto.At("albumCount", int64(len(albums))), + ).Arrays("album") if cover := firstCover(albums); cover != 0 { - artist.attrs = append(artist.attrs, at("coverArt", albumID(cover))) + artist.Attrs = append(artist.Attrs, dto.At("coverArt", dto.AlbumID(cover))) } for _, a := range albums { - artist.add(albumNode(a)) + artist.Add(dto.AlbumNode(a)) } root := envelope("ok") - root.add(artist) + root.Add(artist) h.write(w, r, root) } 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 { - h.fail(w, r, errDataNotFound, "album not found") + h.fail(w, r, dto.ErrDataNotFound, "album not found") return } 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 } if !ok { - h.fail(w, r, errDataNotFound, "album not found") + h.fail(w, r, dto.ErrDataNotFound, "album not found") return } 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) return } - node := albumNode(a).arrays("song") + node := dto.AlbumNode(a).Arrays("song") for _, t := range tracks { - node.add(songNode(t)) + node.Add(dto.SongNode(t)) } root := envelope("ok") - root.add(node) + root.Add(node) h.write(w, r, root) } 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 { - h.fail(w, r, errDataNotFound, "song not found") + h.fail(w, r, dto.ErrDataNotFound, "song not found") return } 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 } if !ok { - h.fail(w, r, errDataNotFound, "song not found") + h.fail(w, r, dto.ErrDataNotFound, "song not found") return } root := envelope("ok") - root.add(songNode(t)) + root.Add(dto.SongNode(t)) h.write(w, r, root) } @@ -268,36 +269,36 @@ func (h *Handlers) handleGetMusicDirectory(w http.ResponseWriter, r *http.Reques id := r.FormValue("id") switch { - case strings.HasPrefix(id, prefixFolder): - libID, ok := parseID(id, prefixFolder) + case strings.HasPrefix(id, dto.PrefixFolder): + libID, ok := dto.ParseID(id, dto.PrefixFolder) if !ok { - h.fail(w, r, errDataNotFound, "directory not found") + h.fail(w, r, dto.ErrDataNotFound, "directory not found") return } h.directoryForLibrary(w, r, u, libID) - case strings.HasPrefix(id, prefixDir): - libID, rel, ok := parseDirID(id) + case strings.HasPrefix(id, dto.PrefixDir): + libID, rel, ok := dto.ParseDirID(id) if !ok { - h.fail(w, r, errDataNotFound, "directory not found") + h.fail(w, r, dto.ErrDataNotFound, "directory not found") return } h.directoryForPath(w, r, u, libID, rel) - case strings.HasPrefix(id, prefixAlbum): - itemID, ok := parseID(id, prefixAlbum) + case strings.HasPrefix(id, dto.PrefixAlbum): + itemID, ok := dto.ParseID(id, dto.PrefixAlbum) if !ok { - h.fail(w, r, errDataNotFound, "directory not found") + h.fail(w, r, dto.ErrDataNotFound, "directory not found") return } h.directoryForAlbum(w, r, u, itemID) - case strings.HasPrefix(id, prefixArtist): - personID, ok := parseID(id, prefixArtist) + case strings.HasPrefix(id, dto.PrefixArtist): + personID, ok := dto.ParseID(id, dto.PrefixArtist) if !ok { - h.fail(w, r, errDataNotFound, "directory not found") + h.fail(w, r, dto.ErrDataNotFound, "directory not found") return } h.directoryForArtist(w, r, u, personID) 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 } - result := el("searchResult3").arrays("artist", "album", "song") + result := dto.El("searchResult3").Arrays("artist", "album", "song") 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 { - 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 { - result.add(albumNode(a)) + result.Add(dto.AlbumNode(a)) } for _, s := range songs { - result.add(songNode(s)) + result.Add(dto.SongNode(s)) } root := envelope("ok") - root.add(result) + root.Add(result) h.write(w, r, root) } @@ -397,12 +398,12 @@ func (h *Handlers) handleGetAlbumList2(w http.ResponseWriter, r *http.Request, u return } - list := el("albumList2").arrays("album") + list := dto.El("albumList2").Arrays("album") for _, a := range albums { - list.add(albumNode(a)) + list.Add(dto.AlbumNode(a)) } root := envelope("ok") - root.add(list) + root.Add(list) 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) return } - node := el("randomSongs").arrays("song") + node := dto.El("randomSongs").Arrays("song") for _, s := range songs { - node.add(songNode(s)) + node.Add(dto.SongNode(s)) } root := envelope("ok") - root.add(node) + root.Add(node) 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) { format := strings.ToLower(strings.TrimSpace(r.FormValue("format"))) 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 } // 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 } if !ok { - h.fail(w, r, errDataNotFound, "song not found") + h.fail(w, r, dto.ErrDataNotFound, "song not found") return } if err := stream.ServeFile(w, r, abs); err != nil { if errors.Is(err, os.ErrNotExist) { - h.fail(w, r, errDataNotFound, "file is not available") + h.fail(w, r, dto.ErrDataNotFound, "file is not available") return } 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 } if !ok { - h.fail(w, r, errDataNotFound, "cover art not found") + h.fail(w, r, dto.ErrDataNotFound, "cover art not found") return } @@ -477,7 +478,7 @@ func (h *Handlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request, u a err = h.db.QueryRowContext(r.Context(), `SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, itemID).Scan(&coverPath) if errors.Is(err, sql.ErrNoRows) { - h.fail(w, r, errDataNotFound, "cover art not found") + h.fail(w, r, dto.ErrDataNotFound, "cover art not found") return } if err != nil { @@ -485,7 +486,7 @@ func (h *Handlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request, u a return } 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 } 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) { ids := r.Form["id"] if len(ids) == 0 { - h.fail(w, r, errMissingParameter, "Required parameter is missing") + h.fail(w, r, dto.ErrMissingParameter, "Required parameter is missing") return } submission := strings.ToLower(r.FormValue("submission")) != "false" for _, id := range ids { - fileIDVal, ok := parseID(id, prefixFile) + fileIDVal, ok := dto.ParseID(id, dto.PrefixFile) if !ok { continue } @@ -560,12 +561,12 @@ func (h *Handlers) handleGetUser(w http.ResponseWriter, r *http.Request, u auth. // behaviour clients expect when they omit it. if target != "" && !strings.EqualFold(target, u.Username) { 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 } other, err := auth.GetUserByUsername(r.Context(), h.db, target) if errors.Is(err, sql.ErrNoRows) { - h.fail(w, r, errDataNotFound, "user not found") + h.fail(w, r, dto.ErrDataNotFound, "user not found") return } if err != nil { @@ -581,28 +582,28 @@ func (h *Handlers) handleGetUser(w http.ResponseWriter, r *http.Request, u auth. return } - user := el("user", - at("username", u.Username), - at("email", ""), - at("scrobblingEnabled", true), - at("adminRole", u.IsAdmin), - at("settingsRole", u.IsAdmin), - at("downloadRole", true), - at("uploadRole", false), - at("playlistRole", true), - at("coverArtRole", true), - at("commentRole", false), - at("podcastRole", false), - at("streamRole", true), - at("jukeboxRole", false), - at("shareRole", false), - at("videoConversionRole", false), - ).arrays("folder") + user := dto.El("user", + dto.At("username", u.Username), + dto.At("email", ""), + dto.At("scrobblingEnabled", true), + dto.At("adminRole", u.IsAdmin), + dto.At("settingsRole", u.IsAdmin), + dto.At("downloadRole", true), + dto.At("uploadRole", false), + dto.At("playlistRole", true), + dto.At("coverArtRole", true), + dto.At("commentRole", false), + dto.At("podcastRole", false), + dto.At("streamRole", true), + dto.At("jukeboxRole", false), + dto.At("shareRole", false), + dto.At("videoConversionRole", false), + ).Arrays("folder") for _, id := range libIDs { - user.add(el("folder", at("id", id))) + user.Add(dto.El("folder", dto.At("id", id))) } root := envelope("ok") - root.add(user) + root.Add(user) 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. func (h *Handlers) handleGetPlaylists(w http.ResponseWriter, r *http.Request) { root := envelope("ok") - root.add(el("playlists").arrays("playlist")) + root.Add(dto.El("playlists").Arrays("playlist")) 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) { h.log.Error("subsonic: "+what, "err", err) - h.fail(w, r, errGeneric, "internal server error") + h.fail(w, r, dto.ErrGeneric, "internal server error") } diff --git a/internal/subsonic/ids.go b/internal/subsonic/ids.go deleted file mode 100644 index dfc94da..0000000 --- a/internal/subsonic/ids.go +++ /dev/null @@ -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- an album / directory-grouped item -// mf- a song, i.e. one physical media_files row -// ar- an artist, from people/item_people -// fd- a music folder (a whole audio library) -// d-: 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-:". 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 . -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 -} diff --git a/internal/subsonic/node.go b/internal/subsonic/node.go index dc8c1e8..b0cf375 100644 --- a/internal/subsonic/node.go +++ b/internal/subsonic/node.go @@ -1,182 +1,24 @@ package subsonic import ( - "bytes" - "encoding/json" - "encoding/xml" - "io" "net/http" - "strconv" + + "github.com/mithral/vellum/internal/subsonic/dto" ) -// 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 -} - -// ── 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) { +// writeJSONResponse writes the JSON envelope. +func writeJSONResponse(w http.ResponseWriter, root *dto.Node) { w.Header().Set("Content-Type", "application/json; charset=utf-8") - enc := json.NewEncoder(w) - enc.SetEscapeHTML(false) - _ = enc.Encode(map[string]any{"subsonic-response": nodeJSON(root)}) + body, err := dto.JSONBody(root) + if err != nil { + return + } + _, _ = w.Write(body) } -// ── XML ──────────────────────────────────────────────────────────────── - // writeXMLResponse writes the XML envelope, including the namespace the XSD // 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") - _, _ = io.WriteString(w, xml.Header) - _ = 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, "") - 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 "" - } + _, _ = w.Write(dto.XMLBody(root)) } diff --git a/internal/subsonic/store.go b/internal/subsonic/store.go index 19c0adf..78623de 100644 --- a/internal/subsonic/store.go +++ b/internal/subsonic/store.go @@ -13,6 +13,7 @@ import ( "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" + "github.com/mithral/vellum/internal/subsonic/dto" ) // libraryRef is an audio library as getMusicFolders shows it. @@ -88,7 +89,7 @@ FROM media_items i` // and order. order MUST come from a fixed set, never from user input; the // callers below pass only literals. The final projected column is aliased // artist_name so alphabeticalByArtist can order by it without a second join. -func (h *Handlers) albumsWhere(ctx context.Context, libs []int64, extra string, extraArgs []any, order string, limit, offset int) ([]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 { limit = 500 } @@ -109,7 +110,7 @@ func (h *Handlers) albumsWhere(ctx context.Context, libs []int64, extra string, 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 { return h.albumsWhere(ctx, libs, ` 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 // presentation concern — song count, duration, artist credit and cover are not // part of catalog's item model — and no longer decides access. -func (h *Handlers) loadAlbum(ctx context.Context, u auth.User, itemID int64) (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) if err != nil { 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) if err != nil { - return albumInfo{}, false, err + return dto.AlbumInfo{}, false, err } if len(albums) == 0 { - return albumInfo{}, false, nil + return dto.AlbumInfo{}, false, nil } return albums[0], true, nil } -func scanAlbums(rows *sql.Rows) ([]albumInfo, error) { - out := []albumInfo{} +func scanAlbums(rows *sql.Rows) ([]dto.AlbumInfo, error) { + out := []dto.AlbumInfo{} for rows.Next() { - var a albumInfo + var a dto.AlbumInfo var hasCover int if err := rows.Scan(&a.ID, &a.LibraryID, &a.Kind, &a.Path, &a.Title, &a.Year, &a.AddedAt, &a.ArtistID, &a.SongCount, &a.DurationMS, &hasCover, &a.Artist); err != nil { @@ -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 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+` WHERE f.item_id = ? AND f.missing = 0 AND i.missing = 0 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: // catalog.LoadFor is the one access rule, returning the same ErrNotFound for a // missing item and a forbidden one so a 404 cannot leak existence. -func (h *Handlers) loadTrack(ctx context.Context, u auth.User, fileIDVal int64) (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+` WHERE f.id = ? AND f.missing = 0 AND i.missing = 0`, fileIDVal) if err != nil { - return track{}, false, err + return dto.Track{}, false, err } defer rows.Close() tracks, err := scanTracks(rows) if err != nil { - return track{}, false, err + return dto.Track{}, false, err } 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 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 } -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 { limit = 500 } @@ -242,10 +243,10 @@ func (h *Handlers) songsWhere(ctx context.Context, libs []int64, extra string, e return scanTracks(rows) } -func scanTracks(rows *sql.Rows) ([]track, error) { - out := []track{} +func scanTracks(rows *sql.Rows) ([]dto.Track, error) { + out := []dto.Track{} for rows.Next() { - var t track + var t dto.Track var hasCover int if err := rows.Scan(&t.FileID, &t.ItemID, &t.LibraryID, &t.Kind, &t.AlbumPath, &t.AlbumTitle, &t.Year, &t.AddedAt, &t.RelPath, &t.Size, &t.DurationMS, &t.Container, @@ -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. -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 '%' || ? || '%' OR EXISTS (SELECT 1 FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.item_id = i.id AND ip.role = 'author' AND p.name LIKE '%' || ? || '%'))` return h.albumsWhere(ctx, libs, extra, []any{q, q, q}, "i.sort_title", limit, offset) } -func (h *Handlers) searchSongs(ctx context.Context, libs []int64, q string, limit, offset int) ([]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 '%' || ? || '%')` 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 children // to parent. withCount controls whether the ArtistID3 albumCount is emitted // (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...) sort.Slice(sorted, func(i, j int) bool { 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 }) - var current *node + var current *dto.Node currentLetter := "" for _, a := range sorted { letter := indexLetter(a.name) if current == nil || letter != currentLetter { - current = el("index", at("name", letter)).arrays("artist") - parent.add(current) + current = dto.El("index", dto.At("name", letter)).Arrays("artist") + parent.Add(current) 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 { - 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 { - 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 "#" } -func firstCover(albums []albumInfo) int64 { +func firstCover(albums []dto.AlbumInfo) int64 { for _, a := range albums { if a.HasCover { return a.ID @@ -444,8 +445,8 @@ func (h *Handlers) resolveAudioFile(ctx context.Context, u auth.User, id string) lib int64 ) switch { - case strings.HasPrefix(id, prefixFile): - fileIDVal, ok := parseID(id, prefixFile) + case strings.HasPrefix(id, dto.PrefixFile): + fileIDVal, ok := dto.ParseID(id, dto.PrefixFile) if !ok { return "", false, nil } @@ -454,8 +455,8 @@ func (h *Handlers) resolveAudioFile(ctx context.Context, u auth.User, id string) return "", false, err } rel, lib = t.RelPath, t.LibraryID - case strings.HasPrefix(id, prefixAlbum): - itemID, ok := parseID(id, prefixAlbum) + case strings.HasPrefix(id, dto.PrefixAlbum): + itemID, ok := dto.ParseID(id, dto.PrefixAlbum) if !ok { 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. func (h *Handlers) coverItemID(ctx context.Context, u auth.User, id string) (int64, bool, error) { switch { - case strings.HasPrefix(id, prefixAlbum): - itemID, ok := parseID(id, prefixAlbum) + case strings.HasPrefix(id, dto.PrefixAlbum): + itemID, ok := dto.ParseID(id, dto.PrefixAlbum) if !ok { 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 itemID, true, nil - case strings.HasPrefix(id, prefixFile): - fileIDVal, ok := parseID(id, prefixFile) + case strings.HasPrefix(id, dto.PrefixFile): + fileIDVal, ok := dto.ParseID(id, dto.PrefixFile) if !ok { return 0, false, nil } diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go index 89c81a4..05bbe24 100644 --- a/internal/subsonic/subsonic.go +++ b/internal/subsonic/subsonic.go @@ -9,6 +9,10 @@ // apitoken.KindSubsonic tokens, so revoking one in the web UI revokes the // client too. // +// The wire shapes — the response envelope, the element names, the error codes, +// the opaque ids — live in internal/subsonic/dto, which is the contract with +// clients. This package owns the HTTP handlers and the queries. +// // # Authentication // // Subsonic carries its credentials on every request, not in a cookie: u names @@ -36,38 +40,13 @@ package subsonic import ( "net/http" "strings" -) -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 + "github.com/mithral/vellum/internal/subsonic/dto" ) // 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), - ) +func envelope(status string) *dto.Node { + return dto.Envelope(status) } // 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. -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) { writeJSONResponse(w, root) 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 // surfaced as a transport error rather than "wrong password". func (h *Handlers) fail(w http.ResponseWriter, r *http.Request, code int, msg string) { - root := envelope("failed") - root.add(el("error", at("code", code), at("message", msg))) + root := envelope(dto.StatusFailed) + root.Add(dto.ErrorNode(code, msg)) h.write(w, r, root) } diff --git a/internal/subsonic/subsonic_test.go b/internal/subsonic/subsonic_test.go index a455de8..8187c99 100644 --- a/internal/subsonic/subsonic_test.go +++ b/internal/subsonic/subsonic_test.go @@ -19,6 +19,7 @@ import ( "github.com/mithral/vellum/internal/apitoken" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/core" + "github.com/mithral/vellum/internal/subsonic/dto" "github.com/mithral/vellum/migrations" ) @@ -250,7 +251,7 @@ func TestPingJSONAndXML(t *testing.T) { srv, f := newFixture(t) jroot := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json"))) - if jroot["status"] != "ok" || jroot["version"] != protocolVersion || jroot["type"] != serverType { + if jroot["status"] != "ok" || jroot["version"] != dto.ProtocolVersion || jroot["type"] != dto.ServerType { t.Errorf("json envelope = %v", jroot) } @@ -263,7 +264,7 @@ func TestPingJSONAndXML(t *testing.T) { if err := xml.Unmarshal([]byte(xmlBody), &parsed); err != nil { 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) } } @@ -349,14 +350,14 @@ func TestGetAlbumList2Newest(t *testing.T) { func TestGetAlbumAndSong(t *testing.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) album := root["album"].(map[string]any) if len(album["song"].([]any)) != 3 { t.Errorf("getAlbum songs = %v", album["song"]) } - body = readBody(t, get(t, srv.URL+"/rest/getSong.view?"+f.authQuery()+"&id="+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) song := root["song"].(map[string]any) if song["album"] != "Album" || song["artist"] != "The Artist" { @@ -366,7 +367,7 @@ func TestGetAlbumAndSong(t *testing.T) { func TestGetMusicDirectory(t *testing.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) dir := root["directory"].(map[string]any) children := dir["child"].([]any) @@ -374,7 +375,7 @@ func TestGetMusicDirectory(t *testing.T) { t.Fatalf("root children = %v; body=%s", children, body) } // Drill into the album directory and confirm its songs appear. - body = readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+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) dir = root["directory"].(map[string]any) if len(dir["child"].([]any)) != 3 { @@ -413,7 +414,7 @@ func TestGetPlaylistsEmpty(t *testing.T) { func TestStreamRawAndRange(t *testing.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 { t.Fatalf("stream status = %d, want 200", resp.StatusCode) } @@ -422,7 +423,7 @@ func TestStreamRawAndRange(t *testing.T) { } 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") rangeResp, err := http.DefaultClient.Do(req) if err != nil { @@ -445,7 +446,7 @@ func TestStreamRawAndRange(t *testing.T) { // than silently served untranscoded. func TestStreamRejectsTranscode(t *testing.T) { srv, f := newFixture(t) - body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+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) if root["status"] != "failed" { 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) { 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 { t.Fatalf("cover status = %d, want 200", resp.StatusCode) } @@ -470,7 +471,7 @@ func TestGetCoverArt(t *testing.T) { func TestScrobbleWritesProgress(t *testing.T) { srv, f := newFixture(t) 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" { 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 { 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) if root["status"] != "failed" { t.Fatalf("revoked grant stream status = %v, want failed; body=%s", root["status"], body)