mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* refactor(jellyfin): inject core/sonic into the Jellyfin Router
* feat(jellyfin): add AudioMuse /info endpoint
* feat(jellyfin): add AudioMuse /similar_tracks endpoint
* feat(jellyfin): gate AudioMuse endpoints on sonic provider
* feat(jellyfin): add AudioMuse /find_path endpoint
* fix(jellyfin): fix case-insensitive route collision across positions
canonicalRouteSegments keyed canonical case by lower-cased segment name
alone, globally. Two unrelated routes sharing a segment name with
different casing at different tree depths (e.g. "Info" in
/System/Info/Public vs "info" in /AudioMuseAI/info) silently overwrote
each other, 404-ing the loser even for exact-case requests. Replace the
flat map with a position-aware trie mirroring the routing tree.
* test(jellyfin): e2e tests for AudioMuse endpoints
* docs(jellyfin): document AudioMuse compatibility endpoints
* test(jellyfin): harden AudioMuse tests and doc note (final-review follow-ups)
- Comment-lock the []string{} (not nil) contract for /AudioMuseAI/info's
AvailableEndpoints so it keeps serializing as [] rather than null, and
add a raw-body assertion to the existing empty-list test to catch a
regression a struct-only unmarshal can't detect.
- Cover the previously-untested engine-error branch in similar_tracks and
find_path, both of which degrade to an empty result.
- Document that find_path's path/total_distance only reflect hops through
libraries the caller can access in multi-library setups.
* refactor(jellyfin): dedup AudioMuse test request helper, presize dedup map
* refactor(sonic): expose sonic.Engine interface; drop typed-nil guard in jellyfin.New
The Jellyfin Router's sonic field was an interface but New() took the concrete
*sonic.Sonic, so a nil arg became a non-nil typed-nil and needed a guard — the
only injected dependency that did. Move the interface (sonic.Engine) beside its
implementation, take it in New() like every other service, and bind it in wire.
* refactor(jellyfin): case-insensitive routing via lowercased paths
Replace the position-aware route trie with a trivial middleware that lowercases
the request path, and register every route in lowercase. Simpler, and no segment
name can collide across positions. caseInsensitivePaths moves into middlewares.go
alongside normalizeQueryKeys. Relies on the invariant that no Jellyfin path segment
carries case-sensitive data (all ids are lowercase hex via dto.EncodeID).
* feat(jellyfin): add AudioMuse /health endpoint
A liveness probe matching the reference plugin: 200 with an empty body when a
SonicSimilarity provider is loaded, 404 otherwise. /AudioMuseAI/info now
advertises it (list alphabetized like the plugin's OrderBy).
Also trims the AudioMuse and case-insensitive-routing comments to their essential
rationale.
* fix(jellyfin): hex-encode user IDs so lowercased paths stay valid
Address PR review: user IDs were the one id the Jellyfin API emitted raw (base62,
uppercase-capable), so lowercasing request paths could alter a userId segment. Encode
them via dto.EncodeID like every other id, making the 'all boundary ids are lowercase
hex' invariant true — no routing special-casing needed. Also caps user-controlled n /
max_steps, fixes the songAgent test comment, and adds leading slashes to the README
endpoint list.
135 lines
4.9 KiB
Go
135 lines
4.9 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
)
|
|
|
|
type authenticateByNameRequest struct {
|
|
Username string `json:"Username"`
|
|
Pw string `json:"Pw"`
|
|
}
|
|
|
|
func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var body authenticateByNameRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path.
|
|
usr, err := api.ds.User(ctx).FindByUsernameWithPassword(body.Username)
|
|
if body.Pw == "" || err != nil || usr == nil || usr.Password != body.Pw {
|
|
log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr)
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
// Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a
|
|
// never/stale "Last Login" in the admin UI.
|
|
if err := api.ds.User(ctx).UpdateLastLoginAt(usr.ID); err != nil {
|
|
log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err)
|
|
}
|
|
|
|
token, err := auth.CreateToken(usr)
|
|
if err != nil {
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
|
|
// SessionInfo is omitted, not partially filled: a stub {Id, UserId} could fail a strict client's
|
|
// parse, and Finamp's login doesn't need it (its AuthenticationResult.sessionInfo is nullable).
|
|
api.ok(w, r, dto.AuthenticationResult{
|
|
User: userToDto(usr, api.serverName(), api.serverID(ctx)),
|
|
AccessToken: token,
|
|
ServerId: api.serverID(ctx),
|
|
})
|
|
}
|
|
|
|
// userToDto builds the User object clients expect. Finamp reads Policy and Configuration right after
|
|
// login and null-crashes if absent, so both are filled with Navidrome-appropriate defaults.
|
|
func userToDto(u *model.User, serverName, serverID string) *dto.UserDto {
|
|
return &dto.UserDto{
|
|
Name: u.UserName,
|
|
Id: dto.EncodeID(u.ID), // hex like every other id, so lowercased paths stay valid
|
|
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: "",
|
|
}
|
|
}
|