mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
* feat(jellyfin): add Quick Connect sign-in Jellyfin clients can now sign in without a password: the client shows a 6-digit code, a signed-in user approves it, and the client redeems a secret for its access token. - core/quickconnect: in-memory store shared by both routers through wire. Codes expire after 10 minutes; a secret redeems only once (Jellyfin allows repeats for 10 minutes); at most 1000 pending requests. - Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect. Admins may approve for another user via UserId, like Swiftfin's admin page. Initiate and redeem share the login rate limiter; Connect does not, since Finamp and Streamyfin poll it every second. - Web UI: a Quick Connect item in the user menu looks up the code and shows the app and device before approving, so a user can't be tricked into approving an unknown device blindly. - Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters when the Jellyfin API is enabled. * refactor(jellyfin): tidy Quick Connect naming and route guards Group the Quick Connect routes under one requireQuickConnect guard, make the request's device a named field so req.Device.ID can't be mistaken for a request id, and rename the web API response type to quickConnectDevice. * refactor(jellyfin): remove duplicated Jellyfin date formatting function * test(jellyfin): set play count and starred in the song fixture literal * refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper * fix(jellyfin): bound the client fields Quick Connect keeps in memory Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version header fields for up to ten minutes. With no header size limit, each pending request could hold about 1 MB, and even a short field kept the whole header alive because the parsed values are substrings of it. Reject fields over 512 bytes and copy the stored values. Also answer 500 instead of 401 when the redeem user lookup fails for a reason other than the user being gone. * fix(jellyfin): rate-limit Quick Connect code approval Any signed-in user could try codes without limit on the Jellyfin Authorize endpoint and the web UI lookup/authorize endpoints, and so could approve another person's pending device for their own account. Apply the same per-IP limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces.
472 lines
16 KiB
Go
472 lines
16 KiB
Go
package dto
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/id"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
// sortName must match the persistence ORDER BY key (see setSortMappings): Finamp's A-Z jump
|
|
// scans SortName client-side, and any mismatch with the server's sort order scrolls to the top.
|
|
func sortName(sortTag, orderName, displayName string) string {
|
|
if conf.Server.PreferSortTags {
|
|
return cmp.Or(sortTag, orderName, displayName)
|
|
}
|
|
return cmp.Or(orderName, displayName)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
parsed, err := time.Parse(time.DateOnly, d)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
s := JellyfinDate(&parsed)
|
|
return &s
|
|
}
|
|
|
|
// Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339.
|
|
const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00"
|
|
|
|
// JellyfinDate formats t as the date 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(jellyfinDateLayout)
|
|
}
|
|
|
|
// 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 s := JellyfinDate(a.PlayDate); s != "" {
|
|
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 {
|
|
albumID := EncodeID(mf.AlbumID)
|
|
item := BaseItemDto{
|
|
Name: mf.Title,
|
|
Id: EncodeID(mf.ID),
|
|
Type: "Audio",
|
|
MediaType: "Audio",
|
|
IsFolder: false,
|
|
LocationType: "FileSystem",
|
|
HasLyrics: new(mf.HasEmbeddedLyrics()),
|
|
ParentId: albumID,
|
|
Album: mf.Album,
|
|
AlbumId: 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 = sortName(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}
|
|
}
|
|
// Clients prefer ImageTags.Primary over AlbumId, so a track's own cover must win here.
|
|
if mf.ImageHash != "" && mf.ImageHash != mf.AlbumImage.ImageHash {
|
|
tag, blurs, ratio := primaryImage(mf.ItemImage, mf.ID, fields)
|
|
item.ImageTags = map[string]string{"Primary": tag}
|
|
item.ImageBlurHashes = blurs
|
|
item.PrimaryImageAspectRatio = ratio
|
|
} else if embeddedArtPending(mf) {
|
|
// Nothing enqueues media files: advertising the id is what makes a client ask, and that
|
|
// request is what extracts the embedded art and queues the track.
|
|
item.ImageTags = map[string]string{"Primary": mf.ID}
|
|
} else if mf.AlbumID != "" {
|
|
if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" {
|
|
item.AlbumPrimaryImageTag = tag
|
|
item.ImageBlurHashes = blurs
|
|
item.PrimaryImageAspectRatio = ratio
|
|
}
|
|
}
|
|
return item
|
|
}
|
|
|
|
func embeddedArtPending(mf model.MediaFile) bool {
|
|
return mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt &&
|
|
mf.ImageHash == "" && !mf.ItemImage.ImageAbsent
|
|
}
|
|
|
|
// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would
|
|
// pin a stale cover forever.
|
|
func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) {
|
|
if img.ImageAbsent {
|
|
return "", nil, nil
|
|
}
|
|
tag = cmp.Or(img.ImageHash, fallback)
|
|
if img.BlurHash != "" {
|
|
blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}}
|
|
}
|
|
if fields.Has("PrimaryImageAspectRatio") {
|
|
ratio = img.AspectRatio()
|
|
}
|
|
return tag, blurs, ratio
|
|
}
|
|
|
|
func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
|
|
tag, blurs, ratio := primaryImage(al.ItemImage, al.ID, fields)
|
|
item := BaseItemDto{
|
|
Name: al.Name,
|
|
Id: EncodeID(al.ID),
|
|
Type: "MusicAlbum",
|
|
IsFolder: true,
|
|
LocationType: "FileSystem",
|
|
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),
|
|
ImageBlurHashes: blurs,
|
|
PrimaryImageAspectRatio: ratio,
|
|
BackdropImageTags: []string{},
|
|
UserData: UserData(al.Annotations, al.ID),
|
|
}
|
|
if tag != "" {
|
|
item.ImageTags = map[string]string{"Primary": tag}
|
|
}
|
|
item.Artists = []string{}
|
|
if al.AlbumArtist != "" {
|
|
item.Artists = append(item.Artists, al.AlbumArtist)
|
|
}
|
|
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
|
|
if fields.Has("SortName") {
|
|
item.SortName = sortName(al.SortAlbumName, al.OrderAlbumName, al.Name)
|
|
}
|
|
return item
|
|
}
|
|
|
|
func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto {
|
|
tag, blurs, ratio := primaryImage(ar.ItemImage, ar.ID, fields)
|
|
item := BaseItemDto{
|
|
Name: ar.Name,
|
|
Id: EncodeID(ar.ID),
|
|
Type: "MusicArtist",
|
|
IsFolder: true,
|
|
AlbumCount: new(ar.AlbumCount),
|
|
SongCount: new(ar.SongCount),
|
|
DateCreated: JellyfinDate(ar.CreatedAt),
|
|
ImageBlurHashes: blurs,
|
|
PrimaryImageAspectRatio: ratio,
|
|
BackdropImageTags: []string{},
|
|
UserData: UserData(ar.Annotations, ar.ID),
|
|
}
|
|
if tag != "" {
|
|
item.ImageTags = map[string]string{"Primary": tag}
|
|
}
|
|
if fields.Has("SortName") {
|
|
item.SortName = sortName(ar.SortArtistName, ar.OrderArtistName, ar.Name)
|
|
}
|
|
return item
|
|
}
|
|
|
|
// LibraryToBaseItem maps a library to the CollectionFolder item clients browse as a top-level node.
|
|
// Manet keeps no library, and so syncs nothing, unless it carries the fields Jellyfin sends here.
|
|
func LibraryToBaseItem(lib model.Library) BaseItemDto {
|
|
id := EncodeLibraryID(lib.ID)
|
|
return BaseItemDto{
|
|
Id: id,
|
|
Name: lib.Name,
|
|
SortName: lib.Name,
|
|
Type: "CollectionFolder",
|
|
CollectionType: "music",
|
|
IsFolder: true,
|
|
Path: lib.Path,
|
|
LocationType: "FileSystem",
|
|
DateCreated: JellyfinDate(&lib.CreatedAt),
|
|
ChildCount: new(lib.TotalAlbums),
|
|
UserData: &UserItemDataDto{Key: id, ItemId: id},
|
|
BackdropImageTags: []string{},
|
|
}
|
|
}
|
|
|
|
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, fields Fields) BaseItemDto {
|
|
tag, blurs, ratio := primaryImage(p.ItemImage, p.ID, fields)
|
|
item := 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),
|
|
DateCreated: JellyfinDate(&p.CreatedAt),
|
|
ImageBlurHashes: blurs,
|
|
PrimaryImageAspectRatio: ratio,
|
|
BackdropImageTags: []string{},
|
|
UserData: UserData(p.Annotations, p.ID),
|
|
}
|
|
if tag != "" {
|
|
item.ImageTags = map[string]string{"Primary": tag}
|
|
}
|
|
// Playlists have no sort tag; the repository orders them by name.
|
|
if fields.Has("SortName") {
|
|
item.SortName = p.Name
|
|
}
|
|
return item
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// NewSessionInfo's Id is stable per client install, as Jellyfin reuses a device's session across logins.
|
|
func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, serverID string) *SessionInfo {
|
|
now := time.Now()
|
|
return &SessionInfo{
|
|
Id: EncodeID(id.NewHash(client, deviceID)),
|
|
UserId: EncodeID(u.ID),
|
|
UserName: u.UserName,
|
|
Client: client,
|
|
DeviceId: deviceID,
|
|
DeviceName: deviceName,
|
|
ApplicationVersion: version,
|
|
ServerId: serverID,
|
|
LastActivityDate: JellyfinDate(&now),
|
|
IsActive: true,
|
|
PlayableMediaTypes: []string{"Audio"},
|
|
SupportedCommands: []string{},
|
|
AdditionalUsers: []any{},
|
|
NowPlayingQueue: []any{},
|
|
PlayState: PlayerStateInfo{RepeatMode: "RepeatNone", PlaybackOrder: "Default"},
|
|
}
|
|
}
|