mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-29 06:37:17 +01:00
fix(jellyfin): return User Policy and Configuration so Finamp completes login
This commit is contained in:
parent
50e0f91032
commit
ae78d779ca
5 changed files with 191 additions and 14 deletions
|
|
@ -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: "",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue