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.
405 lines
15 KiB
Go
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 }
|