Vellum/internal/jellyfin/dto/dto.go
mithral 35442e4aff 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.
2026-10-11 16:03:10 -04:00

405 lines
15 KiB
Go

// Package dto is the Jellyfin wire contract: the request and response shapes
// real clients (the web app, iOS, Android, Roku, TV) send and expect, plus the
// pure functions that build them from Vellum's own types.
//
// It is deliberately separate from internal/jellyfin, which owns the HTTP
// handlers and the queries. A DTO here knows nothing about a request, a
// response writer or a database row: it maps data structures. That is what
// makes the contract reviewable on its own — the field names below are the ones
// clients are coded against, and a rename in here silently breaks an app that
// nobody in this repository can see failing.
//
// Every JSON tag below is load-bearing. Do not rename one without reading the
// Jellyfin OpenAPI first.
package dto
import (
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/library"
)
// This file defines a documented SUBSET of the Jellyfin DTOs. Jellyfin's
// BaseItemDto has roughly a hundred fields; real clients tolerate the absence
// of most of them, and the ones below are what browse and playback actually
// consume. Every struct is PascalCase on the wire because that is the Jellyfin
// convention (the web app's own API is snake_case and is untouched).
// UserDto is the subset of the Jellyfin user shape clients read.
type UserDto struct {
Name string `json:"Name"`
ServerId string `json:"ServerId,omitempty"`
Id string `json:"Id"`
HasPassword bool `json:"HasPassword"`
HasConfiguredPassword bool `json:"HasConfiguredPassword"`
Policy *UserPolicy `json:"Policy,omitempty"`
Configuration *UserConfig `json:"Configuration,omitempty"`
}
// UserPolicy carries the two things a client needs from a user's policy: the
// admin flag and which library folders the account can see. EnabledFolders is
// the user's visible library ids rendered as Jellyfin ids, so a client can hide
// views it is not allowed to open.
type UserPolicy struct {
IsAdministrator bool `json:"IsAdministrator"`
IsDisabled bool `json:"IsDisabled"`
EnableAllFolders bool `json:"EnableAllFolders"`
EnabledFolders []string `json:"EnabledFolders"`
EnableMediaPlayback bool `json:"EnableMediaPlayback"`
EnableContentDownloading bool `json:"EnableContentDownloading"`
}
// UserConfig is a near-empty subset; clients only require the key to exist.
type UserConfig struct {
EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
}
// UserItemDataDto is per-user state for one item.
//
// Ticks are 100ns units. Vellum stores milliseconds, so the conversion is
// x10000 (ms -> ticks) and /10000 (ticks -> ms).
type UserItemDataDto struct {
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
PlayedPercentage float64 `json:"PlayedPercentage,omitempty"`
PlayCount int `json:"PlayCount"`
IsFavorite bool `json:"IsFavorite"`
Played bool `json:"Played"`
}
// BaseItemDto is the browse shape. Type is the Jellyfin item type
// ("Movie"/"Audio"/"Book"/"Photo"/"CollectionFolder"/"Folder"), mapped from
// Vellum's media kind.
type BaseItemDto struct {
Id string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
MediaType string `json:"MediaType,omitempty"`
CollectionType string `json:"CollectionType,omitempty"`
Container string `json:"Container,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
ProductionYear int `json:"ProductionYear,omitempty"`
ParentId string `json:"ParentId,omitempty"`
IsFolder bool `json:"IsFolder"`
SortName string `json:"SortName,omitempty"`
Path string `json:"Path,omitempty"`
ImageTags map[string]string `json:"ImageTags,omitempty"`
UserData *UserItemDataDto `json:"UserData,omitempty"`
MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"`
}
// MediaSourceInfo is one playable source. Vellum never transcodes, so
// SupportsTranscoding is always false and TranscodingUrl is never set.
type MediaSourceInfo struct {
Id string `json:"Id"`
Name string `json:"Name"`
Path string `json:"Path,omitempty"`
Container string `json:"Container,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
Size int64 `json:"Size,omitempty"`
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
SupportsDirectStream bool `json:"SupportsDirectStream"`
SupportsTranscoding bool `json:"SupportsTranscoding"`
DirectStreamUrl string `json:"DirectStreamUrl,omitempty"`
Protocol string `json:"Protocol"`
Type string `json:"Type"`
IsRemote bool `json:"IsRemote"`
MediaStreams []MediaStream `json:"MediaStreams,omitempty"`
}
// MediaStream describes one track inside a file, using the codec facts the
// scanner wrote from ffprobe.
type MediaStream struct {
Type string `json:"Type"` // "Video" | "Audio"
Codec string `json:"Codec,omitempty"`
Index int `json:"Index"`
IsDefault bool `json:"IsDefault"`
Width int `json:"Width,omitempty"`
Height int `json:"Height,omitempty"`
}
// PlaybackInfoResponse is the response to PlaybackInfo. ErrorCode is set only
// when the client demanded a transcode Vellum will not perform.
type PlaybackInfoResponse struct {
MediaSources []MediaSourceInfo `json:"MediaSources"`
PlaySessionId string `json:"PlaySessionId,omitempty"`
ErrorCode string `json:"ErrorCode,omitempty"`
}
// ItemQueryResult is the browse envelope.
type ItemQueryResult struct {
Items []BaseItemDto `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
StartIndex int `json:"StartIndex"`
}
// SystemInfoPublic is the discovery document clients fetch before they have a
// token. Only the four fields the task requires (plus a couple clients read).
type SystemInfoPublic struct {
Id string `json:"Id"`
ServerName string `json:"ServerName"`
Version string `json:"Version"`
ProductName string `json:"ProductName"`
OperatingSystem string `json:"OperatingSystem,omitempty"`
}
// Version is the Jellyfin API version string clients compare against. Vellum
// reports a conservative, widely-supported version rather than its own build
// number, because clients gate features on this value.
const Version = "10.8.10"
// AuthByNameRequest is the AuthenticateByName body. Jellyfin spells the
// password "Pw".
type AuthByNameRequest struct {
Username string `json:"Username"`
Pw string `json:"Pw"`
}
// PlayReport is the body of the three playback-reporting endpoints. Jellyfin
// spells these fields in PascalCase and sends PositionTicks in 100ns units.
type PlayReport struct {
ItemId string `json:"ItemId"`
PositionTicks int64 `json:"PositionTicks"`
PlaySessionId string `json:"PlaySessionId"`
MediaSourceId string `json:"MediaSourceId"`
IsPaused bool `json:"IsPaused"`
}
// ItemView is one browsable item assembled for a Jellyfin DTO.
//
// It composes the two types internal/catalog owns — the item row and its first
// file — with the per-user overlay (progress, view count, favourite) and the
// two browse-only columns catalog.Item does not carry (sort title and parent).
// Nothing here reads the database or checks a grant: catalog.LoadFor loads the
// item and enforces the grant, and catalog.FirstFile reads the file, so there
// is exactly one implementation of "may this user read this item" and exactly
// one place item and file rows are read.
type ItemView struct {
Item catalog.Item
File catalog.File
// SortTitle is a browse-only column; the DTO maps it to SortName.
SortTitle string
// ParentID is a browse-only column mapped to ParentId. Zero means "no
// parent": item ids are positive, so zero is unambiguous.
ParentID int64
// HasCover is true when the scanner extracted a primary image.
HasCover bool
// Per-user state, zero when the user has never touched the item.
PositionMS int64
Percent float64
Finished bool
PlayCount int
Favorite bool
}
// ItemType maps a Vellum media kind to the Jellyfin item type. The four types
// the task requires are Movie, Audio, Book and Photo; anything Vellum adds
// later (games) degrades to Folder so a client can still show it.
func ItemType(kind string) string {
switch kind {
case "video":
return "Movie"
case "audiobooks", "music":
return "Audio"
case "photos":
return "Photo"
case "ebooks", "comics":
return "Book"
default:
return "Folder"
}
}
// MediaType maps a Vellum media kind to the Jellyfin MediaType. It is the
// coarser classification ("Video"/"Audio"/"Book"/"Photo").
func MediaType(kind string) string {
switch kind {
case "video":
return "Video"
case "audiobooks", "music":
return "Audio"
case "photos":
return "Photo"
case "ebooks", "comics":
return "Book"
default:
return ""
}
}
// CollectionType maps a Vellum kind to the Jellyfin collection type, which
// tells a client how to render the folder.
func CollectionType(kind string) string {
switch kind {
case "video":
return "movies"
case "audiobooks", "music":
return "music"
case "photos":
return "homevideos"
case "ebooks", "comics":
return "books"
default:
return ""
}
}
// KindsForTypes maps requested Jellyfin IncludeItemTypes back to Vellum kinds,
// so a client asking for Movies does not receive ebooks. An unknown type maps
// to nothing rather than to everything.
func KindsForTypes(types []string) []string {
var out []string
for _, t := range types {
switch t {
case "Movie":
out = append(out, "video")
case "Audio":
out = append(out, "audiobooks", "music")
case "Book":
out = append(out, "ebooks", "comics")
case "Photo":
out = append(out, "photos")
}
}
return out
}
// ToBaseItemDto renders one ItemView. includeMediaSources is set from the
// Fields query parameter; lists normally omit sources to keep responses small.
func ToBaseItemDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto {
dto := BaseItemDto{
Id: ItemID(v.Item.ID),
Name: v.Item.Title,
Type: ItemType(v.Item.Kind),
MediaType: MediaType(v.Item.Kind),
Container: v.File.Container,
RunTimeTicks: MsToTicks(v.File.DurationMS),
ProductionYear: v.Item.Year,
IsFolder: false,
SortName: v.SortTitle,
Path: v.File.Path,
}
if v.ParentID > 0 {
dto.ParentId = ItemID(v.ParentID)
}
if v.HasCover {
// The tag is the item id: stable, and it changes when the item changes.
dto.ImageTags = map[string]string{"Primary": ItemID(v.Item.ID)}
}
dto.UserData = &UserItemDataDto{
PlaybackPositionTicks: MsToTicks(v.PositionMS),
PlayedPercentage: v.Percent * 100,
PlayCount: v.PlayCount,
IsFavorite: v.Favorite,
Played: v.Finished,
}
if includeMediaSources {
dto.MediaSources = []MediaSourceInfo{BuildMediaSource(v.Item, v.File, apiKey)}
}
return dto
}
// BuildMediaSource renders the single direct-play source for an item. It is
// shared by PlaybackInfo and by list responses that requested the MediaSources
// field. Both inputs come from internal/catalog; a zero File is an item with no
// file, and produces the same defaults a missing file always did.
func BuildMediaSource(it catalog.Item, f catalog.File, apiKey string) MediaSourceInfo {
ms := MediaSourceInfo{
Id: ItemID(it.ID),
Name: it.Title,
Path: f.Path,
Container: f.Container,
RunTimeTicks: MsToTicks(f.DurationMS),
Size: f.Size,
SupportsDirectPlay: true,
SupportsDirectStream: true,
// The hard constraint: no transcoding, ever.
SupportsTranscoding: false,
Protocol: "File",
Type: "Default",
IsRemote: false,
}
ms.DirectStreamUrl = directStreamURL(it, apiKey)
if f.VCodec != "" {
ms.MediaStreams = append(ms.MediaStreams, MediaStream{
Type: "Video", Codec: f.VCodec, Index: 0, IsDefault: true,
Width: f.Width, Height: f.Height,
})
}
if f.ACodec != "" {
ms.MediaStreams = append(ms.MediaStreams, MediaStream{
Type: "Audio", Codec: f.ACodec, Index: 1, IsDefault: true,
})
}
return ms
}
// LibraryDto renders a library as a Jellyfin folder.
func LibraryDto(lib library.Library) BaseItemDto {
return BaseItemDto{
Id: LibraryID(lib.ID),
Name: lib.Name,
Type: "CollectionFolder",
CollectionType: CollectionType(lib.Kind),
IsFolder: true,
SortName: lib.Name,
}
}
// User renders the Jellyfin user shape, including the visible library folders
// so a client can scope its views. visibleLibIDs are the caller's granted
// library ids; this function only renders them, it does not look them up.
func User(u auth.User, serverID string, visibleLibIDs []int64) UserDto {
folders := make([]string, 0, len(visibleLibIDs))
for _, id := range visibleLibIDs {
folders = append(folders, LibraryID(id))
}
return UserDto{
Name: displayName(u),
ServerId: serverID,
Id: ItemID(u.ID),
HasPassword: u.PasswordHash != "",
HasConfiguredPassword: u.PasswordHash != "",
Policy: &UserPolicy{
IsAdministrator: u.IsAdmin,
IsDisabled: false,
EnableAllFolders: u.IsAdmin,
EnabledFolders: folders,
EnableMediaPlayback: true,
EnableContentDownloading: true,
},
Configuration: &UserConfig{},
}
}
// displayName falls back to the username when no display name is set.
func displayName(u auth.User) string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.Username
}
// directStreamURL builds the URL a client should play. Video items stream from
// /Videos, everything else (audio, books, photos) from /Audio, which is the
// same file-serving path either way. The api_key is embedded because media
// elements cannot set headers.
func directStreamURL(it catalog.Item, apiKey string) string {
base := "/Audio/" + ItemID(it.ID) + "/stream"
if it.Kind == "video" {
base = "/Videos/" + ItemID(it.ID) + "/stream"
}
if apiKey == "" {
return base
}
return base + "?api_key=" + apiKey
}
// MsToTicks converts Vellum's milliseconds to Jellyfin's 100ns ticks.
func MsToTicks(ms int64) int64 { return ms * 10000 }
// TicksToMS converts Jellyfin's 100ns ticks to Vellum's milliseconds.
func TicksToMS(ticks int64) int64 { return ticks / 10000 }