Phase 2: split the fat packages along real boundaries
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.
internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
the package that authorizes the item; it imports home.RecordView for that one
thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
return different explore ids, which is the non-determinism the Explore rewrite
exists for.
internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
are not the scanner (modules register types, handlers serve the list, metadata
reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
not of the scan. The one genuinely scanner-owned helper became
library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
detection still short-circuits.
internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
the database writes. The split's whole point is that a provider is testable
WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
because real false matches were found.
dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
grep. That is what makes them reviewable.
- No field name, tag or error code changed.
internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.
Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.
Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.
Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
This commit is contained in:
parent
ac1bd04df3
commit
35442e4aff
43 changed files with 1818 additions and 1281 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@
|
|||
|
||||
# local preview instance
|
||||
demo/
|
||||
.archify/
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package media
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,31 +1,11 @@
|
|||
package media
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mithral/vellum/internal/core"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
db *sql.DB
|
||||
libA int64
|
||||
|
|
@ -1,7 +1,21 @@
|
|||
package media
|
||||
// Package home computes what the landing screen shows: the five shelves and the
|
||||
// counts the sidebar needs.
|
||||
//
|
||||
// It is separated from internal/media because the two answer different
|
||||
// questions. Media serves item RESOURCES — one item, its file, its cover, its
|
||||
// progress. Home computes AGGREGATES over many items: what you were in the
|
||||
// middle of, what you keep opening, what to try next. Neither can be built from
|
||||
// the other, and a change to a shelf query has no business touching the endpoint
|
||||
// that streams a file.
|
||||
//
|
||||
// It depends on internal/catalog only for the shape of an item; the SQL is its
|
||||
// own because the shelves are the only queries in the server that aggregate
|
||||
// across libraries, kinds and users at once.
|
||||
package home
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
|
|
@ -10,6 +24,23 @@ import (
|
|||
"github.com/mithral/vellum/internal/httpx"
|
||||
)
|
||||
|
||||
// Handlers serves the home screen.
|
||||
type Handlers struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
auth *auth.Authenticator
|
||||
}
|
||||
|
||||
// NewHandlers builds the home-screen HTTP layer.
|
||||
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
|
||||
return &Handlers{db: db, log: log, auth: a}
|
||||
}
|
||||
|
||||
// Register mounts the routes.
|
||||
func (h *Handlers) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome))
|
||||
}
|
||||
|
||||
// shelfItem is one card on the home screen.
|
||||
type shelfItem struct {
|
||||
ID int64 `json:"id"`
|
||||
|
|
@ -159,32 +190,9 @@ func (h *Handlers) handleHome(w http.ResponseWriter, r *http.Request, u auth.Use
|
|||
httpx.WriteJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleRecordView increments the user's view count for an item.
|
||||
func (h *Handlers) handleRecordView(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
views, err := RecordView(r.Context(), h.db, u.ID, it.ID)
|
||||
if err != nil {
|
||||
h.fail(w, "record view", err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"item_id": it.ID, "views": views})
|
||||
}
|
||||
|
||||
// handleAuthors returns the people credited on an item.
|
||||
func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
authors, err := h.authors(r, it.ID)
|
||||
if err != nil {
|
||||
h.fail(w, "authors", err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors})
|
||||
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
||||
h.log.Error(what, "err", err)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
|
||||
func scanShelf(rows *sql.Rows) ([]shelfItem, error) {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package media
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -9,20 +9,37 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mithral/vellum/internal/auth"
|
||||
"github.com/mithral/vellum/internal/core"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
)
|
||||
|
||||
// ── shared helpers ─────────────────────────────────────────────────────
|
||||
|
||||
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
|
||||
func homeHandlers(t *testing.T, db *sql.DB) *Handlers {
|
||||
t.Helper()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewHandlers(db, log, nil, t.TempDir())
|
||||
return NewHandlers(db, log, nil)
|
||||
}
|
||||
|
||||
func testDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := core.OpenDB(filepath.Join(t.TempDir(), "home.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
|
||||
|
|
@ -205,7 +222,7 @@ func getHome(t *testing.T, h *Handlers, u auth.User) (homeResponse, []byte) {
|
|||
|
||||
func TestHandleHomeShelvesAndCounts(t *testing.T) {
|
||||
f := setupHome(t)
|
||||
h := mediaHandlers(t, f.db)
|
||||
h := homeHandlers(t, f.db)
|
||||
resp, body := getHome(t, h, auth.User{ID: f.userA})
|
||||
|
||||
// The landing screen contract: all five shelves plus the sidebar counts.
|
||||
|
|
@ -266,7 +283,7 @@ func TestHandleHomeShelvesAndCounts(t *testing.T) {
|
|||
|
||||
func TestHandleHomeGrantScoping(t *testing.T) {
|
||||
f := setupHome(t)
|
||||
h := mediaHandlers(t, f.db)
|
||||
h := homeHandlers(t, f.db)
|
||||
|
||||
// userA holds a grant to libA only. Nothing from libB may surface anywhere.
|
||||
resp, _ := getHome(t, h, auth.User{ID: f.userA})
|
||||
|
|
@ -304,7 +321,7 @@ func TestHandleHomeGrantScoping(t *testing.T) {
|
|||
|
||||
func TestHandleHomeIsolatesUsers(t *testing.T) {
|
||||
f := setupHome(t)
|
||||
h := mediaHandlers(t, f.db)
|
||||
h := homeHandlers(t, f.db)
|
||||
resp, _ := getHome(t, h, auth.User{ID: f.userB})
|
||||
|
||||
if got := ids(resp.Continue); len(got) != 1 || got[0] != f.b1 {
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
// Package library owns library definitions and the scanner that turns files on
|
||||
// disk into rows in media_items / media_files.
|
||||
// Package library owns library definitions — create, list, delete — and the
|
||||
// media-kind registry that says which file extensions each kind of library owns.
|
||||
//
|
||||
// Modules declare what file extensions they care about via RegisterType; this
|
||||
// package never imports a module.
|
||||
// The registry lives here, not in internal/scanner, because it is not the
|
||||
// scanner's business: modules register their types, the admin HTTP surface
|
||||
// serves them, and the metadata enricher reads the grouping mode. Only one of
|
||||
// those three is the scanner, so the registry belongs in the lower layer that
|
||||
// all three already depend on. The scanner imports it from here; nothing here
|
||||
// imports the scanner.
|
||||
package library
|
||||
|
||||
import (
|
||||
|
|
@ -134,8 +138,11 @@ func GroupModeFor(kind string) GroupMode {
|
|||
return GroupByFile
|
||||
}
|
||||
|
||||
// extensionsFor returns the extension set owned by a library kind.
|
||||
func extensionsFor(kind string) map[string]bool {
|
||||
// ExtensionsFor returns the extension set owned by a library kind, each entry
|
||||
// lowercase with a leading dot. The scanner walks a tree by testing every file
|
||||
// name against this set, which is why it lives in the registry rather than in
|
||||
// the scanner: the answer is a property of the kind, not of the scan.
|
||||
func ExtensionsFor(kind string) map[string]bool {
|
||||
typesMu.RLock()
|
||||
defer typesMu.RUnlock()
|
||||
t, ok := types[kind]
|
||||
|
|
|
|||
|
|
@ -1,205 +0,0 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanLifecycle(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
|
||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||
writeFile(t, filepath.Join(root, "sub", "c.epub"), "cccccccccccccccccccccccccccccc")
|
||||
writeFile(t, filepath.Join(root, "notes.txt"), "not media")
|
||||
writeFile(t, filepath.Join(root, ".hidden.epub"), "hidden")
|
||||
writeFile(t, filepath.Join(root, ".git", "d.epub"), "in a hidden dir")
|
||||
|
||||
lib, err := Create(ctx, db, "Ebooks", "ebooks", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// First scan: three supported files, nothing else.
|
||||
res, err := Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if res.Added != 3 || res.Scanned != 3 {
|
||||
t.Fatalf("first scan added=%d scanned=%d, want 3/3 (%+v)", res.Added, res.Scanned, res)
|
||||
}
|
||||
if n, _ := CountItems(ctx, db, lib.ID); n != 3 {
|
||||
t.Errorf("item count = %d, want 3", n)
|
||||
}
|
||||
|
||||
// Second scan: nothing changed.
|
||||
res, err = Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Unchanged != 3 || res.Added != 0 || res.Updated != 0 {
|
||||
t.Errorf("second scan unchanged=%d added=%d updated=%d, want 3/0/0", res.Unchanged, res.Added, res.Updated)
|
||||
}
|
||||
|
||||
// Modify a.epub (different size).
|
||||
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
res, err = Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Updated != 1 || res.Unchanged != 2 {
|
||||
t.Errorf("after modify updated=%d unchanged=%d, want 1/2", res.Updated, res.Unchanged)
|
||||
}
|
||||
|
||||
// Delete b.epub: it goes missing, it is not deleted.
|
||||
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Missing != 1 {
|
||||
t.Errorf("after delete missing=%d, want 1", res.Missing)
|
||||
}
|
||||
if n, _ := CountItems(ctx, db, lib.ID); n != 2 {
|
||||
t.Errorf("item count (excluding missing) = %d, want 2", n)
|
||||
}
|
||||
|
||||
// The row still exists, flagged missing.
|
||||
var missing int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "b.epub").Scan(&missing); err != nil {
|
||||
t.Fatalf("missing item row was deleted: %v", err)
|
||||
}
|
||||
if missing != 1 {
|
||||
t.Errorf("missing flag = %d, want 1", missing)
|
||||
}
|
||||
|
||||
// Restore b.epub.
|
||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||
res, err = Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Restored != 1 {
|
||||
t.Errorf("after restore restored=%d, want 1 (%+v)", res.Restored, res)
|
||||
}
|
||||
if n, _ := CountItems(ctx, db, lib.ID); n != 3 {
|
||||
t.Errorf("item count after restore = %d, want 3", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanGroupsDirectoryKinds(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
// An audiobook is a folder of chapters.
|
||||
writeFile(t, filepath.Join(root, "Author", "Book", "01 - Chapter One.mp3"), "a")
|
||||
writeFile(t, filepath.Join(root, "Author", "Book", "02 - Chapter Two.mp3"), "b")
|
||||
writeFile(t, filepath.Join(root, "Author", "Book", "10 - Chapter Ten.mp3"), "c")
|
||||
// A loose file at the root groups under the library name.
|
||||
writeFile(t, filepath.Join(root, "stray.mp3"), "d")
|
||||
|
||||
lib, err := Create(ctx, db, "Audiobooks", "audiobooks", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
res, err := Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if res.Added != 4 {
|
||||
t.Errorf("added = %d, want 4 (four tracks)", res.Added)
|
||||
}
|
||||
|
||||
// Two items: the directory, and the root group.
|
||||
var items int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items != 2 {
|
||||
t.Fatalf("items = %d, want 2", items)
|
||||
}
|
||||
|
||||
var title string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Author/Book").Scan(&title); err != nil {
|
||||
t.Fatalf("grouped item not found: %v", err)
|
||||
}
|
||||
if title != "Book" {
|
||||
t.Errorf("grouped title = %q, want Book", title)
|
||||
}
|
||||
|
||||
// Tracks must come back in natural order, not lexical.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT f.path FROM media_files f JOIN media_items i ON i.id = f.item_id
|
||||
WHERE i.path = ? ORDER BY f.id`, "Author/Book")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var paths []string
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
want := []string{
|
||||
"Author/Book/01 - Chapter One.mp3",
|
||||
"Author/Book/02 - Chapter Two.mp3",
|
||||
"Author/Book/10 - Chapter Ten.mp3",
|
||||
}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("track count = %d, want %d: %v", len(paths), len(want), paths)
|
||||
}
|
||||
for i := range want {
|
||||
if paths[i] != want[i] {
|
||||
t.Errorf("track %d = %q, want %q", i, paths[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanUnreadableRoot(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "a.epub"), "x")
|
||||
|
||||
lib, err := Create(ctx, db, "Ebooks", "ebooks", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Index it once so there is a known file to go missing.
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Remove the root out from under the library.
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("scan should not fail on a missing root: %v", err)
|
||||
}
|
||||
if res.Added != 0 {
|
||||
t.Errorf("added=%d, want 0", res.Added)
|
||||
}
|
||||
if res.Missing != 1 {
|
||||
t.Errorf("missing=%d, want 1 (existing item now vanished)", res.Missing)
|
||||
}
|
||||
if len(res.Errors) == 0 {
|
||||
t.Error("expected the walk error to be recorded")
|
||||
}
|
||||
}
|
||||
193
internal/media/fixtures_test.go
Normal file
193
internal/media/fixtures_test.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/mithral/vellum/internal/core"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
)
|
||||
|
||||
// These fixtures are duplicated from internal/home. Go test helpers cannot be
|
||||
// shared across packages without being exported from non-test code, and the home
|
||||
// screen's version is a different shape: it seeds progress and views so the
|
||||
// shelves have something to aggregate.
|
||||
|
||||
// ── shared helpers ─────────────────────────────────────────────────────
|
||||
|
||||
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
|
||||
t.Helper()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewHandlers(db, log, nil, t.TempDir())
|
||||
}
|
||||
|
||||
func testDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
|
||||
t.Fatalf("query %q: %v", q, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
|
||||
t.Fatalf("exec %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 {
|
||||
t.Helper()
|
||||
return mustQueryID(t, db,
|
||||
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`, name, kind, root)
|
||||
}
|
||||
|
||||
func newUser(t *testing.T, db *sql.DB, name string) int64 {
|
||||
t.Helper()
|
||||
return mustQueryID(t, db,
|
||||
`INSERT INTO users (username, password_hash) VALUES (?, 'x') RETURNING id`, name)
|
||||
}
|
||||
|
||||
func grantRead(t *testing.T, db *sql.DB, userID, libID int64) {
|
||||
t.Helper()
|
||||
mustExec(t, db,
|
||||
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`,
|
||||
userID, libID)
|
||||
}
|
||||
|
||||
// newItem inserts a media item plus a backing file, because progress rows carry
|
||||
// a NOT NULL file_id foreign key.
|
||||
func newItem(t *testing.T, db *sql.DB, libID int64, kind, path, title, sortTitle string, missing bool, addedAt string) int64 {
|
||||
t.Helper()
|
||||
m := 0
|
||||
if missing {
|
||||
m = 1
|
||||
}
|
||||
id := mustQueryID(t, db, `
|
||||
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, added_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
libID, kind, path, title, sortTitle, m, addedAt)
|
||||
mustExec(t, db,
|
||||
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`,
|
||||
id, "file-"+strconv.FormatInt(id, 10))
|
||||
return id
|
||||
}
|
||||
|
||||
func fileIDFor(t *testing.T, db *sql.DB, itemID int64) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := db.QueryRowContext(context.Background(),
|
||||
`SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id); err != nil {
|
||||
t.Fatalf("file for item %d: %v", itemID, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func newProgress(t *testing.T, db *sql.DB, userID, itemID int64, percent float64, finished bool, updatedAt string) {
|
||||
t.Helper()
|
||||
f := 0
|
||||
if finished {
|
||||
f = 1
|
||||
}
|
||||
mustExec(t, db, `
|
||||
INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished, updated_at)
|
||||
VALUES (?, ?, ?, 'ebooks', 1, ?, ?, ?)`,
|
||||
userID, itemID, fileIDFor(t, db, itemID), percent, f, updatedAt)
|
||||
}
|
||||
|
||||
func newView(t *testing.T, db *sql.DB, userID, itemID int64, views int, lastViewedAt string) {
|
||||
t.Helper()
|
||||
mustExec(t, db,
|
||||
`INSERT INTO item_views (user_id, item_id, views, last_viewed_at) VALUES (?, ?, ?, ?)`,
|
||||
userID, itemID, views, lastViewedAt)
|
||||
}
|
||||
|
||||
// setupHome builds the two-library, three-user fixture the item-detail tests
|
||||
// use: one item the caller may read, one in a library they may not, and one user
|
||||
// with no grants at all.
|
||||
func setupHome(t *testing.T) homeFixture {
|
||||
t.Helper()
|
||||
db := testDB(t)
|
||||
f := homeFixture{db: db}
|
||||
|
||||
f.libA = newLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks")
|
||||
f.libB = newLibrary(t, db, "Audio", "audiobooks", "/media/audio")
|
||||
|
||||
f.userA = newUser(t, db, "alice")
|
||||
f.userB = newUser(t, db, "bob")
|
||||
f.userNone = newUser(t, db, "carol")
|
||||
|
||||
grantRead(t, db, f.userA, f.libA)
|
||||
grantRead(t, db, f.userB, f.libB)
|
||||
|
||||
f.a1 = newItem(t, db, f.libA, "ebooks", "a1.epub", "A One", "a one", false, "2026-01-01T00:00:01.000Z")
|
||||
f.a2 = newItem(t, db, f.libA, "ebooks", "a2.epub", "A Two", "a two", false, "2026-01-01T00:00:02.000Z")
|
||||
f.a3 = newItem(t, db, f.libA, "comics", "a3.cbz", "A Three", "a three", false, "2026-01-01T00:00:03.000Z")
|
||||
f.a4 = newItem(t, db, f.libA, "ebooks", "a4.epub", "A Four", "a four", true, "2026-01-01T00:00:04.000Z")
|
||||
f.a5 = newItem(t, db, f.libA, "ebooks", "a5.epub", "A Five", "a five", false, "2026-01-01T00:00:05.000Z")
|
||||
f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "B One", "b one", false, "2026-01-01T00:00:06.000Z")
|
||||
|
||||
newProgress(t, db, f.userA, f.a1, 0.5, false, "2026-02-01T00:00:03.000Z")
|
||||
newProgress(t, db, f.userA, f.a2, 1.0, true, "2026-02-01T00:00:02.000Z")
|
||||
newProgress(t, db, f.userA, f.a4, 0.3, false, "2026-02-01T00:00:04.000Z")
|
||||
newProgress(t, db, f.userB, f.b1, 0.2, false, "2026-02-02T00:00:01.000Z")
|
||||
|
||||
newView(t, db, f.userA, f.a1, 3, "2026-02-03T00:00:01.000Z")
|
||||
newView(t, db, f.userA, f.a3, 1, "2026-02-03T00:00:02.000Z")
|
||||
newView(t, db, f.userB, f.b1, 9, "2026-02-03T00:00:03.000Z")
|
||||
|
||||
ada := mustQueryID(t, db, `INSERT INTO people (name) VALUES ('Ada Author') RETURNING id`)
|
||||
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a1, ada)
|
||||
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a3, ada)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func itoa(n int64) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
type homeFixture struct {
|
||||
db *sql.DB
|
||||
libA int64
|
||||
libB int64
|
||||
userA int64
|
||||
userB int64
|
||||
userNone int64
|
||||
a1 int64
|
||||
a2 int64
|
||||
a3 int64
|
||||
a4 int64
|
||||
a5 int64
|
||||
b1 int64
|
||||
}
|
||||
|
|
@ -8,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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -13,12 +13,17 @@ import (
|
|||
|
||||
// OpenLibrary matches books by title and author. No API key, no registration.
|
||||
type OpenLibrary struct {
|
||||
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
|
||||
100
internal/providers/openlibrary_test.go
Normal file
100
internal/providers/openlibrary_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenLibrarySearchDBFree is the point of the split: a provider is an HTTP
|
||||
// client, so it is exercisable end to end against a stub server with no
|
||||
// database anywhere in sight.
|
||||
func TestOpenLibrarySearchDBFree(t *testing.T) {
|
||||
var gotTitle, gotAuthor, gotLimit, gotUA string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/search.json" {
|
||||
t.Errorf("path = %q, want /search.json", r.URL.Path)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
gotTitle, gotAuthor, gotLimit = q.Get("title"), q.Get("author"), q.Get("limit")
|
||||
gotUA = r.Header.Get("User-Agent")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"docs":[
|
||||
{"key":"/works/OL45804W","title":"The Hobbit","author_name":["J.R.R. Tolkien"],
|
||||
"first_publish_year":1937,"publisher":["Allen & Unwin"],"language":["eng"],
|
||||
"cover_i":8225261,"isbn":["9780261102217"]},
|
||||
{"key":"/works/OL1W","title":"","author_name":["Nobody"]}
|
||||
]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewOpenLibrary()
|
||||
p.BaseURL = srv.URL
|
||||
|
||||
cands, err := p.Search(context.Background(), Query{Title: "The Hobbit", Author: "Tolkien"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
// The untitled doc is dropped rather than surfaced as an empty candidate.
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1", len(cands))
|
||||
}
|
||||
|
||||
c := cands[0]
|
||||
if c.Provider != "openlibrary" || c.ExternalID != "/works/OL45804W" {
|
||||
t.Errorf("provider id = %s/%s", c.Provider, c.ExternalID)
|
||||
}
|
||||
if c.Title != "The Hobbit" || c.Year != 1937 {
|
||||
t.Errorf("title/year = %q/%d", c.Title, c.Year)
|
||||
}
|
||||
if len(c.Authors) != 1 || c.Authors[0] != "J.R.R. Tolkien" {
|
||||
t.Errorf("Authors = %v", c.Authors)
|
||||
}
|
||||
if c.Publisher != "Allen & Unwin" || c.Language != "eng" {
|
||||
t.Errorf("publisher/language = %q/%q", c.Publisher, c.Language)
|
||||
}
|
||||
if c.ISBN != "9780261102217" {
|
||||
t.Errorf("ISBN = %q", c.ISBN)
|
||||
}
|
||||
if !strings.Contains(c.CoverURL, "covers.openlibrary.org/b/id/8225261") {
|
||||
t.Errorf("CoverURL = %q", c.CoverURL)
|
||||
}
|
||||
|
||||
if gotTitle != "The Hobbit" || gotAuthor != "Tolkien" {
|
||||
t.Errorf("query title/author = %q/%q", gotTitle, gotAuthor)
|
||||
}
|
||||
if gotLimit != "5" {
|
||||
t.Errorf("limit = %q, want 5", gotLimit)
|
||||
}
|
||||
if gotUA == "" {
|
||||
t.Error("no User-Agent sent; Open Library asks callers to identify themselves")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLibrarySkipsRequestWithoutATerm(t *testing.T) {
|
||||
p := NewOpenLibrary()
|
||||
cands, err := p.Search(context.Background(), Query{})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(cands) != 0 {
|
||||
t.Errorf("got %d candidates for an empty query, want 0", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLibraryReportsHTTPFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewOpenLibrary()
|
||||
p.BaseURL = srv.URL
|
||||
|
||||
if _, err := p.Search(context.Background(), Query{Title: "The Hobbit"}); err == nil {
|
||||
t.Fatal("expected an error for a 429 response")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
// Package metadata resolves media items against external catalogues.
|
||||
// Package providers talks to external metadata catalogues.
|
||||
//
|
||||
// One client per service, no database, no policy: a provider turns a Query into
|
||||
// candidates and knows nothing about what the caller does with them. Matching
|
||||
// policy and persistence belong to the caller (internal/metadata), which is
|
||||
// what keeps these clients testable against an httptest server alone.
|
||||
//
|
||||
// Providers are optional by design: a library with no network still browses,
|
||||
// because a failed lookup is recorded and skipped, never surfaced as a scan or
|
||||
// request failure.
|
||||
package metadata
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -1,4 +1,14 @@
|
|||
package library
|
||||
// Package scanner walks a library's root and reconciles it with the database:
|
||||
// files that appeared become items, files that changed are re-upserted with
|
||||
// fresh codec facts, and files that vanished are flagged missing rather than
|
||||
// deleted.
|
||||
//
|
||||
// It is separate from internal/library because the two have different callers.
|
||||
// A scan runs in a background job with no user and no HTTP request, tolerates a
|
||||
// partially readable tree, and must never delete; library CRUD is an admin
|
||||
// surface where a bad request is a 400 and a bad path is a 404. Scanner imports
|
||||
// library (for Library and the media-kind registry), never the other way round.
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -6,7 +16,6 @@ import (
|
|||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
|
@ -16,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()
|
||||
}
|
||||
430
internal/scanner/scan_test.go
Normal file
430
internal/scanner/scan_test.go
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mithral/vellum/internal/core"
|
||||
"github.com/mithral/vellum/internal/library"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := core.OpenDB(filepath.Join(t.TempDir(), "scan.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLifecycle(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
|
||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||
writeFile(t, filepath.Join(root, "sub", "c.epub"), "cccccccccccccccccccccccccccccc")
|
||||
writeFile(t, filepath.Join(root, "notes.txt"), "not media")
|
||||
writeFile(t, filepath.Join(root, ".hidden.epub"), "hidden")
|
||||
writeFile(t, filepath.Join(root, ".git", "d.epub"), "in a hidden dir")
|
||||
|
||||
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// First scan: three supported files, nothing else.
|
||||
res, err := Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if res.Added != 3 || res.Scanned != 3 {
|
||||
t.Fatalf("first scan added=%d scanned=%d, want 3/3 (%+v)", res.Added, res.Scanned, res)
|
||||
}
|
||||
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
|
||||
t.Errorf("item count = %d, want 3", n)
|
||||
}
|
||||
|
||||
// Second scan: nothing changed.
|
||||
res, err = Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Unchanged != 3 || res.Added != 0 || res.Updated != 0 {
|
||||
t.Errorf("second scan unchanged=%d added=%d updated=%d, want 3/0/0", res.Unchanged, res.Added, res.Updated)
|
||||
}
|
||||
|
||||
// Modify a.epub (different size).
|
||||
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
res, err = Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Updated != 1 || res.Unchanged != 2 {
|
||||
t.Errorf("after modify updated=%d unchanged=%d, want 1/2", res.Updated, res.Unchanged)
|
||||
}
|
||||
|
||||
// Delete b.epub: it goes missing, it is not deleted.
|
||||
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Missing != 1 {
|
||||
t.Errorf("after delete missing=%d, want 1", res.Missing)
|
||||
}
|
||||
if n, _ := library.CountItems(ctx, db, lib.ID); n != 2 {
|
||||
t.Errorf("item count (excluding missing) = %d, want 2", n)
|
||||
}
|
||||
|
||||
// The row still exists, flagged missing.
|
||||
var missing int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "b.epub").Scan(&missing); err != nil {
|
||||
t.Fatalf("missing item row was deleted: %v", err)
|
||||
}
|
||||
if missing != 1 {
|
||||
t.Errorf("missing flag = %d, want 1", missing)
|
||||
}
|
||||
|
||||
// Restore b.epub.
|
||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||
res, err = Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Restored != 1 {
|
||||
t.Errorf("after restore restored=%d, want 1 (%+v)", res.Restored, res)
|
||||
}
|
||||
if n, _ := library.CountItems(ctx, db, lib.ID); n != 3 {
|
||||
t.Errorf("item count after restore = %d, want 3", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanGroupsDirectoryKinds(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
// An audiobook is a folder of chapters.
|
||||
writeFile(t, filepath.Join(root, "Author", "Book", "01 - Chapter One.mp3"), "a")
|
||||
writeFile(t, filepath.Join(root, "Author", "Book", "02 - Chapter Two.mp3"), "b")
|
||||
writeFile(t, filepath.Join(root, "Author", "Book", "10 - Chapter Ten.mp3"), "c")
|
||||
// A loose file at the root groups under the library name.
|
||||
writeFile(t, filepath.Join(root, "stray.mp3"), "d")
|
||||
|
||||
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
res, err := Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if res.Added != 4 {
|
||||
t.Errorf("added = %d, want 4 (four tracks)", res.Added)
|
||||
}
|
||||
|
||||
// Two items: the directory, and the root group.
|
||||
var items int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items != 2 {
|
||||
t.Fatalf("items = %d, want 2", items)
|
||||
}
|
||||
|
||||
var title string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Author/Book").Scan(&title); err != nil {
|
||||
t.Fatalf("grouped item not found: %v", err)
|
||||
}
|
||||
if title != "Book" {
|
||||
t.Errorf("grouped title = %q, want Book", title)
|
||||
}
|
||||
|
||||
// Tracks must come back in natural order, not lexical.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT f.path FROM media_files f JOIN media_items i ON i.id = f.item_id
|
||||
WHERE i.path = ? ORDER BY f.id`, "Author/Book")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var paths []string
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
want := []string{
|
||||
"Author/Book/01 - Chapter One.mp3",
|
||||
"Author/Book/02 - Chapter Two.mp3",
|
||||
"Author/Book/10 - Chapter Ten.mp3",
|
||||
}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("track count = %d, want %d: %v", len(paths), len(want), paths)
|
||||
}
|
||||
for i := range want {
|
||||
if paths[i] != want[i] {
|
||||
t.Errorf("track %d = %q, want %q", i, paths[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanUnreadableRoot(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "a.epub"), "x")
|
||||
|
||||
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Index it once so there is a known file to go missing.
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Remove the root out from under the library.
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("scan should not fail on a missing root: %v", err)
|
||||
}
|
||||
if res.Added != 0 {
|
||||
t.Errorf("added=%d, want 0", res.Added)
|
||||
}
|
||||
if res.Missing != 1 {
|
||||
t.Errorf("missing=%d, want 1 (existing item now vanished)", res.Missing)
|
||||
}
|
||||
if len(res.Errors) == 0 {
|
||||
t.Error("expected the walk error to be recorded")
|
||||
}
|
||||
}
|
||||
func TestMissingSinceLifecycle(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
|
||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||
|
||||
lib, err := library.Create(ctx, db, "Ebooks", "ebooks", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("first Scan: %v", err)
|
||||
}
|
||||
|
||||
// A present file has no missing timestamp.
|
||||
var since string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if since != "" {
|
||||
t.Errorf("present file missing_since = %q, want empty", since)
|
||||
}
|
||||
|
||||
// Delete b.epub: it goes missing and the timestamp is recorded.
|
||||
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("missing Scan: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if since == "" {
|
||||
t.Fatal("missing_since was not set on the transition into missing")
|
||||
}
|
||||
first := since
|
||||
|
||||
// A rescan must not refresh the age.
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("rescan: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if since != first {
|
||||
t.Errorf("missing_since reset on rescan: %q -> %q", first, since)
|
||||
}
|
||||
|
||||
// ListMissing surfaces the file with its age.
|
||||
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMissing: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("ListMissing returned %d entries, want 1", len(entries))
|
||||
}
|
||||
e := entries[0]
|
||||
if e.Title != "b" || e.Path != "b.epub" || e.Kind != "ebooks" || e.LibraryID != lib.ID {
|
||||
t.Errorf("unexpected missing entry: %+v", e)
|
||||
}
|
||||
if e.MissingSince != first {
|
||||
t.Errorf("entry missing_since = %q, want %q", e.MissingSince, first)
|
||||
}
|
||||
|
||||
// Restore the file: missing_since must be cleared, not left behind.
|
||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("restore Scan: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if since != "" {
|
||||
t.Errorf("missing_since after restore = %q, want empty", since)
|
||||
}
|
||||
if got, _ := library.ListMissing(ctx, db, []int64{lib.ID}); len(got) != 0 {
|
||||
t.Errorf("ListMissing after restore returned %d entries, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
// Two audiobooks, each a folder of tracks.
|
||||
writeFile(t, filepath.Join(root, "Book A", "01.mp3"), "a1")
|
||||
writeFile(t, filepath.Join(root, "Book A", "02.mp3"), "a2")
|
||||
writeFile(t, filepath.Join(root, "Book B", "01.mp3"), "b1")
|
||||
writeFile(t, filepath.Join(root, "Book B", "02.mp3"), "b2")
|
||||
|
||||
lib, err := library.Create(ctx, db, "Audiobooks", "audiobooks", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
|
||||
// A second library with its own missing item, to prove purge is scoped.
|
||||
otherRoot := t.TempDir()
|
||||
writeFile(t, filepath.Join(otherRoot, "gone.epub"), "x")
|
||||
other, err := library.Create(ctx, db, "Other", "ebooks", otherRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("Create other: %v", err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
|
||||
t.Fatalf("Scan other: %v", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(otherRoot, "gone.epub")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
|
||||
t.Fatalf("Scan other (missing): %v", err)
|
||||
}
|
||||
|
||||
// Remove one track from Book A: the item is not missing, only that file.
|
||||
// Remove Book B entirely: the item and both its files are missing.
|
||||
if err := os.Remove(filepath.Join(root, "Book A", "02.mp3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(root, "Book B")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("second Scan: %v", err)
|
||||
}
|
||||
|
||||
entries, err := library.ListMissing(ctx, db, []int64{lib.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMissing: %v", err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("ListMissing returned %d entries, want 3 (Book A/02 + Book B/01 + Book B/02)", len(entries))
|
||||
}
|
||||
|
||||
// Purge must remove only Book B.
|
||||
n, err := library.PurgeMissing(ctx, db, lib.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeMissing: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("purged = %d, want 1 (only the fully missing item)", n)
|
||||
}
|
||||
|
||||
// Book A survives, with its present and missing files intact.
|
||||
var items int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items != 1 {
|
||||
t.Errorf("media_items after purge = %d, want 1", items)
|
||||
}
|
||||
var bookATitle string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Book A").Scan(&bookATitle); err != nil {
|
||||
t.Fatalf("a non-missing item was purged: %v", err)
|
||||
}
|
||||
var files int
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT count(*) FROM media_files f JOIN media_items i ON i.id = f.item_id
|
||||
WHERE i.library_id = ? AND i.path = ?`, lib.ID, "Book A").Scan(&files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if files != 2 {
|
||||
t.Errorf("Book A files after purge = %d, want 2", files)
|
||||
}
|
||||
|
||||
// The other library's missing item is untouched.
|
||||
var otherItems int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM media_items WHERE library_id = ?`, other.ID).Scan(&otherItems); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if otherItems != 1 {
|
||||
t.Errorf("other library items after purge = %d, want 1 (purge must be scoped)", otherItems)
|
||||
}
|
||||
|
||||
// A second purge has nothing left to remove.
|
||||
if n, err := library.PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 {
|
||||
t.Errorf("second purge = %d, %v; want 0, nil", n, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"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)
|
||||
|
|
|
|||
215
internal/subsonic/dto/ids.go
Normal file
215
internal/subsonic/dto/ids.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
package dto
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mithral/vellum/internal/library"
|
||||
"github.com/mithral/vellum/internal/stream"
|
||||
)
|
||||
|
||||
// Vellum has two id spaces — media_items and media_files — and Subsonic has
|
||||
// one opaque string id, so every id this layer emits is prefixed to say what it
|
||||
// names. The prefix also lets stream/getCoverArt resolve an id without a
|
||||
// separate lookup table.
|
||||
//
|
||||
// al-<itemID> an album / directory-grouped item
|
||||
// mf-<fileID> a song, i.e. one physical media_files row
|
||||
// ar-<personID> an artist, from people/item_people
|
||||
// fd-<libID> a music folder (a whole audio library)
|
||||
// d-<libID>:<relpath> a browsable directory within a library
|
||||
const (
|
||||
PrefixAlbum = "al-"
|
||||
PrefixFile = "mf-"
|
||||
PrefixArtist = "ar-"
|
||||
PrefixFolder = "fd-"
|
||||
PrefixDir = "d-"
|
||||
)
|
||||
|
||||
// AlbumID renders an album id.
|
||||
func AlbumID(itemID int64) string { return PrefixAlbum + strconv.FormatInt(itemID, 10) }
|
||||
|
||||
// FileID renders a song id.
|
||||
func FileID(fileID int64) string { return PrefixFile + strconv.FormatInt(fileID, 10) }
|
||||
|
||||
// ArtistID renders an artist id.
|
||||
func ArtistID(personID int64) string { return PrefixArtist + strconv.FormatInt(personID, 10) }
|
||||
|
||||
// FolderID renders a music folder id.
|
||||
func FolderID(libID int64) string { return PrefixFolder + strconv.FormatInt(libID, 10) }
|
||||
|
||||
// DirID renders a browsable directory id.
|
||||
func DirID(libID int64, rel string) string {
|
||||
return PrefixDir + strconv.FormatInt(libID, 10) + ":" + rel
|
||||
}
|
||||
|
||||
// ParseID splits a prefixed id, returning the numeric part.
|
||||
func ParseID(id, prefix string) (int64, bool) {
|
||||
rest, ok := strings.CutPrefix(id, prefix)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(rest, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// ParseDirID splits "d-<libID>:<relpath>". An empty relpath is the library root.
|
||||
func ParseDirID(id string) (libID int64, rel string, ok bool) {
|
||||
rest, ok := strings.CutPrefix(id, PrefixDir)
|
||||
if !ok {
|
||||
return 0, "", false
|
||||
}
|
||||
lib, rel, found := strings.Cut(rest, ":")
|
||||
if !found {
|
||||
return 0, "", false
|
||||
}
|
||||
n, err := strconv.ParseInt(lib, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return n, rel, true
|
||||
}
|
||||
|
||||
// ── entities ───────────────────────────────────────────────────────────
|
||||
|
||||
// AlbumInfo is one album: a directory-grouped media_items row of an audio kind.
|
||||
type AlbumInfo struct {
|
||||
ID int64
|
||||
LibraryID int64
|
||||
Kind string
|
||||
Path string
|
||||
Title string
|
||||
Year int
|
||||
AddedAt string
|
||||
ArtistID int64
|
||||
Artist string
|
||||
SongCount int
|
||||
DurationMS int64
|
||||
HasCover bool
|
||||
}
|
||||
|
||||
// Track is one song: a media_files row together with its album's context.
|
||||
type Track struct {
|
||||
FileID int64
|
||||
ItemID int64
|
||||
LibraryID int64
|
||||
Kind string
|
||||
AlbumPath string
|
||||
AlbumTitle string
|
||||
Year int
|
||||
AddedAt string
|
||||
RelPath string
|
||||
Size int64
|
||||
DurationMS int64
|
||||
Container string
|
||||
ArtistID int64
|
||||
Artist string
|
||||
Ordinal int
|
||||
HasCover bool
|
||||
}
|
||||
|
||||
// SongNode renders a track as the Subsonic Child/Song element. getSong returns
|
||||
// it bare; getAlbum nests it under <album>.
|
||||
func SongNode(t Track) *Node {
|
||||
ct := stream.ContentType(t.RelPath)
|
||||
suffix := strings.TrimPrefix(strings.ToLower(filepath.Ext(t.RelPath)), ".")
|
||||
|
||||
n := El("song",
|
||||
At("id", FileID(t.FileID)),
|
||||
At("parent", AlbumID(t.ItemID)),
|
||||
At("isDir", false),
|
||||
At("title", library.CleanTitle(filepath.Base(t.RelPath))),
|
||||
At("album", t.AlbumTitle),
|
||||
At("albumId", AlbumID(t.ItemID)),
|
||||
At("artist", t.Artist),
|
||||
At("type", "music"),
|
||||
At("isVideo", false),
|
||||
)
|
||||
if t.ArtistID > 0 {
|
||||
n.Attrs = append(n.Attrs, At("artistId", ArtistID(t.ArtistID)))
|
||||
}
|
||||
if t.HasCover {
|
||||
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(t.ItemID)))
|
||||
}
|
||||
if t.Ordinal > 0 {
|
||||
n.Attrs = append(n.Attrs, At("track", int64(t.Ordinal)))
|
||||
}
|
||||
if t.Year > 0 {
|
||||
n.Attrs = append(n.Attrs, At("year", int64(t.Year)))
|
||||
}
|
||||
n.Attrs = append(n.Attrs,
|
||||
At("size", t.Size),
|
||||
At("contentType", ct),
|
||||
At("suffix", suffix),
|
||||
At("duration", t.DurationMS/1000),
|
||||
At("path", t.RelPath),
|
||||
)
|
||||
if t.AddedAt != "" {
|
||||
n.Attrs = append(n.Attrs, At("created", t.AddedAt))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AlbumNode renders an album as the Subsonic AlbumID3 element. Extra legacy
|
||||
// fields (title, album, isDir) are included because older clients read them and
|
||||
// they cost nothing.
|
||||
func AlbumNode(a AlbumInfo) *Node {
|
||||
parent := FolderID(a.LibraryID)
|
||||
if a.ArtistID > 0 {
|
||||
parent = ArtistID(a.ArtistID)
|
||||
}
|
||||
|
||||
n := El("album",
|
||||
At("id", AlbumID(a.ID)),
|
||||
At("parent", parent),
|
||||
At("name", a.Title),
|
||||
At("title", a.Title),
|
||||
At("album", a.Title),
|
||||
At("isDir", true),
|
||||
At("artist", a.Artist),
|
||||
At("songCount", int64(a.SongCount)),
|
||||
At("duration", a.DurationMS/1000),
|
||||
At("playCount", int64(0)),
|
||||
)
|
||||
if a.ArtistID > 0 {
|
||||
n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID)))
|
||||
}
|
||||
if a.HasCover {
|
||||
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID)))
|
||||
}
|
||||
if a.Year > 0 {
|
||||
n.Attrs = append(n.Attrs, At("year", int64(a.Year)))
|
||||
}
|
||||
if a.AddedAt != "" {
|
||||
n.Attrs = append(n.Attrs, At("created", a.AddedAt))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ChildNode renders an album or directory entry for getMusicDirectory /
|
||||
// getIndexes, where the element is a generic Child rather than AlbumID3.
|
||||
func ChildNode(a AlbumInfo) *Node {
|
||||
n := El("child",
|
||||
At("id", AlbumID(a.ID)),
|
||||
At("parent", FolderID(a.LibraryID)),
|
||||
At("isDir", true),
|
||||
At("title", a.Title),
|
||||
At("album", a.Title),
|
||||
At("artist", a.Artist),
|
||||
At("isVideo", false),
|
||||
)
|
||||
if a.ArtistID > 0 {
|
||||
n.Attrs = append(n.Attrs, At("artistId", ArtistID(a.ArtistID)))
|
||||
}
|
||||
if a.HasCover {
|
||||
n.Attrs = append(n.Attrs, At("coverArt", AlbumID(a.ID)))
|
||||
}
|
||||
if a.Year > 0 {
|
||||
n.Attrs = append(n.Attrs, At("year", int64(a.Year)))
|
||||
}
|
||||
return n
|
||||
}
|
||||
243
internal/subsonic/dto/node.go
Normal file
243
internal/subsonic/dto/node.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// Package dto is the Subsonic wire contract: the response envelope, the
|
||||
// element tree it carries, the protocol's error codes and the opaque id scheme.
|
||||
//
|
||||
// It is deliberately separate from internal/subsonic, which owns the HTTP
|
||||
// handlers and the queries. A node here knows nothing about a request, a
|
||||
// response writer or a database row: it maps data structures into the two
|
||||
// serialisations Subsonic clients speak (XML attributes and flattened JSON).
|
||||
//
|
||||
// The node names and attribute names below are load-bearing. Symfonium,
|
||||
// Ultrasonic, play:Sub and every other client branch on them, and a rename here
|
||||
// fails silently on devices nobody in this repository can see. Likewise error
|
||||
// code 40: a client must read "wrong credentials" out of the envelope, never an
|
||||
// HTTP 401.
|
||||
package dto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
// ProtocolVersion is the Subsonic API version this layer implements. It is
|
||||
// advertised in every envelope.
|
||||
ProtocolVersion = "1.16.1"
|
||||
// ServerType identifies the server implementation in the envelope.
|
||||
ServerType = "vellum"
|
||||
)
|
||||
|
||||
// ServerVersion is advertised as serverVersion in the envelope. The main
|
||||
// binary may set it to its build version; it defaults to "dev".
|
||||
var ServerVersion = "dev"
|
||||
|
||||
// Subsonic error codes. The protocol defines these; clients branch on them.
|
||||
const (
|
||||
ErrGeneric = 0 // a generic error
|
||||
ErrMissingParameter = 10 // required parameter is missing
|
||||
ErrWrongCredentials = 40 // wrong username or password
|
||||
ErrNotAuthorized = 50 // user is not authorized for the operation
|
||||
ErrDataNotFound = 70 // the requested data was not found
|
||||
)
|
||||
|
||||
// StatusOK / StatusFailed are the two envelope `status` values.
|
||||
const (
|
||||
StatusOK = "ok"
|
||||
StatusFailed = "failed"
|
||||
)
|
||||
|
||||
// Attr is one attribute of an element. The value is typed — string, int64 or
|
||||
// bool — rather than a string so the JSON form can emit a real number or
|
||||
// boolean where the protocol expects one, while XML always stringifies it.
|
||||
type Attr struct {
|
||||
Name string
|
||||
Value any
|
||||
}
|
||||
|
||||
// Node is one element in a response tree. Attributes and children are kept in
|
||||
// insertion order, which XML needs (attribute order is cosmetic but stable
|
||||
// output is easier to test) and which lets JSON grouping stay deterministic.
|
||||
//
|
||||
// The tree exists instead of Go structs because Subsonic's XML and JSON forms
|
||||
// are not the same shape: XML uses attributes, while JSON flattens attributes
|
||||
// and child elements into one object and turns repeated child names into
|
||||
// arrays. A single tree with two serializers keeps the two in lockstep.
|
||||
type Node struct {
|
||||
Name string
|
||||
Attrs []Attr
|
||||
Kids []*Node
|
||||
// arrayKids names child elements to emit as JSON arrays even when there is
|
||||
// exactly one (or none). Whether "song" is an object or an array depends on
|
||||
// the parent: getSong returns one object, but album/searchResult3 contain
|
||||
// arrays. That is a property of the container, so it is recorded here rather
|
||||
// than inferred from the element name.
|
||||
arrayKids map[string]bool
|
||||
}
|
||||
|
||||
// El starts an element.
|
||||
func El(name string, attrs ...Attr) *Node {
|
||||
return &Node{Name: name, Attrs: attrs}
|
||||
}
|
||||
|
||||
// Arrays marks child names that should always be JSON arrays under this node,
|
||||
// and that should appear as [] when there are none, so a client that indexes
|
||||
// the array never sees a missing key.
|
||||
func (n *Node) Arrays(names ...string) *Node {
|
||||
if n.arrayKids == nil {
|
||||
n.arrayKids = make(map[string]bool, len(names))
|
||||
}
|
||||
for _, name := range names {
|
||||
n.arrayKids[name] = true
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// At is one attribute.
|
||||
func At(name string, value any) Attr { return Attr{Name: name, Value: value} }
|
||||
|
||||
// Add appends child elements and returns the parent, so trees can be built
|
||||
// inline.
|
||||
func (n *Node) Add(kids ...*Node) *Node {
|
||||
n.Kids = append(n.Kids, kids...)
|
||||
return n
|
||||
}
|
||||
|
||||
// Envelope starts a response. XML needs the namespace; nodeJSON drops it.
|
||||
func Envelope(status string) *Node {
|
||||
return El("subsonic-response",
|
||||
At("xmlns", "http://subsonic.org/restapi"),
|
||||
At("status", status),
|
||||
At("version", ProtocolVersion),
|
||||
At("type", ServerType),
|
||||
At("serverVersion", ServerVersion),
|
||||
)
|
||||
}
|
||||
|
||||
// ErrorNode is the <error> element clients read out of a failed envelope.
|
||||
func ErrorNode(code int, msg string) *Node {
|
||||
return El("error", At("code", code), At("message", msg))
|
||||
}
|
||||
|
||||
// ── JSON ───────────────────────────────────────────────────────────────
|
||||
|
||||
// NodeJSON converts a node into the JSON value Subsonic clients expect. A
|
||||
// "folder" leaf whose only attribute is id is flattened to the bare id, which
|
||||
// is how getuser's folder list is specified ([1, 3], not [{"id":1}]).
|
||||
func NodeJSON(n *Node) any {
|
||||
if n.Name == "folder" && len(n.Attrs) == 1 && len(n.Kids) == 0 && n.Attrs[0].Name == "id" {
|
||||
return n.Attrs[0].Value
|
||||
}
|
||||
|
||||
m := make(map[string]any, len(n.Attrs)+len(n.Kids))
|
||||
for _, a := range n.Attrs {
|
||||
if a.Name == "xmlns" {
|
||||
continue
|
||||
}
|
||||
m[a.Name] = a.Value
|
||||
}
|
||||
|
||||
order := make([]string, 0, len(n.Kids))
|
||||
groups := make(map[string][]*Node, len(n.Kids))
|
||||
for _, k := range n.Kids {
|
||||
if _, ok := groups[k.Name]; !ok {
|
||||
order = append(order, k.Name)
|
||||
}
|
||||
groups[k.Name] = append(groups[k.Name], k)
|
||||
}
|
||||
|
||||
for _, name := range order {
|
||||
kids := groups[name]
|
||||
if len(kids) == 1 && !n.arrayKids[name] {
|
||||
m[name] = NodeJSON(kids[0])
|
||||
continue
|
||||
}
|
||||
arr := make([]any, 0, len(kids))
|
||||
for _, k := range kids {
|
||||
arr = append(arr, NodeJSON(k))
|
||||
}
|
||||
m[name] = arr
|
||||
}
|
||||
|
||||
for name := range n.arrayKids {
|
||||
if _, ok := m[name]; !ok {
|
||||
m[name] = []any{}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// JSONBody encodes the response envelope. HTML escaping is disabled so a path
|
||||
// containing "&" or "<" stays readable rather than becoming &.
|
||||
func JSONBody(root *Node) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
enc := json.NewEncoder(&b)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(map[string]any{"subsonic-response": NodeJSON(root)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// ── XML ────────────────────────────────────────────────────────────────
|
||||
|
||||
// XMLBody renders the response envelope including the XSD namespace and the XML
|
||||
// declaration. It writes to a buffer rather than straight to the response
|
||||
// because the envelope's status and content type are decided by the handler,
|
||||
// not by the tree.
|
||||
func XMLBody(root *Node) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(xml.Header)
|
||||
_ = writeXML(&b, root)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func writeXML(w io.Writer, n *Node) error {
|
||||
var b bytes.Buffer
|
||||
b.WriteByte('<')
|
||||
b.WriteString(n.Name)
|
||||
for _, a := range n.Attrs {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(a.Name)
|
||||
b.WriteString(`="`)
|
||||
_ = xml.EscapeText(&b, []byte(xmlString(a.Value)))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
if len(n.Kids) == 0 {
|
||||
b.WriteString("/>")
|
||||
_, err := w.Write(b.Bytes())
|
||||
return err
|
||||
}
|
||||
b.WriteByte('>')
|
||||
if _, err := w.Write(b.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range n.Kids {
|
||||
if err := writeXML(w, k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := io.WriteString(w, "</"+n.Name+">")
|
||||
return err
|
||||
}
|
||||
|
||||
// xmlString renders a typed attribute value for XML. Booleans are lower-case,
|
||||
// which is what the Subsonic schema uses.
|
||||
func xmlString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
if t {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/mithral/vellum/internal/auth"
|
||||
"github.com/mithral/vellum/internal/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/<method>.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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,206 +0,0 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mithral/vellum/internal/library"
|
||||
"github.com/mithral/vellum/internal/stream"
|
||||
)
|
||||
|
||||
// Vellum has two id spaces — media_items and media_files — and Subsonic has
|
||||
// one opaque string id, so every id this layer emits is prefixed to say what it
|
||||
// names. The prefix also lets stream/getCoverArt resolve an id without a
|
||||
// separate lookup table.
|
||||
//
|
||||
// al-<itemID> an album / directory-grouped item
|
||||
// mf-<fileID> a song, i.e. one physical media_files row
|
||||
// ar-<personID> an artist, from people/item_people
|
||||
// fd-<libID> a music folder (a whole audio library)
|
||||
// d-<libID>:<relpath> a browsable directory within a library
|
||||
const (
|
||||
prefixAlbum = "al-"
|
||||
prefixFile = "mf-"
|
||||
prefixArtist = "ar-"
|
||||
prefixFolder = "fd-"
|
||||
prefixDir = "d-"
|
||||
)
|
||||
|
||||
func albumID(itemID int64) string { return prefixAlbum + strconv.FormatInt(itemID, 10) }
|
||||
func fileID(fileID int64) string { return prefixFile + strconv.FormatInt(fileID, 10) }
|
||||
func artistID(personID int64) string { return prefixArtist + strconv.FormatInt(personID, 10) }
|
||||
func folderID(libID int64) string { return prefixFolder + strconv.FormatInt(libID, 10) }
|
||||
func dirID(libID int64, rel string) string {
|
||||
return prefixDir + strconv.FormatInt(libID, 10) + ":" + rel
|
||||
}
|
||||
|
||||
// parseID splits a prefixed id, returning the numeric part.
|
||||
func parseID(id, prefix string) (int64, bool) {
|
||||
rest, ok := strings.CutPrefix(id, prefix)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(rest, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// parseDirID splits "d-<libID>:<relpath>". An empty relpath is the library root.
|
||||
func parseDirID(id string) (libID int64, rel string, ok bool) {
|
||||
rest, ok := strings.CutPrefix(id, prefixDir)
|
||||
if !ok {
|
||||
return 0, "", false
|
||||
}
|
||||
lib, rel, found := strings.Cut(rest, ":")
|
||||
if !found {
|
||||
return 0, "", false
|
||||
}
|
||||
n, err := strconv.ParseInt(lib, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return n, rel, true
|
||||
}
|
||||
|
||||
// ── entities ───────────────────────────────────────────────────────────
|
||||
|
||||
// albumInfo is one album: a directory-grouped media_items row of an audio kind.
|
||||
type albumInfo struct {
|
||||
ID int64
|
||||
LibraryID int64
|
||||
Kind string
|
||||
Path string
|
||||
Title string
|
||||
Year int
|
||||
AddedAt string
|
||||
ArtistID int64
|
||||
Artist string
|
||||
SongCount int
|
||||
DurationMS int64
|
||||
HasCover bool
|
||||
}
|
||||
|
||||
// track is one song: a media_files row together with its album's context.
|
||||
type track struct {
|
||||
FileID int64
|
||||
ItemID int64
|
||||
LibraryID int64
|
||||
Kind string
|
||||
AlbumPath string
|
||||
AlbumTitle string
|
||||
Year int
|
||||
AddedAt string
|
||||
RelPath string
|
||||
Size int64
|
||||
DurationMS int64
|
||||
Container string
|
||||
ArtistID int64
|
||||
Artist string
|
||||
Ordinal int
|
||||
HasCover bool
|
||||
}
|
||||
|
||||
// songNode renders a track as the Subsonic Child/Song element. getSong returns
|
||||
// it bare; getAlbum nests it under <album>.
|
||||
func songNode(t track) *node {
|
||||
ct := stream.ContentType(t.RelPath)
|
||||
suffix := strings.TrimPrefix(strings.ToLower(filepath.Ext(t.RelPath)), ".")
|
||||
|
||||
n := el("song",
|
||||
at("id", fileID(t.FileID)),
|
||||
at("parent", albumID(t.ItemID)),
|
||||
at("isDir", false),
|
||||
at("title", library.CleanTitle(filepath.Base(t.RelPath))),
|
||||
at("album", t.AlbumTitle),
|
||||
at("albumId", albumID(t.ItemID)),
|
||||
at("artist", t.Artist),
|
||||
at("type", "music"),
|
||||
at("isVideo", false),
|
||||
)
|
||||
if t.ArtistID > 0 {
|
||||
n.attrs = append(n.attrs, at("artistId", artistID(t.ArtistID)))
|
||||
}
|
||||
if t.HasCover {
|
||||
n.attrs = append(n.attrs, at("coverArt", albumID(t.ItemID)))
|
||||
}
|
||||
if t.Ordinal > 0 {
|
||||
n.attrs = append(n.attrs, at("track", int64(t.Ordinal)))
|
||||
}
|
||||
if t.Year > 0 {
|
||||
n.attrs = append(n.attrs, at("year", int64(t.Year)))
|
||||
}
|
||||
n.attrs = append(n.attrs,
|
||||
at("size", t.Size),
|
||||
at("contentType", ct),
|
||||
at("suffix", suffix),
|
||||
at("duration", t.DurationMS/1000),
|
||||
at("path", t.RelPath),
|
||||
)
|
||||
if t.AddedAt != "" {
|
||||
n.attrs = append(n.attrs, at("created", t.AddedAt))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// albumNode renders an album as the Subsonic AlbumID3 element. Extra legacy
|
||||
// fields (title, album, isDir) are included because older clients read them and
|
||||
// they cost nothing.
|
||||
func albumNode(a albumInfo) *node {
|
||||
parent := folderID(a.LibraryID)
|
||||
if a.ArtistID > 0 {
|
||||
parent = artistID(a.ArtistID)
|
||||
}
|
||||
|
||||
n := el("album",
|
||||
at("id", albumID(a.ID)),
|
||||
at("parent", parent),
|
||||
at("name", a.Title),
|
||||
at("title", a.Title),
|
||||
at("album", a.Title),
|
||||
at("isDir", true),
|
||||
at("artist", a.Artist),
|
||||
at("songCount", int64(a.SongCount)),
|
||||
at("duration", a.DurationMS/1000),
|
||||
at("playCount", int64(0)),
|
||||
)
|
||||
if a.ArtistID > 0 {
|
||||
n.attrs = append(n.attrs, at("artistId", artistID(a.ArtistID)))
|
||||
}
|
||||
if a.HasCover {
|
||||
n.attrs = append(n.attrs, at("coverArt", albumID(a.ID)))
|
||||
}
|
||||
if a.Year > 0 {
|
||||
n.attrs = append(n.attrs, at("year", int64(a.Year)))
|
||||
}
|
||||
if a.AddedAt != "" {
|
||||
n.attrs = append(n.attrs, at("created", a.AddedAt))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// childNode renders an album or directory entry for getMusicDirectory /
|
||||
// getIndexes, where the element is a generic Child rather than AlbumID3.
|
||||
func childNode(a albumInfo) *node {
|
||||
n := el("child",
|
||||
at("id", albumID(a.ID)),
|
||||
at("parent", folderID(a.LibraryID)),
|
||||
at("isDir", true),
|
||||
at("title", a.Title),
|
||||
at("album", a.Title),
|
||||
at("artist", a.Artist),
|
||||
at("isVideo", false),
|
||||
)
|
||||
if a.ArtistID > 0 {
|
||||
n.attrs = append(n.attrs, at("artistId", artistID(a.ArtistID)))
|
||||
}
|
||||
if a.HasCover {
|
||||
n.attrs = append(n.attrs, at("coverArt", albumID(a.ID)))
|
||||
}
|
||||
if a.Year > 0 {
|
||||
n.attrs = append(n.attrs, at("year", int64(a.Year)))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
|
@ -1,182 +1,24 @@
|
|||
package subsonic
|
||||
|
||||
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, "</"+n.name+">")
|
||||
return err
|
||||
}
|
||||
|
||||
// xmlString renders a typed attribute value for XML. Booleans are lower-case,
|
||||
// which is what the Subsonic schema uses.
|
||||
func xmlString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
if t {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
_, _ = w.Write(dto.XMLBody(root))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <index> 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue