diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index aaab3a9fc..8f8152831 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -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: "", } } diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index c998930d9..bbe6f0127 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -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() { diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index bfa5ba426..5118b24f7 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -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 { diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index 80e191a51..a1851e89e 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -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) { diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index d53cad846..76a0758a3 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -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()) }) })