Deluan Quintão 5927e693d1
feat(jellyfin): filter items by year and record label (#5817)
* feat(persistence): add AlbumRepository.GetYears for distinct album years

* test(persistence): verify GetYears de-duplicates repeated years

Regression test that adds two albums with the same non-zero max_year
(2005) and verifies that GetYears() returns that year exactly once,
ensuring the SQL DISTINCT clause is applied correctly. Catches any
future removal of DISTINCT from the GetYears query.

* feat(jellyfin): add legacy /Items/Filters endpoint (genres + years)

* feat(jellyfin): add /Studios endpoint from record label tags

* refactor(persistence): drop duplicate columns in tagRepository.GetAll

* fix(jellyfin): exclude missing albums from filter years

GetYears only filtered max_year > 0, so albums whose files were all removed
(missing=true, kept when Scanner.PurgeMissing=never) contributed stale years
to /Items/Filters. Filter them out like the normal album listings do. Also
return an empty slice from the MockAlbumRepo to match the real repository.

* feat(jellyfin): filter /Items by Years=

* feat(jellyfin): filter /Items by StudioIds= (record labels)

* feat(jellyfin): scope filter and studio lists to ParentId library

* refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers

Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call
sites and the duplicated empty-scope guard into two small helpers, so the
empty-scope=unrestricted contract lives in one place. Reuse the existing
names() helper in the Years= e2e test.

* feat(jellyfin): expose record labels as album Studios

Add a Studios field to the album BaseItemDto, populated from the record-label
tags and gated behind Fields=Studios (matching Jellyfin's ItemFields
convention). Studio ids reuse the record-label tag identity, so they round-trip
with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios
empty for music; Feishin reads it as the album's record label.
2026-07-19 12:39:31 -04:00

352 lines
12 KiB
Go

package dto
import (
"cmp"
"fmt"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond.
const ticksPerMillis = 10_000
func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1000 * ticksPerMillis) }
// TicksFromMillis converts milliseconds (Navidrome lyric timestamps) to ticks.
func TicksFromMillis(ms int64) int64 { return ms * ticksPerMillis }
// MillisFromTicks converts ticks (client-reported playback positions) to milliseconds.
func MillisFromTicks(ticks int64) int64 { return ticks / ticksPerMillis }
// premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601
// PremiereDate clients parse, falling back to year; nil when neither exists.
func premiereDate(date string, year int) *string {
d := date
switch len(d) {
case 4:
d += "-01-01"
case 7:
d += "-01"
case 10: // already yyyy-mm-dd
default:
if year <= 0 {
return nil
}
d = fmt.Sprintf("%04d-01-01", year)
}
s := d + "T00:00:00Z"
return &s
}
// jellyfinDate formats t as the ISO 8601 string clients expect, or "" for the zero time so the
// field is omitted rather than sent as a meaningless epoch.
func jellyfinDate(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream.
func channelLayout(n int) string {
switch n {
case 1:
return "mono"
case 2:
return "stereo"
case 6:
return "5.1"
case 8:
return "7.1"
default:
return ""
}
}
// MediaSourceFromMediaFile builds the MediaSourceInfo for direct playback of mf's source file.
// Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo
// responses (Finamp's download dialog reads MediaSources[0].Size from the browse response).
func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo {
streams := make([]MediaStream, 1, 2)
streams[0] = MediaStream{
Type: "Audio",
Index: 0,
Codec: mf.Codec,
BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps.
Channels: mf.Channels,
SampleRate: mf.SampleRate,
ChannelLayout: channelLayout(mf.Channels),
}
// Finamp gates its lyrics view on a Lyric stream in PlaybackInfo, not on HasLyrics.
if mf.HasEmbeddedLyrics() {
streams = append(streams, MediaStream{Type: "Lyric", Index: 1, IsExternal: true})
}
return MediaSourceInfo{
Id: EncodeID(mf.ID),
Protocol: "Http",
Container: mf.Suffix,
Size: mf.Size,
Name: mf.Title,
Type: "Default",
RunTimeTicks: TicksFromSeconds(mf.Duration),
Bitrate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's Bitrate is bps.
SupportsDirectPlay: true,
SupportsDirectStream: true,
SupportsTranscoding: true,
IsRemote: false,
SupportsProbing: true,
MediaStreams: streams,
MediaAttachments: []any{},
Formats: []string{},
}
}
func UserData(a model.Annotations, itemID string) *UserItemDataDto {
// Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto.
encodedID := EncodeID(itemID)
d := &UserItemDataDto{
PlayCount: int(a.PlayCount),
IsFavorite: a.Starred,
Played: a.PlayCount > 0,
Key: encodedID,
ItemId: encodedID,
}
if a.Rating > 0 {
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
d.Rating = &r
}
if a.PlayDate != nil {
s := a.PlayDate.UTC().Format(time.RFC3339)
d.LastPlayedDate = &s
}
return d
}
// SongToBaseItem maps a media file to an Audio BaseItemDto. MediaSources and SortName are attached
// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a
// plain list response); a nil fields set means neither.
func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
item := BaseItemDto{
Name: mf.Title,
Id: EncodeID(mf.ID),
Type: "Audio",
MediaType: "Audio",
IsFolder: false,
LocationType: "FileSystem",
HasLyrics: mf.HasEmbeddedLyrics(),
ParentId: EncodeID(mf.AlbumID),
Album: mf.Album,
AlbumId: EncodeID(mf.AlbumID),
AlbumArtist: mf.AlbumArtist,
RunTimeTicks: TicksFromSeconds(mf.Duration),
DateCreated: jellyfinDate(&mf.CreatedAt),
Container: mf.Suffix,
CanDownload: true,
BackdropImageTags: []string{},
UserData: UserData(mf.Annotations, mf.ID),
}
if fields.Has("MediaSources") {
item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)}
}
if fields.Has("SortName") {
item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title)
}
// Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit).
// Participants holds the per-artist list; fall back to the flattened display fields when absent.
if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 {
item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name })
item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair {
return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)}
})
} else {
if mf.Artist != "" {
item.Artists = []string{mf.Artist}
}
if mf.ArtistID != "" {
item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}}
}
}
if mf.AlbumArtistID != "" {
item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}}
}
// dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion.
item.NormalizationGain = mf.RGTrackGain
item.AlbumNormalizationGain = mf.RGAlbumGain
if mf.Year > 0 {
item.ProductionYear = new(mf.Year)
}
item.PremiereDate = premiereDate(mf.Date, mf.Year)
if mf.TrackNumber > 0 {
item.IndexNumber = new(mf.TrackNumber)
}
if mf.DiscNumber > 0 {
item.ParentIndexNumber = new(mf.DiscNumber)
}
if len(mf.Genres) > 0 {
for _, g := range mf.Genres {
item.Genres = append(item.Genres, g.Name)
item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name})
}
} else if mf.Genre != "" {
item.Genres = []string{mf.Genre}
}
// Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag.
if mf.AlbumID != "" {
item.AlbumPrimaryImageTag = mf.AlbumID
item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}}
}
return item
}
func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
item := BaseItemDto{
Name: al.Name,
Id: EncodeID(al.ID),
Type: "MusicAlbum",
IsFolder: true,
ParentId: EncodeID(al.AlbumArtistID),
AlbumArtist: al.AlbumArtist,
Album: al.Name,
ChildCount: new(al.SongCount),
SongCount: new(al.SongCount),
RunTimeTicks: TicksFromSeconds(al.Duration),
DateCreated: jellyfinDate(&al.CreatedAt),
ImageTags: map[string]string{"Primary": al.ID},
ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}},
BackdropImageTags: []string{},
UserData: UserData(al.Annotations, al.ID),
}
if al.AlbumArtistID != "" {
item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}}
item.ArtistItems = item.AlbumArtists
}
if al.MaxYear > 0 {
item.ProductionYear = new(al.MaxYear)
}
item.PremiereDate = premiereDate(al.Date, al.MaxYear)
if len(al.Genres) > 0 {
for _, g := range al.Genres {
item.Genres = append(item.Genres, g.Name)
item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name})
}
}
// Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios
// list and StudioIds= filter, so a client can display and click through to filter by label.
if fields.Has("Studios") {
for _, label := range al.Tags.Values(model.TagRecordLabel) {
id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID)
item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id})
}
}
// The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same
// convention as tracks; clients read it off the album item as NormalizationGain.
item.NormalizationGain = al.RGAlbumGain
return item
}
func ArtistToBaseItem(ar model.Artist) BaseItemDto {
return BaseItemDto{
Name: ar.Name,
Id: EncodeID(ar.ID),
Type: "MusicArtist",
IsFolder: true,
AlbumCount: new(ar.AlbumCount),
SongCount: new(ar.SongCount),
DateCreated: jellyfinDate(ar.CreatedAt),
ImageTags: map[string]string{"Primary": ar.ID},
ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}},
BackdropImageTags: []string{},
UserData: UserData(ar.Annotations, ar.ID),
}
}
func GenreToBaseItem(g model.Genre) BaseItemDto {
return BaseItemDto{
Name: g.Name,
Id: EncodeID(g.ID),
Type: "MusicGenre",
IsFolder: true,
BackdropImageTags: []string{},
}
}
func StudioToBaseItem(t model.Tag) BaseItemDto {
return BaseItemDto{
Name: t.TagValue,
Id: EncodeID(t.ID),
Type: "Studio",
BackdropImageTags: []string{},
}
}
// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto.
func PlaylistToBaseItem(p model.Playlist) BaseItemDto {
// Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover.
// UpdatedAt versions it (Put bumps it on upload); over-invalidation only costs a refetch.
tag := fmt.Sprintf("%s-%x", p.ID, p.UpdatedAt.UnixMilli())
return BaseItemDto{
Name: p.Name,
Id: EncodeID(p.ID),
Type: "Playlist",
// Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin
// stores them under its data folder), so without this its Playlists tab hides them all.
Path: "/data/playlists/" + p.ID,
IsFolder: true,
MediaType: "Audio",
ChildCount: new(p.SongCount),
RunTimeTicks: TicksFromSeconds(p.Duration),
ImageTags: map[string]string{"Primary": tag},
ImageBlurHashes: map[string]map[string]string{"Primary": {tag: blurHash(tag)}},
BackdropImageTags: []string{},
UserData: UserData(p.Annotations, p.ID),
}
}
// LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain
// from per-line Start presence, so synced drops start-less lines and unsynced never emits Start.
func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto {
d := LyricDto{
Metadata: LyricMetadata{
Artist: cmp.Or(lyrics.DisplayArtist, mf.Artist),
Album: mf.Album,
Title: cmp.Or(lyrics.DisplayTitle, mf.Title),
Length: TicksFromSeconds(mf.Duration),
IsSynced: lyrics.Synced,
},
Lyrics: make([]LyricLine, 0, len(lyrics.Line)),
}
if lyrics.Offset != nil {
offset := TicksFromMillis(*lyrics.Offset)
d.Metadata.Offset = &offset
}
for _, line := range lyrics.Line {
out := LyricLine{Text: line.Value}
if lyrics.Synced {
if line.Start == nil {
continue
}
start := TicksFromMillis(*line.Start)
out.Start = &start
for _, cue := range line.Cue {
if cue.Start == nil {
continue
}
c := LyricLineCue{
Position: cue.ByteStart,
EndPosition: cue.ByteEnd,
Start: TicksFromMillis(*cue.Start),
}
if cue.End != nil {
end := TicksFromMillis(*cue.End)
c.End = &end
}
out.Cues = append(out.Cues, c)
}
}
d.Lyrics = append(d.Lyrics, out)
}
return d
}