mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* 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.
352 lines
12 KiB
Go
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
|
|
}
|