Motivation, from the evidence rather than from taste. Five packages each
implemented "load an item, then check the caller may read it": media, comics,
games, jellyfin (with its own dbItem type and its own SQL), and subsonic. Five
copies of one authorization rule means four can drift, and the failure mode is
not a crash — it is silently serving somebody a library they were never granted.
One implementation is one thing to review.
internal/catalog now owns item and file access, and the access check.
LoadUnchecked(ctx, db, id) -- no access check; jobs and tests only
LoadFor(ctx, db, u, id) -- load AND enforce the grant, in one call
Files / FilesFor / FileByID / FirstFile / FileCount
The unchecked variant is deliberately named so that reaching for it by accident
is hard: it is the dangerous call, so it should not have the friendlier name.
This came from the interface-design rule that a good interface makes the right
thing easy and the wrong thing hard.
The security property is tested, not asserted: a forbidden item and an absent
item return byte-identical errors, so no handler can leak the existence of
another user's library through a 403. That is now enforced in one place.
Migrated all five surfaces onto it. media, comics, games and ebooks went from
four duplicate loaders to zero. jellyfin's dbItem and loadDBItem are gone.
subsonic's per-item authorization moved while its album/artist aggregate
projections stayed put, because grouping items into albums is a presentation
concern, not a data-access one. The remaining raw SQL everywhere is browse and
aggregate queries; no per-item load decides access any more.
internal/arch enforces the layering instead of documenting it. Five rules, each
proven able to fail by deliberately breaking it:
- no upward imports across six explicit layers
- internal/core may depend only on migrations
- internal/catalog must not know about HTTP, or it stops being reusable
- modules must not import each other
- every package must be placed in a layer, so the map cannot go stale
A test that only ever passes is decoration, so each rule was verified by
introducing the violation and confirming the failure.
Verified: gofmt clean, go build ./... exit 0, go vet exit 0, 20 packages pass,
svelte-check 0/0. End to end against a running server: the vellum API serves
item detail with codec facts, an unknown item 404s, Jellyfin AuthenticateByName
mints a token and /Items browses, and Subsonic ping returns status ok. The
subsonic migration was additionally A/B'd byte-identical against the previous
implementation across ~15 endpoints, and Jellyfin/Subsonic both still return the
same error for a forbidden item as for an absent one.
Known follow-ups, recorded rather than silently skipped:
- internal/jellyfin has no test files at all. Its wire contract is the most
client-visible surface in the tree and is now verified only by hand.
- catalog is item-centric, but Subsonic addresses songs file-first (mf-<id>),
so it resolves file to item through its own projection. A catalog lookup
that takes a file id would close that.
- subsonic loadAlbum now scopes the projection to the item's own library
rather than all visible audio libraries. Equivalent by construction, since
an item's kind tracks its library's kind, but a hand-edited mismatch would
differ.
297 lines
11 KiB
Go
297 lines
11 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"database/sql"
|
|
|
|
"github.com/mithral/vellum/internal/catalog"
|
|
)
|
|
|
|
// 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 VisibleLibraryIDs 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"`
|
|
}
|
|
|
|
// 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 and ParentID are browse-only columns; the DTO maps them to
|
|
// SortName and ParentId.
|
|
SortTitle string
|
|
ParentID sql.NullInt64
|
|
|
|
// 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 ""
|
|
}
|
|
}
|
|
|
|
// 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.Valid {
|
|
dto.ParentId = itemID(v.ParentID.Int64)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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 }
|