mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-13 13:48:39 +01:00
* fix(jellyfin): honor likes on /Rating and add POST UserData endpoints
POST .../Rating only read a numeric `rating` param that upstream Jellyfin
does not define, so a request using Jellyfin's `likes` flag (or no value
at all, or a malformed one) silently cleared the user's rating. The
handler now reads `likes` like Jellyfin (true stores 5 stars, false 1
star, matching Jellyfin's 10 and 1), keeps the numeric `rating` param for
existing clients, and answers 400 without writing when the value is
missing, malformed or outside 0-10. Clearing is left to DELETE. The
current /UserItems/{itemId}/Rating spelling is now routed as well.
Adds POST /UserItems/{itemId}/UserData and the legacy
/Users/{userId}/Items/{itemId}/UserData. They write Rating, Likes and
IsFavorite with Jellyfin's precedence (Rating wins over Likes), leave
omitted or null fields untouched, and ignore play-state fields. The body
must be a single JSON object: trailing data, null, or out-of-range values
are rejected before anything is written.
Rating and UserData writes whose userId names a different user now get
403 instead of silently writing the caller's own data. Annotation
responses return the item's full UserData, including Jellyfin's derived
Likes flag (rating >= 6.5). The README compatibility notes are updated.
* refactor(jellyfin): simplify rating and UserData handlers
Moves the Jellyfin 0-10 to stars conversions (StarsFromRating,
StarsFromLikes) into the dto package next to the reverse mapping and the
Likes threshold, so both directions live together. The userId ownership
check is folded into a single applyCallerUserData helper instead of being
called by hand in three handlers, the setFavorite passthrough is dropped,
and decodeUserDataBody now writes its own 400 like the other (value, ok)
helpers.
Tests: withChiURLParam is now variadic, replacing the duplicate
withChiParams helper; the three other-user 403 specs and the
empty-object/no-event spec become table entries; and redundant specs are
removed. Behavior is unchanged.
357 lines
18 KiB
Go
357 lines
18 KiB
Go
package dto
|
|
|
|
// PublicSystemInfo is the unauthenticated handshake payload (GET /System/Info/Public).
|
|
type PublicSystemInfo struct {
|
|
LocalAddress string `json:"LocalAddress,omitempty"`
|
|
ServerName string `json:"ServerName"`
|
|
Version string `json:"Version"`
|
|
ProductName string `json:"ProductName"`
|
|
OperatingSystem string `json:"OperatingSystem,omitempty"`
|
|
Id string `json:"Id"`
|
|
StartupWizardCompleted bool `json:"StartupWizardCompleted"`
|
|
}
|
|
|
|
// SystemInfo is the authenticated variant (GET /System/Info).
|
|
type SystemInfo struct {
|
|
PublicSystemInfo
|
|
HasPendingRestart bool `json:"HasPendingRestart"`
|
|
IsShuttingDown bool `json:"IsShuttingDown"`
|
|
SupportsLibraryMonitor bool `json:"SupportsLibraryMonitor"`
|
|
CachePath string `json:"CachePath,omitempty"`
|
|
}
|
|
|
|
// EndPointInfo describes the caller's network location (GET /System/Endpoint).
|
|
type EndPointInfo struct {
|
|
IsLocal bool `json:"IsLocal"`
|
|
IsInNetwork bool `json:"IsInNetwork"`
|
|
}
|
|
|
|
type NameGuidPair struct {
|
|
Name string `json:"Name"`
|
|
Id string `json:"Id"`
|
|
}
|
|
|
|
type UserItemDataDto struct {
|
|
Rating *float64 `json:"Rating,omitempty"`
|
|
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
|
PlayCount int `json:"PlayCount"`
|
|
IsFavorite bool `json:"IsFavorite"`
|
|
Played bool `json:"Played"`
|
|
Key string `json:"Key"`
|
|
ItemId string `json:"ItemId,omitempty"`
|
|
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
|
|
Likes *bool `json:"Likes,omitempty"`
|
|
}
|
|
|
|
// UpdateUserItemDataDto is the POST .../UserData body; other Jellyfin fields are accepted and ignored.
|
|
type UpdateUserItemDataDto struct {
|
|
Rating *float64 `json:"Rating"`
|
|
Likes *bool `json:"Likes"`
|
|
IsFavorite *bool `json:"IsFavorite"`
|
|
}
|
|
|
|
type BaseItemDto struct {
|
|
Name string `json:"Name"`
|
|
ServerId string `json:"ServerId,omitempty"`
|
|
Id string `json:"Id"`
|
|
// PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items),
|
|
// distinct from Id so a song appearing more than once can be removed by occurrence
|
|
// (DELETE .../Items?EntryIds=...) rather than by song id.
|
|
PlaylistItemId string `json:"PlaylistItemId,omitempty"`
|
|
Type string `json:"Type"`
|
|
IsFolder bool `json:"IsFolder"`
|
|
MediaType string `json:"MediaType,omitempty"`
|
|
CollectionType string `json:"CollectionType,omitempty"`
|
|
LocationType string `json:"LocationType,omitempty"`
|
|
HasLyrics *bool `json:"HasLyrics,omitempty"`
|
|
// ChannelId is always null for music, but Jellyfin emits it on every item and clients may require it.
|
|
ChannelId *string `json:"ChannelId"`
|
|
Tags []string `json:"Tags,omitzero"`
|
|
SortName string `json:"SortName,omitempty"`
|
|
Path string `json:"Path,omitempty"`
|
|
ParentId string `json:"ParentId,omitempty"`
|
|
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
|
IndexNumber *int `json:"IndexNumber,omitempty"`
|
|
ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"`
|
|
ProductionYear *int `json:"ProductionYear,omitempty"`
|
|
// PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side.
|
|
PremiereDate *string `json:"PremiereDate,omitempty"`
|
|
// DateCreated is the ISO 8601 date the item was added to the library; clients show it as
|
|
// "Date Added" and sort "Recently Added" by it.
|
|
DateCreated string `json:"DateCreated,omitempty"`
|
|
Album string `json:"Album,omitempty"`
|
|
AlbumId string `json:"AlbumId,omitempty"`
|
|
AlbumArtist string `json:"AlbumArtist,omitempty"`
|
|
AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"`
|
|
AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"`
|
|
Artists []string `json:"Artists,omitzero"`
|
|
ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"`
|
|
Genres []string `json:"Genres,omitzero"`
|
|
GenreItems []NameGuidPair `json:"GenreItems,omitzero"`
|
|
Studios []NameGuidPair `json:"Studios,omitempty"`
|
|
NormalizationGain *float64 `json:"NormalizationGain,omitempty"`
|
|
AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"`
|
|
ChildCount *int `json:"ChildCount,omitempty"`
|
|
SongCount *int `json:"SongCount,omitempty"`
|
|
AlbumCount *int `json:"AlbumCount,omitempty"`
|
|
ImageTags map[string]string `json:"ImageTags"`
|
|
// ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a
|
|
// de-dup key for image downloads (and a placeholder); absent, it warns the server isn't
|
|
// calculating blurhashes.
|
|
ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"`
|
|
// PrimaryImageAspectRatio is width/height of the Primary image, attached only when the request's
|
|
// Fields asks for it; omitted rather than guessed, since a wrong ratio mis-shapes a placeholder.
|
|
PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"`
|
|
BackdropImageTags []string `json:"BackdropImageTags"`
|
|
UserData *UserItemDataDto `json:"UserData,omitempty"`
|
|
MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"`
|
|
Container string `json:"Container,omitempty"`
|
|
CanDownload bool `json:"CanDownload"`
|
|
}
|
|
|
|
// PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which
|
|
// Finamp probes before allowing playlist edits.
|
|
type PlaylistUserPermissions struct {
|
|
UserId string `json:"UserId"`
|
|
CanEdit bool `json:"CanEdit"`
|
|
}
|
|
|
|
// PlaylistInfo is the response shape for GET /Playlists/{id}. ItemIds are media item ids, not
|
|
// playlist-entry ids (matching real Jellyfin); Finamp reads OpenAccess for the public-visibility toggle.
|
|
type PlaylistInfo struct {
|
|
OpenAccess bool `json:"OpenAccess"`
|
|
Shares []PlaylistUserPermissions `json:"Shares"`
|
|
ItemIds []string `json:"ItemIds"`
|
|
}
|
|
|
|
type QueryResult struct {
|
|
Items []BaseItemDto `json:"Items"`
|
|
TotalRecordCount int `json:"TotalRecordCount"`
|
|
StartIndex int `json:"StartIndex"`
|
|
}
|
|
|
|
type UserDto struct {
|
|
Name string `json:"Name"`
|
|
ServerId string `json:"ServerId,omitempty"`
|
|
ServerName string `json:"ServerName,omitempty"`
|
|
Id string `json:"Id"`
|
|
HasPassword bool `json:"HasPassword"`
|
|
HasConfiguredPassword bool `json:"HasConfiguredPassword"`
|
|
HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"`
|
|
PrimaryImageTag string `json:"PrimaryImageTag,omitempty"`
|
|
Policy *UserPolicy `json:"Policy,omitempty"`
|
|
Configuration *UserConfiguration `json:"Configuration,omitempty"`
|
|
}
|
|
|
|
// UserPolicy mirrors real Jellyfin's User.Policy. Finamp reads it right after login and crashes if
|
|
// it's absent, so every field must be present even though Navidrome lacks most of these concepts.
|
|
type UserPolicy struct {
|
|
IsAdministrator bool `json:"IsAdministrator"`
|
|
IsHidden bool `json:"IsHidden"`
|
|
EnableCollectionManagement bool `json:"EnableCollectionManagement"`
|
|
EnableSubtitleManagement bool `json:"EnableSubtitleManagement"`
|
|
EnableLyricManagement bool `json:"EnableLyricManagement"`
|
|
IsDisabled bool `json:"IsDisabled"`
|
|
BlockedTags []string `json:"BlockedTags"`
|
|
AllowedTags []string `json:"AllowedTags"`
|
|
EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"`
|
|
AccessSchedules []string `json:"AccessSchedules"`
|
|
BlockUnratedItems []string `json:"BlockUnratedItems"`
|
|
EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"`
|
|
EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"`
|
|
EnableRemoteAccess bool `json:"EnableRemoteAccess"`
|
|
EnableLiveTvManagement bool `json:"EnableLiveTvManagement"`
|
|
EnableLiveTvAccess bool `json:"EnableLiveTvAccess"`
|
|
EnableMediaPlayback bool `json:"EnableMediaPlayback"`
|
|
EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"`
|
|
EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"`
|
|
EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"`
|
|
ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"`
|
|
EnableContentDeletion bool `json:"EnableContentDeletion"`
|
|
EnableContentDeletionFromFolders []string `json:"EnableContentDeletionFromFolders"`
|
|
EnableContentDownloading bool `json:"EnableContentDownloading"`
|
|
EnableSyncTranscoding bool `json:"EnableSyncTranscoding"`
|
|
EnableMediaConversion bool `json:"EnableMediaConversion"`
|
|
EnabledDevices []string `json:"EnabledDevices"`
|
|
EnableAllDevices bool `json:"EnableAllDevices"`
|
|
EnabledChannels []string `json:"EnabledChannels"`
|
|
EnableAllChannels bool `json:"EnableAllChannels"`
|
|
EnabledFolders []string `json:"EnabledFolders"`
|
|
EnableAllFolders bool `json:"EnableAllFolders"`
|
|
InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"`
|
|
LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"`
|
|
MaxActiveSessions int `json:"MaxActiveSessions"`
|
|
EnablePublicSharing bool `json:"EnablePublicSharing"`
|
|
BlockedMediaFolders []string `json:"BlockedMediaFolders"`
|
|
BlockedChannels []string `json:"BlockedChannels"`
|
|
RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"`
|
|
AuthenticationProviderId string `json:"AuthenticationProviderId"`
|
|
PasswordResetProviderId string `json:"PasswordResetProviderId"`
|
|
SyncPlayAccess string `json:"SyncPlayAccess"`
|
|
}
|
|
|
|
// UserConfiguration mirrors real Jellyfin's User.Configuration. Like UserPolicy, clients expect it
|
|
// always present, even though most settings don't apply to Navidrome's audio-only library.
|
|
type UserConfiguration struct {
|
|
PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"`
|
|
SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"`
|
|
DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"`
|
|
GroupedFolders []string `json:"GroupedFolders"`
|
|
SubtitleMode string `json:"SubtitleMode"`
|
|
DisplayCollectionsView bool `json:"DisplayCollectionsView"`
|
|
EnableLocalPassword bool `json:"EnableLocalPassword"`
|
|
OrderedViews []string `json:"OrderedViews"`
|
|
LatestItemsExcludes []string `json:"LatestItemsExcludes"`
|
|
MyMediaExcludes []string `json:"MyMediaExcludes"`
|
|
HidePlayedInLatest bool `json:"HidePlayedInLatest"`
|
|
RememberAudioSelections bool `json:"RememberAudioSelections"`
|
|
RememberSubtitleSelections bool `json:"RememberSubtitleSelections"`
|
|
EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
|
|
CastReceiverId string `json:"CastReceiverId"`
|
|
}
|
|
|
|
// SessionInfo mirrors real Jellyfin's SessionInfoDto. JellyBox requires Id and PlayState, and Finamp
|
|
// requires UserId, LastActivityDate and the bools, so none of those may be omitted.
|
|
type SessionInfo struct {
|
|
Id string `json:"Id"`
|
|
UserId string `json:"UserId"`
|
|
UserName string `json:"UserName"`
|
|
Client string `json:"Client"`
|
|
DeviceId string `json:"DeviceId"`
|
|
DeviceName string `json:"DeviceName"`
|
|
ApplicationVersion string `json:"ApplicationVersion"`
|
|
ServerId string `json:"ServerId"`
|
|
LastActivityDate string `json:"LastActivityDate"`
|
|
IsActive bool `json:"IsActive"`
|
|
SupportsMediaControl bool `json:"SupportsMediaControl"`
|
|
SupportsRemoteControl bool `json:"SupportsRemoteControl"`
|
|
HasCustomDeviceName bool `json:"HasCustomDeviceName"`
|
|
PlayableMediaTypes []string `json:"PlayableMediaTypes"`
|
|
SupportedCommands []string `json:"SupportedCommands"`
|
|
AdditionalUsers []any `json:"AdditionalUsers"`
|
|
NowPlayingQueue []any `json:"NowPlayingQueue"`
|
|
PlayState PlayerStateInfo `json:"PlayState"`
|
|
}
|
|
|
|
type PlayerStateInfo struct {
|
|
CanSeek bool `json:"CanSeek"`
|
|
IsPaused bool `json:"IsPaused"`
|
|
IsMuted bool `json:"IsMuted"`
|
|
RepeatMode string `json:"RepeatMode"`
|
|
PlaybackOrder string `json:"PlaybackOrder"`
|
|
}
|
|
|
|
type QuickConnectResult struct {
|
|
Authenticated bool `json:"Authenticated"`
|
|
Secret string `json:"Secret"`
|
|
Code string `json:"Code"`
|
|
DeviceId string `json:"DeviceId"`
|
|
DeviceName string `json:"DeviceName"`
|
|
AppName string `json:"AppName"`
|
|
AppVersion string `json:"AppVersion"`
|
|
DateAdded string `json:"DateAdded"`
|
|
}
|
|
|
|
type AuthenticationResult struct {
|
|
User *UserDto `json:"User"`
|
|
SessionInfo *SessionInfo `json:"SessionInfo"`
|
|
AccessToken string `json:"AccessToken"`
|
|
ServerId string `json:"ServerId"`
|
|
}
|
|
|
|
// MediaStream mirrors real Jellyfin's MediaStream. Finamp declares several bools as non-nullable, so
|
|
// they must always be emitted (no omitempty). Finamp also does MediaStreams.firstWhere((s) => s.type
|
|
// == 'Audio'), so MediaSourceInfo must include at least one Audio stream or that lookup throws.
|
|
type MediaStream struct {
|
|
Codec string `json:"Codec,omitempty"`
|
|
Type string `json:"Type"`
|
|
Index int `json:"Index"`
|
|
BitRate int `json:"BitRate,omitempty"`
|
|
Channels int `json:"Channels,omitempty"`
|
|
SampleRate int `json:"SampleRate,omitempty"`
|
|
ChannelLayout string `json:"ChannelLayout,omitempty"`
|
|
IsInterlaced bool `json:"IsInterlaced"`
|
|
IsDefault bool `json:"IsDefault"`
|
|
IsForced bool `json:"IsForced"`
|
|
IsExternal bool `json:"IsExternal"`
|
|
IsTextSubtitleStream bool `json:"IsTextSubtitleStream"`
|
|
SupportsExternalStream bool `json:"SupportsExternalStream"`
|
|
}
|
|
|
|
// MediaSourceInfo mirrors real Jellyfin's MediaSourceInfo. Finamp declares several bools/arrays as
|
|
// non-nullable, so a missing field deserializes to null and throws a cast error that aborts parsing
|
|
// of the whole item list; emit them always (no omitempty on bools).
|
|
type MediaSourceInfo struct {
|
|
Id string `json:"Id"`
|
|
Path string `json:"Path,omitempty"`
|
|
Protocol string `json:"Protocol"`
|
|
Container string `json:"Container,omitempty"`
|
|
TranscodingUrl string `json:"TranscodingUrl,omitempty"`
|
|
TranscodingSubProtocol string `json:"TranscodingSubProtocol,omitempty"`
|
|
Size int64 `json:"Size,omitempty"`
|
|
Name string `json:"Name,omitempty"`
|
|
IsRemote bool `json:"IsRemote"`
|
|
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
|
Bitrate int `json:"Bitrate,omitempty"`
|
|
SupportsTranscoding bool `json:"SupportsTranscoding"`
|
|
SupportsDirectStream bool `json:"SupportsDirectStream"`
|
|
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
|
|
Type string `json:"Type"`
|
|
ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"`
|
|
IgnoreDts bool `json:"IgnoreDts"`
|
|
IgnoreIndex bool `json:"IgnoreIndex"`
|
|
GenPtsInput bool `json:"GenPtsInput"`
|
|
IsInfiniteStream bool `json:"IsInfiniteStream"`
|
|
UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"`
|
|
RequiresOpening bool `json:"RequiresOpening"`
|
|
RequiresClosing bool `json:"RequiresClosing"`
|
|
RequiresLooping bool `json:"RequiresLooping"`
|
|
SupportsProbing bool `json:"SupportsProbing"`
|
|
HasSegments bool `json:"HasSegments"`
|
|
MediaStreams []MediaStream `json:"MediaStreams"`
|
|
MediaAttachments []any `json:"MediaAttachments"`
|
|
Formats []string `json:"Formats"`
|
|
}
|
|
|
|
type PlaybackInfoResponse struct {
|
|
MediaSources []MediaSourceInfo `json:"MediaSources"`
|
|
PlaySessionId string `json:"PlaySessionId"`
|
|
}
|
|
|
|
// LyricDto mirrors Jellyfin's GET /Audio/{itemId}/Lyrics response. Feishin and Jellify read only
|
|
// Lyrics[].Text and Start (ticks); Metadata is for completeness/Finamp.
|
|
type LyricDto struct {
|
|
Metadata LyricMetadata `json:"Metadata"`
|
|
Lyrics []LyricLine `json:"Lyrics"`
|
|
}
|
|
|
|
type LyricMetadata struct {
|
|
Artist string `json:"Artist,omitempty"`
|
|
Album string `json:"Album,omitempty"`
|
|
Title string `json:"Title,omitempty"`
|
|
Length int64 `json:"Length,omitempty"`
|
|
Offset *int64 `json:"Offset,omitempty"`
|
|
IsSynced bool `json:"IsSynced"`
|
|
}
|
|
|
|
type LyricLine struct {
|
|
Text string `json:"Text"`
|
|
Start *int64 `json:"Start,omitempty"`
|
|
Cues []LyricLineCue `json:"Cues,omitempty"`
|
|
}
|
|
|
|
type LyricLineCue struct {
|
|
Position int `json:"Position"`
|
|
EndPosition int `json:"EndPosition"`
|
|
Start int64 `json:"Start"`
|
|
End *int64 `json:"End,omitempty"`
|
|
}
|
|
|
|
// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present;
|
|
// clients (jellyfin-web) render each unconditionally.
|
|
type QueryFiltersLegacy struct {
|
|
Genres []string `json:"Genres"`
|
|
Tags []string `json:"Tags"`
|
|
OfficialRatings []string `json:"OfficialRatings"`
|
|
Years []int `json:"Years"`
|
|
}
|