From 549dfa7f3027a325905791626820a46f2b0afb28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 19 Sep 2026 13:19:48 -0400 Subject: [PATCH 01/22] feat(jellyfin): advertise Jellyfin 12.1.0 and add the missing 12.x quick wins (#6163) * feat(jellyfin): advertise Jellyfin server version 12.1.0 Streamyfin, jellyfin-android and jellyfin-androidtv refuse servers older than 10.10, Swiftfin warns below 12.0, and @jellyfin/sdk flags anything below its minimum as unsupported, so 10.9.11 locked those clients out. 12.1.0 is the current Jellyfin release (after 10.11 Jellyfin renumbered to 12.0). No client checked has an upper bound or assumes the major is 10, and the value keeps three parts because the Kotlin SDK and Swiftfin reject two-part versions. * feat(jellyfin): acknowledge POST /Sessions/Playing/Ping Jellyfin clients ping this endpoint to keep a transcode job alive while paused. Navidrome ties transcodes to the stream request, so there is nothing to keep alive; answer 204 like Jellyfin instead of a 404. It shares one no-op handler with Sessions/Capabilities, renamed to acknowledge. * feat(jellyfin): reorder playlist entries via Items/{entryId}/Move Adds POST /Playlists/{id}/Items/{entryId}/Move/{newIndex}, which clients use to reorder playlists. It maps the entry's PlaylistItemId (its position) and Jellyfin's zero-based newIndex onto the existing core ReorderTrack, which enforces ownership. As in Jellyfin, an index past the end appends and an unknown entry is a no-op; out-of-range positions never reach Reorder, which would otherwise shift unrelated rows. * feat(jellyfin): honor position when adding items to a playlist POST /Playlists/{id}/Items takes an optional zero-based position (added to the Jellyfin spec in 12.0). Match Jellyfin: zero or negative prepends, past the end appends, otherwise the new items are inserted at that index in the order they were added. Adds PlaylistTrackRepository.Insert and core playlists.InsertTracks, which shift the following entries and insert in one transaction, instead of appending and moving each new track with its own ReorderTrack call. * feat(jellyfin): add type-specific InstantMix routes Jellyfin exposes InstantMix under Songs/, Albums/, Artists/ and Playlists/ as well as Items/, plus the legacy Artists/InstantMix and MusicGenres/InstantMix forms that take the seed as ?id=. Clients generated from the Jellyfin SDKs call the type-specific routes, which 404ed. All of them now share the existing Items/{id}/InstantMix handler; the external provider already builds mixes from song, album, artist, playlist and genre seeds. * feat(jellyfin): honor Width, Height and Fill* image size params The image endpoint only read MaxWidth/MaxHeight, so clients that size covers with fillWidth/fillHeight (Manet, Finamp) or width/height got the full-size original on a cold artwork cache: a 578 KB PNG instead of a 13 KB resize. Jellyfin applies Width/Height, caps them with MaxWidth/MaxHeight, then shrinks to the smallest size that still covers the Fill box. Navidrome resizes on one dimension, so the tightest bound wins and a fill box counts as its larger side. * feat(jellyfin): answer HEAD on audio, file and image routes Fintunes sends HEAD to /Audio/{id}/universal to read the content type and detect direct play (a Content-Length means direct play), and to the audio and image URLs before a download, aborting the download when it fails. Those routes were GET-only, so HEAD got a 404. HEAD now reuses the GET handlers. Direct play goes through Stream.Serve, which already answers HEAD; a transcode answers with the target content type and no length without starting ffmpeg, so a probe never costs a transcode. * fix(jellyfin): clamp playlist move and insert positions before adding one movePlaylistItem computed min(newIndex+1, SongCount): newIndex=MaxInt wrapped to a negative position, and Reorder then left the playlist with a gap (positions 2, 3, 999998), making the moved entry unmovable. Clamp against the playlist length first. addToPlaylist's min(position, MaxInt32)+1 wrapped on 32-bit builds and req.Int truncated large values there, so a far-past-the-end position prepended; parse as int64 and clamp before converting. * fix(playlists): validate reorder positions inside the write transaction Reorder never checked its positions, so a source outside the playlist or a destination past its end shifted rows around a missing entry and left a gap (e.g. ids 1 and 3), which made the moved entry unmovable afterwards. The Jellyfin Move handler guarded this with a SongCount read before ReorderTrack, but a concurrent removal between the two reopened it, and the native API reorder endpoint passed client positions through unchecked. Reorder now reads the last position in the same transaction, returns ErrNotFound for a source outside the playlist and clamps the destination. ReorderTrack uses an immediate transaction so that read and the updates are atomic against other writers. The Jellyfin handler drops its pre-check and maps ErrNotFound to Jellyfin's no-op 204; the native API now answers 404 for an unknown track instead of corrupting the order. --- core/playlists/playlists.go | 19 +++- core/playlists/playlists_test.go | 37 +++++++ model/playlist.go | 1 + persistence/playlist_track_repository.go | 35 ++++++- persistence/playlist_track_repository_test.go | 99 +++++++++++++++++++ server/jellyfin/README.md | 10 +- server/jellyfin/api.go | 20 +++- server/jellyfin/e2e/images_test.go | 4 + server/jellyfin/e2e/playlists_test.go | 83 ++++++++++++++++ server/jellyfin/e2e/sessions_test.go | 4 + server/jellyfin/e2e/similar_test.go | 29 ++++++ server/jellyfin/e2e/streaming_test.go | 14 +++ server/jellyfin/images.go | 20 ++-- server/jellyfin/images_test.go | 11 ++- server/jellyfin/playlists.go | 52 +++++++++- server/jellyfin/playlists_test.go | 12 +++ server/jellyfin/sessions.go | 6 +- server/jellyfin/sessions_test.go | 4 +- server/jellyfin/similar.go | 17 +++- server/jellyfin/stream.go | 9 ++ server/jellyfin/stream_test.go | 30 ++++++ server/jellyfin/system.go | 7 +- tests/mock_playlist_track_repo.go | 6 ++ 23 files changed, 497 insertions(+), 32 deletions(-) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 9ae8f09cb..1f8cc9581 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -32,6 +32,7 @@ type Playlists interface { // Track management AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) + InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) @@ -268,6 +269,22 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids) } +// InsertTracks adds tracks before the 1-based position pos; a position past the end appends. +func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) { + if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { + return 0, err + } + var count int + // Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under + // concurrent writers instead of waiting for the lock. + err := s.ds.WithTxImmediate(func(tx model.DataStore) error { + var err error + count, err = tx.Playlist(ctx).Tracks(playlistID, false).Insert(ids, pos) + return err + }) + return count, err +} + func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) { if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err @@ -302,7 +319,7 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return err } - return s.ds.WithTx(func(tx model.DataStore) error { + return s.ds.WithTxImmediate(func(tx model.DataStore) error { return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos) }) } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index dd182b3f1..dd6213555 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -324,6 +324,43 @@ var _ = Describe("Playlists", func() { }) }) + Describe("InsertTracks", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + }) + + It("inserts the tracks at the given position for the owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + count, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1", "song-2"}, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(2)) + Expect(mockTracks.AddedIds).To(Equal([]string{"song-1", "song-2"})) + Expect(mockTracks.InsertPos).To(Equal(3)) + }) + + It("denies non-owner, non-admin", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + _, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1"}, 1) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(mockTracks.AddedIds).To(BeEmpty()) + }) + + It("denies editing smart playlists", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.InsertTracks(ctx, "pls-smart", []string{"song-1"}, 1) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + }) + Describe("ReorderTrack", func() { var mockTracks *tests.MockPlaylistTrackRepo diff --git a/model/playlist.go b/model/playlist.go index abd11b8c4..f320d845b 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -184,6 +184,7 @@ type PlaylistTrackRepository interface { GetAlbumIDs(options ...QueryOptions) ([]string, error) GetMediaFileIDs(options ...QueryOptions) ([]string, error) Add(mediaFileIds []string) (int, error) + Insert(mediaFileIds []string, pos int) (int, error) AddAlbums(albumIds []string) (int, error) AddArtists(artistIds []string) (int, error) AddDiscs(discs []DiscID) (int, error) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index cf1b8f3fa..847316868 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -195,6 +195,29 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) } +// Insert adds tracks before the 1-based position pos, shifting the following entries down; a +// position past the end appends. Callers must run it in a transaction. +func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, error) { + if len(mediaFileIds) == 0 { + return 0, nil + } + pos = max(pos, 1) + n := len(mediaFileIds) + // Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index. + _, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos)) + if err != nil { + return 0, err + } + res, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId)) + if err != nil { + return 0, err + } + if res == 0 { + return r.Add(mediaFileIds) + } + return n, r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds) +} + func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) { sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number") var ids []string @@ -247,8 +270,18 @@ func (r *playlistTrackRepository) DeleteAll() error { return r.playlistRepo.renumber(r.playlistId) } -// Reorder moves a track from pos to newPos, shifting other tracks accordingly. +// Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped +// to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap. func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { + var res struct{ Max sql.NullInt32 } + if err := r.queryOne(r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil { + return err + } + last := int(res.Max.Int32) + if pos < 1 || pos > last { + return model.ErrNotFound + } + newPos = min(max(newPos, 1), last) if pos == newPos { return nil } diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index 88ddbff48..e0360adb4 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -78,6 +78,105 @@ var _ = Describe("PlaylistTrackRepository", func() { }) }) + Describe("Insert", func() { + var tracks model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"} + Expect(plsRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + + tracks = plsRepo.Tracks(pls.ID, false) + Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2)) + }) + + order := func() []string { + ids, err := tracks.GetMediaFileIDs(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + return ids + } + + DescribeTable("inserts before a 1-based position, keeping the new tracks' order", + func(pos int, want func() []string) { + Expect(tracks.Insert([]string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2)) + Expect(order()).To(Equal(want())) + Expect(tracks.CountAll()).To(Equal(int64(4))) + }, + Entry("in the middle", 2, func() []string { + return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID} + }), + Entry("at the start, for zero or less", 0, func() []string { + return []string{songComeTogether.ID, songAntenna.ID, songDayInALife.ID, songRadioactivity.ID} + }), + Entry("at the end, past the last position", 9, func() []string { + return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID, songAntenna.ID} + }), + ) + + It("renumbers positions contiguously", func() { + Expect(tracks.Insert([]string{songComeTogether.ID}, 1)).To(Equal(1)) + all, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"})) + }) + }) + + Describe("Reorder", func() { + var tracks model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"} + Expect(plsRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + + tracks = plsRepo.Tracks(pls.ID, false) + Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3)) + }) + + rows := func() ([]string, []string) { + all, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + var ids, songs []string + for _, t := range all { + ids = append(ids, t.ID) + songs = append(songs, t.MediaFileID) + } + return ids, songs + } + + DescribeTable("clamps the destination to the playlist", + func(newPos int, want func() []string) { + Expect(tracks.Reorder(1, newPos)).To(Succeed()) + ids, songs := rows() + Expect(ids).To(Equal([]string{"1", "2", "3"})) + Expect(songs).To(Equal(want())) + }, + Entry("past the end moves to the end", 9, func() []string { + return []string{songRadioactivity.ID, songComeTogether.ID, songDayInALife.ID} + }), + Entry("below 1 stays first", -4, func() []string { + return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID} + }), + ) + + DescribeTable("rejects a source position outside the playlist, leaving rows untouched", + func(pos int) { + Expect(tracks.Reorder(pos, 1)).To(MatchError(model.ErrNotFound)) + ids, songs := rows() + Expect(ids).To(Equal([]string{"1", "2", "3"})) + Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})) + }, + Entry("past the end", 4), + Entry("zero", 0), + ) + }) + Describe("Delete", func() { var tracks model.PlaylistTrackRepository const numTracks = deleteChunkSize*2 + 1 diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 5b9a2dba4..a20c9ce4e 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -123,13 +123,13 @@ returns direct children only (no tracks — no track is a library's direct child | Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | | Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | | Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` | -| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Items/{itemId}/InstantMix` | +| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | | Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | | Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | -| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` | +| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) | | Lyrics | `GET Audio/{itemId}/Lyrics` | -| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` | -| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` | +| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` | +| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items` (`POST` honors `position`), `POST Playlists/{playlistId}/Items/{entryId}/Move/{newIndex}`, `GET Playlists/{playlistId}/Users[/{userId}]` | | Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | | AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` | @@ -350,7 +350,7 @@ make test PKG=./server/jellyfin/... just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per track so sidecar/plugin lyrics also light up. Feishin additionally requires server version - ≥ 10.9 — the reason `jellyfinVersion` is 10.9.11. + ≥ 10.9 (`jellyfinVersion` advertises 12.1.0). Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader` is singleflighted), and the load runs detached from the request context with a one-minute bound, so a cancelled request or hung plugin can't fail or pin the load for other waiters. diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 839c43ea5..955ebc3bb 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -93,6 +93,8 @@ func (api *Router) routes() http.Handler { conf.Server.DevArtworkThrottleBacklogTimeout)) r.Get("/items/{itemId}/images/{type}", api.getItemImage) r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage) + r.Head("/items/{itemId}/images/{type}", api.getItemImage) + r.Head("/items/{itemId}/images/{type}/{index}", api.getItemImage) }) inner.Group(func(r chi.Router) { @@ -145,6 +147,12 @@ func (api *Router) routes() http.Handler { r.Get("/items/{itemId}/similar", api.getSimilarItems) r.Get("/albums/{itemId}/similar", api.getSimilarAlbums) r.Get("/items/{itemId}/instantmix", api.getInstantMix) + r.Get("/songs/{itemId}/instantmix", api.getInstantMix) + r.Get("/albums/{itemId}/instantmix", api.getInstantMix) + r.Get("/artists/{itemId}/instantmix", api.getInstantMix) + r.Get("/playlists/{itemId}/instantmix", api.getInstantMix) + r.Get("/artists/instantmix", api.getInstantMixByQuery) + r.Get("/musicgenres/instantmix", api.getInstantMixByQuery) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) r.Get("/studios", api.getStudios) @@ -155,6 +163,7 @@ func (api *Router) routes() http.Handler { r.Post("/playlists/{playlistId}", api.updatePlaylist) r.Post("/playlists/{playlistId}/items", api.addToPlaylist) r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist) + r.Post("/playlists/{playlistId}/items/{entryId}/move/{newIndex}", api.movePlaylistItem) r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers) r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser) @@ -166,6 +175,10 @@ func (api *Router) routes() http.Handler { r.Get("/audio/{itemId}/stream", api.streamAudio) r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) r.Get("/audio/{itemId}/universal", api.streamUniversal) + // Fintunes probes these with HEAD for the content type before playing or downloading. + r.Head("/audio/{itemId}/stream", api.streamAudio) + r.Head("/audio/{itemId}/stream.{container}", api.streamAudio) + r.Head("/audio/{itemId}/universal", api.streamUniversal) r.Get("/audio/{itemId}/main.m3u8", api.streamHls) r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) @@ -174,12 +187,15 @@ func (api *Router) routes() http.Handler { // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. r.Get("/items/{itemId}/file", api.streamFile) r.Get("/items/{itemId}/download", api.streamFile) + r.Head("/items/{itemId}/file", api.streamFile) + r.Head("/items/{itemId}/download", api.streamFile) r.Post("/sessions/playing", api.reportPlaybackStart) r.Post("/sessions/playing/progress", api.reportPlaybackProgress) r.Post("/sessions/playing/stopped", api.reportPlaybackStopped) - r.Post("/sessions/capabilities", api.postCapabilities) - r.Post("/sessions/capabilities/full", api.postCapabilities) + r.Post("/sessions/playing/ping", api.acknowledge) + r.Post("/sessions/capabilities", api.acknowledge) + r.Post("/sessions/capabilities/full", api.acknowledge) // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. r.Get("/socket", api.handleSocket) diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go index 7fb85cba8..d1c3c132b 100644 --- a/server/jellyfin/e2e/images_test.go +++ b/server/jellyfin/e2e/images_test.go @@ -41,6 +41,10 @@ var _ = Describe("Item images", func() { Expect(u.IsAdmin).To(BeTrue()) }) + It("answers HEAD without authentication", func() { + Expect(rawReq("HEAD", "/Items/"+enc(albumID("Abbey Road"))+"/Images/Primary", "").Code).To(Equal(http.StatusOK)) + }) + It("serves images without authentication (public route)", func() { id := albumID("IV") w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 50c2e17f2..174ba8660 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -19,6 +19,7 @@ var _ = Describe("Playlists", func() { playlistItems := func(plID string) dto.QueryResult { return queryResult(get("/Playlists/" + enc(plID) + "/Items")) } + order := func(plID string) []string { return names(playlistItems(plID).Items) } Describe("create", func() { It("creates an empty playlist", func() { @@ -102,6 +103,36 @@ var _ = Describe("Playlists", func() { Expect(playlistItems(plID).TotalRecordCount).To(BeZero()) }) + Describe("at a position", func() { + var plID string + addAt := func(position string, ids ...string) { + url := "/Playlists/" + enc(plID) + "/Items?position=" + position + for _, id := range ids { + url += "&ids=" + enc(id) + } + Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) + } + BeforeEach(func() { + plID = createPlaylist("Insert", []string{enc(songID("Come Together")), enc(songID("Something"))}) + }) + + DescribeTable("inserts at Jellyfin's zero-based position", + func(position string, want []string) { + addAt(position, songID("So What"), songID("Help!")) + Expect(order(plID)).To(Equal(want)) + }, + Entry("in the middle", "1", []string{"Come Together", "So What", "Help!", "Something"}), + Entry("first, for zero or less", "-3", []string{"So What", "Help!", "Come Together", "Something"}), + Entry("last, past the end", "9", []string{"Come Together", "Something", "So What", "Help!"}), + ) + + It("keeps an expanded album's track order", func() { + albumOrder := order(createPlaylist("Album", []string{enc(albumID("Abbey Road"))})) + addAt("1", albumID("Abbey Road")) + Expect(order(plID)).To(Equal(append(append([]string{"Come Together"}, albumOrder...), "Something"))) + }) + }) + It("removes an entry by its PlaylistItemId", func() { plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))}) entryID := playlistItems(plID).Items[0].PlaylistItemId @@ -118,6 +149,58 @@ var _ = Describe("Playlists", func() { }) }) + Describe("move", func() { + move := func(u model.User, plID, entryID string, newIndex string) int { + return jReq(u, "POST", "/Playlists/"+enc(plID)+"/Items/"+entryID+"/Move/"+newIndex, "").Code + } + var plID string + var entries []dto.BaseItemDto + BeforeEach(func() { + plID = createPlaylist("Move", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) + entries = playlistItems(plID).Items + }) + + DescribeTable("moves an entry to Jellyfin's zero-based index", + func(entry int, newIndex string, want []string) { + Expect(move(adminUser, plID, entries[entry].PlaylistItemId, newIndex)).To(Equal(http.StatusNoContent)) + Expect(order(plID)).To(Equal(want)) + var positions []string + for _, it := range playlistItems(plID).Items { + positions = append(positions, it.PlaylistItemId) + } + Expect(positions).To(Equal([]string{dto.EncodePlaylistEntryID("1"), dto.EncodePlaylistEntryID("2"), dto.EncodePlaylistEntryID("3")})) + }, + Entry("towards the end", 0, "2", []string{"Something", "So What", "Come Together"}), + Entry("towards the start", 2, "0", []string{"So What", "Come Together", "Something"}), + Entry("to the end, past the last index", 0, "99", []string{"Something", "So What", "Come Together"}), + Entry("to the end, for the largest int", 0, "9223372036854775807", []string{"Something", "So What", "Come Together"}), + ) + + It("ignores an entry that is not in the playlist", func() { + Expect(move(adminUser, plID, dto.EncodePlaylistEntryID("42"), "0")).To(Equal(http.StatusNoContent)) + Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) + }) + + It("404s on a malformed entry id", func() { + Expect(move(adminUser, plID, enc(songID("So What")), "0")).To(Equal(http.StatusNotFound)) + }) + + It("hides another user's private playlist", func() { + Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusNotFound)) + Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) + }) + + It("forbids a non-owner on a public playlist", func() { + Expect(post("/Playlists/"+enc(plID), `{"Name":"Move","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusForbidden)) + Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) + }) + + It("rejects a negative index", func() { + Expect(move(adminUser, plID, entries[0].PlaylistItemId, "-1")).To(Equal(http.StatusBadRequest)) + }) + }) + Describe("users", func() { It("reports the current user as an editor", func() { plID := createPlaylist("Perms", nil) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go index 2057b43c6..c5c42cb2b 100644 --- a/server/jellyfin/e2e/sessions_test.go +++ b/server/jellyfin/e2e/sessions_test.go @@ -37,6 +37,10 @@ var _ = Describe("Sessions", func() { Expect(mf.PlayCount).To(BeNumerically(">=", 1)) }) + It("acknowledges a playback session ping", func() { + Expect(post("/Sessions/Playing/Ping?playSessionId=abc", "").Code).To(Equal(http.StatusNoContent)) + }) + It("does not count a brief play stopped before the threshold", func() { // Regression: Finamp sends a Stopped report on every track switch, so an immediate skip // (1 second in) must not mark the track played. Seeded tracks are >= 120s, so the 50% diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go index 5f1a88a21..06054f8bf 100644 --- a/server/jellyfin/e2e/similar_test.go +++ b/server/jellyfin/e2e/similar_test.go @@ -150,4 +150,33 @@ var _ = Describe("Similar", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) }) + + Describe("type-specific InstantMix routes", func() { + BeforeEach(func() { + providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Mix Song", LibraryID: 1}} + }) + + It("leads a song mix with the seed on /Songs/{id}/InstantMix", func() { + q := queryResult(get("/Songs/" + enc(songID("So What")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Mix Song"})) + }) + + DescribeTable("returns the provider's mix", + func(path func() string) { + q := queryResult(get(path())) + Expect(names(q.Items)).To(Equal([]string{"Mix Song"})) + }, + Entry("Albums/{id}", func() string { return "/Albums/" + enc(albumID("Abbey Road")) + "/InstantMix" }), + Entry("Artists/{id}", func() string { return "/Artists/" + enc(artistID("Miles Davis")) + "/InstantMix" }), + Entry("Playlists/{id}", func() string { + return "/Playlists/" + enc(createPlaylist("Seed", []string{enc(songID("So What"))})) + "/InstantMix" + }), + Entry("Artists/InstantMix?id=", func() string { return "/Artists/InstantMix?id=" + enc(artistID("Miles Davis")) }), + Entry("MusicGenres/InstantMix?id=", func() string { return "/MusicGenres/InstantMix?id=" + enc(genreID("Jazz")) }), + ) + + It("404s a malformed id query param", func() { + Expect(get("/Artists/InstantMix?id=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) + }) + }) }) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index 67208aed3..9b7380185 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -1,6 +1,7 @@ package e2e import ( + "fmt" "net/http" "strings" @@ -45,6 +46,19 @@ var _ = Describe("Streaming", func() { It("returns 404 for an unknown track", func() { Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound)) }) + + DescribeTable("answers HEAD (Fintunes probes the type before playing or downloading)", + func(path string) { + w := jReq(adminUser, "HEAD", fmt.Sprintf(path, enc(songID("Help!"))), "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).ToNot(BeEmpty()) + }, + Entry("stream", "/Audio/%s/stream"), + Entry("stream.{container}", "/Audio/%s/stream.mp3"), + Entry("universal", "/Audio/%s/universal"), + Entry("File", "/Items/%s/File"), + Entry("Download", "/Items/%s/Download"), + ) }) Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() { diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 9f1b9ce1a..0c9e0380c 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -23,14 +23,17 @@ import ( _ "golang.org/x/image/webp" ) -// imageSize picks the tighter of Jellyfin's two bounds, because Navidrome resizes on a single -// dimension: reading only MaxWidth serves the full-size original to a client that sent MaxHeight. -func imageSize(maxWidth, maxHeight int) int { - w, h := max(maxWidth, 0), max(maxHeight, 0) - if w == 0 || h == 0 { - return max(w, h) +// imageSize reduces Jellyfin's size params to Navidrome's single bound. Width/Height/Max* are +// bounds, so the tightest wins; Fill* must cover its box, so its larger side is the bound. +func imageSize(p *req.Values) int { + fill := max(p.IntOr("fillwidth", 0), p.IntOr("fillheight", 0)) + size := 0 + for _, v := range []int{p.IntOr("width", 0), p.IntOr("height", 0), p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0), fill} { + if v > 0 && (size == 0 || v < size) { + size = v + } } - return min(w, h) + return size } func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { @@ -41,8 +44,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { if !ok { return } - p := req.Params(r) - size := imageSize(p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0)) + size := imageSize(req.Params(r)) artID := api.resolveArtworkID(ctx, itemId) img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 2f32e6ec9..b7b262895 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -65,7 +65,7 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) var _ = Describe("Images", func() { // Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must // still get a resized image rather than the full-size original. - DescribeTable("derives the requested size from MaxWidth or MaxHeight", + DescribeTable("derives the requested size from the Jellyfin size params", func(query string, wantSize int) { ds := &tests.MockDataStore{} ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) @@ -84,6 +84,15 @@ var _ = Describe("Images", func() { Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200), Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200), Entry("neither", "", 0), + Entry("Width only", "width=300", 300), + Entry("Height only", "height=300", 300), + Entry("Width capped by MaxWidth", "width=500&maxwidth=300", 300), + Entry("FillWidth only", "fillwidth=300", 300), + Entry("FillHeight only", "fillheight=300", 300), + Entry("both fill sides, larger one covers the box", "fillwidth=200&fillheight=300", 300), + Entry("fill smaller than MaxWidth wins", "maxwidth=500&fillwidth=300&fillheight=300", 300), + Entry("MaxWidth smaller than fill wins", "maxwidth=200&fillwidth=300&fillheight=300", 200), + Entry("non-positive values are ignored", "fillwidth=0&maxwidth=-5&height=250", 250), ) It("streams album artwork", func() { diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index ac790a821..1052a398f 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "errors" + "math" "net/http" + "strconv" "strings" "github.com/go-chi/chi/v5" @@ -261,8 +263,8 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) } -// addToPlaylist appends items by id, expanding containers into tracks (see expandContainerIDs). -// AddTracks enforces ownership; a locked playlist maps to 403, any other error to 404. +// addToPlaylist adds items by id (containers expand to tracks), inserting at the zero-based position +// when given. Core enforces ownership: a locked playlist maps to 403, any other error to 404. func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, ok := itemIDParam(w, r, "playlistId") @@ -275,7 +277,13 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { return } ids := api.expandContainerIDs(ctx, decoded) - if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { + var err error + if position, perr := req.Params(r).Int64("position"); perr == nil { + _, err = api.playlists.InsertTracks(ctx, id, ids, insertPosition(position)) + } else { + _, err = api.playlists.AddTracks(ctx, id, ids) + } + if err != nil { if errors.Is(err, model.ErrPlaylistNotEditable) { http.Error(w, "Forbidden", http.StatusForbidden) return @@ -286,6 +294,12 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// insertPosition maps Jellyfin's zero-based position to a 1-based one, clamped in int64 first so +// it can't wrap on 32-bit builds. +func insertPosition(position int64) int { + return int(min(max(position, 0), math.MaxInt32-1) + 1) +} + // removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media // file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces // ownership; a locked playlist maps to 403, any other error to 404. @@ -316,6 +330,38 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// movePlaylistItem moves an entry to Jellyfin's zero-based newIndex. Reorder clamps past-the-end +// indexes and rejects unknown entries, which Jellyfin treats as a no-op. +func (api *Router) movePlaylistItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + entry, ok := dto.DecodePlaylistEntryID(chi.URLParam(r, "entryId")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + newIndex, err := strconv.Atoi(chi.URLParam(r, "newIndex")) + if err != nil || newIndex < 0 { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + // Resolve first, so a missing or hidden playlist is still a 404 below the unknown-entry no-op. + if _, err := api.playlists.Get(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + pos, _ := strconv.Atoi(entry) + err = api.playlists.ReorderTrack(ctx, id, pos, min(newIndex, math.MaxInt32-1)+1) + if err != nil && !errors.Is(err, model.ErrNotFound) { + api.playlistError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + // Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries // only editability; ownership is enforced on write, and a lookup error 404s to prevent probing. func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 270f5fe08..ae0a5da3c 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "math" "net/http" "net/http/httptest" "strings" @@ -133,6 +134,17 @@ func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error return f.removeImageErr } +var _ = DescribeTable("insertPosition", + func(position int64, want int) { + Expect(insertPosition(position)).To(Equal(want)) + }, + Entry("zero-based index becomes a 1-based position", int64(2), 3), + Entry("negative prepends", int64(-5), 1), + Entry("beyond int32 stays past the end instead of wrapping", int64(1)<<32+1, math.MaxInt32), + Entry("largest int64 stays past the end", int64(math.MaxInt64), math.MaxInt32), + Entry("smallest int64 prepends", int64(math.MinInt64), 1), +) + var _ = Describe("Playlists", func() { var api *Router var fp *fakePlaylists diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index a145e7359..e60b1bf5b 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -113,8 +113,8 @@ func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } -// postCapabilities acknowledges Jellyfin session-capability negotiation. -// Navidrome doesn't track per-session client capabilities, so this is a no-op. -func (api *Router) postCapabilities(w http.ResponseWriter, _ *http.Request) { +// acknowledge answers requests Navidrome keeps no state for: session capabilities (not tracked per +// session) and playback pings (transcodes live only as long as their stream request). +func (api *Router) acknowledge(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go index 05151c39a..f2f04497c 100644 --- a/server/jellyfin/sessions_test.go +++ b/server/jellyfin/sessions_test.go @@ -152,12 +152,12 @@ var _ = Describe("Sessions", func() { }) }) - Describe("postCapabilities", func() { + Describe("acknowledge", func() { It("returns 204 No Content and does not touch the scrobbler", func() { w := httptest.NewRecorder() r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`))) - api.postCapabilities(w, r) + api.acknowledge(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(pt.reported).To(BeEmpty()) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 82d12f52b..1bd887f43 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -111,11 +111,26 @@ func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) { // a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty // results, never a 404 the client would surface as an error. func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() id, ok := itemIDParam(w, r, "itemId") if !ok { return } + api.instantMix(w, r, id) +} + +// getInstantMixByQuery serves the legacy Artists/InstantMix and MusicGenres/InstantMix forms, +// which take the seed as ?id= instead of a path segment. +func (api *Router) getInstantMixByQuery(w http.ResponseWriter, r *http.Request) { + id, ok := dto.DecodeID(req.Params(r).StringOr("id", "")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + api.instantMix(w, r, id) +} + +func (api *Router) instantMix(w http.ResponseWriter, r *http.Request, id string) { + ctx := r.Context() limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) // Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index c6dc0c80e..b9809bd29 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -4,6 +4,7 @@ import ( "cmp" "fmt" "math" + "mime" "net/http" "net/url" "slices" @@ -139,6 +140,14 @@ func (api *Router) serveAudio(w http.ResponseWriter, r *http.Request, mf *model. func (api *Router) serveStream(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, streamReq stream.Request) { ctx := r.Context() + // A probe must not start a transcode. Omitting Content-Length is what tells a client (Fintunes) + // this is not direct play; direct play falls through to Serve, which answers HEAD itself. + if r.Method == http.MethodHead && streamReq.Format != "" && streamReq.Format != "raw" { + w.Header().Set("Content-Type", mime.TypeByExtension("."+streamReq.Format)) + w.Header().Set("Accept-Ranges", "none") + w.WriteHeader(http.StatusOK) + return + } s, err := api.streamer.NewStream(ctx, mf, streamReq) if err != nil { api.internalError(w, r, err) diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index 3cd692c1b..1a6d1badc 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -244,6 +244,36 @@ var _ = Describe("Stream", func() { }) }) + Describe("HEAD requests", func() { + head := func(query string) *httptest.ResponseRecorder { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("HEAD", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?"+query, nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) + invoke(api.streamAudio, w, r) + return w + } + + It("answers a transcode with the target type and no length, without starting it", func() { + w := head("audioCodec=mp3") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("audio/mpeg")) + Expect(w.Header().Get("Content-Length")).To(BeEmpty()) + Expect(w.Body.String()).To(BeEmpty()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("answers direct play through the streamer, without a body", func() { + w := head("static=true") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(BeEmpty()) + }) + }) + Describe("streamUniversal", func() { universal := func(query string) { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index ccc1277df..0a85d4545 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -20,10 +20,9 @@ import ( "github.com/navidrome/navidrome/server/jellyfin/dto" ) -// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate -// on it, so it must stay a real Jellyfin release, not Navidrome's own version. 10.9+ is required -// for Feishin to use the server lyrics endpoint. -const jellyfinVersion = "10.9.11" +// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients and SDKs gate on +// it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts. +const jellyfinVersion = "12.1.0" func (api *Router) serverName() string { if conf.Server.Jellyfin.ServerName != "" { diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index 83751bb28..5666e7cc7 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -13,6 +13,7 @@ type MockPlaylistTrackRepo struct { DeletedIds []string Reordered bool AddCount int + InsertPos int Err error AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options } @@ -89,6 +90,11 @@ func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { return m.AddCount, nil } +func (m *MockPlaylistTrackRepo) Insert(ids []string, pos int) (int, error) { + m.InsertPos = pos + return m.Add(ids) +} + func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) { if m.Err != nil { return 0, m.Err From fed35e08de2d784af8ad7d7f9533d56b59e6f42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 19 Sep 2026 13:33:19 -0400 Subject: [PATCH 02/22] feat(jellyfin): add opt-in LAN auto-discovery (#6169) * feat(jellyfin): compute the address advertised by auto-discovery * fix(jellyfin): use TLSEnabled and path.Join for the auto-discovery address * feat(jellyfin): answer LAN auto-discovery broadcasts * test(jellyfin): e2e check that auto-discovery matches the public server identity * feat(jellyfin): add opt-in AutoDiscovery option and start the listener * fix(jellyfin): close the auto-discovery socket on read errors and quiet reply failures * refactor(jellyfin): build the auto-discovery address with publicurl and log bind failures in place discoveryAddress now hands only the discovery-specific part (the requester-facing host) to publicurl.AbsoluteURL, so the BaseURL, scheme and BasePath rules live in one place. ServeDiscovery logs its own bind failure and returns nothing, so the caller cannot route the error into the server errgroup. Tests use a DescribeTable and no longer wait on a fixed timeout to prove a packet was ignored. * refactor(jellyfin): run auto-discovery as its own service in the main errgroup Discovery is now a small type that needs only a DataStore, started by startJellyfinDiscovery like the other background services, so the errgroup waits for it on shutdown and startServer is back to a one-line mount. The server id resolution moved to resolveServerID with a package-level lock: the Router and Discovery are separate objects, and a per-Router lock would let them persist two different ids on first boot. * refactor(jellyfin): build Discovery through wire and drop the serverName forwarder startJellyfinDiscovery now follows its siblings: negative guard with a DISABLED debug log, and the service comes from a CreateJellyfinDiscovery wire injector instead of an inline constructor. The Router.serverName method only forwarded to the package function, so its four call sites call the function directly. * fix(jellyfin): skip auto-discovery for unix socket servers without a BaseURL host With Address set to a unix socket nothing listens on Port, so the route-facing IP plus Port pointed clients at a dead URL. Discovery now logs a warning and does not start in that mode unless BaseURL names the proxy host, which AbsoluteURL already advertises as-is. * docs(jellyfin): note which address auto-discovery advertises on restricted binds Also stop using a hostname Address in the fallback spec: a hostname like localhost binds a single interface, so it is not an example of the route-facing fallback being right. An empty Address is. --- cmd/root.go | 13 ++ cmd/wire_gen.go | 9 +- cmd/wire_injectors.go | 7 ++ conf/configuration.go | 2 + server/jellyfin/README.md | 22 ++++ server/jellyfin/api.go | 2 - server/jellyfin/auth.go | 2 +- server/jellyfin/discovery.go | 110 ++++++++++++++++ server/jellyfin/discovery_test.go | 175 ++++++++++++++++++++++++++ server/jellyfin/e2e/discovery_test.go | 50 ++++++++ server/jellyfin/system.go | 45 +++---- server/jellyfin/system_test.go | 12 ++ server/jellyfin/users.go | 2 +- 13 files changed, 425 insertions(+), 26 deletions(-) create mode 100644 server/jellyfin/discovery.go create mode 100644 server/jellyfin/discovery_test.go create mode 100644 server/jellyfin/e2e/discovery_test.go diff --git a/cmd/root.go b/cmd/root.go index c4e360010..f632b0425 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -86,6 +86,7 @@ func runNavidrome(ctx context.Context) { g.Go(startSignaller(ctx)) g.Go(startScheduler(ctx)) g.Go(startPlaybackServer(ctx)) + g.Go(startJellyfinDiscovery(ctx)) g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) g.Go(scheduleDBAnalyzer(ctx)) @@ -343,6 +344,18 @@ func startInsightsCollector(ctx context.Context) func() error { } } +// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server. +func startJellyfinDiscovery(ctx context.Context) func() error { + return func() error { + if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery { + log.Debug("Jellyfin auto-discovery is DISABLED") + return nil + } + CreateJellyfinDiscovery().Serve(ctx) + return nil + } +} + // startPlaybackServer starts the Navidrome playback server, if configured. // It is responsible for the Jukebox functionality func startPlaybackServer(ctx context.Context) func() error { diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index fd04c44c5..d6bf4da07 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -168,6 +168,13 @@ func CreateListenBrainzRouter() *listenbrainz.Router { return router } +func CreateJellyfinDiscovery() *jellyfin.Discovery { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + discovery := jellyfin.NewDiscovery(dataStore) + return discovery +} + func CreateInsights() metrics.Insights { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -249,7 +256,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index a54d4ae1b..527617959 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -36,6 +36,7 @@ var allProviders = wire.NewSet( server.New, subsonic.New, jellyfin.New, + jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, @@ -108,6 +109,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router { )) } +func CreateJellyfinDiscovery() *jellyfin.Discovery { + panic(wire.Build( + allProviders, + )) +} + func CreateInsights() metrics.Insights { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index e549e2a61..4812f2515 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -235,6 +235,7 @@ type jellyfinOptions struct { // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. ExposedPublicUsers string + AutoDiscovery bool // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB // cursor — and its pooled connection — for the whole client-paced response, so without a bound // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. @@ -1087,6 +1088,7 @@ func setViperDefaults() { viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) viper.SetDefault("jellyfin.enabled", false) viper.SetDefault("jellyfin.servername", "") + viper.SetDefault("jellyfin.autodiscovery", false) viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index a20c9ce4e..893642cdd 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -22,6 +22,8 @@ Enabled = true ServerName = "My Music Server" # Optional: usernames to show in the client login user-picker (default: none). See "Public user list". ExposedPublicUsers = "alice, bob" +# Optional: answer LAN auto-discovery broadcasts on UDP 7359 (default: false). See "Auto discovery". +AutoDiscovery = true # Optional: max collection responses streaming at once (default: half the DB connection pool, # min 2). Each streaming response holds a DB connection for its whole duration; excess requests # queue rather than fail. @@ -34,6 +36,7 @@ or via environment variables: ND_JELLYFIN_ENABLED=true ND_JELLYFIN_SERVERNAME="My Music Server" ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" +ND_JELLYFIN_AUTODISCOVERY=true ``` Once enabled, the API is mounted at: @@ -46,6 +49,25 @@ All the paths below are relative to that base URL (e.g. `System/Info/Public` mea `http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**, since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths. +## Auto discovery + +With `AutoDiscovery = true`, Navidrome answers the Jellyfin LAN discovery broadcast +(`who is JellyfinServer?` on UDP port 7359), so clients list the server without a typed URL. +It is off by default because a real Jellyfin server on the same host owns that port. If the port +is taken, Navidrome logs a warning and keeps running without discovery. + +The advertised address is `BaseURL` when it includes a host. Otherwise it is the bind `Address` +when that is a specific IP, or else the local IP that faces the requesting client, plus `Port`. With a +unix socket `Address` there is no port to advertise, so discovery only starts when `BaseURL` has a host. + +Discovery answers on all IPv4 interfaces, but the advertised address follows `BaseURL`, `Address` and +`Port`. If `Address` is a loopback or a single interface IP and `BaseURL` has no host, clients on other +networks get an address they cannot reach. Set `BaseURL` to the address clients should use. + +Docker: publish the port (`-p 7359:7359/udp`). In bridge mode the server only sees its container +IP, so also set `ND_BASEURL` to the LAN address (for example `http://192.168.1.10:4533`), or use +host networking. Keep UDP 7359 on the LAN: never forward it from the internet. + ## Authentication Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 955ebc3bb..eddd14f66 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -3,7 +3,6 @@ package jellyfin import ( "encoding/json" "net/http" - "sync" "time" "github.com/go-chi/chi/v5" @@ -41,7 +40,6 @@ type Router struct { broker events.Broker lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group - serverIDMu sync.Mutex serverIDVal string } diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index bfcfab319..ba4fe40f1 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -45,7 +45,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { a := parseMediaBrowserAuth(r) serverID := api.serverID(ctx) api.ok(w, r, dto.AuthenticationResult{ - User: userToDto(usr, api.serverName(), serverID), + User: userToDto(usr, serverName(), serverID), SessionInfo: dto.NewSessionInfo(usr, a.Client, a.DeviceId, a.Device, a.Version, serverID), AccessToken: token, ServerId: serverID, diff --git a/server/jellyfin/discovery.go b/server/jellyfin/discovery.go new file mode 100644 index 000000000..d111287a6 --- /dev/null +++ b/server/jellyfin/discovery.go @@ -0,0 +1,110 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net" + "strconv" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/publicurl" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/gg" +) + +// Jellyfin clients broadcast this query text to this UDP port. +const ( + discoveryPort = 7359 + discoveryQuery = "who is jellyfinserver?" +) + +type discoveryInfo struct { + Address string `json:"Address"` + Id string `json:"Id"` + Name string `json:"Name"` + EndpointAddress *string `json:"EndpointAddress"` +} + +// Discovery answers LAN auto-discovery broadcasts with the same identity the Router reports. +type Discovery struct { + ds model.DataStore + serverIDVal string +} + +func NewDiscovery(ds model.DataStore) *Discovery { + return &Discovery{ds: ds} +} + +func (d *Discovery) serverID(ctx context.Context) string { + return resolveServerID(ctx, d.ds, &d.serverIDVal) +} + +// Serve runs until ctx is done. A failed bind is only logged: discovery is best-effort. +func (d *Discovery) Serve(ctx context.Context) { + if !hasAdvertisableAddress() { + log.Warn(ctx, "Jellyfin API: auto-discovery is off, a unix socket server needs a BaseURL with a host to advertise") + return + } + // udp4 only: a dual-stack bind can share the port with another server and never get a packet. + conn, err := net.ListenPacket("udp4", net.JoinHostPort("0.0.0.0", strconv.Itoa(discoveryPort))) + if err != nil { + log.Warn(ctx, "Jellyfin API: auto-discovery is off, the UDP port is unavailable. Is another Jellyfin server running?", "port", discoveryPort, err) + return + } + log.Info(ctx, "Jellyfin API: listening for auto-discovery broadcasts", "port", discoveryPort) + d.ServeOn(ctx, conn) +} + +// ServeOn answers discovery queries on conn until ctx is done, then closes conn. +func (d *Discovery) ServeOn(ctx context.Context, conn net.PacketConn) { + defer conn.Close() + stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stop() + buf := make([]byte, 1024) + for { + n, remote, err := conn.ReadFrom(buf) + if err != nil { + if ctx.Err() == nil { + log.Error(ctx, "Jellyfin API: auto-discovery listener stopped", err) + } + return + } + if !strings.Contains(strings.ToLower(string(buf[:n])), discoveryQuery) { + continue + } + info := discoveryInfo{Address: discoveryAddress(ctx, remote), Id: d.serverID(ctx), Name: serverName()} + res, _ := json.Marshal(info) + log.Debug(ctx, "Jellyfin API: answering auto-discovery request", "from", remote.String(), "address", info.Address) + if _, err := conn.WriteTo(res, remote); err != nil { + log.Debug(ctx, "Jellyfin API: could not answer auto-discovery request", "to", remote.String(), err) + } + } +} + +// Behind a unix socket nothing listens on Port, so only a BaseURL host gives clients an address. +func hasAdvertisableAddress() bool { + return conf.Server.BaseHost != "" || !strings.HasPrefix(conf.Server.Address, "unix:") +} + +func discoveryAddress(ctx context.Context, remote net.Addr) string { + scheme := gg.If(conf.Server.TLSEnabled(), "https", "http") + host := net.JoinHostPort(localIPFor(remote), strconv.Itoa(conf.Server.Port)) + return publicurl.AbsoluteURL(request.WithServerAddress(ctx, scheme, host), consts.URLPathJellyfinAPI, nil) +} + +// On a multi-homed host, only the interface that routes to the requester is reachable by it. +func localIPFor(remote net.Addr) string { + if ip := parseIP(conf.Server.Address); ip.IsValid() && !ip.IsUnspecified() { + return ip.String() + } + c, err := net.Dial("udp", remote.String()) + if err != nil { + return conf.Server.Address + } + defer c.Close() + return c.LocalAddr().(*net.UDPAddr).IP.String() +} diff --git a/server/jellyfin/discovery_test.go b/server/jellyfin/discovery_test.go new file mode 100644 index 000000000..bcd11fe76 --- /dev/null +++ b/server/jellyfin/discovery_test.go @@ -0,0 +1,175 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type failingConn struct { + net.PacketConn + closed bool +} + +func (c *failingConn) ReadFrom([]byte) (int, net.Addr, error) { + return 0, nil, errors.New("read failed") +} + +func (c *failingConn) Close() error { + c.closed = true + return nil +} + +var _ = Describe("Discovery", func() { + var d *Discovery + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "Test Server" + conf.Server.Address = "0.0.0.0" + conf.Server.Port = 4533 + conf.Server.BaseHost = "" + conf.Server.BaseScheme = "" + conf.Server.BasePath = "" + conf.Server.TLSCert = "" + conf.Server.TLSKey = "" + d = &Discovery{} + }) + + DescribeTable("discoveryAddress", + func(setup func(), expected string) { + setup() + remote := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 50000} + Expect(discoveryAddress(context.Background(), remote)).To(Equal(expected)) + }, + Entry("uses the BaseURL host and scheme when set", func() { + conf.Server.BaseScheme = "https" + conf.Server.BaseHost = "music.example.com" + conf.Server.BasePath = "/nd" + }, "https://music.example.com/nd/jellyfin"), + Entry("uses a specific bind Address with the Port", func() { + conf.Server.Address = "192.168.1.10" + }, "http://192.168.1.10:4533/jellyfin"), + Entry("falls back to the interface facing the requester when Address is unspecified", func() {}, + "http://127.0.0.1:4533/jellyfin"), + Entry("falls back to the interface facing the requester when Address is empty", func() { + conf.Server.Address = "" + }, "http://127.0.0.1:4533/jellyfin"), + Entry("advertises https when TLS is configured", func() { + conf.Server.TLSCert = "/path/cert.pem" + conf.Server.TLSKey = "/path/key.pem" + }, "https://127.0.0.1:4533/jellyfin"), + Entry("advertises http when only the TLS cert is configured", func() { + conf.Server.TLSCert = "/path/cert.pem" + }, "http://127.0.0.1:4533/jellyfin"), + Entry("keeps a path-only BaseURL as the path prefix", func() { + conf.Server.BasePath = "/music" + }, "http://127.0.0.1:4533/music/jellyfin"), + Entry("does not double the slash when BasePath has a trailing slash", func() { + conf.Server.BasePath = "/music/" + }, "http://127.0.0.1:4533/music/jellyfin"), + ) + + DescribeTable("hasAdvertisableAddress", + func(address, baseHost string, expected bool) { + conf.Server.Address = address + conf.Server.BaseHost = baseHost + Expect(hasAdvertisableAddress()).To(Equal(expected)) + }, + Entry("TCP listener", "0.0.0.0", "", true), + Entry("unix socket without a BaseURL host", "unix:/tmp/navidrome.sock", "", false), + Entry("unix socket behind a proxy named by BaseURL", "unix:/tmp/navidrome.sock", "music.example.com", true), + ) + + It("closes the connection when the read loop fails", func() { + fake := &failingConn{} + d.ServeOn(context.Background(), fake) + Expect(fake.closed).To(BeTrue()) + }) + + Describe("ServeOn", func() { + var ( + server net.PacketConn + client net.PacketConn + cancel context.CancelFunc + done chan struct{} + ) + + BeforeEach(func() { + var err error + server, err = net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + client, err = net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(client.Close) + + var ctx context.Context + ctx, cancel = context.WithCancel(context.Background()) + done = make(chan struct{}) + go func() { + defer close(done) + d.ServeOn(ctx, server) + }() + DeferCleanup(func() { + cancel() + Eventually(done).Should(BeClosed()) + }) + }) + + send := func(msg string) { + _, err := client.WriteTo([]byte(msg), server.LocalAddr()) + Expect(err).ToNot(HaveOccurred()) + } + receive := func() ([]byte, error) { + Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) + buf := make([]byte, 1024) + n, _, err := client.ReadFrom(buf) + return buf[:n], err + } + + It("answers the discovery query with the server identity", func() { + send("who is JellyfinServer?") + res, err := receive() + Expect(err).ToNot(HaveOccurred()) + + var info discoveryInfo + Expect(json.Unmarshal(res, &info)).To(Succeed()) + Expect(info.Address).To(Equal("http://127.0.0.1:4533/jellyfin")) + Expect(info.Id).To(Equal(d.serverID(context.Background()))) + Expect(info.Name).To(Equal("Test Server")) + Expect(string(res)).To(ContainSubstring(`"EndpointAddress":null`)) + }) + + It("matches the query case-insensitively", func() { + send("WHO IS JELLYFINSERVER?") + _, err := receive() + Expect(err).ToNot(HaveOccurred()) + }) + + // The loop is serial, so a reply to the first packet would arrive before the second's. + It("ignores unrelated packets", func() { + send("who is PlexServer?") + send("who is JellyfinServer?") + _, err := receive() + Expect(err).ToNot(HaveOccurred()) + Expect(client.SetReadDeadline(time.Now().Add(50 * time.Millisecond))).To(Succeed()) + _, _, err = client.ReadFrom(make([]byte, 1024)) + Expect(errors.Is(err, os.ErrDeadlineExceeded)).To(BeTrue()) + }) + + It("stops and closes the socket when the context is cancelled", func() { + cancel() + Eventually(done).Should(BeClosed()) + _, _, err := server.ReadFrom(make([]byte, 1)) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/server/jellyfin/e2e/discovery_test.go b/server/jellyfin/e2e/discovery_test.go new file mode 100644 index 000000000..0126ef3c6 --- /dev/null +++ b/server/jellyfin/e2e/discovery_test.go @@ -0,0 +1,50 @@ +package e2e + +import ( + "context" + "encoding/json" + "net" + "time" + + "github.com/navidrome/navidrome/server/jellyfin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Auto discovery", func() { + BeforeEach(func() { setupTestDB() }) + + It("advertises the same Id and Name as /System/Info/Public", func() { + server, err := net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + client, err := net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(client.Close) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + jellyfin.NewDiscovery(ds).ServeOn(ctx, server) + }() + DeferCleanup(func() { + cancel() + Eventually(done).Should(BeClosed()) + }) + + _, err = client.WriteTo([]byte("who is JellyfinServer?"), server.LocalAddr()) + Expect(err).ToNot(HaveOccurred()) + Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) + buf := make([]byte, 1024) + n, _, err := client.ReadFrom(buf) + Expect(err).ToNot(HaveOccurred()) + var reply map[string]any + Expect(json.Unmarshal(buf[:n], &reply)).To(Succeed()) + + var pub map[string]any + parseInto(rawReq("GET", "/System/Info/Public", ""), &pub) + Expect(reply["Id"]).To(Equal(pub["Id"])) + Expect(reply["Name"]).To(Equal(pub["ServerName"])) + Expect(reply["Address"]).To(HaveSuffix("/jellyfin")) + }) +}) diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 0a85d4545..35ade5ff1 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -10,6 +10,7 @@ import ( "net/netip" "path" "strings" + "sync" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" @@ -24,35 +25,37 @@ import ( // it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts. const jellyfinVersion = "12.1.0" -func (api *Router) serverName() string { +func serverName() string { if conf.Server.Jellyfin.ServerName != "" { return conf.Server.Jellyfin.ServerName } return fmt.Sprintf("Navidrome %s", consts.Version) } -// serverID returns a stable Id that survives restarts, get-or-created in the Property table. -// Jellyfin clients cache ServerId across sessions, so a per-process value would break -// re-authentication. api.ds is nil only in unit tests; New() always sets it. -// -// The mutex serializes first-boot resolution so concurrent requests can't persist different -// UUIDs. Only a successful read or persisted id is cached; a transient failure yields a -// temporary id and retries on the next request rather than pinning a value. func (api *Router) serverID(ctx context.Context) string { - api.serverIDMu.Lock() - defer api.serverIDMu.Unlock() - if api.serverIDVal != "" { - return api.serverIDVal + return resolveServerID(ctx, api.ds, &api.serverIDVal) +} + +// Package-level: the Router and Discovery are separate objects and must not persist different ids. +var serverIDMu sync.Mutex + +// Clients cache ServerId across sessions, so it is persisted. Only a successful read or write is +// cached: a transient failure yields a temporary id and retries on the next call. +func resolveServerID(ctx context.Context, ds model.DataStore, cached *string) string { + serverIDMu.Lock() + defer serverIDMu.Unlock() + if *cached != "" { + return *cached } - if api.ds == nil { - api.serverIDVal = newServerID() - return api.serverIDVal + if ds == nil { + *cached = newServerID() + return *cached } - id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) + id, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) switch { case errors.Is(err, model.ErrNotFound): id = newServerID() - if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { + if err := ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { log.Error(ctx, "Jellyfin API: could not persist server id", err) return id } @@ -61,8 +64,8 @@ func (api *Router) serverID(ctx context.Context) string { return newServerID() } // Ids persisted before this change are dashed; normalize on read rather than rewriting the DB. - api.serverIDVal = strings.ReplaceAll(id, "-", "") - return api.serverIDVal + *cached = strings.ReplaceAll(id, "-", "") + return *cached } // newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")). @@ -74,7 +77,7 @@ func newServerID() string { func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { return dto.PublicSystemInfo{ LocalAddress: localAddress(r), - ServerName: api.serverName(), + ServerName: serverName(), Version: jellyfinVersion, ProductName: "Jellyfin Server", Id: api.serverID(r.Context()), @@ -106,7 +109,7 @@ func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) { func (api *Router) ping(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(api.serverName())) + _, _ = w.Write([]byte(serverName())) } // getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 4b3ac240e..c9368f3b5 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/http/httptest" + "sync" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -173,6 +174,17 @@ var _ = Describe("System", func() { Expect(second.serverID(ctx)).To(Equal(id)) }) + It("resolves one id when a Router and a Discovery race on first boot", func() { + r, d := &Router{ds: ds}, NewDiscovery(ds) + ids := make([]string, 2) + var wg sync.WaitGroup + wg.Go(func() { ids[0] = r.serverID(ctx) }) + wg.Go(func() { ids[1] = d.serverID(ctx) }) + wg.Wait() + Expect(ids[0]).ToNot(BeEmpty()) + Expect(ids[1]).To(Equal(ids[0])) + }) + It("memoizes the id across repeated calls on the same Router", func() { r := &Router{ds: ds} id := r.serverID(ctx) diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index ee4ace48e..dddcd99da 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -34,7 +34,7 @@ func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) { ctx := r.Context() u, _ := request.UserFrom(ctx) - api.ok(w, r, userToDto(&u, api.serverName(), api.serverID(ctx))) + api.ok(w, r, userToDto(&u, serverName(), api.serverID(ctx))) } // getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login From be8ec15168b78fda2b30db659f394df2d34e9a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 19 Sep 2026 14:07:42 -0400 Subject: [PATCH 03/22] feat(ui): format dates using the selected language (#6160) Dates were rendered with the browser locale, ignoring the language chosen in Personal settings, so a user browsing in German still saw US-style dates. All date rendering now resolves its locale through a useDateLocale hook that returns the selected language, augmented with the region from navigator.languages when the language carries none (Intl reads a bare "en" as en-US). Applied to DateField, the rated/loved tooltips, the mobile user list and album release dates; the three inline timestamp tooltips now share a formatDateTime helper. Closes #229 --- ui/src/album/AlbumDetails.jsx | 9 ++++--- ui/src/common/DateField.jsx | 4 ++- ui/src/common/DateField.test.jsx | 32 ++++++++++++++++++++++ ui/src/common/LoveButton.jsx | 5 +++- ui/src/common/RatingField.jsx | 5 +++- ui/src/i18n/useDateLocale.js | 14 ++++++++++ ui/src/i18n/useDateLocale.test.js | 45 +++++++++++++++++++++++++++++++ ui/src/user/UserList.jsx | 5 +++- ui/src/utils/formatters.js | 3 +++ 9 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 ui/src/common/DateField.test.jsx create mode 100644 ui/src/i18n/useDateLocale.js create mode 100644 ui/src/i18n/useDateLocale.test.js diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index f55d94ffc..7fbc479db 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -31,6 +31,7 @@ import { SizeField, useAlbumsPerPage, } from '../common' +import { useDateLocale } from '../i18n/useDateLocale' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' import { SafeHTML } from '../common/SafeHTML' @@ -151,6 +152,7 @@ export const Details = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() const record = useRecordContext(props) + const locale = useDateLocale() // Create an array of detail elements let details = [] @@ -161,12 +163,13 @@ export const Details = (props) => { // Calculate date related fields const yearRange = formatRange(record, 'year') - const date = record.date ? formatFullDate(record.date) : yearRange + const date = record.date ? formatFullDate(record.date, locale) : yearRange const originalDate = record.originalDate - ? formatFullDate(record.originalDate) + ? formatFullDate(record.originalDate, locale) : formatRange(record, 'originalYear') - const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate) + const releaseDate = + record?.releaseDate && formatFullDate(record.releaseDate, locale) const dateToUse = originalDate || date const isOriginalDate = originalDate && dateToUse !== date diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx index dce24a2b9..dac9cff08 100644 --- a/ui/src/common/DateField.jsx +++ b/ui/src/common/DateField.jsx @@ -1,12 +1,14 @@ import React from 'react' import { isDateSet } from '../utils/validations' import { DateField as RADateField } from 'react-admin' +import { useDateLocale } from '../i18n/useDateLocale' export const DateField = (props) => { const { record, source } = props + const locale = useDateLocale() const value = record?.[source] if (!isDateSet(value)) return null - return + return } DateField.defaultProps = { diff --git a/ui/src/common/DateField.test.jsx b/ui/src/common/DateField.test.jsx new file mode 100644 index 000000000..47d56756b --- /dev/null +++ b/ui/src/common/DateField.test.jsx @@ -0,0 +1,32 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { DateField } from './DateField' + +vi.mock('react-admin', async (importOriginal) => ({ + ...(await importOriginal()), + useLocale: vi.fn(), +})) + +describe('', () => { + const record = { id: '1', updatedAt: '2026-09-17T14:30:00Z' } + + beforeEach(async () => { + vi.clearAllMocks() + vi.spyOn(navigator, 'languages', 'get').mockReturnValue([]) + const { useLocale } = await import('react-admin') + vi.mocked(useLocale).mockReturnValue('de') + }) + + it('formats the date using the selected language', () => { + render() + expect(screen.getByText('17.9.2026')).toBeInTheDocument() + }) + + it('renders nothing when the date is not set', () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.jsx index 492ba95b3..128b375c2 100644 --- a/ui/src/common/LoveButton.jsx +++ b/ui/src/common/LoveButton.jsx @@ -6,6 +6,8 @@ import IconButton from '@material-ui/core/IconButton' import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' import { useToggleLove } from './useToggleLove' +import { useDateLocale } from '../i18n/useDateLocale' +import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' import config from '../config' import { isDateSet } from '../utils/validations' @@ -40,6 +42,7 @@ export const LoveButton = ({ const record = useRecordContext({ record: recordProp }) || {} const classes = useStyles({ color, visible, loved: record.starred }) const [toggleLove, loading] = useToggleLove(resource, record) + const locale = useDateLocale() const handleToggleLove = useCallback( (e) => { @@ -61,7 +64,7 @@ export const LoveButton = ({ className={clsx(classes.love, className)} title={ isDateSet(record.starredAt) - ? new Date(record.starredAt).toLocaleString() + ? formatDateTime(record.starredAt, locale) : undefined } {...rest} diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx index f92b0d948..f892b475a 100644 --- a/ui/src/common/RatingField.jsx +++ b/ui/src/common/RatingField.jsx @@ -6,6 +6,8 @@ import { isDateSet } from '../utils/validations' import StarBorderIcon from '@material-ui/icons/StarBorder' import clsx from 'clsx' import { useRating } from './useRating' +import { useDateLocale } from '../i18n/useDateLocale' +import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' const useStyles = makeStyles({ @@ -32,6 +34,7 @@ export const RatingField = ({ const record = useRecordContext(rest) || {} const [rate, rating] = useRating(resource, record) const classes = useStyles({ color, visible }) + const locale = useDateLocale() const stopPropagation = (e) => { e.stopPropagation() @@ -50,7 +53,7 @@ export const RatingField = ({ onClick={(e) => stopPropagation(e)} title={ isDateSet(record.ratedAt) - ? new Date(record.ratedAt).toLocaleString() + ? formatDateTime(record.ratedAt, locale) : undefined } > diff --git a/ui/src/i18n/useDateLocale.js b/ui/src/i18n/useDateLocale.js new file mode 100644 index 000000000..c9298a473 --- /dev/null +++ b/ui/src/i18n/useDateLocale.js @@ -0,0 +1,14 @@ +import { useLocale } from 'react-admin' + +// Our language codes are mostly region-less ("en"), and Intl reads a bare "en" +// as en-US. Borrow the region from the browser when it speaks the same language. +const resolveDateLocale = (locale, browserLocales = []) => { + if (!locale || locale.includes('-')) return locale + const base = locale.toLowerCase() + return ( + browserLocales.find((l) => l.toLowerCase().split('-')[0] === base) || locale + ) +} + +export const useDateLocale = () => + resolveDateLocale(useLocale(), navigator.languages) diff --git a/ui/src/i18n/useDateLocale.test.js b/ui/src/i18n/useDateLocale.test.js new file mode 100644 index 000000000..c0ea5ab40 --- /dev/null +++ b/ui/src/i18n/useDateLocale.test.js @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useDateLocale } from './useDateLocale' + +vi.mock('react-admin', () => ({ + useLocale: vi.fn(), +})) + +describe('useDateLocale', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderWith = async (locale, browserLocales) => { + const { useLocale } = await import('react-admin') + vi.mocked(useLocale).mockReturnValue(locale) + vi.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLocales) + return renderHook(() => useDateLocale()).result + } + + it('adds the region from the browser when the language has none', async () => { + const result = await renderWith('en', ['en-GB', 'fr-FR']) + expect(result.current).toEqual('en-GB') + }) + + it('falls back to the language when no browser entry matches', async () => { + const result = await renderWith('de', ['en-US', 'fr-FR']) + expect(result.current).toEqual('de') + }) + + it('keeps a language that already carries a region or script', async () => { + expect((await renderWith('pt-br', ['pt-PT'])).current).toEqual('pt-br') + expect((await renderWith('zh-Hans', ['zh-TW'])).current).toEqual('zh-Hans') + }) + + it('matches the browser language case-insensitively', async () => { + const result = await renderWith('pt', ['PT-PT']) + expect(result.current).toEqual('PT-PT') + }) + + it('returns undefined when there is no language', async () => { + const result = await renderWith(undefined, ['en-GB']) + expect(result.current).toBeUndefined() + }) +}) diff --git a/ui/src/user/UserList.jsx b/ui/src/user/UserList.jsx index 4faf31785..7922ca08d 100644 --- a/ui/src/user/UserList.jsx +++ b/ui/src/user/UserList.jsx @@ -9,6 +9,8 @@ import { } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import { List, DateField } from '../common' +import { useDateLocale } from '../i18n/useDateLocale' +import { formatDateTime } from '../utils/formatters' const UserFilter = (props) => ( @@ -18,6 +20,7 @@ const UserFilter = (props) => ( const UserList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const locale = useDateLocale() return ( { record.userName} secondaryText={(record) => - record.lastLoginAt && new Date(record.lastLoginAt).toLocaleString() + record.lastLoginAt && formatDateTime(record.lastLoginAt, locale) } tertiaryText={(record) => (record.isAdmin ? '[admin]️' : '')} /> diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js index cfcb84b05..f7c59c195 100644 --- a/ui/src/utils/formatters.js +++ b/ui/src/utils/formatters.js @@ -95,6 +95,9 @@ export const formatFullDate = (date, locale) => { return new Date(date).toLocaleDateString(locale, options) } +export const formatDateTime = (value, locale) => + new Date(value).toLocaleString(locale) + export const formatNumber = (value, locale) => { if (value === null || value === undefined) return '0' return value.toLocaleString(locale) From b76ae14286179b9dd69b2a958124ed4a7d032dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 19 Sep 2026 14:57:01 -0400 Subject: [PATCH 04/22] 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. --- cmd/wire_gen.go | 7 +- conf/configuration.go | 2 + core/quickconnect/quickconnect.go | 203 ++++++++++++ core/quickconnect/quickconnect_suite_test.go | 17 + core/quickconnect/quickconnect_test.go | 190 +++++++++++ core/wire_providers.go | 2 + server/jellyfin/README.md | 27 +- server/jellyfin/api.go | 17 +- server/jellyfin/api_test.go | 40 ++- server/jellyfin/auth.go | 7 +- server/jellyfin/dto/dto.go | 11 + server/jellyfin/dto/mappers.go | 20 +- server/jellyfin/dto/mappers_test.go | 6 +- server/jellyfin/e2e/e2e_suite_test.go | 2 + server/jellyfin/e2e/quickconnect_test.go | 91 ++++++ server/jellyfin/e2e/system_test.go | 8 - server/jellyfin/quickconnect.go | 145 +++++++++ server/jellyfin/quickconnect_test.go | 301 ++++++++++++++++++ server/jellyfin/routing_test.go | 2 +- server/jellyfin/socket_test.go | 2 +- server/jellyfin/system.go | 4 - server/jellyfin/system_test.go | 11 - server/nativeapi/config_test.go | 2 +- server/nativeapi/library_test.go | 2 +- server/nativeapi/metadata_test.go | 2 +- server/nativeapi/missing_test.go | 2 +- server/nativeapi/native_api.go | 7 +- server/nativeapi/native_api_song_test.go | 2 +- server/nativeapi/playlists_test.go | 2 +- server/nativeapi/plugin_test.go | 2 +- server/nativeapi/quickconnect.go | 76 +++++ server/nativeapi/quickconnect_test.go | 148 +++++++++ .../user_password_token_refresh_test.go | 2 +- server/serve_index.go | 2 + server/serve_index_test.go | 2 + ui/src/config.js | 1 + ui/src/dataProvider/wrapperDataProvider.js | 9 + ui/src/dialogs/QuickConnectDialog.jsx | 128 ++++++++ ui/src/dialogs/QuickConnectDialog.test.jsx | 103 ++++++ ui/src/dialogs/index.js | 1 + ui/src/i18n/en.json | 11 + ui/src/layout/AppBar.jsx | 73 +++-- ui/src/layout/AppBar.test.jsx | 22 ++ 43 files changed, 1626 insertions(+), 88 deletions(-) create mode 100644 core/quickconnect/quickconnect.go create mode 100644 core/quickconnect/quickconnect_suite_test.go create mode 100644 core/quickconnect/quickconnect_test.go create mode 100644 server/jellyfin/e2e/quickconnect_test.go create mode 100644 server/jellyfin/quickconnect.go create mode 100644 server/jellyfin/quickconnect_test.go create mode 100644 server/nativeapi/quickconnect.go create mode 100644 server/nativeapi/quickconnect_test.go create mode 100644 ui/src/dialogs/QuickConnectDialog.jsx create mode 100644 ui/src/dialogs/QuickConnectDialog.test.jsx diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d6bf4da07..43ba808d0 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -79,7 +80,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider) + quickConnect := quickconnect.GetInstance() + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect) return router } @@ -135,7 +137,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) + quickConnect := quickconnect.GetInstance() + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect) return router } diff --git a/conf/configuration.go b/conf/configuration.go index 4812f2515..6f295ebeb 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -236,6 +236,7 @@ type jellyfinOptions struct { // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. ExposedPublicUsers string AutoDiscovery bool + QuickConnect bool // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB // cursor — and its pooled connection — for the whole client-paced response, so without a bound // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. @@ -1089,6 +1090,7 @@ func setViperDefaults() { viper.SetDefault("jellyfin.enabled", false) viper.SetDefault("jellyfin.servername", "") viper.SetDefault("jellyfin.autodiscovery", false) + viper.SetDefault("jellyfin.quickconnect", true) viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") diff --git a/core/quickconnect/quickconnect.go b/core/quickconnect/quickconnect.go new file mode 100644 index 000000000..9b69bc904 --- /dev/null +++ b/core/quickconnect/quickconnect.go @@ -0,0 +1,203 @@ +// Package quickconnect implements Jellyfin-style Quick Connect: a new client shows a short code, and +// an already signed-in user approves it, so the client can sign in without a password. +package quickconnect + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/random" + "github.com/navidrome/navidrome/utils/singleton" +) + +const timeout = 10 * time.Minute + +// Initiate is unauthenticated, so the number of live requests must be bounded. +const maxPending = 1000 + +var ( + ErrAlreadyAuthorized = errors.New("quick connect request already authorized") + ErrTooManyRequests = errors.New("too many pending quick connect requests") +) + +type Device struct { + ID string + Name string + App string + AppVersion string +} + +type Request struct { + Device Device + Secret string + Code string + DateAdded time.Time + UserID string +} + +func (r Request) Authorized() bool { + return r.UserID != "" +} + +type QuickConnect interface { + Initiate(device Device) (Request, error) + Status(secret string) (Request, error) + // Lookup returns a request still waiting for approval. + Lookup(code string) (Request, error) + Authorize(code, userID string) (Request, error) + // Redeem returns the id of the user who approved the request. It succeeds only once per secret. + Redeem(secret string) (string, error) +} + +type entry struct { + Request + expiresAt time.Time +} + +type quickConnect struct { + mu sync.Mutex + bySecret map[string]*entry + byCode map[string]*entry +} + +// GetInstance returns the shared store: the Jellyfin and native API routers must see the same requests. +func GetInstance() QuickConnect { + return singleton.GetInstance(newStore) +} + +func New() QuickConnect { + return newStore() +} + +func newStore() *quickConnect { + return &quickConnect{ + bySecret: map[string]*entry{}, + byCode: map[string]*entry{}, + } +} + +// Enabled reports whether users can approve codes from the web UI, which needs the Jellyfin API. +func Enabled() bool { + return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect +} + +func (qc *quickConnect) Initiate(device Device) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + qc.expire() + if len(qc.bySecret) >= maxPending { + return Request{}, ErrTooManyRequests + } + // The fields may be substrings of a much larger header; copy them so the header isn't retained. + device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name), + App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)} + now := time.Now() + e := &entry{ + Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now}, + expiresAt: now.Add(timeout), + } + qc.bySecret[e.Secret] = e + qc.byCode[e.Code] = e + return e.Request, nil +} + +func (qc *quickConnect) Status(secret string) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.find(qc.bySecret, secret) + if err != nil { + return Request{}, err + } + return e.Request, nil +} + +func (qc *quickConnect) Lookup(code string) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.findPending(code) + if err != nil { + return Request{}, err + } + return e.Request, nil +} + +func (qc *quickConnect) Authorize(code, userID string) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.findPending(code) + if err != nil { + return Request{}, err + } + e.UserID = userID + e.expiresAt = time.Now().Add(timeout) + return e.Request, nil +} + +func (qc *quickConnect) Redeem(secret string) (string, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.find(qc.bySecret, secret) + if err != nil || !e.Authorized() { + return "", model.ErrNotFound + } + qc.remove(e) + return e.UserID, nil +} + +func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) { + qc.expire() + e, ok := index[key] + if !ok { + return nil, model.ErrNotFound + } + return e, nil +} + +func (qc *quickConnect) findPending(code string) (*entry, error) { + e, err := qc.find(qc.byCode, normalizeCode(code)) + if err == nil && e.Authorized() { + return nil, ErrAlreadyAuthorized + } + return e, err +} + +func (qc *quickConnect) expire() { + now := time.Now() + for _, e := range qc.bySecret { + if !now.Before(e.expiresAt) { + qc.remove(e) + } + } +} + +func (qc *quickConnect) remove(e *entry) { + delete(qc.bySecret, e.Secret) + delete(qc.byCode, e.Code) +} + +func (qc *quickConnect) newCode() string { + for { + code := fmt.Sprintf("%06d", random.Int64N(900000)+100000) + if _, taken := qc.byCode[code]; !taken { + return code + } + } +} + +func newSecret() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// Users often type the code in groups ("123 456"). +func normalizeCode(code string) string { + return strings.Join(strings.Fields(code), "") +} diff --git a/core/quickconnect/quickconnect_suite_test.go b/core/quickconnect/quickconnect_suite_test.go new file mode 100644 index 000000000..029f4e333 --- /dev/null +++ b/core/quickconnect/quickconnect_suite_test.go @@ -0,0 +1,17 @@ +package quickconnect + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestQuickConnect(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "QuickConnect Suite") +} diff --git a/core/quickconnect/quickconnect_test.go b/core/quickconnect/quickconnect_test.go new file mode 100644 index 000000000..b03186807 --- /dev/null +++ b/core/quickconnect/quickconnect_test.go @@ -0,0 +1,190 @@ +package quickconnect + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var device = Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"} + +var _ = Describe("QuickConnect", func() { + var qc QuickConnect + + BeforeEach(func() { + qc = New() + }) + + Describe("Initiate", func() { + It("creates a pending request with a 6-digit code and a random secret", func() { + req, err := qc.Initiate(device) + Expect(err).ToNot(HaveOccurred()) + Expect(req.Code).To(MatchRegexp(`^[1-9]\d{5}$`)) + Expect(req.Secret).To(MatchRegexp(`^[0-9a-f]{64}$`)) + Expect(req.Device).To(Equal(device)) + Expect(req.DateAdded).ToNot(BeZero()) + Expect(req.Authorized()).To(BeFalse()) + }) + + It("never gives two live requests the same code or secret", func() { + codes := map[string]bool{} + secrets := map[string]bool{} + for range 500 { + req, err := qc.Initiate(device) + Expect(err).ToNot(HaveOccurred()) + codes[req.Code] = true + secrets[req.Secret] = true + } + Expect(codes).To(HaveLen(500)) + Expect(secrets).To(HaveLen(500)) + }) + + It("refuses new requests when too many are pending", func() { + for range maxPending { + _, err := qc.Initiate(device) + Expect(err).ToNot(HaveOccurred()) + } + _, err := qc.Initiate(device) + Expect(err).To(MatchError(ErrTooManyRequests)) + }) + }) + + Describe("Status", func() { + It("returns the request for a known secret", func() { + req, _ := qc.Initiate(device) + got, err := qc.Status(req.Secret) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(req)) + }) + + It("returns ErrNotFound for an unknown secret", func() { + _, err := qc.Status("nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Lookup", func() { + It("finds a request by code, ignoring spaces", func() { + req, _ := qc.Initiate(device) + got, err := qc.Lookup(req.Code[:3] + " " + req.Code[3:]) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(req)) + }) + + It("returns ErrNotFound for an unknown code", func() { + _, err := qc.Lookup("000000") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("refuses a request that is already authorized", func() { + req, _ := qc.Initiate(device) + _, _ = qc.Authorize(req.Code, "user-1") + _, err := qc.Lookup(req.Code) + Expect(err).To(MatchError(ErrAlreadyAuthorized)) + }) + }) + + Describe("Authorize", func() { + It("marks the request as authorized by the user", func() { + req, _ := qc.Initiate(device) + got, err := qc.Authorize(" "+req.Code+" ", "user-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Authorized()).To(BeTrue()) + Expect(got.UserID).To(Equal("user-1")) + + status, _ := qc.Status(req.Secret) + Expect(status.Authorized()).To(BeTrue()) + }) + + It("refuses a request that is already authorized", func() { + req, _ := qc.Initiate(device) + _, _ = qc.Authorize(req.Code, "user-1") + _, err := qc.Authorize(req.Code, "user-2") + Expect(err).To(MatchError(ErrAlreadyAuthorized)) + }) + + It("returns ErrNotFound for an unknown code", func() { + _, err := qc.Authorize("000000", "user-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Redeem", func() { + It("returns the approving user once, then forgets the request", func() { + req, _ := qc.Initiate(device) + _, _ = qc.Authorize(req.Code, "user-1") + + userID, err := qc.Redeem(req.Secret) + Expect(err).ToNot(HaveOccurred()) + Expect(userID).To(Equal("user-1")) + + _, err = qc.Redeem(req.Secret) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Status(req.Secret) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Lookup(req.Code) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("refuses a request that is not authorized yet", func() { + req, _ := qc.Initiate(device) + _, err := qc.Redeem(req.Secret) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Status(req.Secret) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) + +// Plain tests: testing/synctest needs a *testing.T, which Ginkgo doesn't give. +func TestPendingRequestExpires(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + qc := New() + req, _ := qc.Initiate(device) + + time.Sleep(timeout - time.Second) + _, err := qc.Status(req.Secret) + g.Expect(err).ToNot(HaveOccurred()) + + time.Sleep(2 * time.Second) + _, err = qc.Status(req.Secret) + g.Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Authorize(req.Code, "user-1") + g.Expect(err).To(MatchError(model.ErrNotFound)) + }) +} + +func TestAuthorizeExtendsExpiry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + qc := New() + req, _ := qc.Initiate(device) + + time.Sleep(timeout - time.Second) + _, err := qc.Authorize(req.Code, "user-1") + g.Expect(err).ToNot(HaveOccurred()) + + time.Sleep(timeout - time.Second) + userID, err := qc.Redeem(req.Secret) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(userID).To(Equal("user-1")) + }) +} + +func TestExpiredRequestsFreeCapacity(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + qc := New() + for range maxPending { + _, _ = qc.Initiate(device) + } + time.Sleep(timeout + time.Second) + _, err := qc.Initiate(device) + g.Expect(err).ToNot(HaveOccurred()) + }) +} diff --git a/core/wire_providers.go b/core/wire_providers.go index a09fcc108..b3df9b2dc 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/stream" ) @@ -32,6 +33,7 @@ var Set = wire.NewSet( ffmpeg.New, scrobbler.GetPlayTracker, playback.GetInstance, + quickconnect.GetInstance, metrics.GetInstance, lyrics.NewLyrics, ) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 893642cdd..c0c5e6ca1 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -24,6 +24,8 @@ ServerName = "My Music Server" ExposedPublicUsers = "alice, bob" # Optional: answer LAN auto-discovery broadcasts on UDP 7359 (default: false). See "Auto discovery". AutoDiscovery = true +# Optional: let users sign in new devices with a 6-digit code (default: true). See "Quick Connect". +QuickConnect = false # Optional: max collection responses streaming at once (default: half the DB connection pool, # min 2). Each streaming response holds a DB connection for its whole duration; excess requests # queue rather than fail. @@ -37,6 +39,7 @@ ND_JELLYFIN_ENABLED=true ND_JELLYFIN_SERVERNAME="My Music Server" ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" ND_JELLYFIN_AUTODISCOVERY=true +ND_JELLYFIN_QUICKCONNECT=false ``` Once enabled, the API is mounted at: @@ -82,6 +85,27 @@ surface. Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch. +### Quick Connect + +Quick Connect signs a new device in without typing a password. The client shows a 6-digit code, +a signed-in user approves it, and the client gets its `AccessToken`. It is on by default +(`Jellyfin.QuickConnect`); when off, `GET QuickConnect/Enabled` returns `false` and every other +Quick Connect call returns 401. + +1. `POST QuickConnect/Initiate` (public; needs `Client`, `Device`, `DeviceId` and `Version` in the + auth header) returns the `Code` and a `Secret`. +2. The user approves the code, either in the Navidrome web UI (user menu → **Quick Connect**, which + shows the app and device before approving) or from a signed-in Jellyfin client with + `POST QuickConnect/Authorize?Code=`. Admins may pass `UserId` to approve for another user. +3. The client polls `GET QuickConnect/Connect?Secret=` until `Authenticated` is `true`. +4. `POST Users/AuthenticateWithQuickConnect` with `{"Secret": "..."}` returns the same result as + `AuthenticateByName`. + +Pending codes live in memory and expire after 10 minutes (a server restart drops them). Unlike +Jellyfin, a secret signs in only once. `Initiate`, `AuthenticateWithQuickConnect` and code approval +(`Authorize` and the web UI) are rate-limited per IP like the login; `Connect` is not, since some +clients poll it every second. + ### Public user list (login picker) `GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the @@ -140,7 +164,8 @@ returns direct children only (no tracks — no track is a library's direct child | Area | Endpoints | |---|---| -| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated), `GET QuickConnect/Enabled` | +| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated) | +| Quick Connect | `GET QuickConnect/Enabled`, `POST QuickConnect/Initiate`, `GET QuickConnect/Connect`, `POST QuickConnect/Authorize` (authenticated), `POST Users/AuthenticateWithQuickConnect` | | Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | | Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | | Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index eddd14f66..4a471d1f0 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -40,17 +41,18 @@ type Router struct { broker events.Broker lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group + quickConnect quickconnect.QuickConnect serverIDVal string } func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, - sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker, quickConnect quickconnect.QuickConnect) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, + sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, quickConnect: quickConnect, lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ SizeLimit: 1000, DefaultTTL: 5 * time.Minute, @@ -81,6 +83,11 @@ func (api *Router) routes() http.Handler { login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) } login.Post("/users/authenticatebyname", api.authenticateByName) + quickConnectLogin := login.With(requireQuickConnect) + quickConnectLogin.Post("/quickconnect/initiate", api.quickConnectInitiate) + quickConnectLogin.Post("/users/authenticatewithquickconnect", api.authenticateWithQuickConnect) + // Not rate-limited: Finamp and Streamyfin poll it every second while the code is shown. + inner.With(requireQuickConnect).Get("/quickconnect/connect", api.quickConnectConnect) inner.Get("/users/public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. @@ -107,6 +114,12 @@ func (api *Router) routes() http.Handler { r.Get("/users/{userId}/views", api.getUserViews) r.Get("/users/me", api.getCurrentUser) r.Get("/users/{userId}", api.getCurrentUser) + // Throttled like login so a signed-in user cannot enumerate other people's pending codes. + approve := r.With(requireQuickConnect) + if conf.Server.AuthRequestLimit > 0 { + approve = approve.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) + } + approve.Post("/quickconnect/authorize", api.quickConnectAuthorize) // Cursor-backed collections: each streams straight from the DB, holding a connection for the // whole client-paced response, so enough slow clients would take the entire pool and stall the diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 605303793..a64dcfe8f 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -19,7 +20,7 @@ import ( var _ = Describe("Router", func() { It("serves the public handshake through the mounted handler", func() { ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -27,7 +28,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -37,7 +38,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -54,7 +55,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -71,7 +72,7 @@ var _ = Describe("Router", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 2 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() @@ -86,11 +87,38 @@ var _ = Describe("Router", func() { Expect(login()).To(Equal(http.StatusTooManyRequests)) }) + It("rate-limits Quick Connect approval by IP when a login limit is configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + conf.Server.Jellyfin.QuickConnect = true + ds := &tests.MockDataStore{} + auth.Init(ds) + usr := model.User{ID: testID("alice"), UserName: "alice"} + Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed()) + token, err := auth.CreateAPIToken(&usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, quickconnect.New()) + + authorize := func() int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/QuickConnect/Authorize?code=000000", nil) + r.RemoteAddr = "10.0.0.1:1234" + r.Header.Set("X-Emby-Token", token) + api.ServeHTTP(w, r) + return w.Code + } + // An unknown code is 404; the limiter cuts in on the 3rd attempt with 429. + Expect(authorize()).To(Equal(http.StatusNotFound)) + Expect(authorize()).To(Equal(http.StatusNotFound)) + Expect(authorize()).To(Equal(http.StatusTooManyRequests)) + }) + It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 1 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Every request arrives on the same proxy connection, so only the resolved client IP // can separate the buckets. handler := middleware.ClientIPFromHeader("X-Real-IP")(api) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index ba4fe40f1..32b4a1432 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -30,10 +30,15 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + api.signIn(w, r, usr) +} + +func (api *Router) signIn(w http.ResponseWriter, r *http.Request, usr *model.User) { + ctx := r.Context() // 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) + log.Error(ctx, "Jellyfin API: could not update last login date", "username", usr.UserName, err) } token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 21e3903f2..4a28eb313 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -233,6 +233,17 @@ type PlayerStateInfo struct { 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"` diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bb17b40c7..fc7e329de 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -51,16 +51,16 @@ func premiereDate(date string, year int) *string { if err != nil { return nil } - s := jellyfinDate(&parsed) + s := JellyfinDate(&parsed) return &s } // Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339. const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00" -// jellyfinDate formats t as the date string clients expect, or "" for the zero time so the +// JellyfinDate formats t as the date string clients expect, or "" for the zero time so the // field is omitted rather than sent as a meaningless epoch. -func jellyfinDate(t *time.Time) string { +func JellyfinDate(t *time.Time) string { if t == nil || t.IsZero() { return "" } @@ -135,7 +135,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto { r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 d.Rating = &r } - if s := jellyfinDate(a.PlayDate); s != "" { + if s := JellyfinDate(a.PlayDate); s != "" { d.LastPlayedDate = &s } return d @@ -159,7 +159,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { AlbumId: albumID, AlbumArtist: mf.AlbumArtist, RunTimeTicks: TicksFromSeconds(mf.Duration), - DateCreated: jellyfinDate(&mf.CreatedAt), + DateCreated: JellyfinDate(&mf.CreatedAt), Container: mf.Suffix, CanDownload: true, BackdropImageTags: []string{}, @@ -265,7 +265,7 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { ChildCount: new(al.SongCount), SongCount: new(al.SongCount), RunTimeTicks: TicksFromSeconds(al.Duration), - DateCreated: jellyfinDate(&al.CreatedAt), + DateCreated: JellyfinDate(&al.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -318,7 +318,7 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { IsFolder: true, AlbumCount: new(ar.AlbumCount), SongCount: new(ar.SongCount), - DateCreated: jellyfinDate(ar.CreatedAt), + DateCreated: JellyfinDate(ar.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -346,7 +346,7 @@ func LibraryToBaseItem(lib model.Library) BaseItemDto { IsFolder: true, Path: lib.Path, LocationType: "FileSystem", - DateCreated: jellyfinDate(&lib.CreatedAt), + DateCreated: JellyfinDate(&lib.CreatedAt), ChildCount: new(lib.TotalAlbums), UserData: &UserItemDataDto{Key: id, ItemId: id}, BackdropImageTags: []string{}, @@ -386,7 +386,7 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { MediaType: "Audio", ChildCount: new(p.SongCount), RunTimeTicks: TicksFromSeconds(p.Duration), - DateCreated: jellyfinDate(&p.CreatedAt), + DateCreated: JellyfinDate(&p.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -460,7 +460,7 @@ func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, server DeviceName: deviceName, ApplicationVersion: version, ServerId: serverID, - LastActivityDate: jellyfinDate(&now), + LastActivityDate: JellyfinDate(&now), IsActive: true, PlayableMediaTypes: []string{"Audio"}, SupportedCommands: []string{}, diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 6dc177363..259904673 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -17,10 +17,10 @@ var _ = Describe("mappers", func() { ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, Year: 1999, Duration: 60, Size: 2_500_000, - Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, + Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, + PlayCount: 2, + Starred: true, } - mf.PlayCount = 2 - mf.Starred = true item := SongToBaseItem(mf, nil) Expect(item.Type).To(Equal("Audio")) Expect(item.MediaType).To(Equal("Audio")) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 1ee19ea01..aa93f7e38 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -44,6 +44,7 @@ import ( "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/storage/storagetest" @@ -338,6 +339,7 @@ func setupTestDB() { sonicSvc, lyrics.NewLyrics(ds, nil), events.NoopBroker(), + quickconnect.New(), ) } diff --git a/server/jellyfin/e2e/quickconnect_test.go b/server/jellyfin/e2e/quickconnect_test.go new file mode 100644 index 000000000..1beffc56f --- /dev/null +++ b/server/jellyfin/e2e/quickconnect_test.go @@ -0,0 +1,91 @@ +package e2e + +import ( + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("QuickConnect", func() { + BeforeEach(func() { + setupTestDB() + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.QuickConnect = true + }) + + clientReq := func(deviceID, method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="`+deviceID+`", Version="1.0"`) + r.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, r) + return w + } + initiate := func() dto.QuickConnectResult { + var pending dto.QuickConnectResult + parseInto(clientReq("new-device", "POST", "/QuickConnect/Initiate", ""), &pending) + Expect(pending.Authenticated).To(BeFalse()) + return pending + } + redeem := func(deviceID, secret string) *httptest.ResponseRecorder { + return clientReq(deviceID, "POST", "/Users/AuthenticateWithQuickConnect", `{"Secret":"`+secret+`"}`) + } + + It("signs a new client in with a code approved by a signed-in user", func() { + Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("true")) + pending := initiate() + + Expect(postAs(regularUser, "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusOK)) + + var status dto.QuickConnectResult + parseInto(rawReq("GET", "/QuickConnect/Connect?Secret="+pending.Secret, ""), &status) + Expect(status.Authenticated).To(BeTrue()) + + // Android TV redeems with a different DeviceId than it initiated with. + w := redeem("other-device", pending.Secret) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.User.Name).To(Equal(regularUser.UserName)) + Expect(res.SessionInfo).ToNot(BeNil()) + Expect(res.SessionInfo.DeviceId).To(Equal("other-device")) + + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + me := httptest.NewRecorder() + router.ServeHTTP(me, r) + Expect(me.Code).To(Equal(http.StatusOK)) + + Expect(redeem("new-device", pending.Secret).Code).To(Equal(http.StatusNotFound)) + }) + + It("lets an admin approve a code for another user", func() { + pending := initiate() + Expect(post("/QuickConnect/Authorize?Code="+pending.Code+"&UserId="+enc(regularUser.ID), "").Code). + To(Equal(http.StatusOK)) + + var res dto.AuthenticationResult + parseInto(redeem("new-device", pending.Secret), &res) + Expect(res.User.Name).To(Equal(regularUser.UserName)) + }) + + It("requires authentication to approve a code", func() { + pending := initiate() + Expect(rawReq("POST", "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("answers 401 on every Quick Connect call when disabled", func() { + conf.Server.Jellyfin.QuickConnect = false + Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("false")) + Expect(clientReq("new-device", "POST", "/QuickConnect/Initiate", "").Code).To(Equal(http.StatusUnauthorized)) + Expect(rawReq("GET", "/QuickConnect/Connect?Secret=x", "").Code).To(Equal(http.StatusUnauthorized)) + Expect(post("/QuickConnect/Authorize?Code=123456", "").Code).To(Equal(http.StatusUnauthorized)) + Expect(redeem("new-device", "x").Code).To(Equal(http.StatusUnauthorized)) + }) +}) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go index ac8c350c4..51443bdc9 100644 --- a/server/jellyfin/e2e/system_test.go +++ b/server/jellyfin/e2e/system_test.go @@ -81,12 +81,4 @@ var _ = Describe("System", func() { Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome")) }) }) - - Describe("GET /QuickConnect/Enabled", func() { - It("reports QuickConnect disabled", func() { - w := rawReq("GET", "/QuickConnect/Enabled", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("false")) - }) - }) }) diff --git a/server/jellyfin/quickconnect.go b/server/jellyfin/quickconnect.go new file mode 100644 index 000000000..c478b1894 --- /dev/null +++ b/server/jellyfin/quickconnect.go @@ -0,0 +1,145 @@ +package jellyfin + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +func requireQuickConnect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !conf.Server.Jellyfin.QuickConnect { + http.Error(w, "Quick connect is disabled", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, conf.Server.Jellyfin.QuickConnect) +} + +// Initiate is unauthenticated and its fields are kept for minutes, so their size must be bounded. +const maxQuickConnectField = 512 + +func (api *Router) quickConnectInitiate(w http.ResponseWriter, r *http.Request) { + a := parseMediaBrowserAuth(r) + if a.Client == "" || a.Device == "" || a.DeviceId == "" || a.Version == "" || + max(len(a.Client), len(a.Device), len(a.DeviceId), len(a.Version)) > maxQuickConnectField { + http.Error(w, "Client, Device, DeviceId and Version are required", http.StatusBadRequest) + return + } + req, err := api.quickConnect.Initiate(quickconnect.Device{ + ID: a.DeviceId, Name: a.Device, App: a.Client, AppVersion: a.Version, + }) + if errors.Is(err, quickconnect.ErrTooManyRequests) { + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, quickConnectResult(req)) +} + +func (api *Router) quickConnectConnect(w http.ResponseWriter, r *http.Request) { + req, err := api.quickConnect.Status(r.URL.Query().Get("secret")) + if err != nil { + http.Error(w, "Unknown secret", http.StatusNotFound) + return + } + api.ok(w, r, quickConnectResult(req)) +} + +func (api *Router) quickConnectAuthorize(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + caller, _ := request.UserFrom(ctx) + userID := caller.ID + if encoded := r.URL.Query().Get("userid"); encoded != "" { + var ok bool + if userID, ok = dto.DecodeID(encoded); !ok { + http.Error(w, "Invalid userId", http.StatusBadRequest) + return + } + } + target := caller + if userID != caller.ID { + if !caller.IsAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + usr, err := api.ds.User(ctx).Get(userID) + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "Unknown user", http.StatusNotFound) + return + } + if err != nil { + api.internalError(w, r, err) + return + } + target = *usr + } + + req, err := api.quickConnect.Authorize(r.URL.Query().Get("code"), target.ID) + switch { + case errors.Is(err, model.ErrNotFound): + http.Error(w, "Unknown code", http.StatusNotFound) + case errors.Is(err, quickconnect.ErrAlreadyAuthorized): + http.Error(w, "Code already used", http.StatusConflict) + case err != nil: + api.internalError(w, r, err) + default: + log.Info(ctx, "Jellyfin API: Quick Connect sign-in approved", "username", target.UserName, + "approvedBy", caller.UserName, "client", req.Device.App, "device", req.Device.Name) + api.ok(w, r, true) + } +} + +func (api *Router) authenticateWithQuickConnect(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var body struct { + Secret string `json:"Secret"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Secret == "" { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + userID, err := api.quickConnect.Redeem(body.Secret) + if err != nil { + http.Error(w, "Unknown secret", http.StatusNotFound) + return + } + usr, err := api.ds.User(ctx).Get(userID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Jellyfin API: Quick Connect user not found", "userID", userID) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + if err != nil { + api.internalError(w, r, err) + return + } + api.signIn(w, r, usr) +} + +func quickConnectResult(req quickconnect.Request) dto.QuickConnectResult { + return dto.QuickConnectResult{ + Authenticated: req.Authorized(), + Secret: req.Secret, + Code: req.Code, + DeviceId: req.Device.ID, + DeviceName: req.Device.Name, + AppName: req.Device.App, + AppVersion: req.Device.AppVersion, + DateAdded: dto.JellyfinDate(&req.DateAdded), + } +} diff --git a/server/jellyfin/quickconnect_test.go b/server/jellyfin/quickconnect_test.go new file mode 100644 index 000000000..0478a356a --- /dev/null +++ b/server/jellyfin/quickconnect_test.go @@ -0,0 +1,301 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fullQuickConnect struct{ quickconnect.QuickConnect } + +func (fullQuickConnect) Initiate(quickconnect.Device) (quickconnect.Request, error) { + return quickconnect.Request{}, quickconnect.ErrTooManyRequests +} + +var _ = Describe("QuickConnect", func() { + const finamp = `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="dev-1", Version="1.0.0"` + var ( + api *Router + ds *tests.MockDataStore + qc quickconnect.QuickConnect + alice = model.User{ID: testID("alice"), UserName: "alice"} + bob = model.User{ID: testID("bob"), UserName: "bob"} + admin = model.User{ID: testID("admin"), UserName: "admin", IsAdmin: true} + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.QuickConnect = true + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + for _, u := range []model.User{alice, bob, admin} { + Expect(ur.Put(&u)).To(Succeed()) + } + qc = quickconnect.New() + api = &Router{ds: ds, quickConnect: qc} + }) + + as := func(r *http.Request, u model.User) *http.Request { + return r.WithContext(request.WithUser(r.Context(), u)) + } + initiate := func() quickconnect.Request { + req, err := qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) + Expect(err).ToNot(HaveOccurred()) + return req + } + + Describe("GET /QuickConnect/Enabled", func() { + DescribeTable("reports the config value", + func(enabled bool, expected string) { + conf.Server.Jellyfin.QuickConnect = enabled + w := httptest.NewRecorder() + api.quickConnectEnabled(w, httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(MatchJSON(expected)) + }, + Entry("enabled", true, "true"), + Entry("disabled", false, "false"), + ) + }) + + Describe("requireQuickConnect", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTeapot) }) + + It("rejects requests with 401 when Quick Connect is disabled", func() { + conf.Server.Jellyfin.QuickConnect = false + w := httptest.NewRecorder() + requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("passes requests through when Quick Connect is enabled", func() { + w := httptest.NewRecorder() + requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) + Expect(w.Code).To(Equal(http.StatusTeapot)) + }) + }) + + Describe("POST /QuickConnect/Initiate", func() { + initiateWith := func(authHeader string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/QuickConnect/Initiate", nil) + r.Header.Set("Authorization", authHeader) + api.quickConnectInitiate(w, r) + return w + } + + It("returns all QuickConnectResult fields, with the device from the auth header", func() { + w := initiateWith(finamp) + + Expect(w.Code).To(Equal(http.StatusOK)) + var raw map[string]any + Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) + // sdk-kotlin declares every field non-null. + Expect(raw).To(HaveKeyWithValue("Authenticated", false)) + Expect(raw).To(HaveKeyWithValue("Secret", MatchRegexp(`^[0-9a-f]{64}$`))) + Expect(raw).To(HaveKeyWithValue("Code", MatchRegexp(`^\d{6}$`))) + Expect(raw).To(HaveKeyWithValue("DeviceId", "dev-1")) + Expect(raw).To(HaveKeyWithValue("DeviceName", "Pixel 7")) + Expect(raw).To(HaveKeyWithValue("AppName", "Finamp")) + Expect(raw).To(HaveKeyWithValue("AppVersion", "1.0.0")) + Expect(raw).To(HaveKeyWithValue("DateAdded", Not(BeEmpty()))) + + _, err := qc.Status(raw["Secret"].(string)) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns 400 when the auth header does not identify the client", func() { + w := initiateWith(`MediaBrowser Client="Finamp", Device="Pixel 7", Version="1.0.0"`) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when a client field is oversized", func() { + long := strings.Repeat("x", maxQuickConnectField+1) + api.quickConnect = fullQuickConnect{qc} + w := initiateWith(`MediaBrowser Client="Finamp", Device="` + long + `", DeviceId="dev-1", Version="1.0.0"`) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 429 when too many requests are pending", func() { + api.quickConnect = fullQuickConnect{qc} + Expect(initiateWith(finamp).Code).To(Equal(http.StatusTooManyRequests)) + }) + }) + + Describe("GET /QuickConnect/Connect", func() { + connect := func(secret string) (int, dto.QuickConnectResult) { + w := httptest.NewRecorder() + invoke(api.quickConnectConnect, w, httptest.NewRequest("GET", "/QuickConnect/Connect?Secret="+secret, nil)) + var res dto.QuickConnectResult + if w.Code == http.StatusOK { + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + } + return w.Code, res + } + + It("reports whether the request has been authorized", func() { + req := initiate() + code, res := connect(req.Secret) + Expect(code).To(Equal(http.StatusOK)) + Expect(res.Authenticated).To(BeFalse()) + Expect(res.Code).To(Equal(req.Code)) + + _, err := qc.Authorize(req.Code, alice.ID) + Expect(err).ToNot(HaveOccurred()) + code, res = connect(req.Secret) + Expect(code).To(Equal(http.StatusOK)) + Expect(res.Authenticated).To(BeTrue()) + }) + + It("returns 404 for an unknown secret", func() { + code, _ := connect("unknown") + Expect(code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("POST /QuickConnect/Authorize", func() { + authorize := func(query string, u model.User) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + invoke(api.quickConnectAuthorize, w, as(httptest.NewRequest("POST", "/QuickConnect/Authorize?"+query, nil), u)) + return w + } + approver := func(req quickconnect.Request) string { + got, err := qc.Status(req.Secret) + Expect(err).ToNot(HaveOccurred()) + return got.UserID + } + + It("approves the code for the caller when no userId is given", func() { + req := initiate() + w := authorize("code="+req.Code, alice) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(MatchJSON("true")) + Expect(approver(req)).To(Equal(alice.ID)) + }) + + It("accepts the caller's own userId", func() { + req := initiate() + w := authorize("Code="+req.Code+"&UserId="+dto.EncodeID(alice.ID), alice) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(approver(req)).To(Equal(alice.ID)) + }) + + It("forbids a non-admin from approving for another user", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), alice) + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(approver(req)).To(BeEmpty()) + }) + + It("lets an admin approve for another user", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), admin) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(approver(req)).To(Equal(bob.ID)) + }) + + It("returns 404 when an admin approves for an unknown user", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId="+dto.EncodeID(testID("ghost")), admin) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(approver(req)).To(BeEmpty()) + }) + + It("returns 400 for a malformed userId", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId=not-a-guid", admin) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 for an unknown code", func() { + w := authorize("code=000000", alice) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 409 for a code that is already approved", func() { + req := initiate() + Expect(authorize("code="+req.Code, alice).Code).To(Equal(http.StatusOK)) + Expect(authorize("code="+req.Code, bob).Code).To(Equal(http.StatusConflict)) + Expect(approver(req)).To(Equal(alice.ID)) + }) + }) + + Describe("POST /Users/AuthenticateWithQuickConnect", func() { + redeem := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateWithQuickConnect", strings.NewReader(body)) + r.Header.Set("Authorization", finamp) + api.authenticateWithQuickConnect(w, r) + return w + } + redeemSecret := func(secret string) *httptest.ResponseRecorder { + return redeem(`{"Secret":"` + secret + `"}`) + } + + It("signs in the approving user once", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, alice.ID) + + w := redeemSecret(req.Secret) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.User.Name).To(Equal("alice")) + Expect(res.ServerId).ToNot(BeEmpty()) + Expect(res.SessionInfo).ToNot(BeNil()) + Expect(res.SessionInfo.DeviceId).To(Equal("dev-1")) + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Subject).To(Equal("alice")) + + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) + }) + + It("accepts a camelCase body", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, alice.ID) + Expect(redeem(`{"secret":"` + req.Secret + `"}`).Code).To(Equal(http.StatusOK)) + }) + + It("returns 404 while the request is not approved", func() { + req := initiate() + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) + }) + + DescribeTable("returns 400 for a bad body", + func(body string) { + Expect(redeem(body).Code).To(Equal(http.StatusBadRequest)) + }, + Entry("not JSON", `nope`), + Entry("no secret", `{}`), + ) + + It("returns 401 when the approving user no longer exists", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, testID("ghost")) + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("returns 500 when the user lookup fails", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, alice.ID) + ds.User(context.Background()).(*tests.MockedUserRepo).Error = errors.New("db down") + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusInternalServerError)) + }) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index 62b52cfff..70da5b1eb 100644 --- a/server/jellyfin/routing_test.go +++ b/server/jellyfin/routing_test.go @@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() { var api *Router BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 401c791fd..b4be0e244 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() { Expect(err).ToNot(HaveOccurred()) token = t - api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 35ade5ff1..49e41470c 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -153,7 +153,3 @@ func parseIP(addr string) netip.Addr { } return ip.Unmap() } - -func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { - api.ok(w, r, false) -} diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index c9368f3b5..2350a7342 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -145,17 +145,6 @@ var _ = Describe("System", func() { Expect(info.IsInNetwork).To(BeTrue()) }) - It("reports quick connect as disabled", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil) - api.quickConnectEnabled(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var enabled bool - Expect(json.Unmarshal(w.Body.Bytes(), &enabled)).To(Succeed()) - Expect(enabled).To(BeFalse()) - }) - Context("serverID with a real DataStore", func() { var ctx context.Context var ds *tests.MockDataStore diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index d1007f457..7c4f00fdd 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -29,7 +29,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 13b33c238..cef2e06ad 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -31,7 +31,7 @@ var _ = Describe("Library API", func() { conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go index ebc9aeb28..ae6e301a8 100644 --- a/server/nativeapi/metadata_test.go +++ b/server/nativeapi/metadata_test.go @@ -66,7 +66,7 @@ var _ = Describe("Metadata API", func() { } auth.Init(ds) provider = &fakeProvider{} - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil) router = server.JWTVerifier(nativeRouter) adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} diff --git a/server/nativeapi/missing_test.go b/server/nativeapi/missing_test.go index 4da550c0d..9d7575a0c 100644 --- a/server/nativeapi/missing_test.go +++ b/server/nativeapi/missing_test.go @@ -40,7 +40,7 @@ var _ = Describe("Missing Files Endpoint", func() { token, err = auth.CreateToken(&user) Expect(err).ToNot(HaveOccurred()) - router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)) + router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)) }) DescribeTable("GET /missing/{id}", diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 57a712a20..f931834c6 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/metrics" playlistsvc "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -48,10 +49,11 @@ type Router struct { pluginManager PluginManager imgUpload artwork.Uploader provider external.Provider + quickConnect quickconnect.QuickConnect } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, quickConnect quickconnect.QuickConnect) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, quickConnect: quickConnect} r.Handler = r.routes() return r } @@ -88,6 +90,7 @@ func (api *Router) routes() http.Handler { api.addMissingFilesRoute(r) api.addKeepAliveRoute(r) api.addInsightsRoute(r) + api.addQuickConnectRoute(r) r.With(adminOnlyMiddleware).Group(func(r chi.Router) { api.addInspectRoute(r) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index 203fcd4cf..954c872a7 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 4a6f5d03d..2f6c578f9 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 1683885e7..4e45ddb92 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/quickconnect.go b/server/nativeapi/quickconnect.go new file mode 100644 index 000000000..61d402cc5 --- /dev/null +++ b/server/nativeapi/quickconnect.go @@ -0,0 +1,76 @@ +package nativeapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" +) + +type quickConnectDevice struct { + AppName string `json:"appName"` + AppVersion string `json:"appVersion"` + DeviceName string `json:"deviceName"` +} + +func (api *Router) addQuickConnectRoute(r chi.Router) { + if !quickconnect.Enabled() { + return + } + r.Route("/quickconnect", func(r chi.Router) { + // Throttled like login so a signed-in user cannot enumerate other people's pending codes. + if conf.Server.AuthRequestLimit > 0 { + r.Use(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) + } + r.Get("/", lookupQuickConnect(api.quickConnect)) + r.Post("/authorize", authorizeQuickConnect(api.quickConnect)) + }) +} + +func lookupQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := qc.Lookup(r.URL.Query().Get("code")) + writeQuickConnectResult(w, r, req, err) + } +} + +func authorizeQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + ctx := r.Context() + user, _ := request.UserFrom(ctx) + req, err := qc.Authorize(body.Code, user.ID) + if err == nil { + log.Info(ctx, "Quick Connect sign-in approved", "username", user.UserName, "client", req.Device.App, "device", req.Device.Name) + } + writeQuickConnectResult(w, r, req, err) + } +} + +func writeQuickConnectResult(w http.ResponseWriter, r *http.Request, req quickconnect.Request, err error) { + switch { + case errors.Is(err, model.ErrNotFound): + http.Error(w, "Unknown code", http.StatusNotFound) + case errors.Is(err, quickconnect.ErrAlreadyAuthorized): + http.Error(w, "Code already used", http.StatusConflict) + case err != nil: + log.Error(r.Context(), "Quick Connect failed", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + default: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(quickConnectDevice{AppName: req.Device.App, AppVersion: req.Device.AppVersion, DeviceName: req.Device.Name}) + } +} diff --git a/server/nativeapi/quickconnect_test.go b/server/nativeapi/quickconnect_test.go new file mode 100644 index 000000000..f2f06f0f3 --- /dev/null +++ b/server/nativeapi/quickconnect_test.go @@ -0,0 +1,148 @@ +package nativeapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Quick Connect endpoints", func() { + var ( + qc quickconnect.QuickConnect + pending quickconnect.Request + user = model.User{ID: "u1", UserName: "alice"} + ) + + BeforeEach(func() { + qc = quickconnect.New() + var err error + pending, err = qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) + Expect(err).ToNot(HaveOccurred()) + }) + + asUser := func(r *http.Request) *http.Request { + return r.WithContext(request.WithUser(r.Context(), user)) + } + decode := func(w *httptest.ResponseRecorder) quickConnectDevice { + var res quickConnectDevice + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + return res + } + finamp := quickConnectDevice{AppName: "Finamp", AppVersion: "1.0.0", DeviceName: "Pixel 7"} + + Describe("GET /quickconnect", func() { + lookup := func(code string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + lookupQuickConnect(qc)(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+code, nil))) + return w + } + + It("describes the device waiting for the code", func() { + w := lookup(pending.Code) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decode(w)).To(Equal(finamp)) + }) + + It("does not approve the code", func() { + lookup(pending.Code) + got, _ := qc.Status(pending.Secret) + Expect(got.Authorized()).To(BeFalse()) + }) + + It("returns 404 for an unknown code", func() { + Expect(lookup("000000").Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 409 for a code that is already approved", func() { + _, _ = qc.Authorize(pending.Code, "someone") + Expect(lookup(pending.Code).Code).To(Equal(http.StatusConflict)) + }) + }) + + Describe("POST /quickconnect/authorize", func() { + authorize := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + authorizeQuickConnect(qc)(w, asUser(httptest.NewRequest("POST", "/quickconnect/authorize", strings.NewReader(body)))) + return w + } + + It("approves the code for the signed-in user", func() { + w := authorize(`{"code":"` + pending.Code + `"}`) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decode(w)).To(Equal(finamp)) + got, _ := qc.Status(pending.Secret) + Expect(got.UserID).To(Equal(user.ID)) + }) + + It("returns 404 for an unknown code", func() { + Expect(authorize(`{"code":"000000"}`).Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 409 for a code that is already approved", func() { + _, _ = qc.Authorize(pending.Code, "someone") + Expect(authorize(`{"code":"` + pending.Code + `"}`).Code).To(Equal(http.StatusConflict)) + }) + + It("returns 400 for a bad body", func() { + Expect(authorize(`nope`).Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("route registration", func() { + serve := func() int { + r := chi.NewRouter() + (&Router{quickConnect: qc}).addQuickConnectRoute(r) + w := httptest.NewRecorder() + r.ServeHTTP(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+pending.Code, nil))) + return w.Code + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.Enabled = true + conf.Server.Jellyfin.QuickConnect = true + }) + + It("mounts the routes when Jellyfin Quick Connect is on", func() { + Expect(serve()).To(Equal(http.StatusOK)) + }) + + It("rate-limits code attempts when a login limit is configured", func() { + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + r := chi.NewRouter() + (&Router{quickConnect: qc}).addQuickConnectRoute(r) + attempt := func() int { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/quickconnect?code=000000", nil) + req.RemoteAddr = "10.0.0.1:1234" + r.ServeHTTP(w, asUser(req)) + return w.Code + } + Expect(attempt()).To(Equal(http.StatusNotFound)) + Expect(attempt()).To(Equal(http.StatusNotFound)) + Expect(attempt()).To(Equal(http.StatusTooManyRequests)) + }) + + It("skips the routes when the Jellyfin API is off", func() { + conf.Server.Jellyfin.Enabled = false + Expect(serve()).To(Equal(http.StatusNotFound)) + }) + + It("skips the routes when Quick Connect is off", func() { + conf.Server.Jellyfin.QuickConnect = false + Expect(serve()).To(Equal(http.StatusNotFound)) + }) + }) +}) diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go index 2a363980f..27454fc7d 100644 --- a/server/nativeapi/user_password_token_refresh_test.go +++ b/server/nativeapi/user_password_token_refresh_test.go @@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func() auth.Init(ds) userService := core.NewUser(ds, noopPluginUnloader{}) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) }) diff --git a/server/serve_index.go b/server/serve_index.go index a538daf1a..651c8c907 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/mime" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -75,6 +76,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "listenBrainzEnabled": conf.Server.ListenBrainz.Enabled, "enableExternalServices": conf.Server.EnableExternalServices, "enableReplayGain": conf.Server.EnableReplayGain, + "enableQuickConnect": quickconnect.Enabled(), "defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat, "separator": string(os.PathSeparator), "enableInspect": conf.Server.Inspect.Enabled, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 78f3873b8..23215a5ef 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -97,6 +97,8 @@ var _ = Describe("serveIndex", func() { Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true), Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true), Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true), + Entry("enableQuickConnect", func() { conf.Server.Jellyfin.Enabled = true; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", true), + Entry("enableQuickConnect without the Jellyfin API", func() { conf.Server.Jellyfin.Enabled = false; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", false), Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true), Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true), Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"), diff --git a/ui/src/config.js b/ui/src/config.js index 39f0cd467..e406e47cf 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -38,6 +38,7 @@ const defaultConfig = { devUIShowConfig: true, devNewEventStream: false, enableReplayGain: true, + enableQuickConnect: false, defaultDownsamplingFormat: 'opus', publicBaseUrl: '/share', separator: '/', diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index e79beb787..f0e44ce1d 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -218,6 +218,15 @@ const wrapperDataProvider = { ({ json }) => ({ data: json }), ) }, + lookupQuickConnect: (code) => + httpClient( + `${REST_URL}/quickconnect?code=${encodeURIComponent(code)}`, + ).then(({ json }) => ({ data: json })), + authorizeQuickConnect: (code) => + httpClient(`${REST_URL}/quickconnect/authorize`, { + method: 'POST', + body: JSON.stringify({ code }), + }).then(({ json }) => ({ data: json })), inspect: (songId) => { return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({ data: json, diff --git a/ui/src/dialogs/QuickConnectDialog.jsx b/ui/src/dialogs/QuickConnectDialog.jsx new file mode 100644 index 000000000..cde7e9eaf --- /dev/null +++ b/ui/src/dialogs/QuickConnectDialog.jsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import PropTypes from 'prop-types' +import { useDataProvider, useNotify, useTranslate } from 'react-admin' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, +} from '@material-ui/core' + +export const QuickConnectDialog = ({ open, onClose }) => { + const translate = useTranslate() + const notify = useNotify() + const dataProvider = useDataProvider() + const [code, setCode] = useState('') + const [pending, setPending] = useState(null) + const [loading, setLoading] = useState(false) + + const handleClose = () => { + setCode('') + setPending(null) + onClose() + } + + const handleError = (error) => { + setPending(null) + const invalid = error?.status === 404 || error?.status === 409 + notify( + invalid ? 'message.quickConnectInvalidCode' : 'message.quickConnectError', + 'warning', + ) + } + + const run = (request, onSuccess) => { + setLoading(true) + request + .then(({ data }) => onSuccess(data)) + .catch(handleError) + .finally(() => setLoading(false)) + } + + const lookup = (event) => { + event.preventDefault() + run(dataProvider.lookupQuickConnect(code), setPending) + } + + const approve = () => + run(dataProvider.authorizeQuickConnect(code), (data) => { + notify('message.quickConnectApproved', 'success', { + app: data.appName, + device: data.deviceName, + }) + handleClose() + }) + + return ( + + + {translate('menu.quickConnect.name')} + + {pending ? ( + <> + + + {translate('menu.quickConnect.confirm', { + app: pending.appName, + version: pending.appVersion, + device: pending.deviceName, + })} + + + + + + + + ) : ( +
+ + + {translate('menu.quickConnect.help')} + + setCode(event.target.value)} + inputProps={{ inputMode: 'numeric', autoComplete: 'off' }} + /> + + + + + +
+ )} +
+ ) +} + +QuickConnectDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +} diff --git a/ui/src/dialogs/QuickConnectDialog.test.jsx b/ui/src/dialogs/QuickConnectDialog.test.jsx new file mode 100644 index 000000000..5dda1cbdf --- /dev/null +++ b/ui/src/dialogs/QuickConnectDialog.test.jsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { DataProviderContext } from 'react-admin' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { describe, afterEach, it, expect, vi } from 'vitest' +import { QuickConnectDialog } from './QuickConnectDialog' + +const finamp = { appName: 'Finamp', appVersion: '1.0.0', deviceName: 'Pixel 7' } + +const renderDialog = (dataProvider, onClose = vi.fn()) => { + render( + + + + + , + ) + return onClose +} + +const enterCode = (code) => { + fireEvent.change(screen.getByRole('textbox'), { target: { value: code } }) + fireEvent.click(screen.getByText('menu.quickConnect.continue')) +} + +describe('QuickConnectDialog', () => { + afterEach(cleanup) + + it('shows the device before approving the code', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + authorizeQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + } + const onClose = renderDialog(dataProvider) + + enterCode('123 456') + await screen.findByText('menu.quickConnect.approve') + expect(dataProvider.lookupQuickConnect.mock.calls[0][0]).toBe('123 456') + expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('menu.quickConnect.approve')) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(dataProvider.authorizeQuickConnect.mock.calls[0][0]).toBe('123 456') + }) + + it('goes back to the code input', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + authorizeQuickConnect: vi.fn(), + } + renderDialog(dataProvider) + + enterCode('123456') + await screen.findByText('menu.quickConnect.approve') + fireEvent.click(screen.getByText('ra.action.back')) + + expect(screen.getByRole('textbox')).toHaveValue('123456') + expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() + }) + + it('stays on the code input when the code is unknown', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockRejectedValue({ status: 404 }), + authorizeQuickConnect: vi.fn(), + } + const onClose = renderDialog(dataProvider) + + enterCode('000000') + await waitFor(() => + expect(dataProvider.lookupQuickConnect).toHaveBeenCalled(), + ) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.queryByText('menu.quickConnect.approve')).toBeNull() + expect(onClose).not.toHaveBeenCalled() + }) + + it('returns to the code input when approval fails', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + authorizeQuickConnect: vi.fn().mockRejectedValue({ status: 409 }), + } + const onClose = renderDialog(dataProvider) + + enterCode('123456') + fireEvent.click(await screen.findByText('menu.quickConnect.approve')) + + await screen.findByRole('textbox') + expect(onClose).not.toHaveBeenCalled() + }) + + it('disables Continue until a code is typed', () => { + renderDialog({ lookupQuickConnect: vi.fn() }) + expect( + screen.getByText('menu.quickConnect.continue').closest('button'), + ).toBeDisabled() + }) +}) diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js index 86586aef0..3234e4685 100644 --- a/ui/src/dialogs/index.js +++ b/ui/src/dialogs/index.js @@ -2,4 +2,5 @@ export * from './AboutDialog' export * from './SelectPlaylistInput' export * from './ListenBrainzTokenDialog' export * from './SaveQueueDialog' +export * from './QuickConnectDialog' export * from './Dialogs' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index fd200fa60..f5af05b7b 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -591,6 +591,9 @@ "remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.", "notifications_blocked": "You have blocked Notifications for this site in your browser's settings", "notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https", + "quickConnectApproved": "%{app} on %{device} is now signed in", + "quickConnectInvalidCode": "Invalid or expired code", + "quickConnectError": "Could not approve the code", "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", "lastfmLinkFailure": "Last.fm could not be linked", "lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled", @@ -622,6 +625,14 @@ "none": "None" }, "settings": "Settings", + "quickConnect": { + "name": "Quick Connect", + "code": "Code", + "help": "Enter the code shown by a Jellyfin app to sign it in to your account", + "confirm": "Sign in %{app} %{version} on %{device} to your account?", + "continue": "Continue", + "approve": "Approve" + }, "version": "Version", "theme": "Theme", "personal": { diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index 561701dce..7de111e67 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -6,12 +6,17 @@ import { usePermissions, getResources, } from 'react-admin' -import { MdInfo, MdPerson, MdSupervisorAccount } from 'react-icons/md' +import { + MdInfo, + MdPerson, + MdPhonelink, + MdSupervisorAccount, +} from 'react-icons/md' import { useSelector } from 'react-redux' import { makeStyles, MenuItem, ListItemIcon, Divider } from '@material-ui/core' import ViewListIcon from '@material-ui/icons/ViewList' import { Dialogs } from '../dialogs/Dialogs' -import { AboutDialog } from '../dialogs' +import { AboutDialog, QuickConnectDialog } from '../dialogs' import PersonalMenu from './PersonalMenu' import ActivityPanel from './ActivityPanel' import NowPlayingPanel from './NowPlayingPanel' @@ -33,33 +38,34 @@ const useStyles = makeStyles( }, ) -const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => { - const classes = useStyles(rest) - const translate = useTranslate() - const [open, setOpen] = React.useState(false) +const DialogMenuItem = forwardRef( + ({ onClick, label, icon, dialog, ...rest }, ref) => { + const classes = useStyles(rest) + const [open, setOpen] = React.useState(false) - const handleOpen = () => { - setOpen(true) - } - const handleClose = () => { - onClick && onClick() - setOpen(false) - } - const label = translate('menu.about') - return ( - <> - - - - - {label} - - - - ) -}) + const handleClose = () => { + onClick && onClick() + setOpen(false) + } + return ( + <> + setOpen(true)} + className={classes.root} + > + + {createElement(icon, { title: label, size: 24 })} + + {label} + + {createElement(dialog, { onClose: handleClose, open })} + + ) + }, +) -AboutMenuItem.displayName = 'AboutMenuItem' +DialogMenuItem.displayName = 'DialogMenuItem' const settingsResources = (resource) => resource.name !== 'user' && @@ -126,13 +132,24 @@ const CustomUserMenu = ({ onClick, ...rest }) => { {config.devActivityPanel && permissions === 'admin' && } + {config.enableQuickConnect && ( + + )} {renderUserMenuItemLink()} {resources .filter(settingsResources) .map((r) => renderSettingsMenuItemLink(r))} - + diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx index f39dd75cb..7c8cd3dcd 100644 --- a/ui/src/layout/AppBar.test.jsx +++ b/ui/src/layout/AppBar.test.jsx @@ -33,12 +33,14 @@ vi.mock('../dialogs/Dialogs', () => ({ })) vi.mock('../dialogs', () => ({ AboutDialog: () =>
, + QuickConnectDialog: () =>
, })) describe('', () => { beforeEach(() => { config.devActivityPanel = true config.enableNowPlaying = true + config.enableQuickConnect = false store = createStore(combineReducers({ activity: activityReducer }), { activity: { nowPlayingCount: 0 }, }) @@ -62,4 +64,24 @@ describe('', () => { ) expect(screen.queryByTestId('now-playing-panel')).toBeNull() }) + + it('shows the Quick Connect menu item when enabled', () => { + config.enableQuickConnect = true + render( + + + , + ) + expect(screen.queryAllByText('menu.quickConnect.name')).not.toHaveLength(0) + }) + + it('hides the Quick Connect menu item when disabled', () => { + render( + + + , + ) + expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0) + expect(screen.queryAllByText('menu.about')).not.toHaveLength(0) + }) }) From fb45ad7b9c705c52b7a8df6051252e5a9010e16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 19 Sep 2026 17:08:49 -0400 Subject: [PATCH 05/22] fix(auth): ExtAuth logout redirect on unauthenticated loads, and warning spam from untrusted sources (#6176) * fix(ui): only redirect to ExtAuth logout URL for proxy-authenticated sessions react-admin calls authProvider.logout() when the boot-time checkAuth fails and after a 401, not only when the user clicks Logout. With ExtAuth.LogoutURL set, every unauthenticated page load (e.g. direct LAN access that bypasses the auth proxy) was sent to the IdP sign-out page and the login form was never shown. Redirect only when the page was authenticated by the reverse proxy (config.auth is present). Other sessions fall back to the login form. Fixes #6175 Signed-off-by: Deluan * fix(server): only warn about untrusted ExtAuth sources when the header is sent UsernameFromExtAuthHeader checked the source IP before looking for the user header, so every request from an IP outside ExtAuth.TrustedSources logged a warning, even when it carried no header at all. With direct LAN access alongside a forward-auth proxy, a single polling client produced a constant stream of warnings (twice per Subsonic request, since the middleware chain resolves the username in both checkRequiredParameters and authenticate). Look for the header first and warn only when an untrusted source actually sends it, which is the case worth seeing: a misconfigured proxy or a spoof attempt. Signed-off-by: Deluan --------- Signed-off-by: Deluan --- server/auth.go | 8 +++--- server/auth_test.go | 53 +++++++++++++++++++++++++++++++++++++ ui/src/authProvider.js | 3 ++- ui/src/authProvider.test.js | 47 ++++++++++++++++++++++++++++++++ ui/src/setupTests.js | 3 +++ 5 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 ui/src/authProvider.test.js diff --git a/server/auth.go b/server/auth.go index fdeb8c455..d9ade7b29 100644 --- a/server/auth.go +++ b/server/auth.go @@ -218,14 +218,14 @@ func UsernameFromExtAuthHeader(r *http.Request) string { log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.") return "" } - if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) { - log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) - return "" - } username := r.Header.Get(conf.Server.ExtAuth.UserHeader) if username == "" { return "" } + if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) { + log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) + return "" + } log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username) return username } diff --git a/server/auth_test.go b/server/auth_test.go index af9ffcaeb..a4d592c51 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -16,12 +16,15 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" ) var _ = Describe("Auth", func() { @@ -234,6 +237,56 @@ var _ = Describe("Auth", func() { }) }) + Describe("UsernameFromExtAuthHeader", func() { + var hook *test.Hook + var r *http.Request + + BeforeEach(func() { + conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16" + prevLevel := log.CurrentLevel() + l, h := test.NewNullLogger() + hook = h + prevLogger := log.SetDefaultLogger(l) + log.SetLevel(log.LevelWarn) + DeferCleanup(func() { + log.SetDefaultLogger(prevLogger) + log.SetLevel(prevLevel) + }) + r = httptest.NewRequest("GET", "/", nil) + }) + + warnings := func() []*logrus.Entry { + var ws []*logrus.Entry + for _, e := range hook.AllEntries() { + if e.Level == logrus.WarnLevel { + ws = append(ws, e) + } + } + return ws + } + + It("returns the username from a trusted source", func() { + r.Header.Set("Remote-User", "janedoe") + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.0.42")) + Expect(UsernameFromExtAuthHeader(r)).To(Equal("janedoe")) + Expect(warnings()).To(BeEmpty()) + }) + + It("does not warn when an untrusted source sends no user header", func() { + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) + Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) + Expect(warnings()).To(BeEmpty()) + }) + + It("warns when an untrusted source sends the user header", func() { + r.Header.Set("Remote-User", "janedoe") + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) + Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) + Expect(warnings()).To(HaveLen(1)) + Expect(warnings()[0].Message).To(Equal("IP is not whitelisted for external authentication")) + }) + }) + Describe("tokenFromHeader", func() { It("returns the token when the Authorization header is set correctly", func() { req := httptest.NewRequest("GET", "/", nil) diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 18badbc9c..f93114ded 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -64,7 +64,8 @@ const authProvider = { logout: () => { removeItems() - if (config.extAuthLogoutURL) { + // Only proxy-authenticated sessions go to the IdP; others (e.g. direct LAN access) get the login form + if (config.extAuthLogoutURL && config.auth) { window.location.href = config.extAuthLogoutURL return Promise.resolve(false) } diff --git a/ui/src/authProvider.test.js b/ui/src/authProvider.test.js new file mode 100644 index 000000000..b16a39815 --- /dev/null +++ b/ui/src/authProvider.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import config from './config' +import authProvider from './authProvider' + +vi.mock('./config', () => ({ default: {} })) + +describe('authProvider.logout', () => { + const logoutURL = 'https://auth.example.com/signout' + + beforeEach(() => { + vi.stubGlobal('location', { href: '' }) + localStorage.setItem('is-authenticated', 'true') + localStorage.setItem('token', 'abc') + }) + + afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() + delete config.extAuthLogoutURL + delete config.auth + }) + + it('clears the stored session', async () => { + await authProvider.logout() + expect(localStorage.getItem('is-authenticated')).toBeNull() + expect(localStorage.getItem('token')).toBeNull() + }) + + it('does not redirect when no logout URL is configured', async () => { + config.auth = { id: '1' } + await expect(authProvider.logout()).resolves.toBeUndefined() + expect(window.location.href).toBe('') + }) + + it('redirects to the logout URL when the page was authenticated by the proxy', async () => { + config.extAuthLogoutURL = logoutURL + config.auth = { id: '1' } + await expect(authProvider.logout()).resolves.toBe(false) + expect(window.location.href).toBe(logoutURL) + }) + + it('does not redirect when the page was not authenticated by the proxy', async () => { + config.extAuthLogoutURL = logoutURL + await expect(authProvider.logout()).resolves.toBeUndefined() + expect(window.location.href).toBe('') + }) +}) diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js index ddb999f3c..7cb46e09c 100644 --- a/ui/src/setupTests.js +++ b/ui/src/setupTests.js @@ -14,6 +14,9 @@ const localStorageMock = (function () { setItem: function (key, value) { store[key] = value.toString() }, + removeItem: function (key) { + delete store[key] + }, clear: function () { store = {} }, From 672c0af580b182ffb219dc5f64c62b456163fd38 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 19 Sep 2026 17:10:49 -0400 Subject: [PATCH 06/22] docs(README): update Docker networking instructions for UDP discovery --- server/jellyfin/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index c0c5e6ca1..e38997569 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -67,9 +67,10 @@ Discovery answers on all IPv4 interfaces, but the advertised address follows `Ba `Port`. If `Address` is a loopback or a single interface IP and `BaseURL` has no host, clients on other networks get an address they cannot reach. Set `BaseURL` to the address clients should use. -Docker: publish the port (`-p 7359:7359/udp`). In bridge mode the server only sees its container -IP, so also set `ND_BASEURL` to the LAN address (for example `http://192.168.1.10:4533`), or use -host networking. Keep UDP 7359 on the LAN: never forward it from the internet. +Docker: use host networking (`network_mode: host` / `--network host`). On Linux, bridge mode does not +deliver broadcasts to the container, even with `-p 7359:7359/udp`, so clients never find the server. +With host networking the server sees the host's IP, so `BaseURL` is not needed for discovery. If host +networking is not an option, leave discovery off. Keep UDP 7359 on the LAN: never forward it from the internet. ## Authentication From 49f626c00a0ef3449530cd37d3121734982d86f9 Mon Sep 17 00:00:00 2001 From: David Vedvick Date: Sat, 19 Sep 2026 20:05:58 -0500 Subject: [PATCH 07/22] feat(smartplaylists): add support for referencing playlists using paths (#5187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add support for referencing playlists using paths Signed-off-by: David * feat: Support relative playlist paths in smartlists Signed-off-by: David * fix(smartplaylists): protect against nil panic Signed-off-by: David * fix(smartplaylists): refreshing child playlists Signed-off-by: David * chore(smartplaylists): log field parsing error Signed-off-by: David * fix(smartplaylists): handle empty playlist paths Signed-off-by: David * refactor(smartplaylists): make NormalizeChildPaths non-mutating Signed-off-by: David * fix(smartplaylists): stop warning on every inPlaylist rule without the looked-up field Rules that reference a playlist by id have no path field, and the reverse, so the warning fired on every refresh. The log call also had a bad argument count. * fix(smartplaylists): ignore empty inPlaylist id and path references An empty path matched every playlist without a file path, including the referencing playlist itself, so the refresh recursed until the stack overflowed. An empty id also shadowed a valid path in the same rule. * fix(smartplaylists): match inPlaylist paths in both NFC and NFD forms A playlist path is stored in the Unicode form the filesystem reports, which can differ from the form typed in the .nsp file. The exact comparison then found no playlist for names with accents. * fix(smartplaylists): keep all criteria fields when normalizing child paths The field-by-field copy dropped RefreshDelay. * fix(smartplaylists): clean absolute inPlaylist path references Only relative references were cleaned, so an absolute reference such as /music/./child.nsp never matched the stored /music/child.nsp. * fix(smartplaylists): stop infinite recursion on playlists that reference each other Two smart playlists referencing each other, by id or by path, recursed until the stack overflowed and the server died. The refresh now tracks visited playlists. * fix(smartplaylists): resolve inPlaylist path references with OS-native separators Playlist.Path is OS-native, but references in a .nsp file use forward slashes. On Windows they never matched, and a leading slash was not seen as absolute. The specs now build OS-native paths, so they also run on Windows. * fix(smartplaylists): warn when a relative inPlaylist path cannot be resolved A playlist created in the UI has no file path, so a relative reference silently matched nothing. * refactor(smartplaylists): simplify child playlist reference handling Share one extractor for child ids and paths, return only the normalized rules instead of a playlist copy, and resolve each path reference in a single switch. * test(smartplaylists): store the Unicode child path in OS-native form Playlist.Path is OS-native, so on Windows the forward-slash fixture never matched the normalized reference. --------- Signed-off-by: David Co-authored-by: Deluan Quintão --- model/criteria/criteria.go | 17 ++-- model/criteria/criteria_test.go | 30 ++++-- model/criteria/operators.go | 35 +++---- model/playlist.go | 64 ++++++++++++ model/playlist_test.go | 99 +++++++++++++++++++ persistence/criteria_sql.go | 25 ++++- persistence/criteria_sql_test.go | 12 ++- persistence/smart_playlist_repository.go | 49 +++++++-- persistence/smart_playlist_repository_test.go | 90 ++++++++++++++++- 9 files changed, 370 insertions(+), 51 deletions(-) diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 5d7dc3826..e6db3b481 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -64,18 +64,21 @@ func (c Criteria) IsPercentageLimit() bool { } func (c Criteria) ChildPlaylistIds() []string { - if c.Expression == nil { - return nil - } + return c.childPlaylistRefs(conjunction.ChildPlaylistIds) +} +func (c Criteria) ChildPlaylistPaths() []string { + return c.childPlaylistRefs(conjunction.ChildPlaylistPaths) +} + +func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string { parent, ok := c.Expression.(conjunction) if !ok { return nil } - - ids := parent.ChildPlaylistIds() - slices.Sort(ids) - return slices.Compact(ids) + refs := extract(parent) + slices.Sort(refs) + return slices.Compact(refs) } func (c Criteria) MarshalJSON() ([]byte, error) { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 5e653150a..de5124568 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -323,19 +323,23 @@ var _ = Describe("Criteria", func() { Context("with child playlists", func() { var ( - topLevelInPlaylistID string - topLevelNotInPlaylistID string - nestedAnyInPlaylistID string - nestedAnyNotInPlaylistID string - nestedAllInPlaylistID string - nestedAllNotInPlaylistID string + topLevelInPlaylistID string + topLevelInPlaylistPath string + topLevelNotInPlaylistID string + nestedAnyInPlaylistID string + nestedAnyNotInPlaylistID string + nestedAllInPlaylistID string + nestedAllNotInPlaylistID string + nestedAnyNotInPlaylistPath string ) BeforeEach(func() { topLevelInPlaylistID = uuid.NewString() + topLevelInPlaylistPath = "./test.nsp" topLevelNotInPlaylistID = uuid.NewString() nestedAnyInPlaylistID = uuid.NewString() nestedAnyNotInPlaylistID = uuid.NewString() + nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u" nestedAllInPlaylistID = uuid.NewString() nestedAllNotInPlaylistID = uuid.NewString() @@ -343,10 +347,12 @@ var _ = Describe("Criteria", func() { goObj = Criteria{ Expression: All{ InPlaylist{"id": topLevelInPlaylistID}, + InPlaylist{"path": topLevelInPlaylistPath}, NotInPlaylist{"id": topLevelNotInPlaylistID}, Any{ InPlaylist{"id": nestedAnyInPlaylistID}, NotInPlaylist{"id": nestedAnyNotInPlaylistID}, + NotInPlaylist{"path": nestedAnyNotInPlaylistPath}, }, All{ InPlaylist{"id": nestedAllInPlaylistID}, @@ -359,6 +365,18 @@ var _ = Describe("Criteria", func() { ids := goObj.ChildPlaylistIds() gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID)) }) + It("extracts all child smart playlist paths from expression criteria", func() { + paths := goObj.ChildPlaylistPaths() + gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath)) + }) + It("ignores empty child playlist paths", func() { + c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}} + gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty()) + }) + It("ignores empty child playlist ids", func() { + c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}} + gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty()) + }) It("extracts child smart playlist IDs from deeply nested expression", func() { goObj = Criteria{ Expression: Any{ diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 14a02ff4b..ec32f2d16 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,8 +1,9 @@ package criteria -// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively +// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively type conjunction interface { ChildPlaylistIds() []string + ChildPlaylistPaths() []string } type ( @@ -16,9 +17,9 @@ func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) } -func (all All) ChildPlaylistIds() (ids []string) { - return extractPlaylistIds(all) -} +func (all All) ChildPlaylistIds() []string { return extractPlaylistField(all, "id") } + +func (all All) ChildPlaylistPaths() []string { return extractPlaylistField(all, "path") } type ( Any []Expression @@ -31,9 +32,9 @@ func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) } -func (any Any) ChildPlaylistIds() (ids []string) { - return extractPlaylistIds(any) -} +func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") } + +func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") } type Is map[string]any type Eq = Is @@ -172,28 +173,20 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) { func (ip IsPresent) fields() map[string]any { return ip } -func extractPlaylistIds(inputRule any) (ids []string) { - var id string - var ok bool - +func extractPlaylistField(inputRule any, field string) (values []string) { switch rule := inputRule.(type) { case Any: for _, rules := range rule { - ids = append(ids, extractPlaylistIds(rules)...) + values = append(values, extractPlaylistField(rules, field)...) } case All: for _, rules := range rule { - ids = append(ids, extractPlaylistIds(rules)...) + values = append(values, extractPlaylistField(rules, field)...) } - case InPlaylist: - if id, ok = rule["id"].(string); ok { - ids = append(ids, id) - } - case NotInPlaylist: - if id, ok = rule["id"].(string); ok { - ids = append(ids, id) + case InPlaylist, NotInPlaylist: + if value, ok := rule.(Expression).fields()[field].(string); ok && value != "" { + values = append(values, value) } } - return } diff --git a/model/playlist.go b/model/playlist.go index f320d845b..19fedc9fa 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -2,12 +2,16 @@ package model import ( "iter" + "maps" + "os" + "path/filepath" "slices" "strconv" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" ) @@ -137,6 +141,66 @@ func (pls Playlist) UploadedImagePath() string { return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) } +// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths. +func (pls Playlist) NormalizedRules() *criteria.Criteria { + if pls.Rules == nil || pls.Rules.Expression == nil { + return pls.Rules + } + + rules := *pls.Rules + rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path) + return &rules +} + +func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression { + switch rule := inputRule.(type) { + case criteria.Any: + anyCriteria := make(criteria.Any, len(rule)) + for i, rules := range rule { + anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) + } + return anyCriteria + case criteria.All: + allCriteria := make(criteria.All, len(rule)) + for i, rules := range rule { + allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) + } + return allCriteria + case criteria.InPlaylist: + return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) + case criteria.NotInPlaylist: + return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) + } + + return inputRule +} + +func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any { + path, ok := rule["path"].(string) + if !ok || path == "" { + return rule + } + + // References use forward slashes to stay portable, while Playlist.Path is OS-native. + path = filepath.FromSlash(path) + switch { + case isAbsPlaylistRef(path): + path = filepath.Clean(path) + case referencingPlaylistPath != "": + path = filepath.Join(filepath.Dir(referencingPlaylistPath), path) + default: + log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path) + } + normalized := maps.Clone(rule) + normalized["path"] = path + return normalized +} + +// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute. +func isAbsPlaylistRef(path string) bool { + return filepath.IsAbs(path) || os.IsPathSeparator(path[0]) +} + type Playlists []Playlist type PlaylistCursor iter.Seq2[Playlist, error] diff --git a/model/playlist_test.go b/model/playlist_test.go index d98c85716..7ffb69b63 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -1,6 +1,7 @@ package model_test import ( + "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -88,4 +89,102 @@ var _ = Describe("Playlist", func() { Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse()) }) }) + + Describe("NormalizedRules()", func() { + // absPath builds an OS-native absolute path so these specs also run on Windows. + absPath := func(parts ...string) string { + abs, err := filepath.Abs(filepath.Join(parts...)) + Expect(err).ToNot(HaveOccurred()) + return abs + } + normalize := func(pls model.Playlist) criteria.Expression { + return pls.NormalizedRules().Expression + } + + It("resolves relative references against the playlist folder", func() { + pls := model.Playlist{ + Path: absPath("test", "nested", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.InPlaylist{"path": "../up.m3u"}, + criteria.NotInPlaylist{"path": "./sibling.nsp"}, + criteria.Any{criteria.InPlaylist{"path": "sub/deep.nsp"}}, + }}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": absPath("test", "up.m3u")}, + criteria.NotInPlaylist{"path": absPath("test", "nested", "sibling.nsp")}, + criteria.Any{criteria.InPlaylist{"path": absPath("test", "nested", "sub", "deep.nsp")}}, + })) + }) + + It("cleans absolute references", func() { + dirty := absPath("music") + string(filepath.Separator) + "." + string(filepath.Separator) + "child.nsp" + pls := model.Playlist{ + Path: absPath("test", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{criteria.NotInPlaylist{"path": dirty}}}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.NotInPlaylist{"path": absPath("music", "child.nsp")}, + })) + }) + + It("treats a leading slash as absolute on every OS", func() { + pls := model.Playlist{ + Path: absPath("test", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "/other/./root.m3u"}}}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": filepath.FromSlash("/other/root.m3u")}, + })) + }) + + It("leaves empty paths and id references untouched", func() { + pls := model.Playlist{ + Path: absPath("test", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.InPlaylist{"path": ""}, + criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, + criteria.Eq{"artist": "Bob Dealin"}, + }}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": ""}, + criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, + criteria.Eq{"artist": "Bob Dealin"}, + })) + }) + + It("skips relative references when the playlist has no path", func() { + pls := model.Playlist{ + Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "../up.m3u"}}}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": filepath.FromSlash("../up.m3u")}, + })) + }) + + It("preserves every other criteria field", func() { + rules := criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"path": "child.nsp"}}, + Sort: "title", + Order: "desc", + Limit: 10, + LimitPercent: 25, + Offset: 5, + RefreshDelay: 3 * time.Hour, + } + pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &rules} + + normalized := *pls.NormalizedRules() + normalized.Expression = rules.Expression + Expect(normalized).To(Equal(rules)) + }) + + It("does not mutate the original playlist rules", func() { + original := criteria.All{criteria.InPlaylist{"path": "child.nsp"}} + pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &criteria.Criteria{Expression: original}} + _ = pls.NormalizedRules() + Expect(original[0]).To(BeEquivalentTo(criteria.InPlaylist{"path": "child.nsp"})) + }) + }) }) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 890f757d7..d2f817438 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -13,6 +13,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" + "golang.org/x/text/unicode/norm" ) type smartPlaylistJoinType int @@ -344,11 +345,15 @@ func startOfPeriod(numDays int64, from time.Time) string { } func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) { - playlistID, ok := values["id"].(string) - if !ok { - return nil, errors.New("playlist id not given") + var condition squirrel.Sqlizer + if playlistId, ok := values["id"].(string); ok && playlistId != "" { + condition = squirrel.Eq{"pl.playlist_id": playlistId} + } else if playlistPath, ok := values["path"].(string); ok && playlistPath != "" { + condition = squirrel.Eq{"playlist.path": pathVariants(playlistPath)} + } else { + return nil, errors.New("playlist id or path not given") } - filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} + filters := squirrel.And{condition} if !c.owner.IsAdmin { if c.owner.ID == "" { filters = append(filters, squirrel.Eq{"playlist.public": 1}) @@ -373,6 +378,18 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil } +// Filesystems disagree on the Unicode form of a name, so match the path in NFC and NFD. +func pathVariants(path string) []string { + variants := []string{path} + if alt := norm.NFC.String(path); alt != path { + variants = append(variants, alt) + } + if alt := norm.NFD.String(path); alt != path { + variants = append(variants, alt) + } + return variants +} + func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { if info.IsRole { return roleCond{role: info.Name(), cond: cond, not: negate} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 8d0069b0d..ef91ec989 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -47,7 +47,10 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), - Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("in playlist [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "lacuslacus.nsp", 1), + Entry("in playlist [id]", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("in playlist [empty id falls back to path]", criteria.InPlaylist{"id": "", "path": "/music/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "/music/x.nsp", 1), + Entry("in playlist [decomposed unicode path]", criteria.InPlaylist{"path": "/m\u00fasica/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?,?) AND playlist.public = ?))", "/m\u00fasica/x.nsp", "/mu\u0301sica/x.nsp", 1), Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3), Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), @@ -268,6 +271,13 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) }) + It("returns an error when inPlaylist has empty path", func() { + _, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"path": ""}}, + withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false})).where() + Expect(err).To(MatchError(ContainSubstring("playlist id or path not given"))) + }) + It("returns an error for a range over a tag/role field", func() { _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where() Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field"))) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 9d2ac9590..24c6f5fc5 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -1,11 +1,14 @@ package persistence import ( + "slices" "time" . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/text/unicode/norm" ) // PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated @@ -16,6 +19,17 @@ import ( // refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + return r.refreshSmartPlaylistTree(pls, map[string]struct{}{}) +} + +// The visited set stops playlists that reference each other from recursing forever. +func (r *playlistRepository) refreshSmartPlaylistTree(pls *model.Playlist, visited map[string]struct{}) bool { + if _, seen := visited[pls.ID]; seen { + log.Trace(r.ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID) + return false + } + visited[pls.ID] = struct{}{} + usr := loggedUser(r.ctx) if !r.shouldRefreshSmartPlaylist(pls, usr) { return false @@ -30,9 +44,9 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return false } - rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr)) + rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr)) - if !r.refreshChildPlaylists(pls, rulesSQL) { + if !r.refreshChildPlaylists(pls, rulesSQL, visited) { return false } @@ -89,28 +103,47 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr // refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria. // Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort. -func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool { +func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool { childPlaylistIds := rulesSQL.ChildPlaylistIds() - if len(childPlaylistIds) == 0 { + childPlaylistPaths := rulesSQL.ChildPlaylistPaths() + if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 0 { return true } - childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}}) + var conditions Or + if len(childPlaylistIds) > 0 { + conditions = append(conditions, Eq{"playlist.id": childPlaylistIds}) + } + if len(childPlaylistPaths) > 0 { + lookupPaths := slices.Concat(slice.Map(childPlaylistPaths, pathVariants)...) + conditions = append(conditions, Eq{"playlist.path": lookupPaths}) + } + + childPlaylists, err := r.GetAll(model.QueryOptions{Filters: conditions}) if err != nil { - log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err) + log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err) return false } - found := make(map[string]struct{}, len(childPlaylists)) + found := make(map[string]struct{}, len(childPlaylists)*2) for i := range childPlaylists { found[childPlaylists[i].ID] = struct{}{} - r.refreshSmartPlaylist(&childPlaylists[i]) + if childPlaylists[i].Path != "" { + found[norm.NFC.String(childPlaylists[i].Path)] = struct{}{} + } + r.refreshSmartPlaylistTree(&childPlaylists[i], visited) } for _, id := range childPlaylistIds { if _, ok := found[id]; !ok { log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) } } + + for _, path := range childPlaylistPaths { + if _, ok := found[norm.NFC.String(path)]; !ok { + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "ownerId", pls.OwnerID) + } + } return true } diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 6f8684d5c..33da0c1b7 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -124,13 +125,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { criteria.Contains{"title": "Day"}, }, } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} + nestedPls := model.Playlist{Name: "Nested [ID]", OwnerID: "userid", Public: true, Rules: childRules} Expect(repo.Put(&nestedPls)).To(Succeed()) DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + childRules = &criteria.Criteria{ Expression: criteria.All{ + criteria.Eq{"artist": "シートベルツ"}, + }, + } + nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules} + Expect(repo.Put(&nestedPathPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPathPls.ID) }) + + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.Any{ criteria.InPlaylist{"id": nestedPls.ID}, + criteria.InPlaylist{"path": nestedPathPls.Path}, }, }} Expect(repo.Put(&parentPls)).To(Succeed()) @@ -148,17 +159,88 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) // Parent should have tracks from the nested playlist - Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - // Nested playlist should now have been refreshed (EvaluatedAt set) + // Nested playlists should now have been refreshed (EvaluatedAt set) nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + nestedPlsAfterParentGet, err = repo.Get(nestedPathPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) }) }) + It("does not recurse forever when two smart playlists reference each other", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + }} + Expect(repo.Put(&plsA)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(plsA.ID) }) + + plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}}, + }} + Expect(repo.Put(&plsB)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(plsB.ID) }) + + plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}} + Expect(repo.Put(&plsA)).To(Succeed()) + + _, err := repo.GetWithTracks(plsA.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("does not treat an empty path as a reference to every playlist without a path", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + }} + Expect(repo.Put(&bystander)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(bystander.ID) }) + + parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"path": ""}}, + }} + Expect(repo.Put(&parent)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parent.ID) }) + + _, err := repo.GetWithTracks(parent.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + reloaded, err := repo.Get(bystander.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(reloaded.EvaluatedAt).To(BeNil()) + }) + + It("matches a child path stored in a different Unicode normalization form", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + }} + Expect(repo.Put(&child)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(child.ID) }) + + parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}}, + }} + Expect(repo.Put(&parent)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parent.ID) }) + + pls, err := repo.GetWithTracks(parent.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + }) + When("refresh delay has not expired", func() { It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour From 2913c13cdf7471eb632fbc87bc02df1d071711bf Mon Sep 17 00:00:00 2001 From: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:10:47 +0300 Subject: [PATCH 08/22] fix(i18n): improve Swedish translation (#6177) Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com> --- resources/i18n/sv.json | 426 ++++++++++++++++++++++------------------- 1 file changed, 226 insertions(+), 200 deletions(-) diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 228cc2cf3..ff4f53a6b 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -11,22 +11,22 @@ "title": "Titel", "artist": "Artist", "album": "Album", - "path": "Sökväg", + "path": "Filsökväg", "genre": "Genre", - "compilation": "Samling", + "compilation": "Samlingsalbum", "year": "År", "size": "Filstorlek", "updatedAt": "Uppdaterad", - "bitRate": "Bitrate", - "discSubtitle": "Underrubrik", + "bitRate": "Bithastighet", + "discSubtitle": "Skivans undertitel", "starred": "Favorit", "comment": "Kommentar", "rating": "Betyg", "quality": "Kvalitet", "bpm": "BPM", "playDate": "Senast spelad", - "channels": "Channels", - "createdAt": "Skapad", + "channels": "Kanaler", + "createdAt": "Tillagd", "grouping": "Gruppering", "mood": "Stämning", "participants": "Ytterligare medverkande", @@ -35,19 +35,21 @@ "rawTags": "Omodifierade taggar", "bitDepth": "Bitdjup", "sampleRate": "Samplingsfrekvens", - "missing": "Saknade", + "missing": "Saknas", "libraryName": "Bibliotek", "composer": "Kompositör", - "disc": "Disc %{discNumber}" + "disc": "Skiva %{discNumber}", + "albumGain": "Volymjustering (album)", + "trackGain": "Volymjustering (låt)" }, "actions": { "addToQueue": "Lägg till i kön", "playNow": "Spela nu", "addToPlaylist": "Lägg till i spellista", - "shuffleAll": "Shuffle", + "shuffleAll": "Blanda alla", "download": "Ladda ner", - "playNext": "Spela nästa", - "info": "Mer information", + "playNext": "Spela härnäst", + "info": "Visa information", "showInPlaylist": "Visa i spellista", "instantMix": "Direktmix" } @@ -62,12 +64,12 @@ "playCount": "Spelningar", "name": "Namn", "genre": "Genre", - "compilation": "Samling", + "compilation": "Samlingsalbum", "year": "År", "updatedAt": "Uppdaterad", "comment": "Kommentar", "rating": "Betyg", - "createdAt": "Skapad", + "createdAt": "Tillagt", "size": "Storlek", "originalDate": "Originaldatum", "releaseDate": "Utgivningsdatum", @@ -80,27 +82,28 @@ "media": "Media", "mood": "Stämning", "date": "Inspelningsdatum", - "missing": "Saknade", + "missing": "Saknas", "libraryName": "Bibliotek" }, "actions": { "playAll": "Spela", "playNext": "Spela härnäst", "addToQueue": "Lägg till i kön", - "shuffle": "Shuffle", + "shuffle": "Blanda", "addToPlaylist": "Lägg till i spellista", "download": "Ladda ner", - "info": "Mer information", - "share": "Dela" + "info": "Visa information", + "share": "Dela", + "refresh": "Uppdatera metadata" }, "lists": { "all": "Alla", - "random": "Blanda", - "recentlyAdded": "Senast tillagda", - "recentlyPlayed": "Senast spelade", + "random": "Slumpmässiga", + "recentlyAdded": "Nyligen tillagda", + "recentlyPlayed": "Nyligen spelade", "mostPlayed": "Mest spelade", "starred": "Favoriter", - "topRated": "Bästa betyg" + "topRated": "Högst betygsatta" } }, "artist": { @@ -114,26 +117,26 @@ "genre": "Genre", "size": "Storlek", "role": "Roll", - "missing": "Saknade" + "missing": "Saknas" }, "roles": { "albumartist": "Albumartist |||| Albumartister", "artist": "Artist |||| Artister", - "composer": "Kompositör |||| Kompositörer", + "composer": "Kompositör |||| Kompositörer", "conductor": "Dirigent |||| Dirigenter", "lyricist": "Textförfattare |||| Textförfattare", "arranger": "Arrangör |||| Arrangörer", "producer": "Producent |||| Producenter", "director": "Inspelningsledare |||| Inspelningsledare", "engineer": "Ljudtekniker |||| Ljudtekniker", - "mixer": "Mixare |||| Mixare", - "remixer": "Remixare |||| Remixare", - "djmixer": "DJ-mixare |||| DJ-mixare", - "performer": "Utövande artist |||| Utövande artister", - "maincredit": "Albumartister eller Artist |||| Albumartister eller Artister" + "mixer": "Mixare |||| Mixare", + "remixer": "Remixare |||| Remixare", + "djmixer": "DJ-mixare |||| DJ-mixare", + "performer": "Utövande artist |||| Utövande artister", + "maincredit": "Albumartist eller artist |||| Albumartister eller artister" }, "actions": { - "shuffle": "Shuffle", + "shuffle": "Blanda", "radio": "Radio", "topSongs": "Topplåtar" } @@ -142,7 +145,7 @@ "name": "Användare |||| Användare", "fields": { "userName": "Användarnamn", - "isAdmin": "Är admin", + "isAdmin": "Administratör", "lastLoginAt": "Senaste inloggning", "updatedAt": "Uppdaterad", "name": "Namn", @@ -151,13 +154,15 @@ "changePassword": "Byt lösenord?", "currentPassword": "Nuvarande lösenord", "newPassword": "Nytt lösenord", - "token": "Token", + "token": "Åtkomsttoken", "lastAccessAt": "Senaste åtkomst", - "libraries": "Bibliotek" + "libraries": "Bibliotek", + "scrobbleFilter": "Scrobblingsfilter" }, "helperTexts": { "name": "Ändringar av ditt namn syns först vid nästa inloggning", - "libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek" + "libraries": "Välj vilka bibliotek användaren ska ha tillgång till, eller lämna tomt för att använda standardbiblioteken", + "scrobbleFilter": "Låtar som matchar dessa regler för smarta spellistor skickas inte till Last.fm, ListenBrainz eller scrobblingtillägg. Samma JSON-syntax och beteende som för smarta spellistor används. Exempel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Lämna tomt för att scrobbla allt. Antalet spelningar som lagras lokalt påverkas inte." }, "notifications": { "created": "Användare skapad", @@ -165,13 +170,14 @@ "deleted": "Användare borttagen" }, "message": { - "listenBrainzToken": "Ange din ListenBrainz användar-token.", + "listenBrainzToken": "Ange din användartoken för ListenBrainz.", "clickHereForToken": "Klicka här för att hämta din token", "selectAllLibraries": "Välj alla bibliotek", - "adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek" + "adminAutoLibraries": "Administratörer har automatiskt åtkomst till alla bibliotek" }, "validation": { - "librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer" + "librariesRequired": "Minst ett bibliotek måste väljas för användare som inte är administratörer", + "invalidScrobbleFilter": "Måste innehålla giltiga regler för smarta spellistor. Begränsning av antalet låtar, förskjutning och uppdateringsfördröjning stöds inte." } }, "player": { @@ -179,12 +185,12 @@ "fields": { "name": "Namn", "transcodingId": "Omkodning", - "maxBitRate": "Max. bitrate", + "maxBitRate": "Högsta bithastighet", "client": "Klient", "userName": "Användarnamn", "lastSeen": "Senast sedd", - "reportRealPath": "Visa hela sökvägen", - "scrobbleEnabled": "Scrobbla till extern tjänst" + "reportRealPath": "Rapportera den faktiska sökvägen", + "scrobbleEnabled": "Scrobbla till externa tjänster" } }, "transcoding": { @@ -192,8 +198,11 @@ "fields": { "name": "Namn", "targetFormat": "Målformat", - "defaultBitRate": "Standardbitrate", + "defaultBitRate": "Standardbithastighet", "command": "Kommando" + }, + "choices": { + "noDefaultBitRate": "Ingen" } }, "playlist": { @@ -207,8 +216,9 @@ "createdAt": "Skapad", "songCount": "Låtar", "comment": "Kommentar", - "sync": "Auto-import", - "path": "Importera från" + "sync": "Automatisk import", + "path": "Importera från", + "starred": "Favorit" }, "actions": { "selectPlaylist": "Välj en spellista:", @@ -216,24 +226,24 @@ "export": "Exportera", "makePublic": "Gör offentlig", "makePrivate": "Gör privat", - "saveQueue": "Spara kö till spellista", - "searchOrCreate": "Sök spellista eller skapa ny...", - "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", - "removeFromSelection": "Ta bort från urval" + "saveQueue": "Spara kön i en spellista", + "searchOrCreate": "Sök spellistor eller skriv för att skapa en ny...", + "pressEnterToCreate": "Tryck på Enter för att skapa en ny spellista", + "removeFromSelection": "Ta bort från urvalet" }, "message": { - "duplicate_song": "Lägg till dubletter", - "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", - "noPlaylistsFound": "Hittade inga spellistor", + "duplicate_song": "Lägg till dubbletter", + "song_exist": "Du håller på att lägga till dubbletter i spellistan. Vill du lägga till dem eller hoppa över dem?", + "noPlaylistsFound": "Inga spellistor hittades", "noPlaylists": "Inga spellistor tillgängliga" } }, "radio": { - "name": "Radio |||| Radior", + "name": "Radiostation |||| Radiostationer", "fields": { "name": "Namn", - "streamUrl": "Stream-URL", - "homePageUrl": "Hemside-URL", + "streamUrl": "Strömmens URL", + "homePageUrl": "Webbplatsens URL", "updatedAt": "Uppdaterad", "createdAt": "Skapad" }, @@ -242,7 +252,7 @@ } }, "share": { - "name": "Dela |||| Delningar", + "name": "Delning |||| Delningar", "fields": { "username": "Delad av", "url": "URL", @@ -252,11 +262,13 @@ "lastVisitedAt": "Senast besökt", "visitCount": "Besök", "format": "Format", - "maxBitRate": "Max. bitrate", + "maxBitRate": "Högsta bithastighet", "updatedAt": "Uppdaterad", "createdAt": "Skapad", - "downloadable": "Tillåt nedladdning?" - } + "downloadable": "Tillåt nedladdningar?" + }, + "notifications": {}, + "actions": {} }, "missing": { "name": "Saknad fil |||| Saknade filer", @@ -267,11 +279,11 @@ "libraryName": "Bibliotek" }, "actions": { - "remove": "Radera", - "remove_all": "Radera alla" + "remove": "Ta bort", + "remove_all": "Ta bort alla" }, "notifications": { - "removed": "Saknade fil(er) borttagna" + "removed": "Saknade filer har tagits bort" }, "empty": "Inga saknade filer" }, @@ -280,8 +292,8 @@ "fields": { "name": "Namn", "path": "Sökväg", - "remotePath": "Ta bort sökväg", - "lastScanAt": "Senaste scan", + "remotePath": "Fjärrsökväg", + "lastScanAt": "Senaste skanning", "songCount": "Låtar", "albumCount": "Album", "artistCount": "Artister", @@ -302,33 +314,33 @@ "statistics": "Statistik" }, "actions": { - "scan": "Scanna bibliotek", + "scan": "Skanna biblioteket", "manageUsers": "Hantera användaråtkomst", - "viewDetails": "Se detaljer", - "quickScan": "Snabbscan", - "fullScan": "Komplett scan" + "viewDetails": "Visa detaljer", + "quickScan": "Snabbskanning", + "fullScan": "Fullständig skanning" }, "notifications": { "created": "Biblioteket har skapats", "updated": "Biblioteket har uppdaterats", - "deleted": "Biblioteket har raderats", - "scanStarted": "Biblioteksscan startad", - "scanCompleted": "Biblioteksscan avslutad", - "quickScanStarted": "Snabbscan startad", - "fullScanStarted": "Komplett scan startad", - "scanError": "Fel vid start av scan. Se loggarna" + "deleted": "Biblioteket har tagits bort", + "scanStarted": "Skanning av biblioteket har startat", + "scanCompleted": "Skanning av biblioteket är klar", + "quickScanStarted": "Snabbskanning har startat", + "fullScanStarted": "Fullständig skanning har startat", + "scanError": "Kunde inte starta skanningen. Kontrollera loggarna" }, "validation": { - "nameRequired": "Biblioteksnamn krävs", - "pathRequired": "Bibliotekssökväg krävs", - "pathNotDirectory": "Bibliotekssökvägen måste vara en katalog", - "pathNotFound": "Bibliotekssökväg hittades inte", - "pathNotAccessible": "Bibliotekssökväg inte tillgänglig", - "pathInvalid": "Ogiltig bibliotekssökväg" + "nameRequired": "Ange ett biblioteksnamn", + "pathRequired": "Ange en sökväg till biblioteket", + "pathNotDirectory": "Bibliotekets sökväg måste peka på en katalog", + "pathNotFound": "Bibliotekets sökväg hittades inte", + "pathNotAccessible": "Bibliotekets sökväg är inte åtkomlig", + "pathInvalid": "Ogiltig sökväg till biblioteket" }, "messages": { - "deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.", - "scanInProgress": "Scanning pågår...", + "deleteConfirm": "Är du säker på att du vill ta bort det här biblioteket? Alla tillhörande data och användarnas åtkomst till biblioteket tas bort.", + "scanInProgress": "Skanning pågår...", "noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren" } }, @@ -339,44 +351,44 @@ "name": "Namn", "description": "Beskrivning", "version": "Version", - "author": "Författare", - "website": "Website", + "author": "Upphovsperson", + "website": "Webbplats", "permissions": "Behörigheter", - "enabled": "Aktiverad", + "enabled": "Aktiverat", "status": "Status", "path": "Sökväg", "lastError": "Fel", "hasError": "Fel", - "updatedAt": "Uppdaterad", - "createdAt": "Installerad", + "updatedAt": "Uppdaterat", + "createdAt": "Installerat", "configKey": "Nyckel", "configValue": "Värde", "allUsers": "Tillåt alla användare", "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", "selectedLibraries": "Valda bibliotek", - "allowWriteAccess": "Tillåt skrivrättigheter" + "allowWriteAccess": "Tillåt skrivåtkomst" }, "sections": { "status": "Status", - "info": "Tilläggsinformation", + "info": "Information om tillägget", "configuration": "Konfiguration", "manifest": "Manifest", - "usersPermission": "Användarbehörigheter", - "libraryPermission": "Biblioteksbehörigheter" + "usersPermission": "Åtkomst till användare", + "libraryPermission": "Åtkomst till bibliotek" }, "status": { - "enabled": "Aktiverad", - "disabled": "Inaktiverad" + "enabled": "Aktiverat", + "disabled": "Inaktiverat" }, "actions": { "enable": "Aktivera", "disable": "Inaktivera", - "disabledDueToError": "Åtgärda felet innan aktivering", + "disabledDueToError": "Åtgärda felet före aktivering", "disabledUsersRequired": "Välj användare före aktivering", "disabledLibrariesRequired": "Välj bibliotek före aktivering", "addConfig": "Lägg till konfiguration", - "rescan": "Scanna om" + "rescan": "Skanna om" }, "notifications": { "enabled": "Tillägg aktiverat", @@ -391,17 +403,18 @@ "configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.", "clickPermissions": "Klicka på en behörighet för mer information", "noConfig": "Ingen konfiguration angiven", - "allUsersHelp": "När den är aktiverad får tillägget tillgång till alla användare, inklusive de som skapas i framtiden.", + "allUsersHelp": "När alternativet är aktiverat får tillägget åtkomst till alla användare, även de som skapas i framtiden.", "noUsers": "Inga användare valda", "permissionReason": "Orsak", - "usersRequired": "Detta tillägg kräver åtkomst till användarinformation. Välj vilka användare insticksprogrammet ska ha åtkomst till, eller aktivera 'Tillåt alla användare'.", - "allLibrariesHelp": "När den är aktiverad får tillägget tillgång till alla bibliotek, inklusive de som skapas i framtiden.", + "usersRequired": "Det här tillägget behöver åtkomst till användarinformation. Välj vilka användare tillägget får åtkomst till eller aktivera 'Tillåt alla användare'.", + "allLibrariesHelp": "När alternativet är aktiverat får tillägget åtkomst till alla bibliotek, även de som skapas i framtiden.", "noLibraries": "Inga bibliotek valda", - "librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.", - "requiredHosts": "Krävda värdar", + "librariesRequired": "Det här tillägget behöver åtkomst till biblioteksinformation. Välj vilka bibliotek tillägget får åtkomst till eller aktivera 'Tillåt alla bibliotek'.", + "requiredHosts": "Värdar som krävs", "configValidationError": "Validering av konfigurationen misslyckades:", - "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", - "allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter." + "schemaRenderError": "Kunde inte visa konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", + "allowWriteAccessHelp": "När alternativet är aktiverat kan tillägget ändra filer i bibliotekens kataloger. Som standard har tillägg endast läsåtkomst.", + "idHelp": "Tilläggets ID hämtas från filnamnet. Använd det när du hänvisar till tillägget i konfigurationsalternativ, till exempel Agents." }, "placeholders": { "configKey": "nyckel", @@ -412,40 +425,40 @@ "ra": { "auth": { "welcome1": "Tack för att du installerade Navidrome!", - "welcome2": "Skapa först ett admin-konto", + "welcome2": "Börja med att skapa ett administratörskonto", "confirmPassword": "Bekräfta lösenord", - "buttonCreateAdmin": "Skapa admin-konto", + "buttonCreateAdmin": "Skapa administratörskonto", "auth_check_error": "Logga in för att fortsätta", "user_menu": "Profil", "username": "Användarnamn", "password": "Lösenord", "sign_in": "Logga in", - "sign_in_error": "Felaktig inloggning, försök igen", + "sign_in_error": "Inloggningen misslyckades. Försök igen", "logout": "Logga ut", - "insightsCollectionNote": "Navidrome samlar anonym användardata för att\nhjälpa projektet att bli bättre. Klicka [här]\nför att läsa mer och avaktivera om du vill" + "insightsCollectionNote": "Navidrome samlar in anonyma användningsdata för\natt förbättra projektet. Klicka [här] för att läsa mer\noch välja bort insamlingen" }, "validation": { "invalidChars": "Använd enbart bokstäver och siffror", - "passwordDoesNotMatch": "Lösenordet matchar inte", - "required": "Krävs", - "minLength": "Måste ha minst %{min} tecken", - "maxLength": "Får maximalt ha %{max} tecken", + "passwordDoesNotMatch": "Lösenorden stämmer inte överens", + "required": "Obligatoriskt", + "minLength": "Måste innehålla minst %{min} tecken", + "maxLength": "Får innehålla högst %{max} tecken", "minValue": "Måste vara minst %{min}", - "maxValue": "Får maximalt vara %{max}", - "number": "Måste vara ett nummer", + "maxValue": "Får vara högst %{max}", + "number": "Måste vara ett tal", "email": "Måste vara en giltig e-postadress", - "oneOf": "Måste vara en av: %{options}", - "regex": "Måste matcha ett specifikt format (regexp): %{pattern}", - "unique": "Måste vara unik", + "oneOf": "Måste vara något av: %{options}", + "regex": "Måste matcha det reguljära uttrycket: %{pattern}", + "unique": "Måste vara unikt", "url": "Måste vara en giltig URL" }, "action": { "add_filter": "Lägg till filter", "add": "Lägg till", "back": "Tillbaka", - "bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda", + "bulk_actions": "1 objekt valt |||| %{smart_count} objekt valda", "cancel": "Avbryt", - "clear_input_value": "Rensa", + "clear_input_value": "Rensa värdet", "clone": "Klona", "confirm": "Bekräfta", "create": "Skapa", @@ -454,8 +467,8 @@ "export": "Exportera", "list": "Lista", "refresh": "Uppdatera", - "remove_filter": "Ta bort filter", - "remove": "Radera", + "remove_filter": "Ta bort det här filtret", + "remove": "Ta bort", "save": "Spara", "search": "Sök", "show": "Visa", @@ -477,15 +490,15 @@ }, "page": { "create": "Skapa %{name}", - "dashboard": "Dashboard", + "dashboard": "Översikt", "edit": "%{name} #%{id}", "error": "Ett fel uppstod", "list": "%{name}", - "loading": "Laddar", - "not_found": "Hittade inget", + "loading": "Läser in", + "not_found": "Hittades inte", "show": "%{name} #%{id}", - "empty": "Ingen %{name} ännu.", - "invite": "Vill du lägga till en?" + "empty": "%{name}: inga poster ännu.", + "invite": "Vill du lägga till något?" }, "input": { "file": { @@ -497,13 +510,13 @@ "upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild." }, "references": { - "all_missing": "Hittade ingen referensdata.", - "many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.", - "single_missing": "Associerade referenser verkar inte längre vara tillgängliga." + "all_missing": "Kunde inte hitta referensdata.", + "many_missing": "Minst en av de kopplade referenserna verkar inte längre vara tillgänglig.", + "single_missing": "Den kopplade referensen verkar inte längre vara tillgänglig." }, "password": { - "toggle_visible": "Dölj password", - "toggle_hidden": "Visa password" + "toggle_visible": "Dölj lösenord", + "toggle_hidden": "Visa lösenord" } }, "message": { @@ -511,16 +524,16 @@ "are_you_sure": "Är du säker?", "bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?", "bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}", - "delete_content": "Vill du verkligen ta bort detta innehåll?", + "delete_content": "Vill du verkligen ta bort det här objektet?", "delete_title": "Ta bort %{name} #%{id}", "details": "Detaljer", "error": "Ett klientfel uppstod och begäran kunde inte slutföras.", - "invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel", - "loading": "Sidan läses in, var god vänta", + "invalid_form": "Formuläret innehåller fel. Kontrollera uppgifterna", + "loading": "Sidan läses in. Vänta ett ögonblick", "no": "Nej", "not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.", "yes": "Ja", - "unsaved_changes": "Du har osparade ändringar. Ignorera dem?" + "unsaved_changes": "Vissa ändringar har inte sparats. Vill du verkligen ignorera dem?" }, "navigation": { "no_results": "Inga resultat hittades", @@ -529,23 +542,23 @@ "page_out_from_end": "Det finns inga fler sidor", "page_out_from_begin": "Det finns ingen sida före sida 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}", - "page_rows_per_page": "Antal per sida:", + "page_rows_per_page": "Objekt per sida:", "next": "Nästa", "prev": "Föregående", "skip_nav": "Hoppa till innehåll" }, "notification": { - "updated": "Element uppdaterat |||| %{smart_count} element uppdaterade", - "created": "Element skapat", - "deleted": "Element borttaget |||| %{smart_count} element borttagna", - "bad_item": "Felaktigt element", - "item_doesnt_exist": "Element finns inte", + "updated": "Objekt uppdaterat |||| %{smart_count} objekt uppdaterade", + "created": "Objekt skapat", + "deleted": "Objekt borttaget |||| %{smart_count} objekt borttagna", + "bad_item": "Felaktigt objekt", + "item_doesnt_exist": "Objektet finns inte", "http_error": "Kommunikationsfel med servern", - "data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information.", - "i18n_error": "Kunde inte läsa in översättningen av det valda språket", + "data_provider_error": "Fel i dataProvider. Kontrollera webbläsarens konsol för mer information.", + "i18n_error": "Kunde inte läsa in översättningarna för det valda språket", "canceled": "Åtgärden avbröts", - "logged_out": "Sessionen har avslutats, anslut på nytt.", - "new_version": "Det finns en ny version! Uppdatera detta fönster." + "logged_out": "Sessionen har avslutats. Logga in igen.", + "new_version": "En ny version finns tillgänglig! Ladda om det här fönstret." }, "toggleFieldsMenu": { "columnsToDisplay": "Kolumner att visa", @@ -556,39 +569,39 @@ }, "message": { "note": "OBSERVERA", - "transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar (redigera eller lägga till).", - "transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.", - "songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan", - "noPlaylistsAvailable": "Ingen tillgänglig", - "delete_user_title": "Ta bort användare '%{name}'", - "delete_user_content": "Är du säker på att du vill ta bort denna användare (inklusive spellistor och inställningar)?", - "notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar", - "notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https", - "lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt", - "lastfmLinkFailure": "Last.fm kunde inte länkas", - "lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat", - "lastfmUnlinkFailure": "Last.fm kunde inte avlänkas", + "transcodingDisabled": "Av säkerhetsskäl går det inte att ändra omkodningsinställningarna i webbgränssnittet. Starta om servern med konfigurationsalternativet %{config} för att redigera eller lägga till omkodningsinställningar.", + "transcodingEnabled": "Navidrome körs med %{config}. Det gör det möjligt att köra systemkommandon via omkodningsinställningarna i webbgränssnittet. Av säkerhetsskäl rekommenderar vi att alternativet är inaktiverat och bara aktiveras när omkodningen ska konfigureras.", + "songsAddedToPlaylist": "1 låt har lagts till i spellistan |||| %{smart_count} låtar har lagts till i spellistan", + "noPlaylistsAvailable": "Inga tillgängliga", + "delete_user_title": "Ta bort användaren '%{name}'", + "delete_user_content": "Är du säker på att du vill ta bort den här användaren och alla användarens data, inklusive spellistor och inställningar?", + "notifications_blocked": "Du har blockerat aviseringar från den här webbplatsen i webbläsarens inställningar", + "notifications_not_available": "Webbläsaren stöder inte skrivbordsaviseringar, eller så ansluter du inte till Navidrome via HTTPS", + "lastfmLinkSuccess": "Last.fm har kopplats och scrobbling har aktiverats", + "lastfmLinkFailure": "Det gick inte att koppla Last.fm", + "lastfmUnlinkSuccess": "Kopplingen till Last.fm har tagits bort och scrobbling har inaktiverats", + "lastfmUnlinkFailure": "Det gick inte att ta bort kopplingen till Last.fm", "openIn": { "lastfm": "Öppna i Last.fm", "musicbrainz": "Öppna i MusicBrainz" }, "lastfmLink": "Läs mer...", - "listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}", - "listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat", - "listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas", + "listenBrainzLinkSuccess": "ListenBrainz har kopplats och scrobbling har aktiverats för användaren: %{user}", + "listenBrainzLinkFailure": "Det gick inte att koppla ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "Kopplingen till ListenBrainz har tagits bort och scrobbling har inaktiverats", + "listenBrainzUnlinkFailure": "Det gick inte att ta bort kopplingen till ListenBrainz", "downloadOriginalFormat": "Ladda ner i originalformat", "shareOriginalFormat": "Dela i originalformat", "shareDialogTitle": "Dela %{resource} '%{name}'", - "shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}", - "shareSuccess": "URL kopierades till urklipp: %{url}", - "shareFailure": "Fel vid kopiering av URL %{url} till urklipp", + "shareBatchDialogTitle": "Dela 1 %{resource} |||| Dela %{smart_count} %{resource}", + "shareSuccess": "URL:en har kopierats till urklipp: %{url}", + "shareFailure": "Kunde inte kopiera URL:en %{url} till urklipp", "downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter", "remove_missing_title": "Ta bort saknade filer", - "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", + "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", "remove_all_missing_title": "Ta bort alla saknade filer", - "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", + "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", "noSimilarSongsFound": "Hittade inga liknande låtar", "noTopSongsFound": "Hittade inga topplåtar", "startingInstantMix": "Laddar direktmix...", @@ -597,7 +610,11 @@ "coverUploaded": "Omslagsbild uppdaterad", "coverRemoved": "Omslagsbild borttagen", "coverUploadError": "Fel vid uppladdning av omslagsbild", - "coverRemoveError": "Fel vid borttagning av omslagsbild" + "coverRemoveError": "Fel vid borttagning av omslagsbild", + "metadataRefreshStarted": "Metadata uppdateras i bakgrunden", + "quickConnectApproved": "%{app} på %{device} är nu inloggad", + "quickConnectInvalidCode": "Koden är ogiltig eller har gått ut", + "quickConnectError": "Kunde inte godkänna koden" }, "menu": { "library": "Bibliotek", @@ -610,17 +627,17 @@ "theme": "Tema", "language": "Språk", "defaultView": "Standardvy", - "desktop_notifications": "Skrivbordsmeddelanden", + "desktop_notifications": "Skrivbordsaviseringar", "lastfmScrobbling": "Scrobbla till Last.fm", "listenBrainzScrobbling": "Scrobbla till ListenBrainz", "replaygain": "ReplayGain-läge", - "preAmp": "ReplayGain PreAmp (dB)", + "preAmp": "Förförstärkning för ReplayGain (dB)", "gain": { "none": "Inaktiverad", - "album": "Använd gain för album", - "track": "Använd gain für låtar" + "album": "Använd albumets volymjustering", + "track": "Använd låtens volymjustering" }, - "lastfmNotConfigured": "Last.fm API-nyckel är inte konfigurerad" + "lastfmNotConfigured": "API-nyckeln för Last.fm är inte konfigurerad" } }, "albumList": "Album", @@ -632,10 +649,19 @@ "multipleLibraries": "%{selected} av %{total} bibliotek", "selectLibraries": "Välj bibliotek", "none": "Inga" - } + }, + "quickConnect": { + "name": "Snabbanslutning", + "code": "Kod", + "help": "Ange koden som visas i en Jellyfin-app för att logga in på ditt konto i appen", + "confirm": "Vill du logga in med ditt konto i %{app} %{version} på %{device}?", + "continue": "Fortsätt", + "approve": "Godkänn" + }, + "onlyFavourites": "Visa endast favoriter" }, "player": { - "playListsText": "Spela kön", + "playListsText": "Uppspelningskö", "openText": "Öppna", "closeText": "Stäng", "notContentText": "Ingen musik", @@ -645,26 +671,26 @@ "previousTrackText": "Föregående låt", "reloadText": "Ladda om", "volumeText": "Volym", - "toggleLyricText": "Låttext av/på", + "toggleLyricText": "Visa eller dölj låttexten", "toggleMiniModeText": "Minimera", - "destroyText": "Radera", + "destroyText": "Stäng spelaren och rensa kön", "downloadText": "Ladda ner", - "removeAudioListsText": "Ta bort audiolistor", + "removeAudioListsText": "Rensa kön", "clickToDeleteText": "Klicka för att ta bort %{name}", "emptyLyricText": "Ingen låttext", "playModeText": { "order": "I ordningsföljd", - "orderLoop": "Upprepa", - "singleLoop": "Upprepa en", - "shufflePlay": "Shuffle" + "orderLoop": "Upprepa alla", + "singleLoop": "Upprepa en låt", + "shufflePlay": "Blanda" } }, "about": { "links": { - "homepage": "Hemsida", + "homepage": "Webbplats", "source": "Källkod", - "featureRequests": "Funktionalitetförfrågan", - "lastInsightsCollection": "Senaste Insights-kollektion", + "featureRequests": "Förslag på nya funktioner", + "lastInsightsCollection": "Senaste insamling av användningsdata", "insights": { "disabled": "Inaktiverad", "waiting": "Väntar" @@ -672,16 +698,16 @@ }, "tabs": { "about": "Om", - "config": "Inställningar" + "config": "Konfiguration" }, "config": { - "configName": "Inställningsnamn", + "configName": "Inställning", "environmentVariable": "Miljövariabel", - "currentValue": "Nuvarande värde", - "configurationFile": "Inställningsfil", - "exportToml": "Exportera inställningar (TOML)", - "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format", - "exportFailed": "Kopiering av inställningarna misslyckades", + "currentValue": "Aktuellt värde", + "configurationFile": "Konfigurationsfil", + "exportToml": "Exportera konfiguration (TOML)", + "exportSuccess": "Konfigurationen har kopierats till urklipp i TOML-format", + "exportFailed": "Kunde inte kopiera konfigurationen", "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner", "downloadToml": "Ladda ner konfiguration (TOML)" @@ -689,28 +715,28 @@ }, "activity": { "title": "Aktivitet", - "totalScanned": "Genomsökta mappar", - "quickScan": "Snabbscan", - "fullScan": "Komplett scan", - "serverUptime": "Serverdrifttid", + "totalScanned": "Skannade mappar totalt", + "quickScan": "Snabb", + "fullScan": "Fullständig", + "serverUptime": "Serverns drifttid", "serverDown": "OFFLINE", - "scanType": "Typ", - "status": "Fel vid scanning", - "elapsedTime": "Spelad tid", - "selectiveScan": "Urval" + "scanType": "Senaste skanning", + "status": "Skanningsfel", + "elapsedTime": "Förfluten tid", + "selectiveScan": "Selektiv" }, "help": { - "title": "Navidrome kortkommandon", + "title": "Kortkommandon i Navidrome", "hotkeys": { "show_help": "Visa denna hjälp", - "toggle_menu": "Växla sidomeny", + "toggle_menu": "Visa eller dölj sidomenyn", "toggle_play": "Spela / pausa", "prev_song": "Föregående låt", "next_song": "Nästa låt", - "vol_up": "Volym upp", - "vol_down": "Volym ner", - "toggle_love": "Lägg till låt i favoriter", - "current_song": "Hoppa till nuvarande låt" + "vol_up": "Höj volymen", + "vol_down": "Sänk volymen", + "toggle_love": "Lägg till låten i favoriter", + "current_song": "Gå till aktuell låt" } }, "nowPlaying": { @@ -718,4 +744,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} \ No newline at end of file +} From a5334ee46b9cbfc5fe3191c5ce9262117f543307 Mon Sep 17 00:00:00 2001 From: strecke <35091155+strecke@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:17:57 +0200 Subject: [PATCH 09/22] fix(ui): update missing German translations (#6146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update missing German translations * Fix typo in idHelp message in German translation * Update German translations to remove formal "Sie" forms --------- Co-authored-by: Deluan Quintão --- resources/i18n/de.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 8f3df0b4d..400a2364d 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -94,7 +94,7 @@ "download": "Herunterladen", "info": "Mehr Informationen", "share": "Freigabe erstellen", - "refresh": "" + "refresh": "Metadaten aktualisieren" }, "lists": { "all": "Alle", @@ -157,12 +157,12 @@ "token": "Token", "lastAccessAt": "Letzter Zugriff am", "libraries": "Bibliotheken", - "scrobbleFilter": "" + "scrobbleFilter": "Scrobble-Filter" }, "helperTexts": { "name": "Die Änderung wird erst nach dem nächsten Login gültig", "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken", - "scrobbleFilter": "" + "scrobbleFilter": "Titel, die diesen Regeln für intelligente Wiedergabelisten entsprechen, werden nicht an Last.fm, ListenBrainz oder Scrobbler-Plugins übermittelt. Es werden dieselbe JSON-Syntax und dasselbe Verhalten wie bei intelligenten Wiedergabelisten verwendet. Beispiel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Ist das Feld leer, wird alles gescrobbelt. Lokale Wiedergabezahlen bleiben davon unberührt." }, "notifications": { "created": "Benutzer erstellt", @@ -177,7 +177,7 @@ }, "validation": { "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein", - "invalidScrobbleFilter": "" + "invalidScrobbleFilter": "Es müssen gültige Regeln für intelligente Wiedergabelisten sein. Limit, Offset und Aktualisierungsverzögerung werden nicht unterstützt." } }, "player": { @@ -202,7 +202,7 @@ "command": "Befehl" }, "choices": { - "noDefaultBitRate": "" + "noDefaultBitRate": "Keine" } }, "playlist": { @@ -398,6 +398,7 @@ "invalidJson": "Konfiguration muss valides JSON sein" }, "messages": { + "idHelp": "Die Plugin-ID, abgeleitet vom Dateinamen. Verwende diese, um in Konfigurationen (z. B. bei Agents) auf dieses Plugin zu verweisen.", "configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.", "clickPermissions": "Berechtigung anklicken für mehr Details", "noConfig": "Keine Konfiguration gesetzt", @@ -411,8 +412,7 @@ "requiredHosts": "Benötigte Hosts", "configValidationError": "Validierung der Konfiguration fehlgeschlagen:", "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.", - "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff.", - "idHelp": "" + "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff." }, "placeholders": { "configKey": "Schlüssel", @@ -573,7 +573,7 @@ "noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar", "delete_user_title": "Benutzer '%{name}' löschen", "delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?", - "notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert", + "notifications_blocked": "Benachrichtigungen für diese Seite sind in den Browsereinstellungen blockiert", "notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen", "lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert", "lastfmLinkFailure": "Last.fm konnte nicht verbunden werden", @@ -609,7 +609,7 @@ "coverRemoved": "Cover entfernt", "coverUploadError": "Fehler beim Hochladen des Covers", "coverRemoveError": "Fehler beim Entfernen des Covers", - "metadataRefreshStarted": "" + "metadataRefreshStarted": "Aktualisiert Metadaten im Hintergrund" }, "menu": { "library": "Bibliothek", @@ -731,4 +731,4 @@ "empty": "Keine Wiedergabe", "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } -} \ No newline at end of file +} From 8e784b6af7baa7ccfcfaf56edaecf56d598ad045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 20 Sep 2026 12:37:18 -0400 Subject: [PATCH 10/22] fix: apply the per-user library filter to bookmarks, playlists and now-playing (#6179) On a multi-library instance, a few reads and writes built their own queries without the per-user library filter that every other media read applies. A user granted only some libraries could see, and store, tracks from libraries they had no access to. - getBookmarks now filters the query. It has to be the query and not the result: the loop below it pre-sizes the response from the bookmark count, so a row dropped afterwards would emit an empty bookmark entry. - createBookmark rejects an id the caller cannot read, returning error 70 to match getSong. Stored rows are left alone rather than purged, so a temporary revoke does not lose saved playback positions. - playlistTrackRepository Read, Count and GetAlbumIDs get the filter their siblings CountAll and GetMediaFileIDs already had. Read is the one that mattered most: its id is the integer playlist position, so it needed no track id at all. - Playlist track writes are filtered in playlistRepository.addTracks, the only writer of playlist_tracks rows apart from smart playlists, so Add, Insert, AddAlbums/AddArtists/AddDiscs and a full replace through Put all go through it. Insert reserves a slot per requested id, so when the filter drops one it renumbers to close the hole. - playTracker.GetNowPlaying honours its context instead of discarding it. The cache is process-global, so the filter belongs in the tracker rather than in the Subsonic handler, and any future caller inherits it. Admins and single-library installs are unaffected: applyLibraryFilter and HasLibraryAccess both short-circuit for them. Scanner playlist sync runs as admin, and M3U and CLI imports already resolve tracks through FindByPaths as the same user, so neither changes. --- core/scrobbler/play_tracker.go | 9 +- core/scrobbler/play_tracker_test.go | 43 ++++- persistence/persistence_suite_test.go | 22 +++ persistence/playlist_repository.go | 42 ++++- persistence/playlist_track_repository.go | 16 +- persistence/playlist_track_repository_test.go | 149 ++++++++++++++++++ persistence/sql_bookmarks.go | 1 + persistence/sql_bookmarks_test.go | 48 ++++++ server/subsonic/bookmarks.go | 8 + server/subsonic/bookmarks_test.go | 48 ++++++ .../e2e/subsonic_multilibrary_test.go | 18 +++ tests/mock_mediafile_repo.go | 9 ++ 12 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 server/subsonic/bookmarks_test.go diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 63397f8f6..e68fcd942 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" + "github.com/navidrome/navidrome/utils/slice" ) const ( @@ -444,8 +445,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP return nil } -func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) { +func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) { + // The cache is process-global, so it holds every user's playback, across all libraries. res := p.playMap.Values() + if user, ok := request.UserFrom(ctx); ok { + res = slice.Filter(res, func(s PlaybackSession) bool { + return user.HasLibraryAccess(s.MediaFile.LibraryID) + }) + } slices.SortFunc(res, func(a, b PlaybackSession) int { return b.Start.Compare(a.Start) }) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 0e768c3f4..d79233e15 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -92,7 +92,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = GinkgoT().Context() - ctx = request.WithUser(ctx, model.User{ID: "u-1"}) + ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} fake = &fakeScrobbler{Authorized: true} @@ -108,6 +108,7 @@ var _ = Describe("PlayTracker", func() { track = model.MediaFile{ ID: "123", + LibraryID: 1, Title: "Track Title", Album: "Track Album", AlbumID: "al-1", @@ -174,6 +175,46 @@ var _ = Describe("PlayTracker", func() { Expect(playing[1].Username).To(Equal("user-1")) Expect(playing[1].MediaFile.ID).To(Equal("123")) }) + + It("hides sessions playing from libraries the caller cannot access", func() { + hidden := track + hidden.ID = "789" + hidden.LibraryID = 2 + _ = ds.MediaFile(ctx).Put(&hidden) + reporter := request.WithPlayer( + request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), + model.Player{ScrobbleEnabled: true}, + ) + _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ + MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) + + playing, err := tracker.GetNowPlaying(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only") + }) + + It("shows every session to an admin", func() { + hidden := track + hidden.ID = "789" + hidden.LibraryID = 2 + _ = ds.MediaFile(ctx).Put(&hidden) + reporter := request.WithPlayer( + request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), + model.Player{ScrobbleEnabled: true}, + ) + _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ + MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) + + adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true}) + playing, err := tracker.GetNowPlaying(adminCtx) + + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].MediaFile.ID).To(Equal("789")) + }) }) Describe("Expiration events", func() { diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index f146cb06b..644284c0b 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -169,6 +169,28 @@ func p(path string) string { return filepath.FromSlash(path) } +// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so +// specs can assert that a query filters by library. Cleans itself up after the spec. +func restrictedFixture(name string) (context.Context, model.Library, model.User) { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + db := GetDBXBuilder() + + lib := model.Library{Name: name + " Library", Path: "/" + name} + lr := NewLibraryRepository(adminCtx, db) + Expect(lr.Put(&lib)).To(Succeed()) + + user := createUserWithLibraries(name+"-restricted", []int{1}) + ur := NewUserRepository(adminCtx, db) + Expect(ur.Put(&user)).To(Succeed()) + Expect(ur.SetUserLibraries(user.ID, []int{1})).To(Succeed()) + + DeferCleanup(func() { + _ = NewUserRepository(adminCtx, db).Delete(user.ID) + _ = NewLibraryRepository(adminCtx, db).(*libraryRepository).delete(squirrel.Eq{"id": lib.ID}) + }) + return adminCtx, lib, user +} + var _ = BeforeSuite(func() { conn := GetDBXBuilder() ctx := log.NewContext(context.TODO()) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index cd3e48d18..2afef9f45 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -13,6 +13,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -276,10 +277,17 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st return err } - return r.addTracks(playlistId, 1, mediaFileIds) + _, err = r.addTracks(playlistId, 1, mediaFileIds) + return err } -func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error { +// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns +// the library check: every caller, including a full replace through Put, goes through it. +func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) (int, error) { + mediaFileIds, err := r.keepAccessible(mediaFileIds) + if err != nil { + return 0, err + } // Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit // Add new tracks, chunk by chunk pos := startingPos @@ -289,14 +297,36 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media ins = ins.Values(playlistId, t, pos) pos++ } - _, err := r.executeSQL(ins) - if err != nil { - return err + if _, err := r.executeSQL(ins); err != nil { + return 0, err } } r.enqueueCoverRebuild(playlistId) - return r.refreshCounters(&model.Playlist{ID: playlistId}) + return len(mediaFileIds), r.refreshCounters(&model.Playlist{ID: playlistId}) +} + +// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked +// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER. +func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, error) { + if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) { + return mediaFileIds, nil + } + accessible := make(map[string]struct{}, len(mediaFileIds)) + for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) { + sq := r.applyLibraryFilter(Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file") + var found []string + if err := r.queryAllSlice(sq, &found); err != nil { + return nil, err + } + for _, id := range found { + accessible[id] = struct{}{} + } + } + return slice.Filter(mediaFileIds, func(id string) bool { + _, ok := accessible[id] + return ok + }), nil } // refreshCounters updates total playlist duration, size and count diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 847316868..848b67be0 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -91,6 +91,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er query := Select(). LeftJoin("media_file f on f.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") return r.count(query, r.parseRestOptions(r.ctx, options...)) } @@ -113,6 +114,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) { ). Join("media_file f on f.id = media_file_id"). Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) + sel = r.applyLibraryFilter(sel, "f") var trk dbPlaylistTrack err := r.queryOne(sel, &trk) return trk.PlaylistTrack, err @@ -157,6 +159,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([] query := r.newSelect(options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "mf") var ids []string err := r.queryAllSlice(query, &ids) if err != nil { @@ -187,12 +190,11 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { // Get next pos (ID) in playlist sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) var res struct{ Max sql.NullInt32 } - err := r.queryOne(sq, &res) - if err != nil { + if err := r.queryOne(sq, &res); err != nil { return 0, err } - return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) + return r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) } // Insert adds tracks before the 1-based position pos, shifting the following entries down; a @@ -215,7 +217,13 @@ func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, e if res == 0 { return r.Add(mediaFileIds) } - return n, r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds) + inserted, err := r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds) + if err != nil || inserted == n { + return inserted, err + } + // The shift above reserved a slot per requested id, so ids dropped by the library filter + // leave a hole. Close it. + return inserted, r.playlistRepo.renumber(r.playlistId) } func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) { diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index e0360adb4..1a6bc9dc6 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -1,11 +1,13 @@ package persistence import ( + "context" "strconv" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -221,4 +223,151 @@ var _ = Describe("PlaylistTrackRepository", func() { Expect(tracks.CountAll()).To(BeZero()) }) }) + + Describe("library access", func() { + var otherLib model.Library + var restrictedUser model.User + var adminCtx, userCtx context.Context + var userTracks model.PlaylistTrackRepository + var plsID string + + BeforeEach(func() { + adminCtx, otherLib, restrictedUser = restrictedFixture("pls") + userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) + db := GetDBXBuilder() + + adminMr := NewMediaFileRepository(adminCtx, db) + Expect(adminMr.Put(&model.MediaFile{ + ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album", + Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist", + })).To(Succeed()) + DeferCleanup(func() { _ = adminMr.Delete("pls-otherlib-track") }) + + adminPls := NewPlaylistRepository(adminCtx, db) + pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true} + Expect(adminPls.Put(&pls)).To(Succeed()) + plsID = pls.ID + DeferCleanup(func() { _ = adminPls.Delete(plsID) }) + Expect(adminPls.Tracks(plsID, false).Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2)) + + userTracks = NewPlaylistRepository(userCtx, db).Tracks(plsID, false) + }) + + It("Read does not return a track outside the user's libraries", func() { + _, err := userTracks.Read("2") + Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access") + }) + + It("Read still returns a track inside the user's libraries", func() { + trk, err := userTracks.Read("1") + Expect(err).ToNot(HaveOccurred()) + Expect(trk.(*model.PlaylistTrack).MediaFile.ID).To(Equal(songDayInALife.ID)) + }) + + It("Count excludes tracks outside the user's libraries", func() { + Expect(userTracks.Count()).To(Equal(int64(1)), "Count must agree with the filtered listing") + }) + + It("GetAlbumIDs excludes albums outside the user's libraries", func() { + Expect(userTracks.GetAlbumIDs()).ToNot(ContainElement("pls-hidden-album")) + }) + + Describe("Add", func() { + var ownTracks model.PlaylistTrackRepository + + BeforeEach(func() { + userPls := NewPlaylistRepository(userCtx, GetDBXBuilder()) + own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName} + Expect(userPls.Put(&own)).To(Succeed()) + DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(own.ID) }) + ownTracks = userPls.Tracks(own.ID, false) + }) + + It("drops ids outside the user's libraries", func() { + Expect(ownTracks.Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1)) + Expect(ownTracks.GetMediaFileIDs()).To(ConsistOf(songDayInALife.ID)) + }) + + It("drops them when reached through AddAlbums", func() { + Expect(ownTracks.AddAlbums([]string{"pls-hidden-album"})).To(BeZero()) + }) + + It("drops them when reached through Insert", func() { + Expect(ownTracks.Add([]string{songDayInALife.ID})).To(Equal(1)) + + Expect(ownTracks.Insert([]string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1)) + + Expect(ownTracks.GetMediaFileIDs()).To(Equal([]string{songComeTogether.ID, songDayInALife.ID})) + trks, err := ownTracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}), + "positions must stay contiguous when an id is dropped") + }) + }) + + Describe("Put", func() { + storedIDs := func(id string) []string { + ids, err := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(id, false).GetMediaFileIDs() + Expect(err).ToNot(HaveOccurred()) + return ids + } + put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string { + pls.OwnerID = owner.ID + pls.Tracks = nil + pls.AddMediaFilesByID(ids) + Expect(NewPlaylistRepository(ctx, GetDBXBuilder()).Put(pls)).To(Succeed()) + DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(pls.ID) }) + return pls.ID + } + + It("drops ids outside the user's libraries when creating a playlist", func() { + id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track") + + Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID})) + }) + + It("drops them when replacing the tracks of an existing playlist", func() { + pls := &model.Playlist{Name: "Replaced"} + put(userCtx, restrictedUser, pls, songDayInALife.ID) + + put(userCtx, restrictedUser, pls, "pls-otherlib-track") + + Expect(storedIDs(pls.ID)).To(BeEmpty()) + }) + + It("does not count a dropped id, so it cannot be told apart from an unknown one", func() { + hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track") + unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track") + + userPls := NewPlaylistRepository(userCtx, GetDBXBuilder()) + h, err := userPls.Get(hidden) + Expect(err).ToNot(HaveOccurred()) + u, err := userPls.Get(unknown) + Expect(err).ToNot(HaveOccurred()) + Expect(h.SongCount).To(Equal(u.SongCount)) + Expect(h.Duration).To(Equal(u.Duration)) + Expect(h.Size).To(Equal(u.Size)) + }) + + It("keeps order and duplicates of the accessible ids", func() { + id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"}, + songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID) + + Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID})) + }) + + It("keeps every id when run as an admin, as the scanner's playlist sync does", func() { + id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track") + + Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"})) + }) + }) + + It("still shows everything to an admin", func() { + adminTracks := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(plsID, false) + Expect(adminTracks.Count()).To(Equal(int64(2))) + _, err := adminTracks.Read("2") + Expect(err).ToNot(HaveOccurred()) + }) + }) }) diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index a9f53430d..cff57dc9d 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -103,6 +103,7 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { sq := r.newSelect().Columns(r.tableName + ".*") sq = r.withAnnotation(sq, idField) sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) + sq = r.applyLibraryFilter(sq) var mfs dbMediaFiles // TODO Decouple from media_file err := r.queryAll(sq, &mfs) if err != nil { diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go index 712a928db..ae01a0e35 100644 --- a/persistence/sql_bookmarks_test.go +++ b/persistence/sql_bookmarks_test.go @@ -71,4 +71,52 @@ var _ = Describe("sqlBookmarks", func() { Expect(mr.GetBookmarks()).To(BeEmpty()) }) }) + + Describe("library access", func() { + var otherLib model.Library + var restrictedUser model.User + var adminCtx context.Context + var userMr model.MediaFileRepository + + BeforeEach(func() { + adminCtx, otherLib, restrictedUser = restrictedFixture("bmk") + + adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(adminMr.Put(&model.MediaFile{ + ID: "bmk-otherlib-track", LibraryID: otherLib.ID, + Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked", + })).To(Succeed()) + DeferCleanup(func() { _ = adminMr.Delete("bmk-otherlib-track") }) + + userCtx := request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) + userMr = NewMediaFileRepository(userCtx, GetDBXBuilder()) + }) + + It("does not return bookmarks for tracks outside the user's libraries", func() { + Expect(userMr.AddBookmark("bmk-otherlib-track", "sneaky", 1)).To(Succeed()) + + Expect(userMr.GetBookmarks()).To(BeEmpty()) + }) + + It("still returns the bookmark for an admin", func() { + adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(adminMr.AddBookmark("bmk-otherlib-track", "mine", 1)).To(Succeed()) + DeferCleanup(func() { _ = adminMr.DeleteBookmark("bmk-otherlib-track") }) + + bms, err := adminMr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bms).To(HaveLen(1)) + Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track")) + }) + + It("keeps returning bookmarks for tracks inside the user's libraries", func() { + Expect(userMr.AddBookmark(songAntenna.ID, "allowed", 5)).To(Succeed()) + DeferCleanup(func() { _ = userMr.DeleteBookmark(songAntenna.ID) }) + + bms, err := userMr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bms).To(HaveLen(1)) + Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) + }) + }) }) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 4a7ebaa6c..7ac492ca8 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -47,6 +47,14 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) position := p.Int64Or("position", 0) repo := api.ds.MediaFile(r.Context()) + ok, err := repo.Exists(id) + if err != nil { + return nil, err + } + if !ok { + return nil, newError(responses.ErrorDataNotFound, "Song not found") + } + err = repo.AddBookmark(id, comment, position) if err != nil { return nil, err diff --git a/server/subsonic/bookmarks_test.go b/server/subsonic/bookmarks_test.go new file mode 100644 index 000000000..0fcb81ab9 --- /dev/null +++ b/server/subsonic/bookmarks_test.go @@ -0,0 +1,48 @@ +package subsonic + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Bookmarks", func() { + var router *Router + var ds *tests.MockDataStore + var mfRepo *tests.MockMediaFileRepo + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"}) + mfRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "visible"}}) + }) + + Describe("CreateBookmark", func() { + It("rejects an id the user cannot read", func() { + r := newGetRequest("id=hidden", "position=1").WithContext(ctx) + + _, err := router.CreateBookmark(r) + + Expect(err).To(HaveOccurred()) + Expect(mapToSubsonicError(err).code).To(Equal(responses.ErrorDataNotFound)) + Expect(mfRepo.BookmarksAdded).To(BeEmpty()) + }) + + It("accepts an id the user can read", func() { + r := newGetRequest("id=visible", "position=1").WithContext(ctx) + + _, err := router.CreateBookmark(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mfRepo.BookmarksAdded).To(ConsistOf("visible")) + }) + }) +}) diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go index 98f87ca17..18e8c6391 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/subsonic/e2e/subsonic_multilibrary_test.go @@ -224,6 +224,24 @@ var _ = Describe("Multi-Library Support", Ordered, func() { Expect(resp.Playlist.Entry).To(HaveLen(1)) Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID)) }) + + It("non-admin user cannot store a song from another library through createPlaylist", func() { + resp := doReqWithUser(userLib1Only, "createPlaylist", + "name", "Restricted Playlist", "songId", lib1SongID, "songId", lib2SongID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + ownID := resp.Playlist.Id + + stored := doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) + Expect(stored.Playlist.Entry).To(HaveLen(1), "the lib2 song must not be persisted") + Expect(stored.Playlist.Entry[0].Id).To(Equal(lib1SongID)) + + By("replacing the tracks of the same playlist") + resp = doReqWithUser(userLib1Only, "createPlaylist", "playlistId", ownID, "songId", lib2SongID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + stored = doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) + Expect(stored.Playlist.Entry).To(BeEmpty()) + }) }) Describe("Cross-library shares", Ordered, func() { diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index a365bd2bd..2093a007d 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -37,6 +37,7 @@ type MockMediaFileRepo struct { FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) MatchesCriteriaValue bool MatchesCriteriaErr error + BookmarksAdded []string } func (m *MockMediaFileRepo) SetError(err bool) { @@ -76,6 +77,14 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) { return nil, model.ErrNotFound } +func (m *MockMediaFileRepo) AddBookmark(id, _ string, _ int64) error { + if m.Err { + return errors.New("error") + } + m.BookmarksAdded = append(m.BookmarksAdded, id) + return nil +} + func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { if m.Err { return nil, errors.New("error") From 8b4125267eda31c48c193776d8dc4653a2e5af48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 20 Sep 2026 13:40:14 -0400 Subject: [PATCH 11/22] fix(artwork): only use image files as local artwork sources (#6180) * fix(playlists): limit local cover paths to images in owner's libraries A local #EXTALBUMARTURL path (absolute or file://) was only checked against the union of all libraries. The artwork resolver then opened it with no further check and served the bytes as the playlist cover, undecoded. Any user who can upload an M3U could read any file under any library root, including libraries they were not granted, through getCoverArt (GHSA-vwq6-xrw5-phpg). resolveImageURL now requires an image extension, and for uploaded playlists (no folder) the library holding the cover must pass the owner's HasLibraryAccess. Scanner and CLI imports keep the all-libraries check, since those files are admin-controlled. resolveLocalFile, used by every file-backed artwork source, now ignores paths without an image extension, which covers playlists stored before this fix that were not resolved yet. openOriginal refuses a stored file-backed row whose path is not an image, so the existing dangling path re-resolves it and the playlist falls back to the generated grid. No migration is needed. * fix(artwork): skip non-image files matched by folder cover patterns Album and disc folder sources opened any file in the folder's image list that matched a cover pattern, without checking its extension. openOriginal now refuses to serve file-backed rows whose path is not an image, so a stored row like that would be refused, re-resolved to the same file, and refused again on every view. The list comes from the scanner, which only records image files, but a database scanned where the OS mime table knows more image types than the serving process could still reach this. Both fromExternalFile variants now skip matches that are not image files, so the album falls back to its next source instead. Also correct the parser comment: a playlist without a folder can come from an API upload or from a CLI import of a file outside all libraries. * fix(artwork): check stored source type before using the resize cache The image-extension check for file-backed rows ran inside openOriginal, which the resize cache skips on a hit. Before the fix, a resized request for a playlist pointing at a non-image file cached the raw bytes, because a failed resize falls back to the original data. After the upgrade the same request still hit that entry and returned the file. serveHash now refuses a file-backed row whose path is not an image before calling serveSource, so both full-size and resized requests go through dangling and re-resolve the item. The stale cache entry is keyed by the old hash and is no longer reachable once the row changes. * test: register mime_types.yaml in test binaries Artwork resolution now skips candidates that are not image files, and model.IsImageFile answers from the process mime table. The server registers the extra image types from resources/mime_types.yaml through a conf hook, but a test binary only does that if it links conf/mime, so the artwork e2e suite fell back to the host table: .jxl resolves on macOS and Linux and does not on Windows, where the #5950 cover spec then found no source. tests.Init now imports conf/mime for its side effect, so every suite that loads the test config sees the same image types as the server. * fix(artwork): drop the image-file guard from the disc art reader The guard was added to both fromExternalFile variants, but disc artwork keeps no state row and is never queued, so it cannot hit the refuse-and-re-resolve loop the guard exists to prevent. The only case where it can fire is a real image whose extension this process's mime table does not know, and there it drops a disc cover that used to work. The album variant keeps the guard, since those resolutions are stored and re-served. --- core/artwork/artwork.go | 5 +++ core/artwork/artwork_test.go | 47 +++++++++++++++++++++++++++ core/artwork/resolve.go | 2 +- core/artwork/resolve_test.go | 17 ++++++++++ core/artwork/sources.go | 2 +- core/artwork/sources_internal_test.go | 12 +++++++ core/playlists/import_test.go | 31 ++++++++++++++++++ core/playlists/parse_m3u.go | 13 +++++--- tests/init_tests.go | 1 + 9 files changed, 123 insertions(+), 7 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 663d06d25..e27fa118e 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -168,6 +168,11 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode if !entityExists(ctx, s.ds, artID) { return nil, ErrUnavailable } + // Checked here, not in openOriginal: a resize-cache hit never opens the source. + if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) { + log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath) + return s.dangling(ctx, artID) + } art, err := s.ds.Artwork(ctx).GetImage(ia.Hash) if err != nil { if errors.Is(err, model.ErrNotFound) { diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index 907b300de..8b35a872e 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -159,6 +159,53 @@ var _ = Describe("Artwork", func() { Expect(readAll(img)).To(Equal(coverBytes)) }) + It("treats a file-backed row pointing at a non-image file as dangling", func() { + dir := GinkgoT().TempDir() + secretPath := filepath.Join(dir, "config.ini") + Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "alni") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd", + Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan)) + }) + + It("refuses a non-image file-backed row even when a resized copy is already cached", func() { + secret := []byte("password=secret") + dir := GinkgoT().TempDir() + secretPath := filepath.Join(dir, "config.ini") + Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "alnic") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee", + Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), + })).To(Succeed()) + + // Older versions cached the raw bytes when the resize failed. + seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil } + stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm}) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(stream)).To(Equal(secret)) + Expect(stream.Close()).To(Succeed()) + Eventually(func(g Gomega) { + s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm, + open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(s.Cached).To(BeTrue()) + _ = s.Close() + }).Should(Succeed()) + + _, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan)) + }) + It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() { dir := GinkgoT().TempDir() imgPath := filepath.Join(dir, "cover.jpg") diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index e7a2d3765..f4f3ef725 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -572,7 +572,7 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold // resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other // open failure says nothing about whether the image exists. func resolveLocalFile(path, source string) (resolution, bool) { - if path == "" { + if path == "" || !model.IsImageFile(path) { return resolution{}, false } f, err := os.Open(path) diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 402a11363..da144d8e2 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -498,6 +498,23 @@ var _ = Describe("resolveItem", func() { Expect(res.refMtime).To(BeNumerically(">", 0)) }) + It("never opens a local ExternalImageURL that is not an image file", func() { + folderRepo.result = nil // no grid tiles, so only the local file could produce a reader + dir := GinkgoT().TempDir() + secretPath := filepath.Join(dir, "config.ini") + Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.sourcePath).ToNot(Equal(secretPath)) + }) + It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { conf.Server.EnableM3UExternalAlbumArt = true folderRepo.result = nil // no grid tiles, so the external failure is what surfaces diff --git a/core/artwork/sources.go b/core/artwork/sources.go index f2abf9da5..ae41acc48 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -37,7 +37,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file) continue } - if !match { + if !match || !model.IsImageFile(name) { continue } f, err := libFS.Open(file) diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go index 4282575a5..5f70b1cc5 100644 --- a/core/artwork/sources_internal_test.go +++ b/core/artwork/sources_internal_test.go @@ -48,6 +48,18 @@ var _ = Describe("fromExternalFile", func() { Expect(b).To(Equal([]byte("a"))) Expect(path).To(Equal("a/cover.jpg")) }) + + It("skips a matching file that is not an image", func() { + fsys := fstest.MapFS{ + "a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")}, + "a/cover.jpg": &fstest.MapFile{Data: []byte("a")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + Expect(path).To(Equal("a/cover.jpg")) + }) }) var _ = Describe("fromTag", func() { diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 445561266..a90a703d9 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -236,6 +236,24 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ExternalImageURL).To(BeEmpty()) }) + It("rejects #EXTALBUMARTURL pointing at a non-image file inside the library", func() { + tmpDir := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("password=secret"), 0600)).To(Succeed()) + + m3u := "#EXTALBUMARTURL:config.ini\ntest.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(BeEmpty()) + }) + It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() { conf.Server.EnableM3UExternalAlbumArt = false @@ -1011,6 +1029,19 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ExternalImageURL).To(BeEmpty()) }) + DescribeTable("restricts a local #EXTALBUMARTURL to the owner's libraries", + func(imageURL, expected string) { + ctx = request.WithUser(ctx, model.User{ID: "123", Libraries: model.Libraries{{ID: 1, Path: "/music"}}}) + repo.data = []string{"tests/test.mp3"} + m3u := "#EXTALBUMARTURL:" + imageURL + "\n/music/tests/test.mp3\n" + pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(Equal(expected)) + }, + Entry("accepts a library the owner can access", "file:///music/cover.jpg", filepath.Clean("/music/cover.jpg")), + Entry("ignores a library the owner cannot access", "file:///new/cover.jpg", ""), + ) + // Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation, // so we need exact matching for non-ASCII characters. It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() { diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index 286f2e420..b9cb154cb 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" "golang.org/x/text/unicode/norm" ) @@ -36,7 +37,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m continue } if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok { - pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher) + owner, _ := request.UserFrom(ctx) + pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner) continue } // Skip empty lines and extended info @@ -286,7 +288,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l // HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). // Local paths (file://, absolute, or relative) are resolved to an absolute path // and validated against known library boundaries via matcher. -func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string { +func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string { value = strings.TrimSpace(value) if value == "" { return "" @@ -302,12 +304,13 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher // Resolve to local absolute path localPath, ok := resolveLocalPath(value, folder) - if !ok { + if !ok || !model.IsImageFile(localPath) { return "" } - // Validate path is within a known library - if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 { + lib, ok := matcher.findLibrary(localPath) + // A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries. + if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) { return "" } return localPath diff --git a/tests/init_tests.go b/tests/init_tests.go index 902cf196d..eee4428a8 100644 --- a/tests/init_tests.go +++ b/tests/init_tests.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/navidrome/navidrome/conf" + _ "github.com/navidrome/navidrome/conf/mime" // registers mime_types.yaml, so tests see the same image types as the server "github.com/navidrome/navidrome/log" ) From 0429fb3d40ffcc2c52d684334ad31c65f8a7e466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 20 Sep 2026 15:27:20 -0400 Subject: [PATCH 12/22] fix(test): stop the Windows test job from failing at random (#6182) * fix(persistence): don't format a nil-model row in wrapCursor * test(plugins): assert task queue delay against the first dispatch, not consecutive gaps * test(artwork): let the e2e worker wait outlast one retry * chore: trim comments * fix(persistence): guard dbFolder and dbMediaFile String() against a nil model --- core/artwork/e2e/e2e_suite_test.go | 3 ++- persistence/folder_repository.go | 8 ++++++++ persistence/folder_repository_test.go | 7 +++++++ persistence/mediafile_repository.go | 8 ++++++++ persistence/mediafile_repository_test.go | 7 +++++++ persistence/sql_base_repository.go | 3 ++- plugins/host_taskqueue_test.go | 9 +++++---- 7 files changed, 39 insertions(+), 6 deletions(-) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go index 8881da3a6..390be14a7 100644 --- a/core/artwork/e2e/e2e_suite_test.go +++ b/core/artwork/e2e/e2e_suite_test.go @@ -56,7 +56,8 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) go func() { done <- worker.Run(runCtx) }() - Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) + // Long enough for one retry (3-7s backoff, 5s poll tick). + Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue()) cancel() Eventually(done, 2*time.Second).Should(Receive(BeNil())) } diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index a4b73d9d6..a1136ad8d 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -27,6 +27,14 @@ type dbFolder struct { ImageFiles string `structs:"-" json:"-"` } +// String guards the promoted Folder.String(), which would dereference a nil Folder. +func (f dbFolder) String() string { + if f.Folder == nil { + return "" + } + return f.Folder.String() +} + func (f *dbFolder) PostScan() error { var err error if f.ImageFiles != "" { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 545d8ad49..b7bc52751 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -310,6 +310,13 @@ var _ = Describe("FolderRepository", func() { }) }) + Describe("dbFolder.String", func() { + It("does not dereference a nil Folder", func() { + Expect(fmt.Sprint(dbFolder{})).To(Equal("")) + Expect(fmt.Sprint(&dbFolder{})).To(Equal("")) + }) + }) + Describe("wrapFolderCursor", func() { It("does not panic when the cursor yields a dbFolder with nil Folder", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 935a64bb9..835c39b1e 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -38,6 +38,14 @@ type dbMediaFile struct { RgTrackPeak *float64 `structs:"-" json:"-"` } +// String guards the promoted MediaFile.String(), which would dereference a nil MediaFile. +func (m dbMediaFile) String() string { + if m.MediaFile == nil { + return "" + } + return m.MediaFile.String() +} + func (m *dbMediaFile) PostScan() error { m.RGTrackGain = m.RgTrackGain m.RGTrackPeak = m.RgTrackPeak diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 1fb939415..d81b5e9ee 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -1203,6 +1203,13 @@ var _ = Describe("MediaRepository", func() { }) }) + Describe("dbMediaFile.String", func() { + It("does not dereference a nil MediaFile", func() { + Expect(fmt.Sprint(dbMediaFile{})).To(Equal("")) + Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("")) + }) + }) + Describe("wrapMediaFileCursor", func() { It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 9248f2a26..bc841db03 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -378,8 +378,9 @@ func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.S for row, err := range cursor { m := toModel(row) if m == nil { + // Don't format row: its String() derefs the nil model (golang/go#81238). var zero T - yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err)) + yield(zero, fmt.Errorf("unexpected nil %T: %w", zero, err)) return } if !yield(*m, err) || err != nil { diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index 72fb4cccd..993ddc7f6 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -689,11 +689,12 @@ var _ = Describe("TaskQueueService", func() { copy(times, dispatchTimes) mu.Unlock() - // Consecutive dispatches should have at least ~160ms gap (80% of 200ms) + // Wake-up latency varies per worker, so check offsets from the first dispatch, not gaps. for i := 1; i < len(times); i++ { - gap := times[i].Sub(times[i-1]) - Expect(gap).To(BeNumerically(">=", 160*time.Millisecond), - fmt.Sprintf("gap between dispatch %d and %d was %v, expected >= 160ms", i-1, i, gap)) + offset := times[i].Sub(times[0]) + minOffset := time.Duration(i)*200*time.Millisecond - 50*time.Millisecond + Expect(offset).To(BeNumerically(">=", minOffset), + fmt.Sprintf("dispatch %d ran %v after the first, expected >= %v", i, offset, minOffset)) } }) }) From 237276efcd1e571714eff5e469eb7328b439fe69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 20 Sep 2026 20:46:46 -0400 Subject: [PATCH 13/22] fix(artwork): block private and loopback addresses in remote image fetches (#6181) * fix(artwork): block private and loopback addresses in remote image fetches fromURL fetched any URL with a plain HTTP client, and two untrusted inputs reach it. A playlist can set #EXTALBUMARTURL to an http(s) URL, which the artwork worker later fetches when EnableM3UExternalAlbumArt is on, so any user who can import a playlist controls the target. Metadata agents, including WASM plugins without the http permission, return image URLs that the core fetches too. Either path could make the server request loopback, LAN or link-local addresses and store the response as artwork that is served back. Add httpclient.NewExternal, which dials through a net.Dialer Control hook that rejects private, loopback, link-local and unspecified addresses. The check runs at dial time on the resolved IP, so DNS names, redirects and DNS rebinding are covered. fromURL now uses one shared client built with it and treats a refused address as a definitive miss, so the item settles absent instead of retrying and tripping the agent's circuit breaker. httpclient.New is unchanged for the other callers. The IP classification moves from plugins to the new utils/netguard package, shared by the plugin host client and the new constructor. The artwork test suite swaps in a client that allows loopback so existing specs can keep using httptest servers; the fromURL specs use the production client to assert the refusal. * fix(httpclient): keep dialing a configured proxy in the guarded client The guard runs on the resolved address, and with HTTP_PROXY set that address is the proxy, not the image host. A proxy on a private address would have had every remote artwork fetch refused, and a refusal settles the item as absent, so covers would silently disappear for those setups. Dial the configured proxy endpoint directly and keep the guard for every other dial. A proxy relays the request itself, so it is the operator's egress policy, the same one every other httpclient.New caller already goes through. * fix(httpclient): exempt only the hop that actually goes through the proxy The exemption matched any dial to a configured proxy's address, but net/http never proxies loopback targets, so a URL aimed at a loopback proxy was dialed directly and skipped the guard. That let an image URL reach that one address. Tag each request with the proxy it resolves to and exempt a dial only when it is that hop. Redirects re-enter the RoundTripper, so every hop is tagged on its own. --- core/artwork/artwork_suite_test.go | 11 +++ core/artwork/sources.go | 13 +++- core/artwork/sources_internal_test.go | 74 +++++++++++++++++++ plugins/host_httpclient.go | 3 +- plugins/host_netguard.go | 8 +- utils/httpclient/httpclient.go | 49 ++++++++++++ .../httpclient_proxy_internal_test.go | 68 +++++++++++++++++ utils/httpclient/httpclient_test.go | 31 ++++++++ utils/netguard/netguard.go | 38 ++++++++++ utils/netguard/netguard_suite_test.go | 17 +++++ utils/netguard/netguard_test.go | 65 ++++++++++++++++ 11 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 utils/httpclient/httpclient_proxy_internal_test.go create mode 100644 utils/netguard/netguard.go create mode 100644 utils/netguard/netguard_suite_test.go create mode 100644 utils/netguard/netguard_test.go diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index 1ea82b7fa..93aacc1fa 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -2,18 +2,21 @@ package artwork import ( "io/fs" + "net/netip" "net/url" "os" "path/filepath" "runtime" "strings" "testing" + "time" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/httpclient" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/goleak" @@ -37,6 +40,14 @@ func TestArtwork(t *testing.T) { RunSpecs(t, "Artwork Suite") } +// productionImageClient keeps the guarded client for the specs that assert it refuses loopback. +var productionImageClient = remoteImageClient + +// httptest servers listen on loopback, which the production client refuses. +var _ = BeforeSuite(func() { + remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")) +}) + // osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. type osDirFS struct{ fs.FS } diff --git a/core/artwork/sources.go b/core/artwork/sources.go index ae41acc48..daa084c16 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/httpclient" + "github.com/navidrome/navidrome/utils/netguard" "go.senan.xyz/taglib" ) @@ -150,10 +151,18 @@ type readCloser struct { io.Closer } +// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach +// internal hosts. Shared so fetches reuse connections. +var remoteImageClient = httpclient.NewExternal(5 * time.Second) + func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { - hc := httpclient.New(5 * time.Second) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil) - resp, err := hc.Do(req) //nolint:gosec + resp, err := remoteImageClient.Do(req) + if errors.Is(err, netguard.ErrPrivateAddress) { + // Retrying cannot change where the URL points: settle absent instead of tripping the breaker. + log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err) + return nil, "", model.ErrNotFound + } if err != nil { return nil, "", err } diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go index 5f70b1cc5..bc81b56e7 100644 --- a/core/artwork/sources_internal_test.go +++ b/core/artwork/sources_internal_test.go @@ -5,13 +5,87 @@ import ( "errors" "io" "io/fs" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" "os" + "strings" + "sync/atomic" "testing/fstest" + "time" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/httpclient" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("fromURL", func() { + var ( + hits atomic.Int32 + target *httptest.Server + ) + + BeforeEach(func() { + hits.Store(0) + target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("image-bytes")) + })) + DeferCleanup(target.Close) + }) + + useClient := func(c *http.Client) { + prev := remoteImageClient + remoteImageClient = c + DeferCleanup(func() { remoteImageClient = prev }) + } + fetch := func(rawURL string) ([]byte, error) { + u, err := url.Parse(rawURL) + Expect(err).ToNot(HaveOccurred()) + r, _, err := fromURL(GinkgoT().Context(), u) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) + } + // Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused. + onlyLocalhostV4 := func() *http.Client { + return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32")) + } + + DescribeTable("refuses private and loopback targets as a definitive miss", + func(rawURL string) { + useClient(productionImageClient) + u, _ := url.Parse(target.URL) + _, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port())) + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(hits.Load()).To(BeZero()) + }, + Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"), + Entry("localhost", "http://localhost:PORT/x"), + Entry("cloud metadata", "http://169.254.169.254/"), + Entry("IPv6 loopback", "http://[::1]/"), + ) + + It("refuses a redirect from an allowed host to a loopback address", func() { + useClient(onlyLocalhostV4()) + redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound)) + DeferCleanup(redirector.Close) + + _, err := fetch(redirector.URL) + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(hits.Load()).To(BeZero()) + }) + + It("fetches from an allowed address", func() { + useClient(onlyLocalhostV4()) + Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes"))) + }) +}) + var _ = Describe("fromExternalFile", func() { It("opens a matching file via the library FS", func() { fsys := fstest.MapFS{ diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 54875ae8a..3265ac4b7 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/plugins/host" "github.com/navidrome/navidrome/utils/httpclient" + "github.com/navidrome/navidrome/utils/netguard" ) const ( @@ -201,7 +202,7 @@ func isPrivateOrLoopback(hostname string) bool { if ip == nil { return false } - return isPrivateIP(ip) + return netguard.IsPrivateIP(ip) } // Verify interface implementation diff --git a/plugins/host_netguard.go b/plugins/host_netguard.go index 2244fed1f..f78aaf6a8 100644 --- a/plugins/host_netguard.go +++ b/plugins/host_netguard.go @@ -4,6 +4,8 @@ import ( "fmt" "net" "slices" + + "github.com/navidrome/navidrome/utils/netguard" ) // checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a @@ -17,7 +19,7 @@ func checkPrivateDial(requiredHosts []string, address string) error { return err } ip := net.ParseIP(host) - if ip == nil || !isPrivateIP(ip) { + if ip == nil || !netguard.IsPrivateIP(ip) { return nil } for _, entry := range requiredHosts { @@ -51,7 +53,3 @@ func ipMatchesEntry(entry string, ip net.IP) bool { } return false } - -func isPrivateIP(ip net.IP) bool { - return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() -} diff --git a/utils/httpclient/httpclient.go b/utils/httpclient/httpclient.go index 7fb48f36d..b0e7b5681 100644 --- a/utils/httpclient/httpclient.go +++ b/utils/httpclient/httpclient.go @@ -3,10 +3,15 @@ package httpclient import ( + "context" + "net" "net/http" + "net/netip" + "net/url" "time" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/utils/netguard" ) type uaTransport struct { @@ -33,3 +38,47 @@ func NewTransport(base http.RoundTripper) http.RoundTripper { func New(timeout time.Duration) *http.Client { return &http.Client{Timeout: timeout, Transport: NewTransport(nil)} } + +// proxyFunc resolves the proxy for a request; tests replace it. +var proxyFunc = http.ProxyFromEnvironment + +type proxyAddrKey struct{} + +// NewExternal is New for URLs from untrusted sources: it refuses to dial private, loopback, +// link-local and unspecified addresses, except those covered by allowed. +func NewExternal(timeout time.Duration, allowed ...netip.Prefix) *http.Client { + t := http.DefaultTransport.(*http.Transport).Clone() + t.Proxy = proxyFunc + direct := net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} + guarded := direct + guarded.Control = netguard.DialControl(allowed...) + t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + // Exempt the hop to the proxy, which relays the request and is operator config. A URL + // aimed at the proxy's own address is not proxied, so it stays guarded. + if proxy, ok := ctx.Value(proxyAddrKey{}).(string); ok && proxy == addr { + return direct.DialContext(ctx, network, addr) + } + return guarded.DialContext(ctx, network, addr) + } + return &http.Client{Timeout: timeout, Transport: NewTransport(&proxyTagger{base: t})} +} + +// proxyTagger records the proxy each request resolves to, so the dialer can tell a hop to the +// proxy from a dial to the URL's own host. +type proxyTagger struct{ base http.RoundTripper } + +func (p *proxyTagger) RoundTrip(req *http.Request) (*http.Response, error) { + if u, err := proxyFunc(req); err == nil && u != nil { + req = req.WithContext(context.WithValue(req.Context(), proxyAddrKey{}, proxyAddr(u))) + } + return p.base.RoundTrip(req) +} + +// proxyAddr mirrors how net/http addresses a proxy connection. +func proxyAddr(u *url.URL) string { + port := u.Port() + if port == "" { + port = map[string]string{"http": "80", "https": "443", "socks5": "1080", "socks5h": "1080"}[u.Scheme] + } + return net.JoinHostPort(u.Hostname(), port) +} diff --git a/utils/httpclient/httpclient_proxy_internal_test.go b/utils/httpclient/httpclient_proxy_internal_test.go new file mode 100644 index 000000000..2d542d8fe --- /dev/null +++ b/utils/httpclient/httpclient_proxy_internal_test.go @@ -0,0 +1,68 @@ +package httpclient + +import ( + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/utils/netguard" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("NewExternal with a proxy", func() { + var proxy *httptest.Server + var proxied atomic.Int32 + var proxyURL *url.URL + + BeforeEach(func() { + proxied.Store(0) + proxy = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + proxied.Add(1) + _, _ = w.Write([]byte("via proxy")) + })) + DeferCleanup(proxy.Close) + proxyURL, _ = url.Parse(proxy.URL) + prev := proxyFunc + DeferCleanup(func() { proxyFunc = prev }) + }) + + It("dials the configured proxy even though it listens on a private address", func() { + proxyFunc = func(*http.Request) (*url.URL, error) { return proxyURL, nil } + + resp, err := NewExternal(time.Second).Get("http://navidrome.example.com/cover.jpg") + Expect(err).ToNot(HaveOccurred()) + defer resp.Body.Close() + Expect(proxied.Load()).To(Equal(int32(1))) + }) + + // net/http never proxies loopback targets, so a URL aimed at a loopback proxy is dialed directly. + It("refuses a direct dial to the proxy's own address when the request is not proxied", func() { + proxyFunc = func(r *http.Request) (*url.URL, error) { + if r.URL.Hostname() == "127.0.0.1" { + return nil, nil + } + return proxyURL, nil + } + + _, err := NewExternal(time.Second).Get(proxy.URL + "/secret") + Expect(err).To(MatchError(netguard.ErrPrivateAddress)) + Expect(proxied.Load()).To(BeZero()) + }) + + It("still refuses a direct dial to a private address", func() { + proxyFunc = func(r *http.Request) (*url.URL, error) { + if r.URL.Scheme == "https" { + return proxyURL, nil + } + return nil, nil + } + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + DeferCleanup(target.Close) + + _, err := NewExternal(time.Second).Get(target.URL) + Expect(err).To(MatchError(netguard.ErrPrivateAddress)) + }) +}) diff --git a/utils/httpclient/httpclient_test.go b/utils/httpclient/httpclient_test.go index c86b51165..7b1e58797 100644 --- a/utils/httpclient/httpclient_test.go +++ b/utils/httpclient/httpclient_test.go @@ -3,10 +3,12 @@ package httpclient_test import ( "net/http" "net/http/httptest" + "net/netip" "time" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/utils/httpclient" + "github.com/navidrome/navidrome/utils/netguard" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -16,6 +18,7 @@ var _ = Describe("httpclient", func() { var receivedUA string BeforeEach(func() { + receivedUA = "" server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedUA = r.Header.Get("User-Agent") })) @@ -48,6 +51,34 @@ var _ = Describe("httpclient", func() { }) }) + Describe("NewExternal", func() { + It("refuses to connect to a loopback server", func() { + c := httpclient.NewExternal(time.Second) + _, err := c.Get(server.URL) + Expect(err).To(MatchError(netguard.ErrPrivateAddress)) + Expect(receivedUA).To(BeEmpty()) + }) + + It("refuses a redirect from an allowed host to a private address", func() { + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound) + })) + DeferCleanup(redirector.Close) + + c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.1/32")) + _, err := c.Get(redirector.URL) + Expect(err).To(MatchError(netguard.ErrPrivateAddress)) + }) + + It("connects to addresses covered by an allowed prefix and sets the User-Agent", func() { + c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.0/8")) + resp, err := c.Get(server.URL) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) + }) + }) + Describe("NewTransport", func() { It("uses the default transport when base is nil", func() { c := &http.Client{Transport: httpclient.NewTransport(nil)} diff --git a/utils/netguard/netguard.go b/utils/netguard/netguard.go new file mode 100644 index 000000000..43d80ecc9 --- /dev/null +++ b/utils/netguard/netguard.go @@ -0,0 +1,38 @@ +// Package netguard keeps outbound connections driven by untrusted input away from internal addresses. +package netguard + +import ( + "errors" + "fmt" + "net" + "net/netip" + "syscall" +) + +var ErrPrivateAddress = errors.New("dial to private/loopback address blocked") + +// IsPrivateIP reports whether ip is loopback, private, link-local or unspecified. +func IsPrivateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} + +// DialControl returns a net.Dialer Control hook that rejects private addresses not covered by allowed. +// It sees the resolved IP, so DNS names, redirects and rebinding cannot get around it. +func DialControl(allowed ...netip.Prefix) func(network, address string, c syscall.RawConn) error { + return func(_, address string, _ syscall.RawConn) error { + ap, err := netip.ParseAddrPort(address) + if err != nil { + return fmt.Errorf("%w: unparseable address %q: %w", ErrPrivateAddress, address, err) + } + addr := ap.Addr().Unmap() + if !IsPrivateIP(addr.AsSlice()) { + return nil + } + for _, p := range allowed { + if p.Contains(addr) { + return nil + } + } + return fmt.Errorf("%w: %s", ErrPrivateAddress, address) + } +} diff --git a/utils/netguard/netguard_suite_test.go b/utils/netguard/netguard_suite_test.go new file mode 100644 index 000000000..7769e0c82 --- /dev/null +++ b/utils/netguard/netguard_suite_test.go @@ -0,0 +1,17 @@ +package netguard_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNetguard(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Netguard Suite") +} diff --git a/utils/netguard/netguard_test.go b/utils/netguard/netguard_test.go new file mode 100644 index 000000000..733204226 --- /dev/null +++ b/utils/netguard/netguard_test.go @@ -0,0 +1,65 @@ +package netguard_test + +import ( + "net" + "net/netip" + + "github.com/navidrome/navidrome/utils/netguard" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("netguard", func() { + DescribeTable("IsPrivateIP", + func(addr string, expected bool) { + Expect(netguard.IsPrivateIP(net.ParseIP(addr))).To(Equal(expected)) + }, + Entry("IPv4 loopback", "127.0.0.1", true), + Entry("IPv4 loopback range", "127.0.0.2", true), + Entry("IPv6 loopback", "::1", true), + Entry("IPv4-mapped IPv6 loopback", "::ffff:127.0.0.1", true), + Entry("10.x", "10.1.2.3", true), + Entry("172.16.x", "172.16.0.1", true), + Entry("192.168.x", "192.168.1.10", true), + Entry("IPv6 unique local", "fd00::1", true), + Entry("link-local (cloud metadata)", "169.254.169.254", true), + Entry("IPv6 link-local", "fe80::1", true), + Entry("link-local multicast", "224.0.0.1", true), + Entry("IPv4 unspecified", "0.0.0.0", true), + Entry("IPv6 unspecified", "::", true), + Entry("public IPv4", "93.184.216.34", false), + Entry("172.32.x is outside the private block", "172.32.0.1", false), + Entry("public IPv6", "2606:4700:4700::1111", false), + ) + + Describe("DialControl", func() { + It("rejects private and loopback addresses", func() { + control := netguard.DialControl() + for _, addr := range []string{"127.0.0.1:80", "[::1]:443", "169.254.169.254:80", "10.0.0.1:8080", "0.0.0.0:80"} { + Expect(control("tcp", addr, nil)).To(MatchError(netguard.ErrPrivateAddress), addr) + } + }) + + It("allows public addresses", func() { + control := netguard.DialControl() + Expect(control("tcp", "93.184.216.34:443", nil)).To(Succeed()) + Expect(control("tcp6", "[2606:4700:4700::1111]:443", nil)).To(Succeed()) + }) + + It("allows private addresses covered by an allowed prefix, and only those", func() { + control := netguard.DialControl(netip.MustParsePrefix("127.0.0.1/32")) + Expect(control("tcp", "127.0.0.1:8080", nil)).To(Succeed()) + Expect(control("tcp", "127.0.0.2:8080", nil)).To(MatchError(netguard.ErrPrivateAddress)) + }) + + It("matches IPv4-mapped IPv6 addresses against IPv4 prefixes", func() { + control := netguard.DialControl(netip.MustParsePrefix("127.0.0.0/8")) + Expect(control("tcp", "[::ffff:127.0.0.1]:80", nil)).To(Succeed()) + }) + + It("fails closed when the address is not an IP", func() { + Expect(netguard.DialControl()("tcp", "localhost:80", nil)).To(HaveOccurred()) + Expect(netguard.DialControl()("tcp", "garbage", nil)).To(HaveOccurred()) + }) + }) +}) From 9e8811e4c5cfb22a73acdca840256836794d0f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 20 Sep 2026 21:10:41 -0400 Subject: [PATCH 14/22] fix(server): enforce player ownership on create and registration (#6184) POST /api/player passed the ownership check using the userId from the request body, then saved with the body id. When that id belonged to another user's player, the save became an update with no owner restriction, overwriting the row and moving it to the caller. Save now always creates a new player and ignores any id in the body; edits keep going through the owner-scoped Update. Player registration also reused a player by the id sent in the Subsonic player cookie or the Jellyfin DeviceId without checking its owner, letting a user attach to another user's player and overwrite its name, user agent and IP. Register now only reuses a player owned by the requesting user, falling back to the user's own players otherwise. --- core/players.go | 2 +- core/players_test.go | 15 +++++++++++++-- persistence/player_repository.go | 2 +- persistence/player_repository_test.go | 21 +++++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/core/players.go b/core/players.go index 963914514..b757f8460 100644 --- a/core/players.go +++ b/core/players.go @@ -38,7 +38,7 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip user, _ := request.UserFrom(ctx) if playerID != "" { plr, err = p.ds.Player(ctx).Get(playerID) - if err == nil && plr.Client != client { + if err == nil && (plr.Client != client || plr.UserId != user.ID) { playerID = "" } } diff --git a/core/players_test.go b/core/players_test.go index 90c265fcc..55ec16833 100644 --- a/core/players_test.go +++ b/core/players_test.go @@ -61,8 +61,19 @@ var _ = Describe("Players", func() { Expect(trc).To(BeNil()) }) + It("does not reuse another user's player by ID", func() { + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "otheruser", UserAgent: "Pixel", TranscodingId: "1"} + repo.add(plr) + p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).ToNot(Equal("123")) + Expect(p.UserId).To(Equal("userid")) + Expect(repo.lastSaved).To(Equal(p)) + Expect(trc).To(BeNil()) + }) + It("finds players by ID", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) @@ -93,7 +104,7 @@ var _ = Describe("Players", func() { }) It("finds player by ID and return its transcoding", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}, TranscodingId: "1"} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index a2b079d53..a2114b060 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -126,7 +126,7 @@ func (r *playerRepository) Save(entity any) (string, error) { if !r.isPermitted(t) { return "", rest.ErrPermissionDenied } - return r.put(t.ID, t) + return r.put("", t) // Save only creates; edits go through the owner-scoped Update } func (r *playerRepository) Update(id string, entity any, cols ...string) error { diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index b7085a1fb..4a7701ac2 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -288,6 +288,27 @@ var _ = Describe("PlayerRepository", func() { Expect(*stored).To(Equal(adminPlayer1)) }) + It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() { + spoofed := model.Player{ + ID: adminPlayer1.ID, + Name: "HIJACKED", + UserId: regularUser.ID, + ReportRealPath: true, + } + + id, err := regularRepo.Save(&spoofed) + Expect(err).To(BeNil()) + Expect(id).ToNot(Equal(adminPlayer1.ID)) + + stored, err := adminRepo.Get(adminPlayer1.ID) + Expect(err).To(BeNil()) + Expect(*stored).To(Equal(adminPlayer1)) + + created, err := adminRepo.Get(id) + Expect(err).To(BeNil()) + Expect(created.UserId).To(Equal(regularUser.ID)) + }) + It("does not let a regular user reassign their own player to another user", func() { // Owner updates their own player but tries to give it away to the admin. The update // succeeds for the other fields, but user_id is never written, so ownership stays put. From cabfd16f9faee4a205fc6470339df2fa009be359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 20 Sep 2026 21:36:35 -0400 Subject: [PATCH 15/22] fix(ui): update Finnish, Dutch translations from POEditor (#6148) Co-authored-by: navidrome-bot --- resources/i18n/fi.json | 17 ++++++++++++++--- resources/i18n/nl.json | 36 +++++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 3d1edbbd5..f79d71f94 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -202,7 +202,7 @@ "command": "Komento" }, "choices": { - "noDefaultBitRate": "" + "noDefaultBitRate": "Ei mikään" } }, "playlist": { @@ -609,7 +609,10 @@ "coverRemoved": "Kansikuva poistettu", "coverUploadError": "Virhe ladattaessa kansikuvaa", "coverRemoveError": "Virhe poistettaessa kansikuvaa", - "metadataRefreshStarted": "Metatietoja päivitetään taustalla" + "metadataRefreshStarted": "Metatietoja päivitetään taustalla", + "quickConnectApproved": "", + "quickConnectInvalidCode": "", + "quickConnectError": "" }, "menu": { "library": "Kirjasto", @@ -645,7 +648,15 @@ "selectLibraries": "Valitse kirjastot", "none": "Ei mitään" }, - "onlyFavourites": "Näytä vain suosikit" + "onlyFavourites": "Näytä vain suosikit", + "quickConnect": { + "name": "", + "code": "", + "help": "", + "confirm": "", + "continue": "", + "approve": "" + } }, "player": { "playListsText": "Jono", diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 46c3df9de..cb276b8f4 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -93,7 +93,8 @@ "addToPlaylist": "Toevoegen aan afspeellijst", "download": "Downloaden", "info": "Meer info", - "share": "Delen" + "share": "Delen", + "refresh": "Metadata verversen" }, "lists": { "all": "Alle", @@ -155,11 +156,13 @@ "newPassword": "Nieuw wachtwoord", "token": "Token", "lastAccessAt": "Meest recente toegang", - "libraries": "Bibliotheken" + "libraries": "Bibliotheken", + "scrobbleFilter": "Scrobble filter" }, "helperTexts": { "name": "Naamswijziging wordt pas zichtbaar bij de volgende login", - "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken" + "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken", + "scrobbleFilter": "Nummers binnen deze slimme afspeellijst regels worden niet gestuurd naar Last.fm, ListenBrainz of scrobbler plugins. Gebruikt dezelfde JSON syntaxis en gedrag als slimme afspeellijsten. Voorbeeld: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leeglaten om alles te scrobblen. Lokale afspeelaantallen vallen hierbuiten." }, "notifications": { "created": "Aangemaakt door gebruiker", @@ -173,7 +176,8 @@ "adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken" }, "validation": { - "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers" + "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers", + "invalidScrobbleFilter": "Moeten geldige slimme afspeellijst regels zijn. Geen ondersteuning voor Limit, Offset en Refresh Delay" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Doelformaat", "defaultBitRate": "Standaard bitrate", "command": "Commando" + }, + "choices": { + "noDefaultBitRate": "Geen" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Nummers", "comment": "Commentaar", "sync": "Auto-importeren", - "path": "Importeer vanuit" + "path": "Importeer vanuit", + "starred": "Favoriet" }, "actions": { "selectPlaylist": "Selecteer een afspeellijst:", @@ -403,7 +411,8 @@ "requiredHosts": "Benodigde hosts", "configValidationError": "Configuratiecheck mislukt", "schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.", - "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen." + "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen.", + "idHelp": "De plugin ID, afgeleid van zijn bestandsnaam. Gebruik dit als gerefereerd wordt aan deze plugin in de configuratie opties, zoals Agenten." }, "placeholders": { "configKey": "Sleutel", @@ -599,7 +608,11 @@ "coverUploaded": "Albumhoes bijgewerkt", "coverRemoved": "Albumhoes verwijderd", "coverUploadError": "Fout bij het toevoegen albumhoes", - "coverRemoveError": "Fout bij verwijderen albumhoes" + "coverRemoveError": "Fout bij verwijderen albumhoes", + "metadataRefreshStarted": "Metadata verversen op de achtergrond", + "quickConnectApproved": "", + "quickConnectInvalidCode": "", + "quickConnectError": "" }, "menu": { "library": "Bibliotheek", @@ -634,6 +647,15 @@ "multipleLibraries": "%{selected} van %{total} bibliotheken", "selectLibraries": "Selecteer bibliotheken", "none": "Geen" + }, + "onlyFavourites": "Toon alleen favorieten", + "quickConnect": { + "name": "", + "code": "", + "help": "", + "confirm": "", + "continue": "", + "approve": "" } }, "player": { From c3b9b4ecb37f443770866a2068db5326118e7b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 20 Sep 2026 22:02:23 -0400 Subject: [PATCH 16/22] fix(subsonic): rate limit failed authentication attempts (#6185) * fix(subsonic): limit failed authentication attempts per client IP and username The Subsonic API checked credentials on every request with no limit on failures, so any account could be brute-forced over /rest/*. Failed u+p, t+s and jwt attempts are now capped per (client IP, lower-cased username) using AuthRequestLimit and AuthWindowLength, the same settings that guard the UI login. Every request carries credentials, so only failures count: a slot is taken before the check and given back on success or on a server error, which also stops concurrent guesses from overshooting the limit. Blocked attempts get the same response as a wrong password (HTTP 200, error code 40, no Retry-After), so an attacker cannot tell a block from a wrong guess. Reverse proxy and internal authentication are not limited. The client IP helper behind ClientIPRateLimiter is now exported as server.ClientIP, so spoofed forwarding headers cannot open a fresh bucket. * refactor(subsonic): simplify failed authentication limiter Release the limiter slot from a single place in authenticate(), after the user lookup and credential check, instead of separately in the canceled branch. Store attempt counters by value instead of by pointer, and drop limiter unit tests that only repeated the middleware specs. * fix(subsonic): wait for an in-flight auth check instead of rejecting Slots were reserved before the credential check and only released afterwards, so once AuthRequestLimit checks for the same client IP and username overlapped, the next request was answered with error code 40 even when its credentials were valid. Clients that fan out parallel requests hit this constantly: a burst of six valid logins lost one, a burst of fifty lost forty five, and the web UI authenticates its own /rest calls the same way. A key now carries a slot channel of AuthRequestLimit capacity, and a request waits on it rather than failing when other checks for that key are in flight. Failures are recorded after the check, and a request is only rejected when the key already reached the limit within the window. A waiting request gives up if its context is canceled. Concurrent guesses still cannot run unchecked: at most AuthRequestLimit checks run at once and the rest are turned away as soon as the failures land. * docs(subsonic): state the real guess ceiling of the auth limiter The comment claimed a burst cannot overshoot, which reads as a hard cap of AuthRequestLimit. Allowing concurrent checks means a window admits up to 2*limit-1 guesses, so say that instead. --- server/middlewares.go | 13 +- server/subsonic/auth_limiter.go | 134 +++++++++++++++++++ server/subsonic/auth_limiter_test.go | 143 ++++++++++++++++++++ server/subsonic/middlewares.go | 30 +++-- server/subsonic/middlewares_test.go | 187 +++++++++++++++++++++++++++ 5 files changed, 494 insertions(+), 13 deletions(-) create mode 100644 server/subsonic/auth_limiter.go create mode 100644 server/subsonic/auth_limiter_test.go diff --git a/server/middlewares.go b/server/middlewares.go index c54e947f3..674337e92 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -232,15 +232,20 @@ func trustedProxyPrefixes(list string) []string { return prefixes } -// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware, -// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer -// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket. +// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers +// cannot be rotated for a fresh bucket. func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler { return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) { - return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil + return ClientIP(r), nil }) } +// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The +// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket. +func ClientIP(r *http.Request) string { + return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))) +} + // reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key // can only be set once. func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.Handler { diff --git a/server/subsonic/auth_limiter.go b/server/subsonic/auth_limiter.go new file mode 100644 index 000000000..88df6eb50 --- /dev/null +++ b/server/subsonic/auth_limiter.go @@ -0,0 +1,134 @@ +package subsonic + +import ( + "cmp" + "context" + "hash/maphash" + "sync" + "time" + + "github.com/navidrome/navidrome/consts" +) + +// authLimiter caps failed Subsonic logins per key. Checks run at most `limit` at a time and failures +// are recorded afterwards, so a window admits up to 2*limit-1 guesses and valid requests only wait. +type authLimiter struct { + limit int + window time.Duration + seed maphash.Seed + mu sync.Mutex + keys map[uint64]*authAttempts // hashed, so attacker-chosen usernames cannot bloat memory + lastSweep time.Time +} + +type authAttempts struct { + failures int + start time.Time + slots chan struct{} + refs int +} + +// authSlot is a reserved credential check. A nil slot releases nothing, which is what a disabled +// limiter hands back. +type authSlot struct { + limiter *authLimiter + entry *authAttempts +} + +// newAuthLimiter returns nil when limit is not positive. A nil limiter allows everything. +func newAuthLimiter(limit int, window time.Duration) *authLimiter { + if limit <= 0 { + return nil + } + return &authLimiter{ + limit: limit, + window: cmp.Or(window, consts.DefaultAuthWindowLength), + seed: maphash.MakeSeed(), + keys: map[uint64]*authAttempts{}, + } +} + +// acquire reserves a credential check for key, waiting while other checks for the same key are in +// flight. It only fails when the key already reached `limit` failures in the current window. +func (l *authLimiter) acquire(ctx context.Context, key string) (*authSlot, bool) { + if l == nil { + return nil, true + } + a, ok := l.reserve(key) + if !ok { + return nil, false + } + + select { + case a.slots <- struct{}{}: + case <-ctx.Done(): + l.unref(a) + return nil, false + } + + l.mu.Lock() + blocked := a.failures >= l.limit + if blocked { + a.refs-- + } + l.mu.Unlock() + if blocked { + <-a.slots + return nil, false + } + return &authSlot{limiter: l, entry: a}, true +} + +func (l *authLimiter) reserve(key string) (*authAttempts, bool) { + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + l.sweep(now) + + h := maphash.String(l.seed, key) + a := l.keys[h] + switch { + case a == nil: + a = &authAttempts{start: now, slots: make(chan struct{}, l.limit)} + l.keys[h] = a + case now.Sub(a.start) >= l.window: + a.failures, a.start = 0, now + } + if a.failures >= l.limit { + return nil, false + } + a.refs++ + return a, true +} + +func (l *authLimiter) unref(a *authAttempts) { + l.mu.Lock() + a.refs-- + l.mu.Unlock() +} + +func (s *authSlot) release(failed bool) { + if s == nil { + return + } + s.limiter.mu.Lock() + if failed { + s.entry.failures++ + } + s.entry.refs-- + s.limiter.mu.Unlock() + <-s.entry.slots +} + +// sweep drops idle expired keys once per window, so memory is bounded by recent attempts. +func (l *authLimiter) sweep(now time.Time) { + if now.Sub(l.lastSweep) < l.window { + return + } + for h, a := range l.keys { + if a.refs == 0 && now.Sub(a.start) >= l.window { + delete(l.keys, h) + } + } + l.lastSweep = now +} diff --git a/server/subsonic/auth_limiter_test.go b/server/subsonic/auth_limiter_test.go new file mode 100644 index 000000000..66e1a594f --- /dev/null +++ b/server/subsonic/auth_limiter_test.go @@ -0,0 +1,143 @@ +package subsonic + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("authLimiter", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + acquire := func(l *authLimiter, key string) (*authSlot, bool) { + GinkgoHelper() + return l.acquire(ctx, key) + } + + It("blocks a key after the configured number of failures", func() { + l := newAuthLimiter(2, time.Minute) + for range 2 { + slot, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + slot.release(true) + } + + _, ok := acquire(l, "k") + Expect(ok).To(BeFalse()) + }) + + It("never counts successful checks", func() { + l := newAuthLimiter(2, time.Minute) + for range 50 { + slot, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + slot.release(false) + } + }) + + It("keeps keys independent", func() { + l := newAuthLimiter(1, time.Minute) + slot, _ := acquire(l, "a") + slot.release(true) + _, ok := acquire(l, "a") + Expect(ok).To(BeFalse()) + + _, ok = acquire(l, "b") + Expect(ok).To(BeTrue()) + }) + + It("waits for an in-flight check instead of failing the request", func() { + l := newAuthLimiter(1, time.Minute) + held, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + + waiting := make(chan bool, 1) + go func() { + slot, ok := l.acquire(ctx, "k") + slot.release(false) + waiting <- ok + }() + Consistently(waiting, 50*time.Millisecond).ShouldNot(Receive()) + + held.release(false) + Eventually(waiting).Should(Receive(BeTrue())) + }) + + It("stops waiting when the request is canceled", func() { + l := newAuthLimiter(1, time.Minute) + held, _ := acquire(l, "k") + DeferCleanup(func() { held.release(false) }) + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + _, ok := l.acquire(canceled, "k") + Expect(ok).To(BeFalse()) + }) + + It("does not let concurrent guesses overshoot the limit", func() { + l := newAuthLimiter(5, time.Minute) + hold := make(chan struct{}) + var checks atomic.Int32 + var wg sync.WaitGroup + for range 50 { + wg.Go(func() { + slot, ok := l.acquire(ctx, "k") + if !ok { + return + } + checks.Add(1) + <-hold + slot.release(true) + }) + } + + Eventually(checks.Load).Should(Equal(int32(5))) + Consistently(checks.Load, 100*time.Millisecond).Should(Equal(int32(5))) + close(hold) + wg.Wait() + + _, ok := acquire(l, "k") + Expect(ok).To(BeFalse()) + }) + + It("allows everything when the limit is disabled", func() { + l := newAuthLimiter(0, time.Minute) + for range 10 { + slot, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + slot.release(true) + } + }) +}) + +// testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. +func TestAuthLimiterWindow(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + l := newAuthLimiter(1, 20*time.Second) + for _, key := range []string{"a", "b"} { + slot, ok := l.acquire(ctx, key) + g.Expect(ok).To(BeTrue()) + slot.release(true) + } + _, ok := l.acquire(ctx, "a") + g.Expect(ok).To(BeFalse()) + + time.Sleep(20 * time.Second) + slot, ok := l.acquire(ctx, "a") + g.Expect(ok).To(BeTrue()) + slot.release(false) + g.Expect(l.keys).To(HaveLen(1), "expired keys must be dropped") + }) +} diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 6dfa2263f..35e13eaa5 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -98,6 +98,7 @@ func checkRequiredParameters(next http.Handler) http.Handler { } func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { + limiter := newAuthLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -126,21 +127,32 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { salt, _ := p.String("s") jwt, _ := p.String("jwt") - usr, err = ds.User(ctx).FindByUsernameWithPassword(username) - if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + // Blocked attempts get the same response as a wrong password, so they reveal nothing + limitKey := server.ClientIP(r) + "\x00" + strings.ToLower(username) + slot, allowed := limiter.acquire(ctx, limitKey) + if !allowed { + if ctx.Err() != nil { + return + } + log.Warn(ctx, "API: Too many failed login attempts", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr) + sendError(w, r, newError(responses.ErrorAuthenticationFail)) return } + + usr, err = ds.User(ctx).FindByUsernameWithPassword(username) + if err == nil { + err = validateCredentials(usr, pass, token, salt, jwt) + } + invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth) + slot.release(invalidLogin) switch { - case errors.Is(err, model.ErrNotFound): + case errors.Is(err, context.Canceled): + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + return + case invalidLogin: log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) case err != nil: log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - default: - err = validateCredentials(usr, pass, token, salt, jwt) - if err != nil { - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - } } } diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index cb34b92e7..62de09e76 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -8,6 +8,8 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" + "sync/atomic" "time" "github.com/navidrome/navidrome/conf" @@ -306,6 +308,166 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeFalse()) }) }) + + When("failed attempts reach AuthRequestLimit", func() { + var cp http.Handler + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 3 + conf.Server.AuthWindowLength = time.Minute + cp = authenticate(ds)(next) + }) + + serve := func(r *http.Request) *httptest.ResponseRecorder { + next.called = false + rec := httptest.NewRecorder() + cp.ServeHTTP(rec, r) + return rec + } + failTimes := func(n int, params ...string) { + for range n { + Expect(serve(newGetRequest(params...)).Body.String()).To(ContainSubstring(`code="40"`)) + } + } + + It("rejects the correct password exactly like a wrong one", func() { + failTimes(3, "u=admin", "p=WRONG") + + rec := serve(newGetRequest("u=admin", "p=wordpass")) + + Expect(next.called).To(BeFalse()) + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(rec.Header().Get("Retry-After")).To(BeEmpty()) + }) + + It("counts attempts against unknown usernames", func() { + failTimes(3, "u=newuser", "p=secret") + _ = ds.User(context.TODO()).Put(&model.User{UserName: "newuser", NewPassword: "secret"}) + + serve(newGetRequest("u=newuser", "p=secret")) + Expect(next.called).To(BeFalse()) + }) + + It("treats usernames case-insensitively", func() { + failTimes(3, "u=ADMIN", "p=WRONG") + + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeFalse()) + }) + + It("does not count successful logins", func() { + for range 10 { + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeTrue()) + } + }) + + It("does not count server errors", func() { + userRepo := ds.User(context.TODO()).(*tests.MockedUserRepo) + userRepo.Error = errors.New("db down") + failTimes(5, "u=admin", "p=wordpass") + userRepo.Error = nil + + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeTrue()) + }) + + It("does not block other usernames from the same IP", func() { + _ = ds.User(context.TODO()).Put(&model.User{UserName: "other", NewPassword: "otherpass"}) + failTimes(3, "u=admin", "p=WRONG") + + serve(newGetRequest("u=other", "p=otherpass")) + Expect(next.called).To(BeTrue()) + }) + + It("does not block the same username from another IP", func() { + failTimes(3, "u=admin", "p=WRONG") + + r := newGetRequest("u=admin", "p=wordpass") + r.RemoteAddr = "198.51.100.7:1234" + serve(r) + Expect(next.called).To(BeTrue()) + }) + + It("does not limit reverse proxy authentication", func() { + conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24" + conf.Server.ExtAuth.UserHeader = "Remote-User" + failTimes(3, "u=admin", "p=WRONG") + + r := newGetRequest() + r.Header.Add("Remote-User", "admin") + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1")) + serve(r) + Expect(next.called).To(BeTrue()) + }) + + It("is disabled when AuthRequestLimit is 0", func() { + conf.Server.AuthRequestLimit = 0 + cp = authenticate(ds)(next) + failTimes(10, "u=admin", "p=WRONG") + + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeTrue()) + }) + }) + + When("valid requests overlap", func() { + var gate *gatedUserRepo + var gatedDS model.DataStore + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 5 + conf.Server.AuthWindowLength = time.Minute + gate = &gatedUserRepo{ + UserRepository: ds.User(context.TODO()), + entered: make(chan struct{}, 64), + proceed: make(chan struct{}), + } + gatedDS = &gatedDataStore{DataStore: ds, users: gate} + }) + + It("lets every valid request through while checks are in flight", func() { + const burst = 6 + cp := authenticate(gatedDS)(&countingHandler{}) + var passed atomic.Int32 + var wg sync.WaitGroup + for range burst { + wg.Go(func() { + rec := httptest.NewRecorder() + cp.ServeHTTP(rec, newGetRequest("u=admin", "p=wordpass")) + if !strings.Contains(rec.Body.String(), `code="40"`) { + passed.Add(1) + } + }) + } + for range conf.Server.AuthRequestLimit { + Eventually(gate.entered).Should(Receive()) + } + close(gate.proceed) + wg.Wait() + + Expect(passed.Load()).To(Equal(int32(burst))) + }) + + It("caps concurrent credential checks for wrong passwords", func() { + cp := authenticate(gatedDS)(&countingHandler{}) + var wg sync.WaitGroup + for i := range 100 { + wg.Go(func() { + cp.ServeHTTP(httptest.NewRecorder(), newGetRequest("u=admin", fmt.Sprintf("p=wrong%d", i))) + }) + } + + limit := int32(conf.Server.AuthRequestLimit) + Eventually(gate.lookups.Load).Should(Equal(limit)) + Consistently(gate.lookups.Load, 100*time.Millisecond).Should(Equal(limit)) + close(gate.proceed) + wg.Wait() + }) + }) }) Describe("AdminOnly", func() { @@ -560,3 +722,28 @@ func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) } return &model.Player{ID: id}, mp.transcoding, nil } + +type gatedDataStore struct { + model.DataStore + users model.UserRepository +} + +func (g *gatedDataStore) User(context.Context) model.UserRepository { return g.users } + +type gatedUserRepo struct { + model.UserRepository + entered chan struct{} + proceed chan struct{} + lookups atomic.Int32 +} + +func (g *gatedUserRepo) FindByUsernameWithPassword(username string) (*model.User, error) { + g.lookups.Add(1) + g.entered <- struct{}{} + <-g.proceed + return g.UserRepository.FindByUsernameWithPassword(username) +} + +type countingHandler struct{ calls atomic.Int32 } + +func (c *countingHandler) ServeHTTP(http.ResponseWriter, *http.Request) { c.calls.Add(1) } From 6b3938b5b659e1a9f707efb16649667084bfb3b9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 20 Sep 2026 22:25:55 -0400 Subject: [PATCH 17/22] fix(subsonic): warn when a nowPlaying scrobble sends multiple ids The scrobble endpoint accepts multiple ids, but a nowPlaying notification (submission=false) describes a single track, so only the first id is used. The extra ids were dropped silently, which made client bugs invisible. Log a warning instead, keeping the existing behavior for clients that rely on it. --- server/subsonic/media_annotation.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index cfbff3ecb..a43162cf4 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -181,6 +181,9 @@ func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err) } } else { + if len(ids) > 1 { + log.Warn(ctx, "Multiple ids sent to a nowPlaying notification, only the first one will be used", "ids", ids) + } err := api.scrobblerNowPlaying(ctx, ids[0], position) if err != nil { log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err) From 00bb120288af84f3764e684b9b2bab70d5028219 Mon Sep 17 00:00:00 2001 From: Antonio Russo Date: Mon, 21 Sep 2026 07:06:12 -0600 Subject: [PATCH 18/22] fix(contrib): support libblas in systemd sandbox (#6190) Navidrome calls out to ffprobe, which in turn may use libblas on some setups (e.g., Debian 13). The previous syscall filter excluded the "mbind" syscall (via @resources). Through direct experimentation, mbind is required by libblas, and so it is added to the allowed syscall list. Signed-off-by: Antonio Enrico Russo --- contrib/navidrome.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/navidrome.service b/contrib/navidrome.service index 5e6cbedce..ef61d2c65 100644 --- a/contrib/navidrome.service +++ b/contrib/navidrome.service @@ -36,7 +36,7 @@ RestrictNamespaces=yes RestrictRealtime=yes SystemCallFilter=@system-service SystemCallFilter=~@privileged @resources -SystemCallFilter=setrlimit +SystemCallFilter=setrlimit mbind SystemCallArchitectures=native UMask=0066 From 07c756db3c0a4f44856a230d71cd40e4cf8e3768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 21 Sep 2026 16:18:38 -0400 Subject: [PATCH 19/22] fix(ui): update Portuguese (BR) translations from POEditor (#6197) Co-authored-by: navidrome-bot --- resources/i18n/pt-br.json | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 2584da6cd..6fcde56ca 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -202,7 +202,7 @@ "command": "Comando" }, "choices": { - "noDefaultBitRate": "" + "noDefaultBitRate": "Nenhum" } }, "playlist": { @@ -609,7 +609,10 @@ "coverRemoved": "Capa removida", "coverUploadError": "Erro ao enviar capa", "coverRemoveError": "Erro ao remover capa", - "metadataRefreshStarted": "Atualizando metadados em segundo plano" + "metadataRefreshStarted": "Atualizando metadados em segundo plano", + "quickConnectApproved": "%{app} em %{device} está conectado agora", + "quickConnectInvalidCode": "Código inválido ou expirado", + "quickConnectError": "Não foi possível aprovar o código" }, "menu": { "library": "Biblioteca", @@ -645,7 +648,15 @@ "selectLibraries": "Selecionar Bibliotecas", "none": "Nenhuma" }, - "onlyFavourites": "Somente favoritas" + "onlyFavourites": "Somente favoritas", + "quickConnect": { + "name": "Conexão Rápida", + "code": "Código", + "help": "Digite o código exibido por um aplicativo Jellyfin para conectá-lo à sua conta", + "confirm": "Conectar %{app} %{version} em %{device} à sua conta?", + "continue": "Continuar", + "approve": "Aprovar" + } }, "player": { "playListsText": "Fila de Execução", From cb7b042e36b00db3d20de83e175a30287361e8b0 Mon Sep 17 00:00:00 2001 From: Karl Ostendorf Date: Mon, 21 Sep 2026 23:25:46 +0200 Subject: [PATCH 20/22] fix(artwork): make stored images group-readable (#6189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Karl Ostendorf Co-authored-by: Deluan Quintão --- core/artwork/image_store.go | 3 +++ core/artwork/image_store_test.go | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go index 5dbe727e4..2ded3e636 100644 --- a/core/artwork/image_store.go +++ b/core/artwork/image_store.go @@ -98,6 +98,9 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { if err := tmp.Close(); err != nil { return err } + if err := os.Chmod(tmp.Name(), 0640); err != nil { + return err + } return os.Rename(tmp.Name(), dst) } diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go index f7b2467ca..7de3ba761 100644 --- a/core/artwork/image_store_test.go +++ b/core/artwork/image_store_test.go @@ -48,6 +48,17 @@ var _ = Describe("ImageStore", func() { Expect(got).To(Equal(data)) }) + It("writes group-readable image files", func() { + tests.SkipOnWindows("uses Unix file permission bits") + data := []byte("jpeg-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + + info, err := os.Stat(store.path(h, "image/jpeg")) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640))) + }) + It("is idempotent on duplicate writes and preserves the original content", func() { data := []byte("dup") h, _ := hashImage(bytes.NewReader(data)) From 285dc4f39155954e6bb9af93f88da71b9b9cbe21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 21 Sep 2026 17:34:32 -0400 Subject: [PATCH 21/22] fix(lastfm): report a failure when the artist page has no image (#6198) GetArtistImages scrapes the og:image tag off the Last.fm artist page, because the API only ever returns the placeholder image. Last.fm now answers non-browser clients with a Fastly bot challenge, served as a 200 with valid HTML, so the query found no og:image and the agent returned an empty list with no error. The artwork worker read that as a definitive "this artist has no image" and settled the state as absent, silently and with nothing in the log. A real artist page always carries an og:image, so its absence now returns an error instead. The worker keeps the previous state, other agents still get their turn, and its per-agent circuit breaker bounds the retries. The error is deliberately not a RetryLaterError: that would park the whole Last.fm agent, including the API-backed biography, similar-artists and top-songs calls, which the page block does not affect. The new fixture is the real 3038-byte challenge page. Fixes #6192 --- adapters/lastfm/agent.go | 8 +- adapters/lastfm/agent_test.go | 27 +++++- .../lastfm.artist.page.challenge.html | 88 +++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/lastfm.artist.page.challenge.html diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 7f005db1a..dbd73c30d 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -241,6 +241,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti var ( artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name + + // Not a RetryLaterError on purpose: parking the agent would also stall its API-backed + // methods, which the page block does not affect. + errNoArtistPage = errors.New("no artist image in Last.fm page") ) func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) { @@ -267,7 +271,9 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) var res []agents.ExternalImage n := cascadia.Query(node, artistOpenGraphQuery) if n == nil { - return res, nil + // A real artist page always has og:image; its absence means a bot challenge or a redesign. + log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL) + return nil, errNoArtistPage } for _, attr := range n.Attr { if attr.Key != "content" { diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index ce81e0916..b9fb786c3 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -648,18 +648,41 @@ var _ = Describe("lastfmAgent", func() { Expect(images).To(BeEmpty()) }) - It("returns empty list if page has no meta tags", func() { + It("errors when the page has no meta tags", func() { fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") apiClient.Res = http.Response{Body: fApi, StatusCode: 200} fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html") httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + _, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).To(MatchError(errNoArtistPage)) + }) + + It("errors when Last.fm serves a bot challenge page", func() { + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") + httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + images, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(MatchError(errNoArtistPage)) Expect(images).To(BeEmpty()) }) + It("does not park the agent: the failure is not a retry-later", func() { + // A RetryLaterError would cool down the agent's API-backed methods too. + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") + httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + + _, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse()) + }) + It("returns error if API call fails", func() { apiClient.Err = errors.New("api error") _, err := agent.GetArtistImages(ctx, "123", "U2", "") diff --git a/tests/fixtures/lastfm.artist.page.challenge.html b/tests/fixtures/lastfm.artist.page.challenge.html new file mode 100644 index 000000000..f431891bd --- /dev/null +++ b/tests/fixtures/lastfm.artist.page.challenge.html @@ -0,0 +1,88 @@ + + + + + + + + Client Challenge + + + + + + + + From 9e3deb4330b346ab0e78aa4e48605d074a6bcee3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 21 Sep 2026 19:27:48 -0400 Subject: [PATCH 22/22] ci: scope digest artifact cleanup to the current run The cleanup job listed artifacts repo-wide, so it deleted digest files uploaded by any concurrent pipeline run. When the v0.64.1 tag run overlapped with a master run, the master run's cleanup removed three of the tag run's digests before the manifest job downloaded them, and 0.64.1/latest shipped with only linux/arm64, arm/v7 and riscv64. List artifacts for the current run instead, so a run can only delete its own digests. --- .github/workflows/pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index aa8e29e49..2702163d3 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -549,7 +549,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do + for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact done