Deluan Quintão b76ae14286
feat(jellyfin): add Quick Connect sign-in (#6174)
* 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.
2026-09-19 14:57:01 -04:00

350 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"`
}
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"`
}