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:
mithral 2026-10-11 16:03:10 -04:00
parent ac1bd04df3
commit 35442e4aff
43 changed files with 1818 additions and 1281 deletions

1
.gitignore vendored
View file

@ -18,3 +18,4 @@
# local preview instance
demo/
.archify/

View file

@ -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")
}

View file

@ -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,

View file

@ -1,4 +1,4 @@
package media
package home
import (
"context"

View file

@ -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

View file

@ -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) {

View file

@ -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 {

View file

@ -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 }

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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]

View file

@ -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")
}
}

View 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
}

View file

@ -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.
//

View file

@ -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

View file

@ -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 {

View file

@ -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")
}

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -1,4 +1,4 @@
package metadata
package providers
import (
"context"

View file

@ -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

View 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")
}
}

View file

@ -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"

View file

@ -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()
}

View 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)
}
}

View file

@ -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)

View 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
}

View 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 ""
}
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)