fix(jellyfin): return User Policy and Configuration so Finamp completes login

This commit is contained in:
Deluan 2026-07-05 19:56:26 -04:00
commit ae78d779ca
5 changed files with 191 additions and 14 deletions

View file

@ -37,20 +37,95 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
return
}
// SessionInfo is omitted (not just empty): real Jellyfin returns a large object here, and
// ours would be a partial {Id, UserId} that a strict client could fail to parse. Finamp's
// login doesn't require it (its AuthenticationResult.sessionInfo is nullable).
api.ok(w, r, dto.AuthenticationResult{
User: userToDto(usr, api.serverName()),
User: userToDto(usr, api.serverName(), api.serverID(ctx)),
AccessToken: token,
ServerId: api.publicInfo(ctx).Id,
SessionInfo: &dto.SessionInfo{Id: parseEmbyAuth(r).DeviceId, UserId: usr.ID},
ServerId: api.serverID(ctx),
})
}
func userToDto(u *model.User, serverName string) *dto.UserDto {
// userToDto builds the User object real Jellyfin clients expect. Policy and Configuration
// are always populated on real Jellyfin servers; Finamp reads them right after login and
// null-crashes if they're absent, so we fill them with Navidrome-appropriate defaults.
func userToDto(u *model.User, serverName, serverID string) *dto.UserDto {
return &dto.UserDto{
Name: u.UserName,
Id: u.ID,
ServerId: serverID,
ServerName: serverName,
HasPassword: true,
HasConfiguredPassword: true,
Policy: userPolicy(u),
Configuration: userConfiguration(),
}
}
func userPolicy(u *model.User) *dto.UserPolicy {
return &dto.UserPolicy{
IsAdministrator: u.IsAdmin,
IsHidden: false,
EnableCollectionManagement: false,
EnableSubtitleManagement: false,
EnableLyricManagement: false,
IsDisabled: false,
BlockedTags: []string{},
AllowedTags: []string{},
EnableUserPreferenceAccess: true,
AccessSchedules: []string{},
BlockUnratedItems: []string{},
EnableRemoteControlOfOtherUsers: false,
EnableSharedDeviceControl: false,
EnableRemoteAccess: true,
EnableLiveTvManagement: false,
EnableLiveTvAccess: false,
EnableMediaPlayback: true,
EnableAudioPlaybackTranscoding: true,
EnableVideoPlaybackTranscoding: true,
EnablePlaybackRemuxing: true,
ForceRemoteSourceTranscoding: false,
EnableContentDeletion: false,
EnableContentDeletionFromFolders: []string{},
EnableContentDownloading: true,
EnableSyncTranscoding: true,
EnableMediaConversion: true,
EnabledDevices: []string{},
EnableAllDevices: true,
EnabledChannels: []string{},
EnableAllChannels: false,
EnabledFolders: []string{},
EnableAllFolders: true,
InvalidLoginAttemptCount: 0,
LoginAttemptsBeforeLockout: -1,
MaxActiveSessions: 0,
EnablePublicSharing: true,
BlockedMediaFolders: []string{},
BlockedChannels: []string{},
RemoteClientBitrateLimit: 0,
AuthenticationProviderId: "",
PasswordResetProviderId: "",
SyncPlayAccess: "CreateAndJoinGroups",
}
}
func userConfiguration() *dto.UserConfiguration {
return &dto.UserConfiguration{
PlayDefaultAudioTrack: true,
SubtitleLanguagePreference: "",
DisplayMissingEpisodes: false,
GroupedFolders: []string{},
SubtitleMode: "Default",
DisplayCollectionsView: false,
EnableLocalPassword: false,
OrderedViews: []string{},
LatestItemsExcludes: []string{},
MyMediaExcludes: []string{},
HidePlayedInLatest: true,
RememberAudioSelections: true,
RememberSubtitleSelections: true,
EnableNextEpisodeAutoPlay: true,
CastReceiverId: "",
}
}

View file

@ -40,6 +40,33 @@ var _ = Describe("AuthenticateByName", func() {
claims, err := auth.Validate(res.AccessToken)
Expect(err).ToNot(HaveOccurred())
Expect(claims.Subject).To(Equal("alice"))
// Finamp reads Policy/Configuration right after login and null-crashes if they're absent.
Expect(res.User.Policy).ToNot(BeNil())
Expect(res.User.Policy.IsAdministrator).To(BeFalse())
Expect(res.User.Policy.EnableAllFolders).To(BeTrue())
Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue())
Expect(res.User.Configuration).ToNot(BeNil())
// Ours is a partial SessionInfo; a strict client may fail to parse it, and Finamp's
// login doesn't require it, so it should be omitted entirely rather than sent partial.
Expect(res.SessionInfo).To(BeNil())
})
It("reflects an administrator in the User.Policy", func() {
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "admin1", UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed())
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateByName",
strings.NewReader(`{"Username":"root","Pw":"secret"}`))
api.authenticateByName(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.AuthenticationResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.User.Policy).ToNot(BeNil())
Expect(res.User.Policy.IsAdministrator).To(BeTrue())
})
It("rejects invalid credentials with 401", func() {

View file

@ -79,14 +79,85 @@ type QueryResult struct {
}
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"`
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 object. Finamp (and presumably other
// up-to-date clients) reads it right after login and crashes if it's absent, so every
// field below must be present even though Navidrome has no concept of most of them.
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 object. Like UserPolicy,
// clients expect it to always be present, even though most of these settings (subtitles,
// TV episode tracking) 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"`
}
type SessionInfo struct {

View file

@ -28,8 +28,9 @@ func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) {
}
func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) {
u, _ := request.UserFrom(r.Context())
api.ok(w, r, userToDto(&u, api.serverName()))
ctx := r.Context()
u, _ := request.UserFrom(ctx)
api.ok(w, r, userToDto(&u, api.serverName(), api.serverID(ctx)))
}
func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) {

View file

@ -71,5 +71,8 @@ var _ = Describe("Users", func() {
var u dto.UserDto
Expect(json.Unmarshal(w.Body.Bytes(), &u)).To(Succeed())
Expect(u.Name).To(Equal("alice"))
Expect(u.Policy).ToNot(BeNil())
Expect(u.Policy.IsAdministrator).To(BeFalse())
Expect(u.Configuration).ToNot(BeNil())
})
})