From b2b4fc19433c522f0806c6801482ecae71fbb4f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 6 Jul 2026 22:32:43 -0400 Subject: [PATCH 01/56] fix(ui): update Turkish translations from POEditor (#5685) Co-authored-by: navidrome-bot --- resources/i18n/tr.json | 97 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index d1fdb2ed4..ff387aff8 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -36,7 +36,11 @@ "bitDepth": "Bit derinliği", "sampleRate": "Örnekleme Oranı", "missing": "Eksik", - "libraryName": "Kütüphane" + "libraryName": "Kütüphane", + "composer": "Besteci", + "disc": "Disk %{discNumber}", + "albumGain": "Albüm Kazancı", + "trackGain": "Parça Kazancı" }, "actions": { "addToQueue": "Oynatma Sırasına Ekle", @@ -46,7 +50,8 @@ "download": "İndir", "playNext": "Dinlenenden Sonra Oynat", "info": "Bilgiler", - "showInPlaylist": "Çalma Listesinde Göster" + "showInPlaylist": "Çalma Listesinde Göster", + "instantMix": "Anında Karışım" } }, "album": { @@ -328,6 +333,82 @@ "scanInProgress": "Tarama devam ediyor...", "noLibrariesAssigned": "Bu kullanıcıya hiçbir kütüphane atanmadı" } + }, + "plugin": { + "name": "Eklenti |||| Eklentiler", + "fields": { + "id": "Kimlik", + "name": "Ad", + "description": "Açıklama", + "version": "Sürüm", + "author": "Geliştirici", + "website": "Web Sitesi", + "permissions": "İzinler", + "enabled": "Etkin", + "status": "Durum", + "path": "Yol", + "lastError": "Son Hata", + "hasError": "Hata", + "updatedAt": "Güncellendi", + "createdAt": "Yüklendi", + "configKey": "Anahtar", + "configValue": "Değer", + "allUsers": "Tüm Kullanıcılara İzin Ver", + "selectedUsers": "Seçili Kullanıcılar", + "allLibraries": "Tüm Kütüphanelere İzin Ver", + "selectedLibraries": "Seçili Kütüphaneler", + "allowWriteAccess": "Yazma Erişimine İzin Ver" + }, + "sections": { + "status": "Durum", + "info": "Eklenti Bilgileri", + "configuration": "Yapılandırma", + "manifest": "Manifest", + "usersPermission": "Kullanıcı İzinleri", + "libraryPermission": "Kütüphane İzinleri" + }, + "status": { + "enabled": "Etkin", + "disabled": "Devre Dışı" + }, + "actions": { + "enable": "Etkinleştir", + "disable": "Devre Dışı Bırak", + "disabledDueToError": "Etkinleştirmeden Önce Hatayı Düzeltin", + "disabledUsersRequired": "Etkinleştirmeden Önce Kullanıcı Seçin", + "disabledLibrariesRequired": "Etkinleştirmeden Önce Kütüphane Seçin", + "addConfig": "Yapılandırma Ekle", + "rescan": "Yeniden Tara" + }, + "notifications": { + "enabled": "Eklenti etkinleştirildi", + "disabled": "Eklenti devre dışı bırakıldı", + "updated": "Eklenti güncellendi", + "error": "Eklenti güncellenirken hata oluştu" + }, + "validation": { + "invalidJson": "Yapılandırma geçerli bir JSON olmalı" + }, + "messages": { + "configHelp": "Eklentiyi anahtar-değer çiftleriyle yapılandırın. Eklenti yapılandırma gerektirmiyorsa boş bırakın.", + "clickPermissions": "Ayrıntıları görmek için bir izne tıklayın", + "noConfig": "Yapılandırma ayarlanmamış", + "allUsersHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kullanıcılara erişebilir.", + "noUsers": "Kullanıcı seçilmedi", + "permissionReason": "Gerekçe", + "usersRequired": "Bu eklenti kullanıcı bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kullanıcıları seçin veya 'Tüm Kullanıcılara İzin Ver' seçeneğini etkinleştirin.", + "allLibrariesHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kütüphanelere erişebilir.", + "noLibraries": "Kütüphane seçilmedi", + "librariesRequired": "Bu eklenti kütüphane bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kütüphaneleri seçin veya 'Tüm Kütüphanelere İzin Ver' \nseçeneğini etkinleştirin.", + "requiredHosts": "Gerekli Sunucular", + "configValidationError": "Yapılandırma doğrulanamadı:", + "schemaRenderError": "Yapılandırma formu oluşturulamadı. Eklentinin şeması geçersiz olabilir.", + "allowWriteAccessHelp": "Etkinleştirildiğinde eklenti, kütüphane dizinlerindeki dosyaları değiştirebilir. Eklentiler varsayılan olarak salt okunur erişime sahiptir." + }, + "placeholders": { + "configKey": "anahtar", + "configValue": "değer" + } } }, "ra": { @@ -511,7 +592,14 @@ "remove_all_missing_title": "Tüm eksik dosyaları kaldırın", "remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır.", "noSimilarSongsFound": "Benzer şarkı bulunamadı", - "noTopSongsFound": "En iyi şarkı listesi boş" + "noTopSongsFound": "En iyi şarkı listesi boş", + "startingInstantMix": "Anında Karışım yükleniyor...", + "uploadCover": "Kapak Görseli Yükle", + "removeCover": "Kapak Görselini Kaldır", + "coverUploaded": "Kapak görseli güncellendi", + "coverRemoved": "Kapak görseli kaldırıldı", + "coverUploadError": "Kapak görseli yüklenirken hata oluştu", + "coverRemoveError": "Kapak görseli kaldırılırken hata oluştu" }, "menu": { "library": "Kütüphane", @@ -597,7 +685,8 @@ "exportSuccess": "Yapılandırma TOML formatında dışa aktarıldı", "exportFailed": "Yapılandırma kopyalanamadı", "devFlagsHeader": "Geliştirme Bayrakları (değişime/kaldırılmaya tabidir)", - "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir" + "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir", + "downloadToml": "Yapılandırmayı İndir (TOML)" } }, "activity": { From 6c95a66ad6e49632f2e9656d9bc2ca8008ed85e0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 6 Jul 2026 22:34:46 -0400 Subject: [PATCH 02/56] chore(deps): update Go dependencies to latest versions Signed-off-by: Deluan --- go.mod | 16 ++++++++-------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/go.mod b/go.mod index 4803c23ee..71aabdcd7 100644 --- a/go.mod +++ b/go.mod @@ -19,10 +19,10 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 - github.com/gen2brain/webp v0.6.3 - github.com/go-chi/chi/v5 v5.3.0 + github.com/gen2brain/webp v0.6.4 + github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/cors v1.2.2 - github.com/go-chi/httprate v0.15.0 + github.com/go-chi/httprate v0.16.0 github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 github.com/go-viper/mapstructure/v2 v2.5.0 @@ -33,7 +33,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 github.com/jellydator/ttlcache/v3 v3.4.1 - github.com/kardianos/service v1.2.4 + github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 github.com/mattn/go-sqlite3 v1.14.47 @@ -41,10 +41,10 @@ require ( github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 - github.com/pelletier/go-toml/v2 v2.4.2 + github.com/pelletier/go-toml/v2 v2.4.3 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.27.1 + github.com/pressly/goose/v3 v3.27.2 github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 @@ -64,7 +64,7 @@ require ( golang.org/x/sync v0.21.0 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 - golang.org/x/text v0.38.0 + golang.org/x/text v0.39.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -96,7 +96,7 @@ require ( github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect diff --git a/go.sum b/go.sum index 1972e34c2..ec532b0a0 100644 --- a/go.sum +++ b/go.sum @@ -65,27 +65,27 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gen2brain/webp v0.6.3 h1:DbXXCkiHN6zq2qIuTsPSZQhVi2VcQ0UPzKbKqKENfsQ= -github.com/gen2brain/webp v0.6.3/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= +github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= +github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= -github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= +github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= +github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs= @@ -134,14 +134,14 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= -github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= +github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI= +github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -196,8 +196,8 @@ github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= -github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q= -github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -206,8 +206,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= -github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= +github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= +github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -334,8 +334,8 @@ golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -356,11 +356,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= -modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= -modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= From f48943c05873dad7cab4e159747453a5d7677f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 8 Jul 2026 12:37:17 -0400 Subject: [PATCH 03/56] fix(plugins): discard buffered scrobbles when a plugin is removed (#5737) * fix(plugins): discard buffered scrobbles when a plugin is removed Scrobbles are buffered in the DB per service, keyed by the plugin name. When a plugin was removed (deleted from the plugins folder and detected by the sync), its pending buffer entries were left behind forever: the drain goroutine is stopped on the next scrobbler refresh, so the rows were never retried nor discarded. Worse, if a plugin with the same name was installed later, the stale entries would be drained into it - potentially a completely unrelated plugin that just reuses the name. Add a Discard(service) method to ScrobbleBufferRepository and call it from removePluginFromDB, right after the plugin record is deleted. Disabling a plugin intentionally keeps its buffered scrobbles, consistent with the buffer's purpose of surviving temporary outages, and transient unload/reload cycles during config updates are unaffected since they never delete the plugin record. * fix(plugins): don't wipe builtin scrobbler queues on plugin removal Buffer entries are keyed by service name only, and removePluginFromDB runs for any removed plugin file, so removing a plugin named e.g. lastfm.ndp - regardless of its capability - would discard the builtin Last.fm retry queue. Skip the discard when the plugin name is owned by a registered builtin scrobbler, exposed via a new scrobbler.IsBuiltinScrobbler helper. Reported by Codex review on the PR. Also drop the testBroker usage from the new removePluginFromDB spec: it is defined in manager_test.go which is excluded on Windows, breaking the Windows test build. sendPluginRefreshEvent is nil-safe, so no broker is needed. --- core/scrobbler/play_tracker.go | 7 +++ core/scrobbler/play_tracker_test.go | 7 +++ model/scrobble_buffer.go | 1 + persistence/scrobble_buffer_repository.go | 4 ++ .../scrobble_buffer_repository_test.go | 22 ++++++++ plugins/manager_sync.go | 11 ++++ plugins/manager_sync_test.go | 55 +++++++++++++++++++ tests/mock_scrobble_buffer_repo.go | 16 ++++++ 8 files changed, 123 insertions(+) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 860a80bce..a2d7e8639 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -491,3 +491,10 @@ func Register(name string, init Constructor) { } constructors[name] = init } + +// IsBuiltinScrobbler reports whether name belongs to a registered builtin +// scrobbler (e.g. "lastfm", "listenbrainz"). +func IsBuiltinScrobbler(name string) bool { + _, ok := constructors[name] + return ok +} diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index b5a478c2a..382abb811 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -104,6 +104,13 @@ var _ = Describe("PlayTracker", func() { Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) }) + Describe("IsBuiltinScrobbler", func() { + It("reports whether the name belongs to a registered builtin scrobbler", func() { + Expect(IsBuiltinScrobbler("fake")).To(BeTrue()) + Expect(IsBuiltinScrobbler("some-plugin")).To(BeFalse()) + }) + }) + Describe("GetNowPlaying", func() { It("returns current playing music", func() { track2 := track diff --git a/model/scrobble_buffer.go b/model/scrobble_buffer.go index c75a82853..43ee2cc01 100644 --- a/model/scrobble_buffer.go +++ b/model/scrobble_buffer.go @@ -20,4 +20,5 @@ type ScrobbleBufferRepository interface { Next(service string, userId string) (*ScrobbleEntry, error) Dequeue(entry *ScrobbleEntry) error Length() (int64, error) + Discard(service string) error } diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index 3cfb836bf..cf54c664a 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -93,6 +93,10 @@ func (r *scrobbleBufferRepository) Dequeue(entry *model.ScrobbleEntry) error { return r.delete(Eq{"id": entry.ID}) } +func (r *scrobbleBufferRepository) Discard(service string) error { + return r.delete(Eq{"service": service}) +} + func (r *scrobbleBufferRepository) Length() (int64, error) { return r.count(Select()) } diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go index edf59ce49..3aa71070e 100644 --- a/persistence/scrobble_buffer_repository_test.go +++ b/persistence/scrobble_buffer_repository_test.go @@ -191,6 +191,28 @@ var _ = Describe("ScrobbleBufferRepository", func() { }) + Describe("Discard", func() { + It("deletes all entries for a service, keeping other services intact", func() { + Expect(scrobble.Discard("a")).To(Succeed()) + + count, err := scrobble.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + + entry, err := scrobble.Next("b", "2222") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil()) + }) + + It("is a no-op for a service without entries", func() { + Expect(scrobble.Discard("nonexistent")).To(Succeed()) + + count, err := scrobble.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(4))) + }) + }) + Describe("UserIds", func() { It("should return ordered list for services", func() { ids, err := scrobble.UserIDs("a") diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 23d904309..2119f1a5a 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -107,6 +108,16 @@ func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepos if err := repo.Delete(pluginID); err != nil { return fmt.Errorf("deleting plugin from DB: %w", err) } + // Discard any scrobbles still buffered for the removed plugin, so they are + // not delivered to an unrelated plugin that reuses the same name later. + // Skip names owned by builtin scrobblers: buffer entries are keyed by + // service name, so removing a plugin file named e.g. "lastfm.ndp" must not + // wipe the builtin Last.fm retry queue. + if scrobbler.IsBuiltinScrobbler(pluginID) { + log.Debug(ctx, "Keeping buffered scrobbles: name is owned by a builtin scrobbler", "plugin", pluginID) + } else if err := m.ds.ScrobbleBuffer(ctx).Discard(pluginID); err != nil { + log.Error(ctx, "Error discarding buffered scrobbles for removed plugin", "plugin", pluginID, err) + } log.Info(ctx, "Plugin removed", "plugin", pluginID) m.sendPluginRefreshEvent(ctx, events.Any) return nil diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go index e2adebfad..26da2079b 100644 --- a/plugins/manager_sync_test.go +++ b/plugins/manager_sync_test.go @@ -1,12 +1,67 @@ package plugins import ( + "context" "path/filepath" + "time" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("removePluginFromDB", func() { + It("discards buffered scrobbles for the removed plugin", func() { + ctx := context.Background() + buffer := tests.CreateMockedScrobbleBufferRepo() + Expect(buffer.Enqueue("my-plugin", "user1", "track1", time.Now())).To(Succeed()) + Expect(buffer.Enqueue("other-plugin", "user1", "track2", time.Now())).To(Succeed()) + + repo := tests.CreateMockPluginRepo() + plugin := model.Plugin{ID: "my-plugin", Enabled: false} + repo.SetData(model.Plugins{plugin}) + + // No broker: sendPluginRefreshEvent is nil-safe, and testBroker is + // defined in manager_test.go, which is excluded on Windows. + m := &Manager{ + ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, + } + Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) + + _, err := repo.Get("my-plugin") + Expect(err).To(MatchError(model.ErrNotFound)) + + remaining, err := buffer.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(remaining).To(Equal(int64(1))) + entry, err := buffer.Next("other-plugin", "user1") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil(), "entries of other services must be kept") + }) + + It("keeps buffered scrobbles of a builtin scrobbler sharing the removed plugin's name", func() { + ctx := context.Background() + scrobbler.Register("builtin-svc", func(model.DataStore) scrobbler.Scrobbler { return nil }) + buffer := tests.CreateMockedScrobbleBufferRepo() + Expect(buffer.Enqueue("builtin-svc", "user1", "track1", time.Now())).To(Succeed()) + + repo := tests.CreateMockPluginRepo() + plugin := model.Plugin{ID: "builtin-svc", Enabled: false} + repo.SetData(model.Plugins{plugin}) + + m := &Manager{ + ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, + } + Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) + + remaining, err := buffer.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(remaining).To(Equal(int64(1)), "builtin scrobbler queue must not be wiped") + }) +}) + var _ = Describe("ComputeFileSHA256", func() { It("returns a consistent 64-char lowercase hex hash for the same file", func() { dir := GinkgoT().TempDir() diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go index 5865f423a..2eb5e8a93 100644 --- a/tests/mock_scrobble_buffer_repo.go +++ b/tests/mock_scrobble_buffer_repo.go @@ -83,6 +83,22 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { return nil } +func (m *MockedScrobbleBufferRepo) Discard(service string) error { + if m.Error != nil { + return m.Error + } + m.mu.Lock() + defer m.mu.Unlock() + newData := model.ScrobbleEntries{} + for _, e := range m.Data { + if e.Service != service { + newData = append(newData, e) + } + } + m.Data = newData + return nil +} + func (m *MockedScrobbleBufferRepo) Length() (int64, error) { if m.Error != nil { return 0, m.Error From 4652b4660205abf38a484a2dc7856b4f6d5c24aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 8 Jul 2026 12:37:42 -0400 Subject: [PATCH 04/56] fix(plugins): populate username for buffered plugin scrobbles (#5736) Plugin scrobblers read the username from the request context via getUsernameFromContext, but buffered scrobbles are persisted to the DB scrobble buffer (which stores only the userId) and later drained by a background worker running on context.Background(). That context carries no authenticated user, so ScrobbleRequest.Username was always empty for WASM scrobbler plugins. NowPlaying is unaffected because it is dispatched synchronously on context.WithoutCancel(requestCtx), which retains the user. Restore the user in the drain path: processUserQueue now looks up the user by the buffered userId and injects it into the context via request.WithUser before dispatching, mirroring the pattern already used in play_tracker. Builtin scrobblers are unaffected as they resolve the account from the userId argument rather than the context. --- core/scrobbler/buffered_scrobbler.go | 9 +++++++++ core/scrobbler/buffered_scrobbler_test.go | 13 +++++++++++++ core/scrobbler/play_tracker_test.go | 17 +++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 67593e9eb..408ab410d 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" ) // Loader is a function that loads a scrobbler by name. @@ -129,6 +130,14 @@ func (b *bufferedScrobbler) processQueue(ctx context.Context) bool { } func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool { + // Scrobbles are drained on a background context that no longer carries the + // request's authenticated user. Restore it from the buffered userId so that + // scrobblers relying on the user in the context (e.g. plugins) still get it. + if user, err := b.ds.User(ctx).Get(userId); err != nil { + log.Warn(ctx, "Could not load user for buffered scrobble", "userId", userId, "scrobbler", b.service, err) + } else { + ctx = request.WithUser(ctx, *user) + } buffer := b.ds.ScrobbleBuffer(ctx) for { entry, err := buffer.Next(b.service, userId) diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index 9fbca6f71..d11f0b003 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -20,8 +20,11 @@ var _ = Describe("BufferedScrobbler", func() { BeforeEach(func() { ctx = context.Background() buffer = tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) ds = &tests.MockDataStore{ MockedScrobbleBuffer: buffer, + MockedUser: userRepo, } scr = &fakeScrobbler{Authorized: true} bs = newBufferedScrobbler(ds, scr, "test") @@ -62,6 +65,16 @@ var _ = Describe("BufferedScrobbler", func() { Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now)) }) + It("restores the user in the context when draining buffered scrobbles", func() { + track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} + scrobble := Scrobble{MediaFile: track, TimeStamp: time.Now()} + + Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed()) + + Eventually(scr.ScrobbleCalled.Load).Should(BeTrue()) + Expect(scr.GetUsername()).To(Equal("alice")) + }) + It("stops the background goroutine when Stop is called", func() { // Replace the real run method with one that signals when it exits done := make(chan struct{}) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 382abb811..74b4be893 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -1098,6 +1098,13 @@ func (f *fakeScrobbler) GetUserID() string { return "" } +func (f *fakeScrobbler) GetUsername() string { + if p := f.username.Load(); p != nil { + return *p + } + return "" +} + func (f *fakeScrobbler) GetTrack() *model.MediaFile { return f.track.Load() } @@ -1129,6 +1136,16 @@ func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *mo func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { f.userID.Store(&userId) + // Capture username from context (this is what plugin scrobblers do) + username, _ := request.UsernameFrom(ctx) + if username == "" { + if u, ok := request.UserFrom(ctx); ok { + username = u.UserName + } + } + if username != "" { + f.username.Store(&username) + } f.LastScrobble.Store(&s) f.ScrobbleCalled.Store(true) if f.Error != nil { From 42d4363f6135167b7fce5c8fd9070a25fab10172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 8 Jul 2026 16:40:07 -0400 Subject: [PATCH 05/56] fix(service): rewrite systemd service template for kardianos/service v1.3.0 (#5743) kardianos/service v1.3.0 (shipped in Navidrome 0.63.0) replaced Go's text/template with a small custom engine that uses bare keys ({{Description}}) instead of dotted fields ({{.Description}}). Our custom SystemdScript was still written in text/template syntax, so 'navidrome service install' failed with 'FATA service: unknown template key ".Description"', breaking the .deb postinst and leaving an empty/masked systemd unit. Rewrite the template in the new engine's syntax and add a test that validates every key and pipeline function used in the template against the set the library provides to systemd templates, so future engine/key drift is caught at test time. Verified end-to-end on Linux: the previous binary reproduces the reported fatal error and the fixed one generates a complete unit file. Fixes #5742 --- cmd/svc.go | 23 ++++++++++----------- cmd/svc_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 cmd/svc_test.go diff --git a/cmd/svc.go b/cmd/svc.go index cc8d6bb54..7fec708ff 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -232,22 +232,21 @@ func buildExecuteCmd() *cobra.Command { } const systemdScript = `[Unit] -Description={{.Description}} -ConditionFileIsExecutable={{.Path|cmdEscape}} -{{range $i, $dep := .Dependencies}} -{{$dep}} {{end}} - +Description={{Description}} +ConditionFileIsExecutable={{Path | cmdEscape}} +{{range Dependencies}}{{.}} +{{end}} [Service] StartLimitInterval=5 StartLimitBurst=10 -ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}} -{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}} -{{if .UserName}}User={{.UserName}}{{end}} -{{if .Restart}}Restart={{.Restart}}{{end}} -{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}} +ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}} +{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}} +{{if UserName}}User={{UserName}}{{end}} +{{if Restart}}Restart={{Restart}}{{end}} +{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}} TimeoutStopSec=20 RestartSec=120 -EnvironmentFile=-/etc/sysconfig/{{.Name}} +EnvironmentFile=-/etc/sysconfig/{{Name}} Environment="ND_SYSTEMD_PRIORITY_LOGGING=1" DevicePolicy=closed @@ -260,7 +259,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictNamespaces=yes RestrictRealtime=yes SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap -{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}} +{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}} ProtectSystem=full [Install] diff --git a/cmd/svc_test.go b/cmd/svc_test.go new file mode 100644 index 000000000..7c34563b3 --- /dev/null +++ b/cmd/svc_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "regexp" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("systemdScript template", func() { + systemdKeys := map[string]bool{ + "Description": true, "Path": true, "Name": true, "Dependencies": true, + "Arguments": true, "ChRoot": true, "WorkingDirectory": true, + "UserName": true, "ReloadSignal": true, "PIDFile": true, + "LogDirectory": true, "OutputFileSupport": true, "LimitNOFILE": true, + "Restart": true, "SuccessExitStatus": true, "EnvVars": true, + } + systemdFuncs := map[string]bool{"cmd": true, "cmdEscape": true} + + actionRe := regexp.MustCompile(`\{\{(.*?)\}\}`) + + parseAction := func(action string) (key string, funcs []string) { + action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-")) + kw, rest, _ := strings.Cut(action, " ") + switch kw { + case "end", "else": + return "", nil + case "if", "range": + return strings.TrimSpace(rest), nil + } + parts := strings.Split(action, "|") + for _, p := range parts[1:] { + funcs = append(funcs, strings.TrimSpace(p)) + } + return strings.TrimSpace(parts[0]), funcs + } + + It("only references keys and functions the service library provides", func() { + matches := actionRe.FindAllStringSubmatch(systemdScript, -1) + Expect(matches).ToNot(BeEmpty()) + + for _, m := range matches { + key, funcs := parseAction(m[1]) + if key != "" && key != "." { + Expect(systemdKeys).To(HaveKey(key), + "template action %q uses a key unknown to kardianos/service", m[0]) + } + for _, fn := range funcs { + Expect(systemdFuncs).To(HaveKey(fn), + "template action %q uses an unknown pipeline function", m[0]) + } + } + }) +}) From 052f10fd68984a01cc2c87eee9edcc16dbfa6ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 9 Jul 2026 06:30:44 -0400 Subject: [PATCH 06/56] fix(build): prevent 32-bit startup crash (segfault/SIGILL) in downloads binaries (#5739) * fix(build): force nodynamic webp tag on 32-bit standalone binaries gen2brain/webp's native libwebp backend links ebitengine/purego, whose reverse callbacks are unsupported on 32-bit ARM and x86. purego registers its callback in package init(), so the binary crashes at startup (SIGSEGV or SIGILL) before any Navidrome code runs. The nodynamic build tag from #5606 forces the safe WASM path, but it was only applied to the Docker-image build stage. The standalone build stage, which produces the downloads-page tarballs and the deb/rpm packages, still linked purego, so the armv7/v6/v5 and 386 downloads crashed on launch (#5738, #5735). Move the tag decision into release/build-tags.sh, shared by both build stages so they can no longer drift, and add release/verify-binary.sh as a build-time guard that fails if a 32-bit binary links purego. * fix(build): harden webp build-tag scripts per review - verify-binary.sh: fail loudly when the target binary is missing (e.g. an unmatched glob) instead of letting `go version -m` fail inside a pipeline and silently pass, which would bypass the guard. - build-tags.sh / verify-binary.sh: fall back to `go env GOARCH` when xx-info is unavailable, so the scripts stay correct outside the xx build image. (Not `uname -m`, which reports the build host, not the cross target.) - Dockerfile: use `set -e` in the standalone build block and drop the redundant `|| exit 1` suffixes; keep the debug GOENV dump non-fatal. * chore(build): quote -tags argument in both build stages Defensive quoting per review; the value comes from release/build-tags.sh and contains no whitespace today, but quoting prevents word-splitting if it ever does. * fix(build): link 32-bit arm binaries with LLD to fix startup crash The standalone armv7/v6/v5 binaries of 0.63.0 crash before main() with SIGSEGV/SIGILL (issues #5738, #5735). Root cause, established from a core dump of the crashing binary under qemu: GNU ld emits corrupt R_ARM_IRELATIVE addends for libatomic's ifunc resolvers (wrong address and missing Thumb bit) once .text outgrows the 16MB Thumb branch range. glibc's static-init ifunc resolution then does `blx` into ARM-mode garbage and the process dies before any log output. v0.62.0 was unaffected only because its .text was still under 16MB (15.1MB); v0.63.0 crossed the line (17.5MB), so every 0.63.0 32-bit arm build crashes regardless of Go or dependency versions. Link 32-bit arm with LLD (already installed in the build stage), which emits correct IRELATIVE addends. Verified under qemu: the armv7 artifact built by the unchanged pipeline now boots to "Navidrome server is ready" with SQLite migrations working, where the previous binary segfaulted at startup. Also add a CI smoke test that runs each cross-compiled linux binary under binfmt/qemu right after building it, so any future crashes-at-startup-on-some-arch regression fails the pipeline instead of shipping in a release. --- .github/workflows/pipeline.yml | 15 +++++++++++++++ Dockerfile | 29 ++++++++++++++++++----------- release/build-tags.sh | 22 ++++++++++++++++++++++ release/verify-binary.sh | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) create mode 100755 release/build-tags.sh create mode 100755 release/verify-binary.sh diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 228bac9e7..8c714945e 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -300,6 +300,21 @@ jobs: GIT_SHA=${{ env.GIT_SHA }} GIT_TAG=${{ env.GIT_TAG }} + - name: Set up QEMU for smoke test + if: env.IS_LINUX == 'true' + uses: docker/setup-qemu-action@v3 + + # The binary is static, so binfmt+qemu runs it directly on the runner. + # Catches startup crashes in cross-compiled binaries before they ship, + # e.g. the broken ifunc relocations on 32-bit arm from issue #5738. + - name: Smoke-test binary + if: env.IS_LINUX == 'true' + run: | + BIN=./output/${{ env.PLATFORM }}/navidrome + chmod +x "$BIN" + "$BIN" --help >/dev/null + echo "OK: ${{ matrix.platform }} binary starts" + - name: Upload Binaries uses: actions/upload-artifact@v7 with: diff --git a/Dockerfile b/Dockerfile index e8a00f470..df5df52ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,20 +69,15 @@ RUN --mount=type=bind,source=. \ set -e xx-go --wrap export CGO_ENABLED=1 - # Native libwebp (gen2brain/webp) uses ebitengine/purego reverse callbacks, - # which purego does not support on 32-bit ARM or x86 and crash with a SIGSEGV - # (issue #5597). Build those arches with the "nodynamic" tag so gen2brain/webp - # is WASM-only and never links the purego path. 64-bit arches keep native libwebp. - BUILD_TAGS=netgo,sqlite_fts5 - if [ "$(xx-info arch)" = "arm" ] || [ "$(xx-info arch)" = "386" ]; then - BUILD_TAGS=${BUILD_TAGS},nodynamic - fi + BUILD_TAGS=$(./release/build-tags.sh) # -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve. - go build -tags=${BUILD_TAGS} -ldflags="-w -s \ + go build -tags="${BUILD_TAGS}" -ldflags="-w -s \ -linkmode=external -extldflags '-latomic' \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ -o /out/navidrome . + # Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738). + ./release/verify-binary.sh /out/navidrome # Fail the build if the binary is accidentally statically linked: dlopen (and # therefore native libwebp detection) only works with a dynamic interpreter. file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; } @@ -116,11 +111,12 @@ RUN --mount=type=bind,source=. \ --mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \ --mount=type=cache,target=/root/.cache \ --mount=type=cache,target=/go/pkg/mod </dev/null || true # Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler. # So let's use gcc for everything except Darwin. @@ -129,14 +125,25 @@ RUN --mount=type=bind,source=. \ export CXX=$(xx-info)-g++ export LD_EXTRA="-extldflags '-static -latomic'" fi + # GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers + # (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch + # range, making static arm binaries jump to garbage inside glibc's ifunc + # resolution and crash before main() (issue #5738). Link 32-bit arm with LLD, + # which emits correct addends. + if [ "$(xx-info arch)" = "arm" ]; then + export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'" + fi if [ "$(xx-info os)" = "windows" ]; then export EXT=".exe" fi - go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \ + BUILD_TAGS=$(./release/build-tags.sh) + go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ -o /out/navidrome${EXT} . + # Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738). + ./release/verify-binary.sh /out/navidrome* EOT # Verify if the binary was built for the correct platform and it is statically linked diff --git a/release/build-tags.sh b/release/build-tags.sh new file mode 100755 index 000000000..f719117ff --- /dev/null +++ b/release/build-tags.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Print the Go build tags for the xx-cc target platform (used by the Dockerfile). +# +# gen2brain/webp's native libwebp backend links ebitengine/purego, whose reverse +# callbacks are unsupported on 32-bit ARM and x86 and SIGSEGV at package-init time, +# taking the whole process down at startup (issues #5597 / #5606 / #5738). Force the +# WASM-only path there with the "nodynamic" tag; 64-bit arches keep native libwebp. +# +# This is the single source of truth for the tag decision: both Dockerfile build +# stages (Docker-image and standalone downloads) call it so they cannot drift apart. +set -e + +# Prefer xx-info (the cross-build target arch); fall back to `go env GOARCH` so the +# script is still correct when run outside the xx environment. Both report the +# cross-compilation target, unlike `uname -m`, which would report the build host. +arch=$(xx-info arch 2>/dev/null || go env GOARCH) + +tags="netgo,sqlite_fts5" +case "${arch}" in + arm | 386) tags="${tags},nodynamic" ;; +esac +printf '%s' "${tags}" diff --git a/release/verify-binary.sh b/release/verify-binary.sh new file mode 100755 index 000000000..cde775992 --- /dev/null +++ b/release/verify-binary.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Fail the build if a 32-bit ARM/x86 binary links ebitengine/purego, which would +# SIGSEGV at startup on those arches (issue #5738). +# +# Independent safety net for build-tags.sh: it inspects the actual build metadata +# recorded in the binary (survives stripping) instead of trusting the requested +# tags, so it still fires if the tag decision is wrong or gen2brain/webp changes +# its build-tag semantics. Runs in the Dockerfile, where xx-info and go are present. +# +# Usage: verify-binary.sh [...] +set -e + +# Prefer xx-info (the cross-build target arch); fall back to `go env GOARCH` so the +# check is still correct when run outside the xx environment. +arch=$(xx-info arch 2>/dev/null || go env GOARCH) + +case "${arch}" in + arm | 386) ;; + *) exit 0 ;; # 64-bit arches legitimately link purego for native libwebp +esac + +for bin in "$@"; do + # Fail loudly if the expected binary is missing (e.g. an unmatched glob), rather + # than letting `go version -m` fail inside the pipeline and silently pass. + if [ ! -f "${bin}" ]; then + echo "ERROR: expected binary '${bin}' not found; purego verification did not run." + exit 1 + fi + if go version -m "${bin}" | grep -q "ebitengine/purego"; then + echo "ERROR: 32-bit binary '${bin}' links ebitengine/purego; it will SIGSEGV at startup (issue #5738)." + echo " Ensure the 'nodynamic' build tag is applied (see release/build-tags.sh)." + exit 1 + fi +done From 4381366e663b4647cfec1eb5597c39c3c42e9d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 9 Jul 2026 19:43:04 -0400 Subject: [PATCH 07/56] ci: validate DB migration order on pull requests (#5750) * ci: add DB migration ordering/naming validation script * ci: run DB migration validation on pull requests * ci: don't reject non-migration .go files * ci: fetch latest master before validating migration order * ci: annotate the offending migration file on validation failure * ci: gate Build on the migration check so a bad migration fails fast * ci: reject migrations placed in a subdirectory of db/migrations * ci: reword fetch-step comment to not hard-code the base branch name --- .github/workflows/pipeline.yml | 23 +++- .github/workflows/validate-migrations.sh | 150 +++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100755 .github/workflows/validate-migrations.sh diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 8c714945e..d21d0a681 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -96,6 +96,23 @@ jobs: exit 1 fi + validate-migrations: + name: Validate DB migrations + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # Refresh the base branch so the check compares against its CURRENT tip, + # not the (possibly stale) commit the PR was opened against. + - name: Fetch latest base branch + run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" + - name: Validate migration ordering and naming + env: + BASE_REF: origin/${{ github.event.pull_request.base.ref }} + run: ./.github/workflows/validate-migrations.sh + go: name: Test Go code runs-on: ubuntu-latest @@ -257,7 +274,11 @@ jobs: build: name: Build - needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled] + needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] + # validate-migrations only runs on pull_request, so it is "skipped" on push/tag + # builds. Run Build unless a dependency actually failed — a *skipped* dependency + # (the migration check on non-PR events) must not block release builds. + if: ${{ !cancelled() && !failure() }} strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] diff --git a/.github/workflows/validate-migrations.sh b/.github/workflows/validate-migrations.sh new file mode 100755 index 000000000..07d05c3a6 --- /dev/null +++ b/.github/workflows/validate-migrations.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# +# Validates DB migrations added by a pull request: +# 1. Ordering - an added migration must be NEWER than the latest migration +# already on the base branch. Goose applies migrations in +# timestamp order, so an older-timestamped migration would be +# silently skipped on databases already upgraded past it. +# 2. Uniqueness - no two migration files may share a timestamp. +# 3. Naming - files must match YYYYMMDDHHMMSS_lower_snake_name.(sql|go). +# +# On failure it prints a human-readable message and, when running in GitHub +# Actions, emits an error annotation bound to the offending file so the message +# also renders inline in the PR "Files changed" tab. +# +# Compares HEAD against $BASE_REF (default origin/master). Requires full history +# (fetch-depth: 0 in CI). +# -e is intentionally omitted: the script accumulates violations into $status +# and must not exit on the first non-zero command (grep no-match, a false [[ ]] +# in an if, `is_migration || continue`). +set -uo pipefail +export LC_ALL=C + +MIGRATIONS_DIR="db/migrations" +BASE_REF="${BASE_REF:-origin/master}" +NAME_RE='^[0-9]{14}_[a-z0-9_]+\.(sql|go)$' + +status=0 + +# Log a message to stderr and mark the run as failed. +fail() { + printf '%s\n' "$1" >&2 + status=1 +} + +# Emit a GitHub Actions error annotation bound to a file, so the message renders +# inline on the offending migration in the PR "Files changed" tab. No-op outside +# CI. `%`, newline and CR are encoded as required by the workflow-command syntax +# (the `%` replacement must run first so the encodings we add aren't re-escaped). +annotate() { # $1=file $2=message + [ "${GITHUB_ACTIONS:-}" = "true" ] || return 0 + local msg="$2" + msg="${msg//'%'/%25}" + msg="${msg//$'\n'/%0A}" + msg="${msg//$'\r'/%0D}" + printf '::error file=%s,line=1::%s\n' "$1" "$msg" +} + +# Report a migration problem: log it, annotate the offending file, mark failed. +report() { # $1=file $2=message + fail "$2" + printf '\n' >&2 + annotate "$1" "$2" +} + +human_ts() { + local t="$1" + printf '%s-%s-%s %s:%s:%s' "${t:0:4}" "${t:4:2}" "${t:6:2}" "${t:8:2}" "${t:10:2}" "${t:12:2}" +} + +is_migration() { # $1=basename -> 0 if a .sql/.go file with a 14-digit prefix + local b="$1" + case "$b" in + *.sql | *.go) ;; + *) return 1 ;; + esac + [[ "${b%%_*}" =~ ^[0-9]{14}$ ]] +} + +if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then + printf '❌ Cannot resolve base ref "%s". In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2 + exit 1 +fi + +# --- Newest timestamp already on the base branch --- +base_max="" +base_max_file="" +while IFS= read -r f; do + [ -z "$f" ] && continue + b="$(basename "$f")" + is_migration "$b" || continue + ts="${b%%_*}" + if [[ "$ts" > "$base_max" ]]; then + base_max="$ts" + base_max_file="$f" + fi +done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATIONS_DIR" 2>/dev/null) + +# --- Ordering + naming on files added by this PR --- +while IFS= read -r f; do + [ -z "$f" ] && continue + b="$(basename "$f")" + case "$b" in + *.sql) ;; # any .sql in this dir must be a migration + *.go) [[ "$b" == [0-9]* ]] || continue ;; # non-timestamped .go = helper (e.g. migration.go), skip + *) continue ;; + esac + if [ "${f%/*}" != "$MIGRATIONS_DIR" ]; then + report "$f" "❌ Migration file in a subdirectory: $f + Migrations must live directly in $MIGRATIONS_DIR/ — only $MIGRATIONS_DIR/*.sql (and + top-level .go migrations) are embedded, so a nested file would be SILENTLY SKIPPED. + Move it to $MIGRATIONS_DIR/$b." + continue + fi + if ! [[ "$b" =~ $NAME_RE ]]; then + report "$f" "❌ Malformed migration filename: $f + Expected YYYYMMDDHHMMSS_lower_snake_name.(sql|go); the name segment must be lowercase. + Regenerate with: make migration-sql name= (or make migration-go name=)" + continue + fi + ts="${b%%_*}" + if [[ -n "$base_max" ]] && ! [[ "$ts" > "$base_max" ]]; then + report "$f" "❌ Migration ordering error: $f ($(human_ts "$ts")) + is older than (or equal to) the newest migration already on ${BASE_REF#origin/}: + $base_max_file ($(human_ts "$base_max")) + + Goose applies migrations in timestamp order, so databases already upgraded + past that point would SILENTLY SKIP your migration. + + Fix: regenerate it with a current timestamp: + make migration-sql name= (or make migration-go name=) + then move your SQL/Go body into the new file and delete the old one." + fi +done < <(git diff --diff-filter=A --name-only "$BASE_REF"...HEAD -- "$MIGRATIONS_DIR" 2>/dev/null) + +# --- Duplicate timestamps across the merged set (HEAD) --- +all_migs="$(git ls-tree -r --name-only HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)" +dups="$(printf '%s\n' "$all_migs" | while IFS= read -r f; do + b="$(basename "$f")" + is_migration "$b" || continue + printf '%s\n' "${b%%_*}" +done | sort | uniq -d)" +if [ -n "$dups" ]; then + while IFS= read -r ts; do + [ -z "$ts" ] && continue + colliding="$(printf '%s\n' "$all_migs" | grep "/${ts}_" || true)" + printf '❌ Duplicate migration timestamp %s used by multiple files:\n' "$ts" >&2 + while IFS= read -r cf; do + [ -z "$cf" ] && continue + printf ' %s\n' "$cf" >&2 + annotate "$cf" "Duplicate migration timestamp $ts — shared by another migration. Timestamps must be unique; regenerate one with make migration-*." + done <<< "$colliding" + printf ' Every migration needs a unique timestamp. Regenerate one with make migration-*.\n' >&2 + status=1 + done <<< "$dups" +fi + +if [ "$status" -eq 0 ]; then + echo "✅ DB migrations OK (ordering, uniqueness, naming)." +fi +exit "$status" From 7fa13761d734159e2f0a502a31affacc88e66d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 10 Jul 2026 10:52:05 -0400 Subject: [PATCH 08/56] fix(scanner): resolve file symlinks with the production local storage FS (#5755) * fix(scanner): resolve file symlinks with the production local storage FS The symlink classification added for GHSA-r5qr-m328-qcf4 relied on fs.ReadLink, but the local storage FS wraps os.DirFS behind the fs.FS interface, hiding its ReadLinkFS implementation. Every resolution failed at the first hop, so the scanner silently skipped ALL file symlinks, regardless of target or the FollowSymlinks setting. Libraries made of symlinks (e.g. shared-pool setups) lost all their tracks after upgrading to 0.63. The local storage now exposes full OS-level resolution (EvalSymlinks) through a new optional storage.SymlinkResolverFS interface, which the scanner prefers over the fs.ReadLink hop loop. This also classifies a chain by its FINAL target even when it passes through an audio-named intermediate outside the library, closing a bypass the hop loop had. Regular (non-symlink) entries keep the same early-return path, so scan performance is unaffected for normal libraries. Fixes #5752 * fix(test): keep watcher specs off the real local storage The watcher specs spawn watchLibrary goroutines that are not joined on spec teardown. Now that the scanner test binary registers the file:// storage, those leaked goroutines reached newLocalStorage, which reads conf.Server on construction, racing with the configtest cleanup that restores the config snapshot (caught by CI's race detector). Point the mock libraries at a fake storage scheme, which never touches the config and does not support watching, so the goroutine exits immediately. * fix(storage): reject invalid fs paths in ResolveSymlink Defense-in-depth for the SymlinkResolverFS contract: names must be valid fs.FS paths. A lexical ".." in the name would otherwise escape the library root via filepath.Join cleaning. No current caller can produce such a name (they come from ReadDir walks), but the guard enforces the documented contract at the boundary. --- core/storage/interface.go | 8 ++++ core/storage/local/local.go | 13 +++++- core/storage/local/local_test.go | 72 +++++++++++++++++++++++++++++++ scanner/scanner_suite_test.go | 17 ++++++++ scanner/walk_dir_tree.go | 16 +++++++ scanner/walk_dir_tree_test.go | 73 ++++++++++++++++++++++++++++++++ scanner/watcher_test.go | 9 +++- 7 files changed, 205 insertions(+), 3 deletions(-) diff --git a/core/storage/interface.go b/core/storage/interface.go index dc08ca00a..02c1d14d9 100644 --- a/core/storage/interface.go +++ b/core/storage/interface.go @@ -17,6 +17,14 @@ type MusicFS interface { ReadTags(path ...string) (map[string]metadata.Info, error) } +// SymlinkResolverFS is an optional interface for MusicFS implementations backed by a real +// filesystem. ResolveSymlink resolves the whole symlink chain of the named entry at the OS +// level and returns the final target's path — including targets outside the FS root, which +// fs.ReadLink-based resolution cannot follow. +type SymlinkResolverFS interface { + ResolveSymlink(name string) (string, error) +} + // Watcher is a storage with the ability watch the FS and notify changes type Watcher interface { // Start starts a watcher on the whole FS and returns a channel to send detected changes. diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 5384581e0..32aff0955 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -54,12 +54,23 @@ func (s *localStorage) FS() (storage.MusicFS, error) { if _, err := os.Stat(path); err != nil { //nolint:gosec return nil, fmt.Errorf("%w: %s", err, path) } - return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil + return &localFS{FS: os.DirFS(path), extractor: s.extractor, root: path}, nil } type localFS struct { fs.FS extractor Extractor + root string +} + +// ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the +// OS level, so links whose targets live outside the library folder (not reachable through +// the fs.FS abstraction) still resolve to their final target. +func (lfs *localFS) ResolveSymlink(name string) (string, error) { + if !fs.ValidPath(name) { + return "", &fs.PathError{Op: "resolvesymlink", Path: name, Err: fs.ErrInvalid} + } + return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name))) } func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index d65d8214a..90bdd4b5b 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -199,6 +199,78 @@ var _ = Describe("LocalStorage", func() { }) }) + Describe("localFS.ResolveSymlink", func() { + var musicFS storage.MusicFS + + BeforeEach(func() { + if runtime.GOOS == "windows" { + Skip("symlink semantics") + } + u, err := storage.LocalPathToURL(tempDir) + Expect(err).ToNot(HaveOccurred()) + musicFS, err = newLocalStorage(u).FS() + Expect(err).ToNot(HaveOccurred()) + }) + + It("implements storage.SymlinkResolverFS", func() { + _, ok := musicFS.(storage.SymlinkResolverFS) + Expect(ok).To(BeTrue()) + }) + + It("resolves a chain that leaves the library folder to its final target", func() { + outside, err := os.MkdirTemp("", "navidrome-symlink-outside-") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(outside) }) + + target := filepath.Join(outside, "final.txt") + Expect(os.WriteFile(target, []byte("data"), 0600)).To(Succeed()) + mid := filepath.Join(outside, "mid.wav") + Expect(os.Symlink(target, mid)).To(Succeed()) + Expect(os.Symlink(mid, filepath.Join(tempDir, "link.wav"))).To(Succeed()) + + resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("link.wav") + Expect(err).ToNot(HaveOccurred()) + expected, err := filepath.EvalSymlinks(target) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved).To(Equal(expected)) + }) + + It("resolves entries in subfolders (slash-separated fs paths)", func() { + Expect(os.MkdirAll(filepath.Join(tempDir, "sub"), 0755)).To(Succeed()) + target := filepath.Join(tempDir, "real.mp3") + Expect(os.WriteFile(target, []byte("audio"), 0600)).To(Succeed()) + Expect(os.Symlink(target, filepath.Join(tempDir, "sub", "link.mp3"))).To(Succeed()) + + resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("sub/link.mp3") + Expect(err).ToNot(HaveOccurred()) + expected, err := filepath.EvalSymlinks(target) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved).To(Equal(expected)) + }) + + It("returns an error for a broken symlink", func() { + Expect(os.Symlink(filepath.Join(tempDir, "missing.mp3"), filepath.Join(tempDir, "broken.mp3"))).To(Succeed()) + + _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("broken.mp3") + Expect(err).To(HaveOccurred()) + }) + + It("rejects names that are not valid fs paths", func() { + for _, name := range []string{"../outside.mp3", "/etc/hosts", "sub/../../outside.mp3", ""} { + _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink(name) + Expect(err).To(MatchError(fs.ErrInvalid), name) + } + }) + + It("returns an error for a symlink loop", func() { + Expect(os.Symlink(filepath.Join(tempDir, "loop2.mp3"), filepath.Join(tempDir, "loop1.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(tempDir, "loop1.mp3"), filepath.Join(tempDir, "loop2.mp3"))).To(Succeed()) + + _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("loop1.mp3") + Expect(err).To(HaveOccurred()) + }) + }) + Describe("localFS.ReadTags", func() { var testFile string diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 9ee6fc89b..10be0401f 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -2,17 +2,34 @@ package scanner_test import ( "context" + "io/fs" "os" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/goleak" ) +// The local storage is registered in this test binary, so any spec (or background watcher) +// touching a file:// library needs a default extractor to avoid a startup fatal. +type noopSuiteExtractor struct{} + +func (noopSuiteExtractor) Parse(...string) (map[string]metadata.Info, error) { return nil, nil } +func (noopSuiteExtractor) Version() string { return "0" } + +func init() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return noopSuiteExtractor{} + }) +} + func TestScanner(t *testing.T) { // Only run goleak checks when the GOLEAK env var is set if os.Getenv("GOLEAK") != "" { diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 55bbab684..887344b1b 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -5,11 +5,13 @@ import ( "io/fs" "maps" "path" + "path/filepath" "slices" "sort" "strings" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -232,6 +234,20 @@ func resolveEntryName(ctx context.Context, fsys fs.FS, dirPath string, entry fs. log.Trace(ctx, "Scanner: Skipping symlink, following is disabled", "path", linkPath) return "", false } + // OS-backed filesystems can resolve the whole chain, even when it leaves the FS root + // (e.g. a link into another folder/drive), so the final target is always what gets + // classified. The fs.ReadLink loop below can't see past the root: it classifies by the + // last in-chain name it can reach. + if resolver, ok := fsys.(storage.SymlinkResolverFS); ok { + target, err := resolver.ResolveSymlink(linkPath) + if err != nil { + log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err) + return "", false + } + resolved := filepath.Base(target) + log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", target, "name", resolved) + return resolved, true + } cur := linkPath for hop := 0; hop < maxSymlinkHops; hop++ { target, err := fs.ReadLink(fsys, cur) diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index f3b13a4ef..9fb650c4d 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -432,6 +432,79 @@ var _ = Describe("walk_dir_tree", func() { }) }) + // Regression for #5752: the production localFS must resolve file symlinks. + // It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based + // resolution is not available and full OS-level resolution is required. + Context("production local storage FS", func() { + var libRoot string + var musicFS storage.MusicFS + + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + + // Reproduces the reported layout: a "pool" with the real files and a + // library containing only symlinks into the pool. + base := GinkgoT().TempDir() + pool := filepath.Join(base, "pool") + libRoot = filepath.Join(base, "userlib") + Expect(os.MkdirAll(pool, 0755)).To(Succeed()) + Expect(os.MkdirAll(libRoot, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pool, "real.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pool, "secrets.txt"), []byte("TOPSECRET"), 0600)).To(Succeed()) + // mid.wav lives OUTSIDE the library and has an audio name, but points at a + // non-audio file. A chain through it must be classified by the FINAL target. + Expect(os.Symlink(filepath.Join(pool, "secrets.txt"), filepath.Join(pool, "mid.wav"))).To(Succeed()) + + Expect(os.Symlink("../pool/real.mp3", filepath.Join(libRoot, "relative.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(pool, "real.mp3"), filepath.Join(libRoot, "absolute.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed()) + + u, err := storage.LocalPathToURL(libRoot) + Expect(err).ToNot(HaveOccurred()) + s, err := storage.For(u.String()) + Expect(err).ToNot(HaveOccurred()) + musicFS, err = s.FS() + Expect(err).ToNot(HaveOccurred()) + }) + + walkRoot := func() *folderEntry { + job := &scanJob{fs: musicFS, lib: model.Library{Path: libRoot}} + results, err := walkDirTree(GinkgoT().Context(), job) + Expect(err).ToNot(HaveOccurred()) + var root *folderEntry + for folder := range results { + if folder.path == "." { + root = folder + } + } + Expect(root).ToNot(BeNil()) + return root + } + + It("imports symlinks to out-of-library audio files", func() { + root := walkRoot() + Expect(root.audioFiles).To(HaveKey("relative.mp3")) + Expect(root.audioFiles).To(HaveKey("absolute.mp3")) + }) + + It("rejects a chain that ends in a non-audio file, even through an audio-named intermediate", func() { + root := walkRoot() + Expect(root.audioFiles).ToNot(HaveKey("evil.wav")) + }) + + It("skips broken symlinks", func() { + root := walkRoot() + Expect(root.audioFiles).ToNot(HaveKey("broken.mp3")) + }) + + It("skips all file symlinks when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + root := walkRoot() + Expect(root.audioFiles).To(BeEmpty()) + }) + }) + Context("out-of-tree escape (temp dir)", func() { var root string BeforeEach(func() { diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index ffe9f8b15..15e49e195 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -30,10 +31,14 @@ var _ = Describe("Watcher", func() { ctx, cancel = context.WithCancel(GinkgoT().Context()) DeferCleanup(cancel) + // Use a fake storage scheme: watchLibrary goroutines spawned by Run/Watch are not + // joined on spec teardown, and the real file:// storage reads conf.Server on + // construction, racing with the configtest cleanup that restores the config. + storagetest.Register("fake-watcher", &storagetest.FakeFS{}) lib = &model.Library{ ID: 1, Name: "Test Library", - Path: "/test/library", + Path: "fake-watcher:///test/library", } // Set up mocks @@ -234,7 +239,7 @@ var _ = Describe("Watcher", func() { lib2 = &model.Library{ ID: 2, Name: "Test Library 2", - Path: "/test/library2", + Path: "fake-watcher:///test/library2", } mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo) From 116a4407184b6f21221a6aa7702fb738a3915773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 10 Jul 2026 18:43:31 -0400 Subject: [PATCH 09/56] test(scanner): fix flaky Windows search_normalized rescan test (#5758) The 'repopulates a stale search_normalized on a full rescan' spec runs two full scans back-to-back. Whether the second scan refreshes the unchanged artist depends on folderEntry.isOutdated(), which compares folder.updated_at (written during the first scan) against the second scan's library.last_scan_started_at using a strict time.Before(). Both are time.Now() values captured milliseconds apart. On Linux's fine-grained clock they are always distinct, so the test passes. On Windows the coarse wall-clock granularity frequently makes the two timestamps land in the same tick and compare equal, so Before() returns false, the folder is treated as up-to-date and skipped, the artist is never re-persisted, and search_normalized stays empty -- failing the assertion intermittently across unrelated PRs. Backdate the folder's updated_at an hour before the second scan so the comparison is unambiguous on every platform. This is a test-only timing artifact (real rescans never run milliseconds apart on an unchanged library), so no production code changes are needed. --- scanner/scanner_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 7f3dca775..cc3720717 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -6,6 +6,7 @@ import ( "errors" "path/filepath" "testing/fstest" + "time" "github.com/Masterminds/squirrel" "github.com/google/uuid" @@ -212,6 +213,15 @@ var _ = Describe("Scanner", Ordered, func() { _, err := db.Db().ExecContext(ctx, "UPDATE artist SET search_normalized = '' WHERE name = 'GØGGS'") Expect(err).ToNot(HaveOccurred()) + // Backdate the folder so the next full scan reliably sees it as outdated. + // isOutdated() compares folder.updated_at (written by this scan) against the + // next scan's last_scan_started_at with a strict Before(); back-to-back scans + // can capture both within one clock tick on Windows (coarse wall-clock), making + // the refresh flaky. Backdating forces the comparison to be unambiguous. + _, err = db.Db().ExecContext(ctx, + "UPDATE folder SET updated_at = ?", time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(runScanner(ctx, true)).To(Succeed()) Expect(searchNormalized()).To(Equal("GOGGS")) }) From 205c85da5523338807808202d8877ccaa6e82776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 10 Jul 2026 19:15:49 -0400 Subject: [PATCH 10/56] fix(plugins): surface host service failures when loading plugins (#5756) * fix(plugins): surface host service failures when loading plugins When a host service failed to initialize during plugin load (e.g. the taskqueue database could not be created because the data folder is not writable), the error was logged and swallowed, and its host functions were silently omitted. Instantiation then failed with a misleading error such as '"task_createqueue" is not exported in module "extism:host/user"', which reads as a plugin/host API mismatch and gets wrongly blamed on plugin authors (see kgarner7/navidrome-listenbrainz-daily-playlist#26). Host service factories now return an error, and loadPluginWithConfig fails fast with the actual cause (e.g. 'creating Task service: creating plugin data directory: ...'), which is also stored in the plugin's last_error. Closers accumulated before a load failure are now closed (via a deferred guard covering all failure paths), so partially-created services no longer leak goroutines or database handles. Also fix a panic in 'navidrome plugin enable': CLI commands use the plugin Manager without calling Start, so manager.ctx was nil and newTaskQueueService panicked in context.WithCancel. Long-lived host services (taskqueue, kvstore, websocket) now receive their lifecycle context explicitly in the constructor, sourced from serviceContext.baseCtx(), which falls back to context.Background() for the unstarted-manager case. * docs(plugins): correct websocket readLoop lifecycle comment The comment claimed the read loop's context is always cancelled during application shutdown, which is not true when the manager was never started (one-shot CLI runs, where baseCtx falls back to context.Background()). Clarify that connection closure via Close() on plugin unload is what ends the read loop, with context cancellation as a server-shutdown backstop. Addresses review feedback on #5756. * test(plugins): exclude plugin-loading specs from Windows builds The new loadPluginWithConfig specs reference test suite helpers (testdataDir, noopMetricsRecorder) defined in plugins_suite_test.go, which is excluded on Windows, breaking test compilation there. Move the specs to their own file with the same build constraint, keeping the pure-function specs in manager_loader_test.go running on Windows as before. --- plugins/host_taskqueue.go | 5 +- plugins/host_taskqueue_test.go | 16 +---- plugins/host_websocket.go | 15 +++-- plugins/manager_loader.go | 98 +++++++++++++++++++---------- plugins/manager_loader_load_test.go | 78 +++++++++++++++++++++++ 5 files changed, 158 insertions(+), 54 deletions(-) create mode 100644 plugins/manager_loader_load_test.go diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index a5db3344f..2f74c0aa4 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -82,7 +82,8 @@ type taskQueueServiceImpl struct { } // newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database. -func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { +// The given ctx bounds the service's background work (queue workers, cleanup loop). +func newTaskQueueService(ctx context.Context, pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating plugin data directory: %w", err) @@ -102,7 +103,7 @@ func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int return nil, fmt.Errorf("creating taskqueue schema: %w", err) } - ctx, cancel := context.WithCancel(manager.ctx) //nolint:gosec // cancel is stored in struct and called in Close() + ctx, cancel := context.WithCancel(ctx) //nolint:gosec // cancel is stored in struct and called in Close() s := &taskQueueServiceImpl{ pluginName: pluginName, diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index faff79c8e..d459fd69b 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -42,15 +42,11 @@ var _ = Describe("TaskQueueService", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DataFolder = conf.NewDir(tmpDir) - // Create a mock manager with context - managerCtx, cancel := context.WithCancel(ctx) manager = &Manager{ plugins: make(map[string]*plugin), - ctx: managerCtx, } - DeferCleanup(cancel) - service, err = newTaskQueueService("test_plugin", manager, 5) + service, err = newTaskQueueService(ctx, "test_plugin", manager, 5) Expect(err).ToNot(HaveOccurred()) }) @@ -730,14 +726,11 @@ var _ = Describe("TaskQueueService", func() { service.Close() // Create a new service pointing to the same DB - managerCtx2, cancel2 := context.WithCancel(ctx) - DeferCleanup(cancel2) manager2 := &Manager{ plugins: make(map[string]*plugin), - ctx: managerCtx2, } - service, err = newTaskQueueService("test_plugin", manager2, 5) + service, err = newTaskQueueService(ctx, "test_plugin", manager2, 5) Expect(err).ToNot(HaveOccurred()) // Override callback to succeed @@ -775,14 +768,11 @@ var _ = Describe("TaskQueueService", func() { Describe("Plugin isolation", func() { It("uses separate databases for different plugins", func() { - managerCtx2, cancel2 := context.WithCancel(ctx) - DeferCleanup(cancel2) manager2 := &Manager{ plugins: make(map[string]*plugin), - ctx: managerCtx2, } - service2, err := newTaskQueueService("other_plugin", manager2, 5) + service2, err := newTaskQueueService(ctx, "other_plugin", manager2, 5) Expect(err).ToNot(HaveOccurred()) defer service2.Close() diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index eef1e6236..90403f4c0 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -54,6 +54,7 @@ type wsConnection struct { // webSocketServiceImpl implements host.WebSocketService. // It provides plugins with WebSocket communication capabilities. type webSocketServiceImpl struct { + baseCtx context.Context // bounds the read loops, which outlive the Connect() call pluginName string manager *Manager requiredHosts []string @@ -63,8 +64,9 @@ type webSocketServiceImpl struct { } // newWebSocketService creates a new WebSocketService for a plugin. -func newWebSocketService(pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl { +func newWebSocketService(ctx context.Context, pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl { return &webSocketServiceImpl{ + baseCtx: ctx, pluginName: pluginName, manager: manager, requiredHosts: permission.RequiredHosts, @@ -129,11 +131,12 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade s.connections[connectionID] = wsConn s.mu.Unlock() - // Start read goroutine with manager's context. - // We use manager.ctx instead of the caller's ctx because the readLoop must - // outlive the Connect() call. The manager's context is cancelled during - // application shutdown, ensuring graceful cleanup. - go s.readLoop(s.manager.ctx, connectionID, wsConn) + // Start read goroutine with the service's base context instead of the + // caller's ctx, because the readLoop must outlive the Connect() call. + // Connections are closed by Close() when the plugin is unloaded, which ends + // the readLoop; the base context is a backstop that also ends it on server + // shutdown (it is never cancelled in one-shot CLI runs). + go s.readLoop(s.baseCtx, connectionID, wsConn) log.Debug(ctx, "WebSocket connected", "plugin", s.pluginName, "connectionID", connectionID, "url", urlStr) return connectionID, nil diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 604fba3a7..757ededb5 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -30,11 +30,23 @@ type serviceContext struct { allLibraries bool // If true, plugin can access all libraries } +// baseCtx returns the manager's lifecycle context, for host services that +// outlive the plugin call that created them. It falls back to +// context.Background() when the manager was never started, which is the case +// for CLI commands (e.g. `navidrome plugin enable`) that load plugins without +// calling Start. +func (c *serviceContext) baseCtx() context.Context { + if c.manager.ctx == nil { + return context.Background() + } + return c.manager.ctx +} + // hostServiceEntry defines a host service for table-driven registration. type hostServiceEntry struct { name string hasPermission func(*Permissions) bool - create func(*serviceContext) ([]extism.HostFunction, io.Closer) + create func(*serviceContext) ([]extism.HostFunction, io.Closer, error) } // hostServices defines all available host services. @@ -43,119 +55,117 @@ var hostServices = []hostServiceEntry{ { name: "Config", hasPermission: func(p *Permissions) bool { return true }, // Always available, no permission required - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newConfigService(ctx.pluginName, ctx.config) - return host.RegisterConfigHostFunctions(service), nil + return host.RegisterConfigHostFunctions(service), nil, nil }, }, { name: "SubsonicAPI", hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) - return host.RegisterSubsonicAPIHostFunctions(service), nil + return host.RegisterSubsonicAPIHostFunctions(service), nil, nil }, }, { name: "Scheduler", hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance()) - return host.RegisterSchedulerHostFunctions(service), service + return host.RegisterSchedulerHostFunctions(service), service, nil }, }, { name: "WebSocket", hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Websocket - service := newWebSocketService(ctx.pluginName, ctx.manager, perm) - return host.RegisterWebSocketHostFunctions(service), service + service := newWebSocketService(ctx.baseCtx(), ctx.pluginName, ctx.manager, perm) + return host.RegisterWebSocketHostFunctions(service), service, nil }, }, { name: "Artwork", hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newArtworkService() - return host.RegisterArtworkHostFunctions(service), nil + return host.RegisterArtworkHostFunctions(service), nil, nil }, }, { name: "Cache", hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newCacheService(ctx.pluginName) - return host.RegisterCacheHostFunctions(service), service + return host.RegisterCacheHostFunctions(service), service, nil }, }, { name: "Library", hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Library service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries) - return host.RegisterLibraryHostFunctions(service), nil + return host.RegisterLibraryHostFunctions(service), nil, nil }, }, { name: "KVStore", hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Kvstore - service, err := newKVStoreService(ctx.manager.ctx, ctx.pluginName, perm) + service, err := newKVStoreService(ctx.baseCtx(), ctx.pluginName, perm) if err != nil { - log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err) - return nil, nil + return nil, nil, err } - return host.RegisterKVStoreHostFunctions(service), service + return host.RegisterKVStoreHostFunctions(service), service, nil }, }, { name: "Users", hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) - return host.RegisterUsersHostFunctions(service), nil + return host.RegisterUsersHostFunctions(service), nil, nil }, }, { name: "Matcher", hasPermission: func(p *Permissions) bool { return p != nil && p.Matcher != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { hasFilesystemPerm := ctx.permissions.Library != nil && ctx.permissions.Library.Filesystem service := newMatcherService( ctx.manager.ds, hasFilesystemPerm, newUserAccess(ctx.allowedUsers, ctx.allUsers), newLibraryAccess(ctx.allowedLibraries, ctx.allLibraries), ) - return host.RegisterMatcherHostFunctions(service), nil + return host.RegisterMatcherHostFunctions(service), nil, nil }, }, { name: "HTTP", hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Http service := newHTTPService(ctx.pluginName, perm) - return host.RegisterHTTPHostFunctions(service), nil + return host.RegisterHTTPHostFunctions(service), nil, nil }, }, { name: "Task", hasPermission: func(p *Permissions) bool { return p != nil && p.Taskqueue != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Taskqueue maxConcurrency := int32(1) if perm.MaxConcurrency > 0 { maxConcurrency = int32(perm.MaxConcurrency) } - service, err := newTaskQueueService(ctx.pluginName, ctx.manager, maxConcurrency) + service, err := newTaskQueueService(ctx.baseCtx(), ctx.pluginName, ctx.manager, maxConcurrency) if err != nil { - log.Error("Failed to create Task service", "plugin", ctx.pluginName, err) - return nil, nil + return nil, nil, err } - return host.RegisterTaskHostFunctions(service), service + return host.RegisterTaskHostFunctions(service), service, nil }, }, } @@ -256,6 +266,7 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { // loadPluginWithConfig loads a plugin with configuration from DB. // The p.Path should point to an .ndp package file. func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { + // NewContext falls back to context.Background() when m.ctx is nil (unstarted manager) ctx := log.NewContext(m.ctx, "plugin", p.ID) if m.stopped.Load() { @@ -328,6 +339,15 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { // Build host functions based on permissions from manifest var hostFunctions []extism.HostFunction var closers []io.Closer + loaded := false + // On success the closers are owned by the registered plugin; on any + // failure past this point, close them so partially-created services + // don't leak goroutines or file handles. + defer func() { + if !loaded { + closeAll(closers) + } + }() svcCtx := &serviceContext{ pluginName: p.ID, @@ -341,7 +361,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } for _, entry := range hostServices { if entry.hasPermission(pkg.Manifest.Permissions) { - funcs, closer := entry.create(svcCtx) + funcs, closer, err := entry.create(svcCtx) + if err != nil { + return fmt.Errorf("creating %s service: %w", entry.name, err) + } hostFunctions = append(hostFunctions, funcs...) if closer != nil { closers = append(closers, closer) @@ -400,6 +423,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), } m.mu.Unlock() + loaded = true // Call plugin init function callPluginInit(ctx, m.plugins[p.ID]) @@ -407,6 +431,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return nil } +// closeAll closes host service closers accumulated before a load failure, +// so partially-created services don't leak goroutines or file handles. +func closeAll(closers []io.Closer) { + for _, c := range closers { + _ = c.Close() + } +} + // parsePluginConfig parses a JSON config string into a map of string values. // For Extism, all config values must be strings, so non-string values are serialized as JSON. func parsePluginConfig(configJSON string) (map[string]string, error) { diff --git a/plugins/manager_loader_load_test.go b/plugins/manager_loader_load_test.go new file mode 100644 index 000000000..8f35548af --- /dev/null +++ b/plugins/manager_loader_load_test.go @@ -0,0 +1,78 @@ +//go:build !windows + +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("loadPluginWithConfig", func() { + var manager *Manager + var dataDir string + + BeforeEach(func() { + pluginsDir := GinkgoT().TempDir() + dataDir = GinkgoT().TempDir() + + src := filepath.Join(testdataDir, "test-taskqueue"+PackageExtension) + data, err := os.ReadFile(src) + Expect(err).ToNot(HaveOccurred()) + dest := filepath.Join(pluginsDir, "test-taskqueue"+PackageExtension) + Expect(os.WriteFile(dest, data, 0600)).To(Succeed()) + hash := sha256.Sum256(data) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(pluginsDir) + conf.Server.Plugins.AutoReload = false + conf.Server.DataFolder = conf.NewDir(dataDir) + + repo := tests.CreateMockPluginRepo() + repo.Permitted = true + repo.SetData(model.Plugins{{ + ID: "test-taskqueue", + Path: dest, + SHA256: hex.EncodeToString(hash[:]), + Enabled: false, + }}) + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: &tests.MockDataStore{MockedPlugin: repo}, + metrics: noopMetricsRecorder{}, + subsonicRouter: http.NotFoundHandler(), + } + }) + + Describe("host service creation failures", func() { + It("reports the Task service creation error instead of a missing host function", func() { + Expect(manager.Start(GinkgoT().Context())).To(Succeed()) + DeferCleanup(func() { _ = manager.Stop() }) + + // Block the taskqueue data dir by creating a file where the directory should be + Expect(os.WriteFile(filepath.Join(dataDir, "plugins"), nil, 0600)).To(Succeed()) + + err := manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue") + Expect(err).To(MatchError(ContainSubstring("creating Task service"))) + Expect(err).ToNot(MatchError(ContainSubstring("not exported"))) + }) + }) + + Describe("unstarted manager", func() { + It("enables a taskqueue plugin on a manager that was never started", func() { + // CLI commands (navidrome plugin enable) use the manager without calling Start + Expect(manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue")).To(Succeed()) + DeferCleanup(func() { _ = manager.unloadPlugin("test-taskqueue") }) + }) + }) +}) From e91687e760a8b4e19dc74b6a9b1ff7f3674565ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 10 Jul 2026 20:27:29 -0400 Subject: [PATCH 11/56] fix(smartplaylist): reject NSP mixing top-level 'any' and 'all' (#5759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(scanner): fix flaky Windows search_normalized rescan test The 'repopulates a stale search_normalized on a full rescan' spec runs two full scans back-to-back. Whether the second scan refreshes the unchanged artist depends on folderEntry.isOutdated(), which compares folder.updated_at (written during the first scan) against the second scan's library.last_scan_started_at using a strict time.Before(). Both are time.Now() values captured milliseconds apart. On Linux's fine-grained clock they are always distinct, so the test passes. On Windows the coarse wall-clock granularity frequently makes the two timestamps land in the same tick and compare equal, so Before() returns false, the folder is treated as up-to-date and skipped, the artist is never re-persisted, and search_normalized stays empty -- failing the assertion intermittently across unrelated PRs. Backdate the folder's updated_at an hour before the second scan so the comparison is unambiguous on every platform. This is a test-only timing artifact (real rescans never run milliseconds apart on an unchanged library), so no production code changes are needed. * fix(smartplaylist): reject NSP mixing top-level 'any' and 'all' A smart playlist (.nsp) that specified both a top-level "any" and a top-level "all" group was imported by silently keeping only "any" and discarding "all", regardless of key order. The Criteria model holds a single top-level Expression, so it cannot represent both groups, and the parser picked "any" without reporting the dropped rules. Make Criteria.UnmarshalJSON return an error when both keys are present at the top level, so the scanner fails loudly (logging the playlist as invalid) instead of silently losing rules. Users should nest one group inside the other, as shown in the documented examples. Fixes #5757 * fix(smartplaylist): reject top-level any+all by key presence Address code review feedback: the previous guard checked decoded slice lengths, so it only rejected the mixed top-level any/all form when both groups were non-empty. An input like {"any":[],"all":[...]} (or a null group) slipped past and silently used just one group — the same class of silent drop this change set out to prevent. Decode the two keys as json.RawMessage and detect presence by key rather than length, so any file that provides both top-level keys is rejected regardless of whether one group is empty or null. * refactor(smartplaylist): detect top-level any+all via presence type Replace the json.RawMessage + manual double-unmarshal in Criteria.UnmarshalJSON with a small optionalConjunction wrapper whose UnmarshalJSON records that its key was present. Because encoding/json invokes UnmarshalJSON even for a JSON null, this keeps the exact behavior (a present-but-empty or null group still counts, so mixing both top-level keys is rejected) while decoding in a single pass — no raw-message capture, no re-decode, no shadow variables. No behavior change; existing tests pass unchanged. --- core/playlists/parse_nsp_test.go | 14 ++++++++++++++ model/criteria/criteria.go | 27 ++++++++++++++++----------- model/criteria/criteria_test.go | 22 ++++++++++++++++++++++ model/criteria/json.go | 14 ++++++++++++++ 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/core/playlists/parse_nsp_test.go b/core/playlists/parse_nsp_test.go index 516a5355d..d6d69866f 100644 --- a/core/playlists/parse_nsp_test.go +++ b/core/playlists/parse_nsp_test.go @@ -113,6 +113,20 @@ var _ = Describe("parseNSP", func() { Expect(err.Error()).To(ContainSubstring("SmartPlaylist")) }) + It("rejects a NSP that mixes top-level 'any' and 'all' instead of silently dropping a group", func() { + nsp := `{ + "name": "Overplayed Favorites", + "any": [{"inPlaylist": {"path": "most-played-favorites.nsp"}}], + "all": [{"notInPlaylist": {"path": "favorites-not-played-in-4-yrs.nsp"}}], + "sort": "playCount, lastPlayed" + }` + pls := &model.Playlist{} + err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("SmartPlaylist")) + Expect(err.Error()).To(And(ContainSubstring("all"), ContainSubstring("any"))) + }) + It("gracefully handles non-string name field", func() { nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}` pls := &model.Playlist{Name: "Original"} diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 31d208d08..8c3d183a9 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -103,21 +103,26 @@ func (c Criteria) MarshalJSON() ([]byte, error) { func (c *Criteria) UnmarshalJSON(data []byte) error { var aux struct { - All unmarshalConjunctionType `json:"all"` - Any unmarshalConjunctionType `json:"any"` - Sort string `json:"sort"` - Order string `json:"order"` - Limit int `json:"limit"` - LimitPercent int `json:"limitPercent"` - Offset int `json:"offset"` + All optionalConjunction `json:"all"` + Any optionalConjunction `json:"any"` + Sort string `json:"sort"` + Order string `json:"order"` + Limit int `json:"limit"` + LimitPercent int `json:"limitPercent"` + Offset int `json:"offset"` } if err := json.Unmarshal(data, &aux); err != nil { return err } - if len(aux.Any) > 0 { - c.Expression = Any(aux.Any) - } else if len(aux.All) > 0 { - c.Expression = All(aux.All) + // A Criteria has a single top-level group. Reject files that provide both keys + // (even when one is [] or null) rather than silently dropping one of them. + if aux.All.present && aux.Any.present { + return errors.New("invalid criteria json: 'all' and 'any' cannot both be used at the top level; nest one inside the other instead") + } + if len(aux.Any.rules) > 0 { + c.Expression = Any(aux.Any.rules) + } else if len(aux.All.rules) > 0 { + c.Expression = All(aux.All.rules) } else { return errors.New("invalid criteria json. missing rules (key 'all' or 'any')") } diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 092cfd36a..7f214e703 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -80,6 +80,28 @@ var _ = Describe("Criteria", func() { }) }) + Context("with both top-level 'all' and 'any'", func() { + It("returns an error instead of silently dropping one of the groups", func() { + jsonStr := `{"any":[{"inPlaylist":{"path":"a.nsp"}}],"all":[{"notInPlaylist":{"path":"b.nsp"}}]}` + var c Criteria + err := json.Unmarshal([]byte(jsonStr), &c) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any"))) + }) + + DescribeTable("rejects both keys even when one group is present but empty", + func(jsonStr string) { + var c Criteria + err := json.Unmarshal([]byte(jsonStr), &c) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any"))) + }, + Entry("empty any", `{"any":[],"all":[{"is":{"loved":true}}]}`), + Entry("empty all", `{"all":[],"any":[{"is":{"loved":true}}]}`), + Entry("null any", `{"any":null,"all":[{"is":{"loved":true}}]}`), + ) + }) + Describe("LimitPercent", func() { Describe("JSON round-trip", func() { It("marshals and unmarshals limitPercent", func() { diff --git a/model/criteria/json.go b/model/criteria/json.go index beded9d1f..d0f453524 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -33,6 +33,20 @@ func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error { return nil } +// optionalConjunction is a top-level "all"/"any" value that remembers whether its +// key was present at all, so a Criteria providing both can be rejected. encoding/json +// calls UnmarshalJSON even for a JSON null, so present is set whenever the key appears +// — including as [] or null — while an absent key leaves it false. +type optionalConjunction struct { + present bool + rules unmarshalConjunctionType +} + +func (o *optionalConjunction) UnmarshalJSON(data []byte) error { + o.present = true + return json.Unmarshal(data, &o.rules) +} + func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { m := make(map[string]any) err := json.Unmarshal(rawValue, &m) From be10f89c117925fabf10394b8d2962a370108b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 11 Jul 2026 09:18:14 -0400 Subject: [PATCH 12/56] ci: don't skip release jobs after the DB migration check on tag pushes (#5760) * ci: don't skip release jobs after the DB migration check on tag pushes The validate-migrations job added in #5750 was gated at job level with if: github.event_name == 'pull_request', so it concluded "skipped" on tag pushes. GitHub Actions propagates a skipped job transitively through the needs chain (actions/runner#491): even though Build overrode its own gate with !cancelled() && !failure() and ran successfully, every job downstream of Build (msi, release, push-manifest-*, PKG uploads) still failed the implicit success() check and was skipped, which broke the v0.63.2 release. Move the pull_request gate from the job to its steps. On non-PR events all steps are skipped and the job concludes "success", so downstream jobs run normally. This also restores the default success() gate on Build, keeping the fail-fast behavior on PRs with a bad migration. * ci: trim workflow comment Condense the explanation of the step-level pull_request gate on the validate-migrations job to the essential rationale. --- .github/workflows/pipeline.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index d21d0a681..86a1055f8 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -99,16 +99,20 @@ jobs: validate-migrations: name: Validate DB migrations runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + # PR-only gate is at step level: a job-level skip would propagate through + # the needs chain (actions/runner#491) and skip all release jobs on tag pushes. steps: - uses: actions/checkout@v7 + if: github.event_name == 'pull_request' with: fetch-depth: 0 # Refresh the base branch so the check compares against its CURRENT tip, # not the (possibly stale) commit the PR was opened against. - name: Fetch latest base branch + if: github.event_name == 'pull_request' run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" - name: Validate migration ordering and naming + if: github.event_name == 'pull_request' env: BASE_REF: origin/${{ github.event.pull_request.base.ref }} run: ./.github/workflows/validate-migrations.sh @@ -275,10 +279,6 @@ jobs: build: name: Build needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] - # validate-migrations only runs on pull_request, so it is "skipped" on push/tag - # builds. Run Build unless a dependency actually failed — a *skipped* dependency - # (the migration check on non-PR events) must not block release builds. - if: ${{ !cancelled() && !failure() }} strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] From a5efba9a08e349b12cdc25f5c511080cd5f417ad Mon Sep 17 00:00:00 2001 From: Andrew Katsikas Date: Sun, 12 Jul 2026 12:25:23 -0400 Subject: [PATCH 13/56] fix(ui): Set Cache-Control: no-cache on index.html - #5766 (#5767) * fix(serve_index): Set Cache-Control: no-cache on index.html - #5766 Signed-off-by: apkatsikas * fix(serve_index): Set Cache-Control: no-cache, no-store and must-revalidate on index.html - #5766 Signed-off-by: apkatsikas --------- Signed-off-by: apkatsikas --- server/serve_index.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/serve_index.go b/server/serve_index.go index 13fa4a9ce..a538daf1a 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -107,6 +107,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl addShareData(r, data, shareInfo) w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") err = t.Execute(w, data) if err != nil { log.Error(r, "Could not execute `index.html` template", err) From 6b9f85efcc42aab1156ada175a18390f5cb0fbb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 12 Jul 2026 13:27:01 -0400 Subject: [PATCH 14/56] fix(subsonic): omit bit depth for lossy targets in transcode decision (#5768) getTranscodeDecision was copying the source file's bit depth into the transcodeStream details, so a 24-bit FLAC negotiated to Opus reported audioBitdepth=24. Lossy codecs (Opus, MP3, AAC) have no PCM bit depth, and ffmpeg only honors a bit depth constraint (-sample_fmt) for lossless outputs, so the value was both meaningless and misleading to clients that use it as a quality indicator. Only set the transcoded stream's bit depth when the target format is lossless; a zero value omits audioBitdepth from the response. This also makes audioBitdepth codec limitations a no-op for lossy targets instead of rejecting the profile. Lossless targets (e.g. FLAC->FLAC downconvert) keep reporting and clamping bit depth as before. --- core/stream/codec.go | 15 ++++++++----- core/stream/decider.go | 2 +- core/stream/decider_test.go | 44 ++++++++++++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/core/stream/codec.go b/core/stream/codec.go index 28bff75c4..56d163324 100644 --- a/core/stream/codec.go +++ b/core/stream/codec.go @@ -43,14 +43,17 @@ func normalizeSourceSampleRate(sampleRate int, codec string) int { return sampleRate } -// normalizeSourceBitDepth adjusts the source bit depth for codecs that use -// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is -// what ffmpeg produces). For other codecs, returns the depth unchanged. -func normalizeSourceBitDepth(bitDepth int, codec string) int { - if strings.EqualFold(codec, "dsd") && bitDepth == 1 { +// targetBitDepth returns the bit depth for a transcoded stream: 0 for lossy +// targets (they have no PCM bit depth), otherwise the source depth, with DSD +// adjusted to the 24-bit PCM that ffmpeg produces. +func targetBitDepth(srcBitDepth int, srcCodec string, targetIsLossless bool) int { + if !targetIsLossless { + return 0 + } + if strings.EqualFold(srcCodec, "dsd") && srcBitDepth == 1 { return 24 } - return bitDepth + return srcBitDepth } // codecFixedOutputSampleRate returns the mandatory output sample rate for codecs diff --git a/core/stream/decider.go b/core/stream/decider.go index 7940c6862..3c6b01e05 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -269,7 +269,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai Codec: strings.ToLower(profile.AudioCodec), SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), Channels: src.Channels, - BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec), + BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless), IsLossless: targetIsLossless, } if ts.Codec == "" { diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 03c4ea437..577207636 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -656,6 +656,44 @@ var _ = Describe("Decider", func() { Expect(decision.TargetBitDepth).To(Equal(24)) }) + It("omits bit depth when transcoding to a lossy format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(BeZero()) + Expect(decision.TargetBitDepth).To(BeZero()) + }) + + It("ignores audioBitdepth limitation when transcoding to a lossy format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "opus", + Limitations: []Limitation{ + {Name: LimitationAudioBitdepth, Comparison: ComparisonGreaterThanEqual, Values: []string{"32"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(BeZero()) + }) + It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ @@ -695,9 +733,9 @@ var _ = Describe("Decider", func() { // DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000 Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) Expect(decision.TargetSampleRate).To(Equal(48000)) - // DSD 1-bit → 24-bit PCM - Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) - Expect(decision.TargetBitDepth).To(Equal(24)) + // MP3 is lossy: no bit depth on the transcoded stream + Expect(decision.TranscodeStream.BitDepth).To(BeZero()) + Expect(decision.TargetBitDepth).To(BeZero()) }) It("converts DSD sample rate for FLAC target without codec limit", func() { From 969e7e108ca4874d28bcf1e83401b454db65b81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 13 Jul 2026 09:04:24 -0400 Subject: [PATCH 15/56] fix(share): enforce track membership on public share streams (#5769) * fix(share): enforce track membership on public share streams The public share stream endpoint (GET /share/s/{jwt}) validated that the share existed, was unexpired, and that the share owner had library access to the requested track, but it never verified that the track was actually a member of the share. It also accepted stream tokens with no share id (sid) claim, skipping share checks entirely. Enforce that the requested media file belongs to share.Tracks, and make the sid claim mandatory on the stream path. The only producer of stream tokens (encodeMediafileShare) always sets sid, so no legitimate flow is affected; the image endpoint decodes independently and is unchanged. Also document why a JWT is used to represent a shared track: it is a signed, scoped capability for a single public share, not part of authentication. * docs(share): clarify JWT usage comment wording --- server/public/handle_shares.go | 16 ++++++++++ server/public/handle_streams.go | 47 +++++++++++++++++----------- server/public/handle_streams_test.go | 36 +++++++++++++++------ 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 18bfcc01c..76f674483 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -97,6 +97,22 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { return &s } +// encodeMediafileShare builds the signed token embedded in a public share link +// for a single track. +// +// NOTE ON JWT USAGE: This is deliberately NOT part of Navidrome's authentication. +// The token is a signed, opaque capability that identifies one shared track +// (plus its transcode format/bitrate and the parent share id). We use a JWT here +// (reusing the library we already have) because it is a simple way to get three +// properties for a public link: the embedded ids can't be enumerated by guessing, +// the signature +// makes the claims tamper-evident, and the self-contained exp lets us reject +// stale links without a DB lookup. It carries no user identity (no subject, no +// admin flag) and grants access to nothing beyond the share it belongs to; the +// stream handler still verifies the share exists, is unexpired, and that the +// track is actually a member of it. An attacker who can forge these tokens +// necessarily already holds the signing secret, which also signs real user +// sessions, so that scenario is out of scope for the share boundary specifically. func encodeMediafileShare(s model.Share, id string) string { claims := auth.Claims{ ID: id, diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 8fc407e9e..15abab693 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -3,6 +3,7 @@ package public import ( "errors" "net/http" + "slices" "strconv" "time" @@ -25,23 +26,20 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - var shareOwner *model.User - if info.shareID != "" { - share, err := pub.ds.Share(ctx).Get(info.shareID) - if err != nil { - checkShareError(ctx, w, err, info.shareID) - return - } - if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { - checkShareError(ctx, w, model.ErrExpired, info.shareID) - return - } - shareOwner, err = pub.ds.User(ctx).Get(share.UserID) - if err != nil { - log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + shareOwner, err := pub.ds.User(ctx).Get(share.UserID) + if err != nil { + log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return } mf, err := pub.ds.MediaFile(ctx).Get(info.id) @@ -56,7 +54,8 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { } // 404 rather than 403 so the response doesn't reveal whether the id exists. - if shareOwner != nil && !shareOwner.HasLibraryAccess(mf.LibraryID) { + // The track must belong to the share AND be within the owner's libraries. + if !shareContainsTrack(share, mf.ID) || !shareOwner.HasLibraryAccess(mf.LibraryID) { http.Error(w, "not found", http.StatusNotFound) return } @@ -98,6 +97,15 @@ type shareTrackInfo struct { shareID string } +func shareContainsTrack(share *model.Share, mediaFileID string) bool { + return slices.ContainsFunc(share.Tracks, func(mf model.MediaFile) bool { + return mf.ID == mediaFileID + }) +} + +// decodeStreamInfo decodes the signed share-link token. This is a scoped +// public-share capability, not an auth credential; see encodeMediafileShare for +// why a JWT is used here. func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { c, err := auth.Validate(tokenString) if err != nil { @@ -106,6 +114,9 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } + if c.ShareID == "" { + return shareTrackInfo{}, errors.New("required claim \"sid\" not found") + } return shareTrackInfo{ id: c.ID, format: c.Format, diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 6fa083045..2f32ea6f2 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -71,14 +71,11 @@ var _ = Describe("decodeStreamInfo", func() { Expect(err).To(HaveOccurred()) }) - It("handles tokens without shareID (backward compat)", func() { + It("rejects a token without a shareID claim", func() { claims := auth.Claims{ID: "mf-123", Format: "opus"} token, _ := auth.CreatePublicToken(claims) - info, err := decodeStreamInfo(token) - Expect(err).NotTo(HaveOccurred()) - Expect(info.id).To(Equal("mf-123")) - Expect(info.format).To(Equal("opus")) - Expect(info.shareID).To(BeEmpty()) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) }) }) @@ -133,7 +130,7 @@ var _ = Describe("handleStream", func() { shareOwnedBy := func(owner model.User, mf model.MediaFile) { shareRepo.ID = "share123" - shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID} + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}} userRepo := tests.CreateMockUserRepo() Expect(userRepo.Put(&owner)).To(Succeed()) ds.MockedUser = userRepo @@ -171,6 +168,25 @@ var _ = Describe("handleStream", func() { Expect(streamer.called).To(BeFalse()) }) + It("returns 404 when the track is not a member of the share", func() { + owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true} + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&owner)).To(Succeed()) + ds.MockedUser = userRepo + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}}) + ds.MockedMediaFile = mfRepo + shareRepo.ID = "share123" + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{{ID: "mf-shared"}}} + + claims := auth.Claims{ID: "mf-other", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(streamer.called).To(BeFalse()) + }) + It("streams a track inside the share owner's libraries", func() { shareOwnedBy( model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, @@ -217,12 +233,12 @@ var _ = Describe("handleStream", func() { Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) - It("skips share check for tokens without shareID (backward compat)", func() { + It("returns 400 for tokens without a shareID", func() { claims := auth.Claims{ID: "mf-123"} token, _ := auth.CreatePublicToken(claims) w := makeRequest(token) - // Should get past share check, then fail on media file lookup (no mock data) - Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(streamer.called).To(BeFalse()) }) It("returns 400 for an invalid token", func() { From 4998ac2c591d7c1b17bf36f5109f64f521d1169c Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:32:03 +0000 Subject: [PATCH 16/56] feat(server): add scrobble history Native API (#5761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial scrobble api * feat: add scrobble retrieval api * address feedback (1) * fix spelling * be explicit about get * add primary key field, update index, remove rowid references * use unix timestamp for input and output --------- Co-authored-by: Deluan Quintão --- core/scrobbler/play_tracker_test.go | 2 +- ...ary_key_and_update_index_for_scrobbles.sql | 39 ++++ model/scrobble.go | 12 +- persistence/persistence.go | 2 + persistence/persistence_suite_test.go | 20 ++ persistence/scrobble_repository.go | 65 +++++++ persistence/scrobble_repository_test.go | 181 +++++++++++++++--- server/nativeapi/native_api.go | 3 +- tests/mock_scrobble_repo.go | 23 ++- 9 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 74b4be893..831b0ce0d 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -290,7 +290,7 @@ var _ = Describe("PlayTracker", func() { Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1)) Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123")) Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1")) - Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts)) + Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts.Unix())) }) It("does not record scrobble when history is disabled", func() { diff --git a/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql new file mode 100644 index 000000000..220d7cf75 --- /dev/null +++ b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql @@ -0,0 +1,39 @@ +-- +goose Up +CREATE TABLE scrobbles_tmp( + id INTEGER PRIMARY KEY, + media_file_id VARCHAR(255) NOT NULL + REFERENCES media_file(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + user_id VARCHAR(255) NOT NULL + REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + submission_time INTEGER NOT NULL +); +INSERT INTO scrobbles_tmp SELECT ROWID, media_file_id, user_id, submission_time FROM scrobbles; + +DROP INDEX scrobbles_date; +DROP TABLE scrobbles; +ALTER TABLE scrobbles_tmp RENAME TO scrobbles; +CREATE INDEX scrobbles_user_time ON scrobbles(user_id, submission_time); + + +-- +goose Down +CREATE TABLE scrobbles_tmp( + media_file_id VARCHAR(255) NOT NULL + REFERENCES media_file(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + user_id VARCHAR(255) NOT NULL + REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + submission_time INTEGER NOT NULL +); +INSERT INTO scrobbles_tmp SELECT media_file_id, user_id, submission_time FROM scrobbles; + +DROP INDEX scrobbles_user_time; +DROP TABLE scrobbles; +ALTER TABLE scrobbles_tmp RENAME TO scrobbles; +CREATE INDEX scrobbles_date ON scrobbles(submission_time); \ No newline at end of file diff --git a/model/scrobble.go b/model/scrobble.go index e1567abc3..a8022fc16 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -3,11 +3,17 @@ package model import "time" type Scrobble struct { - MediaFileID string - UserID string - SubmissionTime time.Time + ID int64 `structs:"id" json:"id"` + MediaFileID string `structs:"media_file_id" json:"mediaFileId"` + UserID string `json:"-"` + SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` } type ScrobbleRepository interface { + CountAll(options ...QueryOptions) (int64, error) + Get(id string) (*Scrobble, error) + GetAll(options ...QueryOptions) (Scrobbles, error) RecordScrobble(mediaFileID string, submissionTime time.Time) error } + +type Scrobbles []Scrobble diff --git a/persistence/persistence.go b/persistence/persistence.go index 83211bdd5..1164eb70f 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -123,6 +123,8 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository return s.Tag(ctx).(model.ResourceRepository) case model.Plugin: return s.Plugin(ctx).(model.ResourceRepository) + case model.Scrobble: + return s.Scrobble(ctx).(model.ResourceRepository) } log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) return nil diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index abc5c4b6a..4f2fd7fe2 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -4,6 +4,7 @@ import ( "context" "path/filepath" "testing" + "time" "github.com/Masterminds/squirrel" _ "github.com/mattn/go-sqlite3" @@ -157,6 +158,13 @@ var ( testUsers = model.Users{adminUser, regularUser, thirdUser} ) +var ( + firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()} + secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()} + thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()} + scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble} +) + func p(path string) string { return filepath.FromSlash(path) } @@ -304,6 +312,18 @@ var _ = BeforeSuite(func() { songComeTogether.Starred = true songComeTogether.StarredAt = mf.StarredAt testSongs[1] = songComeTogether + + scrobbleRepo := NewScrobbleRepository(ctx, conn).(*scrobbleRepository) + for _, s := range scrobbles { + _, err := scrobbleRepo.executeSQL(squirrel.Insert("scrobbles").SetMap(map[string]any{ + "media_file_id": s.MediaFileID, + "user_id": s.UserID, + "submission_time": s.SubmissionTime, + })) + if err != nil { + panic(err) + } + } }) func GetDBXBuilder() *dbx.DB { diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 219a48198..7cc60ae23 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -5,6 +5,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) @@ -13,11 +14,34 @@ type scrobbleRepository struct { sqlRepository } +func fromTs(_ string, value any) Sqlizer { + return GtOrEq{"scrobbles.submission_time": value} +} + +func toTs(_ string, value any) Sqlizer { + return LtOrEq{"scrobbles.submission_time": value} +} + +func (r *scrobbleRepository) baseQuery(options ...model.QueryOptions) SelectBuilder { + user := loggedUser(r.ctx) + + return r.newSelect(options...). + Columns("id", "media_file_id", "submission_time"). + Where(Eq{"scrobbles.user_id": user.ID}) +} + func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository { r := &scrobbleRepository{} r.ctx = ctx r.db = db r.tableName = "scrobbles" + r.registerModel(&model.Scrobble{}, map[string]filterFunc{ + "from": fromTs, + "to": toTs, + }) + r.setSortMappings(map[string]string{ + "submission_time": "submission_time", + }) return r } @@ -32,3 +56,44 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t _, err := r.executeSQL(insert) return err } + +func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { + return r.count(r.baseQuery(), options...) +} + +func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { + sel := r.baseQuery().Where(Eq{"id": id}) + var res model.Scrobble + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + sel := r.baseQuery(options...) + var scrobbles model.Scrobbles + err := r.queryAll(sel, &scrobbles) + return scrobbles, err +} + +func (r *scrobbleRepository) Read(id string) (any, error) { + return r.Get(id) +} + +func (r *scrobbleRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *scrobbleRepository) EntityName() string { + return "scrobble" +} + +func (r *scrobbleRepository) NewInstance() any { + return &model.Scrobble{} +} + +var _ model.ScrobbleRepository = (*scrobbleRepository)(nil) +var _ model.ResourceRepository = (*scrobbleRepository)(nil) diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go index d43848d03..e9103b127 100644 --- a/persistence/scrobble_repository_test.go +++ b/persistence/scrobble_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -15,32 +16,33 @@ import ( var _ = Describe("ScrobbleRepository", func() { var repo model.ScrobbleRepository - var rawRepo sqlRepository var ctx context.Context - var fileID string - var userID string - - BeforeEach(func() { - fileID = id.NewRandom() - userID = id.NewRandom() - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) - db := GetDBXBuilder() - repo = NewScrobbleRepository(ctx, db) - - rawRepo = sqlRepository{ - ctx: ctx, - tableName: "scrobbles", - db: db, - } - }) - - AfterEach(func() { - _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute() - _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute() - _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute() - }) Describe("RecordScrobble", func() { + var fileID string + var userID string + var rawRepo sqlRepository + + BeforeEach(func() { + fileID = id.NewRandom() + userID = id.NewRandom() + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) + db := GetDBXBuilder() + repo = NewScrobbleRepository(ctx, db) + + rawRepo = sqlRepository{ + ctx: ctx, + tableName: "scrobbles", + db: db, + } + }) + + AfterEach(func() { + _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute() + _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute() + _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute() + }) + It("records a scrobble event", func() { submissionTime := time.Now().UTC() @@ -81,4 +83,137 @@ var _ = Describe("ScrobbleRepository", func() { Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix())) }) }) + + Context("admin user (id userid)", func() { + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("Returns the number of scrobbles in the DB for admin user", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + + It("returns scrobbles in a range", func() { + Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(1))) + }) + }) + + Describe("Get", func() { + It("returns an existing scrobble for the user", func() { + scrobble, err := repo.Get("1") + Expect(err).To(BeNil()) + Expect(scrobble.ID).To(Equal(int64(1))) + Expect(scrobble.MediaFileID).To(Equal("1001")) + Expect(scrobble.SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) + + }) + + It("does not return a scrobble that exists for another user", func() { + _, err := repo.Get("2") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + _, err := repo.Get("444") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all scrobbles in reverse order", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Sort: "submission_time", + Order: "DESC", + }) + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(2)) + + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) + + Expect(scrobbles[1].ID).To(Equal(int64(1))) + Expect(scrobbles[1].MediaFileID).To(Equal("1001")) + Expect(scrobbles[1].SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) + }) + + It("returns scrobbles in a range", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Filters: squirrel.GtOrEq{"submission_time": 1}}) + + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) + }) + }) + }) + + Context("non-admin user", func() { + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), regularUser) + repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("Returns the number of scrobbles in the DB for admin user", func() { + Expect(repo.CountAll()).To(Equal(int64(1))) + }) + + It("returns scrobbles in a range", func() { + Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(0))) + }) + }) + + Describe("Get", func() { + It("returns an existing scrobble for the user", func() { + scrobble, err := repo.Get("2") + Expect(err).To(BeNil()) + Expect(scrobble.ID).To(Equal(int64(2))) + Expect(scrobble.MediaFileID).To(Equal("1003")) + Expect(scrobble.SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + + It("does not return a scrobble that exists for another user", func() { + _, err := repo.Get("1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + _, err := repo.Get("444") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all scrobbles in reverse order", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Sort: "submission_time", + Order: "DESC", + }) + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + + It("returns scrobbles in a range", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Filters: squirrel.GtOrEq{"submission_time": 1}}) + + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + }) + }) }) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..5a7023eb6 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -72,7 +72,8 @@ func (api *Router) routes() http.Handler { api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) - api.R(r, "/tag", model.Tag{}, true) + api.R(r, "/tag", model.Tag{}, false) + api.R(r, "/scrobble", model.Scrobble{}, false) if conf.Server.EnableSharing { api.RX(r, "/share", api.share.NewRepository, true) } diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index 34561c257..d6d88d221 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -2,6 +2,7 @@ package tests import ( "context" + "strconv" "time" "github.com/navidrome/navidrome/model" @@ -13,12 +14,32 @@ type MockScrobbleRepo struct { ctx context.Context } +func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { + for idx := range m.RecordedScrobbles { + if strconv.FormatInt(m.RecordedScrobbles[idx].ID, 10) == id { + return &m.RecordedScrobbles[idx], nil + } + } + + return nil, model.ErrNotFound +} + +func (m *MockScrobbleRepo) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + return m.RecordedScrobbles, nil +} + +func (m *MockScrobbleRepo) CountAll(options ...model.QueryOptions) (int64, error) { + return int64(len(m.RecordedScrobbles)), nil +} + func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error { user, _ := request.UserFrom(m.ctx) m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ MediaFileID: fileID, UserID: user.ID, - SubmissionTime: submissionTime, + SubmissionTime: submissionTime.Unix(), }) return nil } + +var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil) From cc315dcc8c38fc40e721e90d9d22c6f0aeec97d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 13 Jul 2026 12:04:29 -0400 Subject: [PATCH 17/56] perf(db): keep query planner statistics trustworthy with full ANALYZE (#5740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(db): keep query planner statistics trustworthy with full ANALYZE PRAGMA optimize's internal ANALYZE runs with a limited analysis budget (~2000 rows) that writes wrong sqlite_stat1 entries for low-cardinality indexes: on a 96K-track library it claimed (missing, library_id) narrows to ~2000 rows when it matches the whole table. The planner then prefers that index over the sort index and falls back to a full-table temp B-tree sort per request, turning paginated song listings into multi-second queries (reproduced at 5.5s on real hardware; ~90x slower than with correct stats). Every index-creating migration re-triggered the poisoning via the post-migration optimize, and the daily optimizer could re-trigger it on large library changes. Setting analysis_limit on the connection does not help: optimize ignores it. Run a plain full ANALYZE instead: after migrations with schema changes, and in db.Optimize (daily schedule and scan-end). Stats are stored in the database file, so one connection suffices and the per-connection pool loop is gone. The Optimize call at shutdown is removed: stats are maintained at migration/scan/daily points, and an ANALYZE during shutdown only delays it and races container stop timeouts. * perf(db): drop startup PRAGMA optimize that re-poisons planner stats The startup PRAGMA optimize=0x10002 runs SQLite's budget-limited internal ANALYZE (bit 0x02), which writes truncated sqlite_stat1 rows for low-cardinality indexes -- the exact statistics-poisoning this PR set out to eliminate. Because DevOptimizeDB defaults to true, a restart with no pending migrations would re-poison the planner until the next scan or daily Optimize. Remove it: statistics are already refreshed with a full ANALYZE after schema-changing migrations (Init) and via Optimize at scan-end and on the daily schedule, so nothing on the startup path needs to touch them. Also clarify that Optimize is a no-op unless DevOptimizeDB is enabled. * chore(db): remove the DevOptimizeDB flag and skip Optimize on quick scans The flag only gated the optimize/ANALYZE maintenance calls and there is no reason to leave planner statistics unmaintained; the guards are gone along with the flag. The scan-end Optimize now runs only after full scans — quick scans barely move the statistics, and the daily schedule covers drift. * style(scanner): drop redundant comment in runOptimize * chore(persistence): drop the no-op PRAGMA optimize from ScanEnd Mask 0x10000 only selects candidate tables by size change; without the 0x02 action bit optimize does nothing (verified: sqlite_stat1 stays stale after a 100x table growth). The scan-end statistics refresh is db.Optimize's full ANALYZE, and the expression-collation-index concern the old comment guarded against no longer applies. * fix(scanner): run the post-scan ANALYZE in the server process With the external scanner (the default), the scan pipeline runs in a subprocess, so its ANALYZE was invisible to the server: SQLite loads sqlite_stat1 into the process's shared schema cache, and an ANALYZE from another process does not refresh it — verified with the production DSN that even brand-new pool connections keep planning with the old statistics until the server restarts. An in-process ANALYZE, by contrast, is immediately visible to every pooled connection through the same shared cache. Move the full-scan Optimize from the scanner pipeline to the scan controller, which always runs in the server process. * fix(scanner): honor promoted full scans in the optimize gate A quick scan resuming an interrupted full scan is promoted inside the scanner (possibly in a subprocess); mirror the promotion in the controller so the post-scan ANALYZE isn't skipped. * refactor: apply cleanup review findings - drop forceFullRescan's inline ANALYZE: Init already runs a full ANALYZE after any migration batch with schema changes, so upgrades including a full-rescan migration analyzed the whole DB twice - resumingFullScan uses a filtered CountAll instead of fetching and scanning all libraries - document why CallScan (CLI) deliberately skips the post-scan Optimize * perf(db): make planner analysis maintenance resilient Check analysis freshness every 30 minutes and refresh statistics when the last successful run is over 24 hours old or a scan marked them pending. Persist successful analysis state, retry skipped or failed maintenance, coordinate checks with scans, and cover standalone CLI full scans. * perf(db): avoid analyzing routine quick-scan changes Reserve pending analysis for full scans, unscanned libraries, and retry state. Incremental quick scans now rely on the 24-hour freshness window instead of triggering a full ANALYZE at the next maintenance check. * fix(scan): analyze resumed full scans in CLI * fix(db): back off failed analysis retries * feat(db): allow disabling scheduled analysis * test(db): remove redundant analysis coverage * refactor(db): split ANALYZE maintenance into optimize.go and dedupe call sites - move query-planner statistics code from db.go to its own optimize.go (and matching optimize_test.go) - log ANALYZE elapsed time inside Optimize/OptimizeIfNeeded instead of repeating the timing block at every call site - drop the LastDBAnalyzeAttemptAt write on success: it is only read while failures >= 1, and every failure rewrites it first - extract runPostScanAnalysis (cmd) and anyIncludedLibrary (scanner) helpers --- cmd/root.go | 22 ++- cmd/scan.go | 37 ++++- cmd/scan_test.go | 15 ++ conf/configuration.go | 4 +- conf/configuration_test.go | 13 ++ consts/consts.go | 7 +- core/metrics/insights.go | 1 + core/metrics/insights/data.go | 79 +++++------ db/db.go | 49 +------ db/export_test.go | 7 +- db/migrations/migration.go | 8 -- db/optimize.go | 224 ++++++++++++++++++++++++++++++ db/optimize_test.go | 162 +++++++++++++++++++++ persistence/library_repository.go | 9 -- scanner/controller.go | 79 ++++++++++- scanner/controller_test.go | 38 +++++ scanner/scanner.go | 13 -- scanner/scanner_selective_test.go | 36 ++++- 18 files changed, 673 insertions(+), 130 deletions(-) create mode 100644 db/optimize.go create mode 100644 db/optimize_test.go diff --git a/cmd/root.go b/cmd/root.go index 08773176a..b231aae0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -86,7 +86,7 @@ func runNavidrome(ctx context.Context) { g.Go(startPlaybackServer(ctx)) g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) - g.Go(scheduleDBOptimizer(ctx)) + g.Go(scheduleDBAnalyzer(ctx)) g.Go(startPluginManager(ctx)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { @@ -275,16 +275,24 @@ func schedulePeriodicBackup(ctx context.Context) func() error { } } -func scheduleDBOptimizer(ctx context.Context) func() error { +func scheduleDBAnalyzer(ctx context.Context) func() error { return func() error { - log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) + if !conf.Server.EnableScheduledDBAnalyze { + log.Info(ctx, "Scheduled DB analysis is DISABLED") + return nil + } + log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule) schedulerInstance := scheduler.GetInstance() - _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { - if scanner.IsScanning() { - log.Debug(ctx, "Skipping DB optimization because a scan is in progress") + _, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() { + release, ok := scanner.LockForMaintenance() + if !ok { + log.Debug(ctx, "Skipping DB analysis check because a scan is in progress") return } - db.Optimize(ctx) + defer release() + if _, err := db.OptimizeIfNeeded(ctx); err != nil { + log.Error(ctx, "Error analyzing DB", err) + } }) return err } diff --git a/cmd/scan.go b/cmd/scan.go index d8a563396..320b401d4 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/gob" + "errors" "fmt" "os" "strings" @@ -43,15 +44,20 @@ var scanCmd = &cobra.Command{ }, } -func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) { +func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) { + var changesDetected bool + var scanErrors []error for status := range pl.ReadOrDone(ctx, progress) { if status.Warning != "" { log.Warn(ctx, "Scan warning", "error", status.Warning) } if status.Error != "" { log.Error(ctx, "Scan error", "error", status.Error) + scanErrors = append(scanErrors, errors.New(status.Error)) + } + if status.ChangesDetected { + changesDetected = true } - // Discard the progress status, we only care about errors } if fullScan { @@ -59,6 +65,7 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre } else { log.Info("Finished rescan") } + return changesDetected, errors.Join(scanErrors...) } func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) { @@ -95,6 +102,16 @@ func runScanner(ctx context.Context) { log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets)) } + effectiveFullScan := fullScan + if !subprocess { + effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets) + if effectiveFullScan { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Error marking DB analysis pending", err) + } + } + } + progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets) if err != nil { log.Fatal(ctx, "Failed to scan", err) @@ -104,7 +121,21 @@ func runScanner(ctx context.Context) { if subprocess { trackScanAsSubprocess(ctx, progress) } else { - trackScanInteractively(ctx, progress) + changesDetected, scanErr := trackScanInteractively(ctx, progress) + runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr) + } +} + +func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) { + if changesDetected { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Error marking DB analysis pending", err) + } + } + if effectiveFullScan && scanErr == nil { + if err := db.Optimize(ctx); err != nil { + log.Error(ctx, "Error analyzing DB", err) + } } } diff --git a/cmd/scan_test.go b/cmd/scan_test.go index beeecca19..309d09f98 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -1,14 +1,29 @@ package cmd import ( + "context" "os" "path/filepath" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("trackScanInteractively", func() { + It("reports changes and scan errors", func() { + progress := make(chan *scanner.ProgressInfo, 2) + progress <- &scanner.ProgressInfo{ChangesDetected: true} + progress <- &scanner.ProgressInfo{Error: "scan failed"} + close(progress) + + changesDetected, err := trackScanInteractively(context.Background(), progress) + Expect(changesDetected).To(BeTrue()) + Expect(err).To(MatchError("scan failed")) + }) +}) + var _ = Describe("readTargetsFromFile", func() { var tempDir string diff --git a/conf/configuration.go b/conf/configuration.go index 8646bf075..e939ebb7e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -51,6 +51,7 @@ type configOptions struct { EnableExternalServices bool EnableM3UExternalAlbumArt bool EnableInsightsCollector bool + EnableScheduledDBAnalyze bool EnableMediaFileCoverArt bool TranscodingCacheSize string ImageCacheSize string @@ -147,7 +148,6 @@ type configOptions struct { DevEnablePluginsInsights bool DevPluginCompilationTimeout time.Duration DevExternalArtistFetchMultiplier float64 - DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool } @@ -800,6 +800,7 @@ func setViperDefaults() { viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("enableinsightscollector", true) + viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authwindowlength", 20*time.Second) @@ -891,7 +892,6 @@ func setViperDefaults() { viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devexternalartistfetchmultiplier", 1.5) - viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 9c25a0d19..e43c91a4b 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -58,6 +58,19 @@ var _ = Describe("Configuration", func() { }) }) + Describe("scheduled DB analysis", func() { + It("is enabled by default", func() { + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue()) + }) + + It("can be disabled", func() { + viper.Set("enablescheduleddbanalyze", false) + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse()) + }) + }) + Describe("ValidateURL", func() { It("accepts a valid http URL", func() { fn := conf.ValidateURL("TestOption", "http://example.com/path") diff --git a/consts/consts.go b/consts/consts.go index 3795b590a..73f89b450 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -20,6 +20,10 @@ const ( LastScanErrorKey = "LastScanError" LastScanTypeKey = "LastScanType" LastScanStartTimeKey = "LastScanStartTime" + LastDBAnalyzeAtKey = "LastDBAnalyzeAt" + LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" + DBAnalyzePendingKey = "DBAnalyzePending" + DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" @@ -28,7 +32,8 @@ const ( DefaultSessionTimeout = 48 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year - OptimizeDBSchedule = "@every 24h" + DBAnalyzeCheckSchedule = "@every 30m" + DBAnalyzeMaxAge = 24 * time.Hour // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option diff --git a/core/metrics/insights.go b/core/metrics/insights.go index bcd0343c2..78391779a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -223,6 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup + data.Config.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != "" data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID data.Config.HasCustomTags = len(conf.Server.Tags) > 0 diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 34648a49b..126d759bc 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -43,45 +43,46 @@ type Data struct { FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"` } `json:"library"` Config struct { - LogLevel string `json:"logLevel,omitempty"` - LogFileConfigured bool `json:"logFileConfigured,omitempty"` - TLSConfigured bool `json:"tlsConfigured,omitempty"` - ScannerEnabled bool `json:"scannerEnabled,omitempty"` - ScannerExtractor string `json:"scannerExtractor,omitempty"` - ScanSchedule string `json:"scanSchedule,omitempty"` - ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` - ScanOnStartup bool `json:"scanOnStartup,omitempty"` - TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` - ImageCacheSize string `json:"imageCacheSize,omitempty"` - EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` - EnableDownloads bool `json:"enableDownloads,omitempty"` - EnableSharing bool `json:"enableSharing,omitempty"` - EnableStarRating bool `json:"enableStarRating,omitempty"` - EnableLastFM bool `json:"enableLastFM,omitempty"` - EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` - EnableDeezer bool `json:"enableDeezer,omitempty"` - EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` - EnableJukebox bool `json:"enableJukebox,omitempty"` - EnablePrometheus bool `json:"enablePrometheus,omitempty"` - EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` - CoverArtQuality int `json:"coverArtQuality,omitempty"` - EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` - UICoverArtSize int `json:"uiCoverArtSize,omitempty"` - EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` - EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` - SessionTimeout uint64 `json:"sessionTimeout,omitempty"` - SearchFullString bool `json:"searchFullString,omitempty"` - SearchBackend string `json:"searchBackend,omitempty"` - RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` - PreferSortTags bool `json:"preferSortTags,omitempty"` - BackupSchedule string `json:"backupSchedule,omitempty"` - BackupCount int `json:"backupCount,omitempty"` - DevActivityPanel bool `json:"devActivityPanel,omitempty"` - DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` - HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` - ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` - HasCustomPID bool `json:"hasCustomPID,omitempty"` - HasCustomTags bool `json:"hasCustomTags,omitempty"` + LogLevel string `json:"logLevel,omitempty"` + LogFileConfigured bool `json:"logFileConfigured,omitempty"` + TLSConfigured bool `json:"tlsConfigured,omitempty"` + ScannerEnabled bool `json:"scannerEnabled,omitempty"` + ScannerExtractor string `json:"scannerExtractor,omitempty"` + ScanSchedule string `json:"scanSchedule,omitempty"` + ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` + ScanOnStartup bool `json:"scanOnStartup,omitempty"` + EnableScheduledDBAnalyze bool `json:"enableScheduledDBAnalyze,omitempty"` + TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` + ImageCacheSize string `json:"imageCacheSize,omitempty"` + EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` + EnableDownloads bool `json:"enableDownloads,omitempty"` + EnableSharing bool `json:"enableSharing,omitempty"` + EnableStarRating bool `json:"enableStarRating,omitempty"` + EnableLastFM bool `json:"enableLastFM,omitempty"` + EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` + EnableDeezer bool `json:"enableDeezer,omitempty"` + EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` + EnableJukebox bool `json:"enableJukebox,omitempty"` + EnablePrometheus bool `json:"enablePrometheus,omitempty"` + EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` + CoverArtQuality int `json:"coverArtQuality,omitempty"` + EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` + UICoverArtSize int `json:"uiCoverArtSize,omitempty"` + EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` + EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` + SessionTimeout uint64 `json:"sessionTimeout,omitempty"` + SearchFullString bool `json:"searchFullString,omitempty"` + SearchBackend string `json:"searchBackend,omitempty"` + RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` + PreferSortTags bool `json:"preferSortTags,omitempty"` + BackupSchedule string `json:"backupSchedule,omitempty"` + BackupCount int `json:"backupCount,omitempty"` + DevActivityPanel bool `json:"devActivityPanel,omitempty"` + DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` + HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` + ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` + HasCustomPID bool `json:"hasCustomPID,omitempty"` + HasCustomTags bool `json:"hasCustomTags,omitempty"` } `json:"config"` Plugins map[string]PluginInfo `json:"plugins,omitempty"` } diff --git a/db/db.go b/db/db.go index 6e5b2f569..3f3f61d71 100644 --- a/db/db.go +++ b/db/db.go @@ -6,6 +6,7 @@ import ( "embed" "fmt" "runtime" + "time" "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" @@ -47,12 +48,6 @@ func Db() *sql.DB { if err != nil { log.Fatal("Error opening database", err) } - if conf.Server.DevOptimizeDB { - _, err = db.Exec("PRAGMA optimize=0x10002") - if err != nil { - log.Error("Error applying PRAGMA optimize", err) - } - } return db }) } @@ -61,9 +56,6 @@ func Close(ctx context.Context) { // Ignore cancellations when closing the DB ctx = context.WithoutCancel(ctx) - // Run optimize before closing - Optimize(ctx) - log.Info(ctx, "Closing Database") err := Db().Close() if err != nil { @@ -102,11 +94,11 @@ func Init(ctx context.Context) func() { log.Fatal(ctx, "Failed to apply new migrations", err) } - if hasSchemaChanges && conf.Server.DevOptimizeDB { - log.Debug(ctx, "Applying PRAGMA optimize after schema changes") - _, err = db.ExecContext(ctx, "PRAGMA optimize") + if hasSchemaChanges { + log.Debug(ctx, "Running ANALYZE after schema changes") + err = optimizeAt(ctx, db, time.Now()) if err != nil { - log.Error(ctx, "Error applying PRAGMA optimize", err) + log.Error(ctx, "Error running ANALYZE", err) } } @@ -115,37 +107,6 @@ func Init(ctx context.Context) func() { } } -// Optimize runs PRAGMA optimize on each connection in the pool -func Optimize(ctx context.Context) { - if !conf.Server.DevOptimizeDB { - return - } - numConns := Db().Stats().OpenConnections - if numConns == 0 { - log.Debug(ctx, "No open connections to optimize") - return - } - log.Debug(ctx, "Optimizing open connections", "numConns", numConns) - var conns []*sql.Conn - for range numConns { - conn, err := Db().Conn(ctx) - conns = append(conns, conn) - if err != nil { - log.Error(ctx, "Error getting connection from pool", err) - continue - } - _, err = conn.ExecContext(ctx, "PRAGMA optimize;") - if err != nil { - log.Error(ctx, "Error running PRAGMA optimize", err) - } - } - - // Return all connections to the Connection Pool - for _, conn := range conns { - conn.Close() - } -} - type statusLogger struct{ numPending int } func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) } diff --git a/db/export_test.go b/db/export_test.go index 734a4462f..02b88cd66 100644 --- a/db/export_test.go +++ b/db/export_test.go @@ -2,6 +2,9 @@ package db // Definitions for testing private methods var ( - IsSchemaEmpty = isSchemaEmpty - BackupPath = backupPath + IsSchemaEmpty = isSchemaEmpty + BackupPath = backupPath + OptimizeDBAt = optimizeAt + OptimizeDBIfNeeded = optimizeIfNeeded + RecordAnalyzeFailure = recordAnalyzeFailure ) diff --git a/db/migrations/migration.go b/db/migrations/migration.go index 9b1098af1..df1c392a5 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -7,7 +7,6 @@ import ( "strings" "sync" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" ) @@ -21,13 +20,6 @@ func notice(ctx context.Context, tx *sql.Tx, msg string) { // Call this in migrations that requires a full rescan func forceFullRescan(ctx context.Context, tx *sql.Tx) error { - // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. - if conf.Server.DevOptimizeDB { - _, err := tx.ExecContext(ctx, `ANALYZE;`) - if err != nil { - return err - } - } _, err := tx.ExecContext(ctx, fmt.Sprintf(` INSERT OR REPLACE into property (id, value) values ('%s', '1'); `, consts.FullScanAfterMigrationFlagKey)) diff --git a/db/optimize.go b/db/optimize.go new file mode 100644 index 000000000..f46906c4e --- /dev/null +++ b/db/optimize.go @@ -0,0 +1,224 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" +) + +var analyzeMux sync.Mutex + +// Optimize refreshes the query-planner statistics with a full ANALYZE. PRAGMA optimize is avoided +// because its limited analysis misestimates Navidrome's low-cardinality indexes. +func Optimize(ctx context.Context) error { + analyzeMux.Lock() + defer analyzeMux.Unlock() + start := time.Now() + if err := optimizeAt(ctx, Db(), start); err != nil { + return err + } + log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) + return nil +} + +// OptimizeIfNeeded refreshes statistics when they are stale or a database-changing operation +// marked them for refresh. +func OptimizeIfNeeded(ctx context.Context) (bool, error) { + analyzeMux.Lock() + defer analyzeMux.Unlock() + start := time.Now() + ran, err := optimizeIfNeeded(ctx, Db(), start) + if err != nil || !ran { + return ran, err + } + log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) + return true, nil +} + +func optimizeIfNeeded(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + due, err := optimizeDue(ctx, db, now) + if err != nil || !due { + return false, err + } + return true, optimizeAt(ctx, db, now) +} + +func optimizeDue(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + backingOff, err := analyzeRetryBackoffActive(ctx, db, now) + if err != nil || backingOff { + return false, err + } + + pending, found, err := getProperty(ctx, db, consts.DBAnalyzePendingKey) + if err != nil { + return false, err + } + if found && pending == "1" { + return true, nil + } + + value, found, err := getProperty(ctx, db, consts.LastDBAnalyzeAtKey) + if err != nil { + return false, err + } + if !found { + return true, nil + } + + lastAnalyze, valid := parseAnalyzeTime(value) + if !valid || lastAnalyze.After(now) { + return true, nil + } + return now.Sub(lastAnalyze) >= consts.DBAnalyzeMaxAge, nil +} + +func parseAnalyzeTime(value string) (time.Time, bool) { + parsed, err := time.Parse(time.RFC3339Nano, value) + return parsed, err == nil +} + +func analyzeRetryBackoffActive(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + value, found, err := getProperty(ctx, db, consts.DBAnalyzeFailureCountKey) + if err != nil || !found { + return false, err + } + failures, _ := strconv.Atoi(value) + if failures < 1 { + return false, nil + } + + value, found, err = getProperty(ctx, db, consts.LastDBAnalyzeAttemptAtKey) + if err != nil || !found { + return false, err + } + lastAttempt, valid := parseAnalyzeTime(value) + if !valid || lastAttempt.After(now) { + return false, nil + } + return now.Sub(lastAttempt) < analyzeRetryDelay(failures), nil +} + +func analyzeRetryDelay(failures int) time.Duration { + switch failures { + case 1: + return 30 * time.Minute + case 2: + return time.Hour + case 3: + return 2 * time.Hour + default: + return 24 * time.Hour + } +} + +// MarkOptimizePending requests a statistics refresh on the next scheduled maintenance check. +func MarkOptimizePending(ctx context.Context) error { + analyzeMux.Lock() + defer analyzeMux.Unlock() + return markOptimizePending(ctx, Db()) +} + +func markOptimizePending(ctx context.Context, db *sql.DB) error { + return putProperty(ctx, db, consts.DBAnalyzePendingKey, "1") +} + +func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error { + if err := markOptimizePending(ctx, db); err != nil { + return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err)) + } + log.Debug(ctx, "Refreshing query planner statistics") + _, err := db.ExecContext(ctx, "ANALYZE") + if err != nil { + return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err)) + } + if err = recordAnalyzeSuccess(ctx, db, now); err != nil { + return recordAnalyzeError(ctx, db, now, err) + } + return nil +} + +func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("recording ANALYZE time: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err = putProperty(ctx, tx, consts.LastDBAnalyzeAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("recording ANALYZE time: %w", err) + } + if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "0"); err != nil { + return fmt.Errorf("clearing pending ANALYZE: %w", err) + } + if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, "0"); err != nil { + return fmt.Errorf("clearing ANALYZE failure count: %w", err) + } + if err = tx.Commit(); err != nil { + return fmt.Errorf("recording ANALYZE state: %w", err) + } + return nil +} + +func recordAnalyzeError(ctx context.Context, db *sql.DB, now time.Time, analyzeErr error) error { + if err := recordAnalyzeFailure(ctx, db, now); err != nil { + return errors.Join(analyzeErr, fmt.Errorf("recording ANALYZE failure: %w", err)) + } + return analyzeErr +} + +func recordAnalyzeFailure(ctx context.Context, db *sql.DB, now time.Time) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + value, found, err := getProperty(ctx, tx, consts.DBAnalyzeFailureCountKey) + if err != nil { + return err + } + failures := 0 + if found { + failures, _ = strconv.Atoi(value) + failures = max(failures, 0) + } + if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "1"); err != nil { + return err + } + if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, strconv.Itoa(failures+1)); err != nil { + return err + } + if err = putProperty(ctx, tx, consts.LastDBAnalyzeAttemptAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { + return err + } + return tx.Commit() +} + +type sqlExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +type sqlQueryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func putProperty(ctx context.Context, db sqlExecer, key, value string) error { + _, err := db.ExecContext(ctx, `insert into property(id, value) values(?, ?) + on conflict(id) do update set value=excluded.value`, key, value) + return err +} + +func getProperty(ctx context.Context, db sqlQueryer, key string) (string, bool, error) { + var value string + err := db.QueryRowContext(ctx, "select value from property where id=?", key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return value, err == nil, err +} diff --git a/db/optimize_test.go b/db/optimize_test.go new file mode 100644 index 000000000..da9b3b9c9 --- /dev/null +++ b/db/optimize_test.go @@ -0,0 +1,162 @@ +package db_test + +import ( + "context" + "database/sql" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/db" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Optimize", func() { + var ( + ctx context.Context + database *sql.DB + now time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + now = time.Date(2026, time.July, 9, 12, 0, 0, 0, time.UTC) + var err error + database, err = sql.Open(db.Dialect, "file::memory:") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(database.Close) + + _, err = database.Exec(`create table property( + id varchar(255) primary key, + value varchar(255) not null default '' + )`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("create table analyze_probe(id integer primary key, flag int)") + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec(`insert into analyze_probe(flag) + with recursive s(x) as (select 1 union all select x+1 from s where x < 3000) + select 0 from s`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("create index probe_flag on analyze_probe(flag)") + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("analyze") + Expect(err).ToNot(HaveOccurred()) + }) + + putProperty := func(key, value string) { + _, err := database.Exec(`insert into property(id, value) values(?, ?) + on conflict(id) do update set value=excluded.value`, key, value) + Expect(err).ToNot(HaveOccurred()) + } + + getProperty := func(key string) string { + var value string + Expect(database.QueryRow("select value from property where id=?", key).Scan(&value)).To(Succeed()) + return value + } + + poisonStats := func() { + _, err := database.Exec("update sqlite_stat1 set stat='3000 50' where idx='probe_flag'") + Expect(err).ToNot(HaveOccurred()) + } + + It("replaces poisoned planner statistics with full-quality ones", func() { + poisonStats() + putProperty(consts.DBAnalyzePendingKey, "1") + + Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed()) + + var stat string + err := database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat) + Expect(err).ToNot(HaveOccurred()) + // A full ANALYZE sees all 3000 rows share one value: avg rows per key = row count. + Expect(stat).To(Equal("3000 3000")) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + It("runs when no previous analysis was recorded", func() { + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + }) + + It("skips a recent analysis when no refresh is pending", func() { + lastAnalyze := now.Add(-23 * time.Hour) + putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze.Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "0") + poisonStats() + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeFalse()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze.Format(time.RFC3339Nano))) + + var stat string + Expect(database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)).To(Succeed()) + Expect(stat).To(Equal("3000 50")) + }) + + It("runs when the previous analysis is stale", func() { + putProperty(consts.LastDBAnalyzeAtKey, now.Add(-consts.DBAnalyzeMaxAge).Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "0") + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + }) + + It("runs when a refresh is pending even if the previous analysis is recent", func() { + putProperty(consts.LastDBAnalyzeAtKey, now.Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "1") + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + DescribeTable("backs off after consecutive analysis failures", + func(failures string, retryDelay time.Duration) { + putProperty(consts.DBAnalyzePendingKey, "1") + putProperty(consts.DBAnalyzeFailureCountKey, failures) + putProperty(consts.LastDBAnalyzeAttemptAtKey, now.Format(time.RFC3339Nano)) + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay-time.Nanosecond)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeFalse()) + + ran, err = db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("0")) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }, + Entry("for 30 minutes after the first failure", "1", 30*time.Minute), + Entry("for one hour after the second failure", "2", time.Hour), + Entry("for two hours after the third failure", "3", 2*time.Hour), + Entry("for 24 hours after the fourth failure", "4", 24*time.Hour), + ) + + It("records consecutive analysis failures", func() { + putProperty(consts.DBAnalyzeFailureCountKey, "2") + + Expect(db.RecordAnalyzeFailure(ctx, database, now)).To(Succeed()) + + Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("3")) + Expect(getProperty(consts.LastDBAnalyzeAttemptAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("1")) + }) + + It("does not record success when analysis fails", func() { + lastAnalyze := now.Add(-48 * time.Hour).Format(time.RFC3339Nano) + putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze) + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + + Expect(db.OptimizeDBAt(canceledCtx, database, now)).To(MatchError(ContainSubstring("context canceled"))) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) + }) +}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 3789a71c9..5a0142423 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -173,15 +173,6 @@ func (r *libraryRepository) ScanEnd(id int) error { Set("last_scan_started_at", time.Time{}). Where(Eq{"id": id}) _, err := r.executeSQL(sq) - if err != nil { - return err - } - // https://www.sqlite.org/pragma.html#pragma_optimize - // Use mask 0x10000 to check table sizes without running ANALYZE - // Running ANALYZE can cause query planner issues with expression-based collation indexes - if conf.Server.DevOptimizeDB { - _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;")) - } return err } diff --git a/scanner/controller.go b/scanner/controller.go index 175b92e26..463718ba3 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "slices" + "sync" "sync/atomic" "time" @@ -13,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -211,6 +214,16 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ ctx := request.AddValues(s.rootCtx, requestCtx) ctx = auth.WithAdminUser(ctx, s.ds) + // A quick scan is promoted to a full one when it resumes an interrupted full scan; that happens + // inside the scanner (possibly in a subprocess), so mirror it here for the analysis gate. Must + // be read before the scan: ScanEnd clears the flag. + effectiveFullScan := EffectiveFullScan(ctx, s.ds, fullScan, targets) + if effectiveFullScan || s.includesUnscannedLibrary(ctx, targets) { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Scanner: Error marking DB analysis pending", err) + } + } + // Send the initial scan status event s.sendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0}) progress := make(chan *ProgressInfo, 100) @@ -229,6 +242,15 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ if scanError != nil { _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error()) } + // Refresh the query-planner statistics after a successful full scan. This must run in the + // server process: with the external scanner, an ANALYZE in the subprocess is invisible to the + // server's pooled connections; their shared schema cache keeps the old statistics until the + // process restarts. + if effectiveFullScan && scanError == nil { + if err := db.Optimize(ctx); err != nil { + log.Error(ctx, "Scanner: Error analyzing DB", err) + } + } // If changes were detected, send a refresh event to all clients if s.changesDetected { log.Debug(ctx, "Library changes imported. Sending refresh event") @@ -255,18 +277,73 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ // This is a global variable that is used to prevent multiple scans from running at the same time. // "There can be only one" - https://youtu.be/sqcLjcSloXs?si=VlsjEOjTJZ68zIyg -var running atomic.Bool +var ( + running atomic.Bool + scanMaintenanceMux sync.Mutex +) func lockScan(ctx context.Context) (func(), error) { if !running.CompareAndSwap(false, true) { log.Debug(ctx, "Scanner already running, ignoring request") return func() {}, ErrAlreadyScanning } + scanMaintenanceMux.Lock() return func() { + scanMaintenanceMux.Unlock() running.Store(false) }, nil } +// LockForMaintenance prevents a scan from starting while database maintenance is running. +func LockForMaintenance() (func(), bool) { + if !scanMaintenanceMux.TryLock() { + return func() {}, false + } + if running.Load() { + scanMaintenanceMux.Unlock() + return func() {}, false + } + return scanMaintenanceMux.Unlock, true +} + +// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted +// full scan in one of the included libraries. +func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { + if fullScan { + return true + } + return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { + return library.FullScanInProgress + }) +} + +func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool { + return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool { + return library.LastScanAt.IsZero() + }) +} + +// anyIncludedLibrary reports whether any library included in the scan (all of them when targets is +// empty) matches pred. +func anyIncludedLibrary(ctx context.Context, ds model.DataStore, targets []model.ScanTarget, pred func(model.Library) bool) bool { + libraries, err := ds.Library(ctx).GetAll() + if err != nil { + return false + } + if len(targets) == 0 { + return slices.ContainsFunc(libraries, pred) + } + + targeted := make(map[int]struct{}, len(targets)) + for _, target := range targets { + targeted[target.LibraryID] = struct{}{} + } + return slices.ContainsFunc(libraries, func(library model.Library) bool { + _, ok := targeted[library.ID] + return ok && pred(library) + }) +} + func (s *controller) trackProgress(ctx context.Context, progress <-chan *ProgressInfo) ([]string, error) { s.count.Store(0) s.folderCount.Store(0) diff --git a/scanner/controller_test.go b/scanner/controller_test.go index d60d432b4..e4814da64 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -55,3 +55,41 @@ var _ = Describe("Controller", func() { }) }) }) + +var _ = Describe("LockForMaintenance", func() { + It("allows only one database maintenance operation at a time", func() { + release, ok := scanner.LockForMaintenance() + Expect(ok).To(BeTrue()) + DeferCleanup(release) + + _, ok = scanner.LockForMaintenance() + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("EffectiveFullScan", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + libraries := &tests.MockLibraryRepo{} + libraries.SetData(model.Libraries{ + {ID: 1, FullScanInProgress: true}, + {ID: 2}, + }) + ds = &tests.MockDataStore{MockedLibrary: libraries} + }) + + It("detects an interrupted full scan in a targeted library", func() { + targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) + }) + + It("detects an interrupted full scan when scanning all libraries", func() { + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, nil)).To(BeTrue()) + }) + + It("ignores interrupted full scans in untargeted libraries", func() { + targets := []model.ScanTarget{{LibraryID: 2, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse()) + }) +}) diff --git a/scanner/scanner.go b/scanner/scanner.go index 871b0c696..27e2b19d2 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/run" @@ -161,9 +160,6 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Update last_scan_completed_at for all libraries s.runUpdateLibraries(ctx, &state), - - // Optimize DB - s.runOptimize(ctx), ) if err != nil { log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) @@ -280,15 +276,6 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun } } -func (s *scannerImpl) runOptimize(ctx context.Context) func() error { - return func() error { - start := time.Now() - db.Optimize(ctx) - log.Debug(ctx, "Scanner: Optimized DB", "elapsed", time.Since(start)) - return nil - } -} - func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { return func() error { start := time.Now() diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 6c70eb268..17772bf9d 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -4,10 +4,12 @@ import ( "context" "path/filepath" "testing/fstest" + "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" @@ -80,7 +82,7 @@ var _ = Describe("ScanFolders", Ordered, func() { rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"}) pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"}) - createFS(fstest.MapFS{ + fsys = createFS(fstest.MapFS{ "rock/track1.mp3": rock(track(1, "Rock Track 1")), "rock/track2.mp3": rock(track(2, "Rock Track 2")), "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")), @@ -122,6 +124,38 @@ var _ = Describe("ScanFolders", Ordered, func() { // Verify files in the pop folder were NOT scanned Expect(paths).ToNot(ContainElement("pop/track6.mp3")) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("1")) + }) + }) + + Describe("Planner statistics maintenance", func() { + It("does not mark routine quick-scan changes for immediate analysis", func() { + rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) + fsys = createFS(fstest.MapFS{ + "rock/track1.mp3": rock(track(1, "Rock Track 1")), + }) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + + fsys.Add("rock/track2.mp3", rock(track(2, "Rock Track 2")), time.Now().Add(time.Second)) + _, err = s.ScanAll(ctx, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + It("does not treat an interrupted scan in an untargeted library as a full scan", func() { + otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} + Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + Expect(ds.Library(ctx).ScanBegin(lib.ID, true)).To(Succeed()) + + lastAnalyze := "2026-07-09T12:00:00Z" + Expect(ds.Property(ctx).Put(consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.DBAnalyzePendingKey, "0")).To(Succeed()) + + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: otherLib.ID, FolderPath: "."}}) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) }) }) From edddc1acb5a566919cf3cfb026cf4c2a702c00fd Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 13 Jul 2026 14:06:50 -0400 Subject: [PATCH 18/56] chore(deps): update go-sqlite3, reflex, and golang.org/x dependencies to latest versions Signed-off-by: Deluan --- go.mod | 26 +++++++++++++------------- go.sum | 58 ++++++++++++++++++++++++++-------------------------------- 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 71aabdcd7..014a43a56 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.47 + github.com/mattn/go-sqlite3 v1.14.48 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.32.0 @@ -59,12 +59,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.43.0 - golang.org/x/net v0.56.0 - golang.org/x/sync v0.21.0 - golang.org/x/sys v0.46.0 - golang.org/x/term v0.44.0 - golang.org/x/text v0.39.0 + golang.org/x/image v0.44.0 + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 + golang.org/x/text v0.40.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -75,7 +75,7 @@ require ( github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/reflex v0.3.1 // indirect + github.com/cespare/reflex v0.3.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -89,7 +89,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect + github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -133,10 +133,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index ec532b0a0..064974edb 100644 --- a/go.sum +++ b/go.sum @@ -16,13 +16,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= -github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE= +github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ= +github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -62,7 +61,6 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= @@ -105,8 +103,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -143,11 +141,8 @@ github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -174,8 +169,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= -github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -309,39 +304,38 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= -golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= From feda8de7e9e172aeeba9ebbf3ccb5ba454bb85af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 13 Jul 2026 16:08:07 -0400 Subject: [PATCH 19/56] ci: bump github-script, cache and setup-qemu actions to latest majors (#5778) Update the GitHub Actions that had newer major versions available; all other actions in the workflows were already pinned to their latest major tag. - actions/github-script: v7 -> v9 - actions/cache: v5 -> v6 - docker/setup-qemu-action: v3 -> v4 --- .github/workflows/download-link-on-pr.yml | 2 +- .github/workflows/pipeline.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 076f963d4..5b421331b 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -8,7 +8,7 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 86a1055f8..8e6e8126a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -166,7 +166,7 @@ jobs: - name: Cache ffmpeg id: ffmpeg-cache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: C:\ffmpeg key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 @@ -323,7 +323,7 @@ jobs: - name: Set up QEMU for smoke test if: env.IS_LINUX == 'true' - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 # The binary is static, so binfmt+qemu runs it directly on the runner. # Catches startup crashes in cross-compiled binaries before they ship, From 9ae252c418fe3884524a33eea3d262e569c3ac6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 14 Jul 2026 07:20:17 -0400 Subject: [PATCH 20/56] feat(ui): add Artists, Songs, and Playlists to Default View options (#5754) * Add resource lists to default view options * refactor(ui): reuse getStoredDefaultView in AlbumList default-view redirect Avoid duplicating the localStorage fallback logic and skip the unused albumLists lookup in the resource-redirect branch, per PR review feedback. --- ui/src/album/AlbumList.jsx | 12 +++++-- ui/src/personal/SelectDefaultView.jsx | 9 ++--- ui/src/personal/defaultViews.js | 20 +++++++++++ ui/src/personal/defaultViews.test.js | 48 +++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 ui/src/personal/defaultViews.js create mode 100644 ui/src/personal/defaultViews.test.js diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 0b8c256df..a860c85bb 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -28,7 +28,11 @@ import { import AlbumListActions from './AlbumListActions' import AlbumTableView from './AlbumTableView' import AlbumGridView from './AlbumGridView' -import albumLists, { defaultAlbumList } from './albumLists' +import albumLists from './albumLists' +import { + getStoredDefaultView, + isResourceDefaultView, +} from '../personal/defaultViews' import config from '../config' import AlbumInfo from './AlbumInfo' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' @@ -220,8 +224,10 @@ const AlbumList = (props) => { // If it does not have filter/sort params (usually coming from Menu), // reload with correct filter/sort params if (!location.search) { - const type = - albumListType || localStorage.getItem('defaultView') || defaultAlbumList + const type = albumListType || getStoredDefaultView() + if (isResourceDefaultView(type)) { + return + } const listParams = albumLists[type] if (type === 'random') { refresh() diff --git a/ui/src/personal/SelectDefaultView.jsx b/ui/src/personal/SelectDefaultView.jsx index 71c87305c..e90fd65bc 100644 --- a/ui/src/personal/SelectDefaultView.jsx +++ b/ui/src/personal/SelectDefaultView.jsx @@ -1,13 +1,10 @@ import { SelectInput, useTranslate } from 'react-admin' -import albumLists, { defaultAlbumList } from '../album/albumLists' +import { getDefaultViewChoices, getStoredDefaultView } from './defaultViews' export const SelectDefaultView = (props) => { const translate = useTranslate() - const current = localStorage.getItem('defaultView') || defaultAlbumList - const choices = Object.keys(albumLists).map((type) => ({ - id: type, - name: translate(`resources.album.lists.${type}`), - })) + const current = getStoredDefaultView() + const choices = getDefaultViewChoices(translate) return ( + resourceDefaultViews.includes(defaultView) + +export const getDefaultViewChoices = (translate) => [ + ...Object.keys(albumLists).map((type) => ({ + id: type, + name: translate(`resources.album.lists.${type}`), + })), + ...resourceDefaultViews.map((resource) => ({ + id: resource, + name: translate(`resources.${resource}.name`, { smart_count: 2 }), + })), +] + +export const getStoredDefaultView = () => + localStorage.getItem('defaultView') || defaultAlbumList diff --git a/ui/src/personal/defaultViews.test.js b/ui/src/personal/defaultViews.test.js new file mode 100644 index 000000000..44057a736 --- /dev/null +++ b/ui/src/personal/defaultViews.test.js @@ -0,0 +1,48 @@ +import { + getDefaultViewChoices, + getStoredDefaultView, + isResourceDefaultView, + resourceDefaultViews, +} from './defaultViews' +import albumLists, { defaultAlbumList } from '../album/albumLists' + +describe('defaultViews', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('includes album lists and top-level resource lists as choices', () => { + const choices = getDefaultViewChoices((key, options) => + options?.smart_count ? `${key}:${options.smart_count}` : key, + ) + + expect(choices.map((choice) => choice.id)).toEqual([ + ...Object.keys(albumLists), + ...resourceDefaultViews, + ]) + expect(choices).toEqual( + expect.arrayContaining([ + { id: 'artist', name: 'resources.artist.name:2' }, + { id: 'song', name: 'resources.song.name:2' }, + { id: 'playlist', name: 'resources.playlist.name:2' }, + ]), + ) + }) + + it('identifies resource-backed default views', () => { + expect(isResourceDefaultView('artist')).toBe(true) + expect(isResourceDefaultView('song')).toBe(true) + expect(isResourceDefaultView('playlist')).toBe(true) + expect(isResourceDefaultView('recentlyAdded')).toBe(false) + }) + + it('falls back to the default album list when no default view is stored', () => { + expect(getStoredDefaultView()).toBe(defaultAlbumList) + }) + + it('returns the stored default view', () => { + localStorage.setItem('defaultView', 'playlist') + + expect(getStoredDefaultView()).toBe('playlist') + }) +}) From ca27335d0671a08210fb7e4663ab21d7291ff651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 14 Jul 2026 07:38:25 -0400 Subject: [PATCH 21/56] feat(playlists): per-user starred/rating annotations (backend) (#5749) * feat(playlists): add average_rating column to playlist table * feat(playlists): store and read per-user starred/rating annotations * feat(playlists): clean up annotations when a playlist is deleted * feat(subsonic): route star/unstar of a playlist to the playlist repository * feat(subsonic): route setRating of a playlist to the playlist repository * test(subsonic): guard that playlist responses never expose annotations * fix(playlists): clean stale mis-typed annotations on upgrade; cover GetAll read-back * fix(playlists): scope annotation join by item_type and harden delete Address code-review findings on the playlist-annotations branch: - withAnnotation: add an item_type predicate to the LEFT JOIN so a mis-typed annotation row sharing an id can no longer leak into (or duplicate) another entity's read. Correct for every caller since each repo writes annotations with item_type = tableName. Regression test added. - migration: reclassify legacy media_file-typed rows for playlist ids to item_type='playlist' (instead of deleting them), preserving users' prior playlist star/rating; run before the average_rating backfill so those ratings are included. - playlist Delete: replace the per-request full-table cleanAnnotations() anti-join with a targeted, permission-safe (rows-affected gated), best-effort delete so a cleanup failure no longer misreports an already-committed delete as an error. - MockPlaylistRepo: implement GetAll/IncPlayCount/ReassignAnnotation to remove the dead All field and the nil-interface panic traps. - test: use slices.IndexFunc instead of a hand-rolled find loop. * feat(playlists): streamline playlist deletion by relying on annotation sweep * docs(playlists): trim comments in annotation migration and test Condense the verbose comments added in this branch per the project's comment-minimalism guideline, keeping only the non-obvious rationale. The migration's reclassify block is shortened while preserving the safety invariant (playlist and media_file ids never collide, so the item_type rewrite touches only mis-typed rows and cannot violate the unique key) and the ordering note. The redundant 'Populate average_rating' comment is dropped since the UPDATE is self-evident. The repository test's leakage comment is condensed to two lines. No code behavior changes. * refactor(subsonic): resolve setStar targets via GetEntityByID Replace setStar's Album/Artist/Playlist Exists probe chain with a single model.GetEntityByID lookup and a type switch, mirroring setRating. This removes three per-id existence queries and keeps the two annotation paths consistent. An id that resolves to no known entity is logged and skipped rather than filed as a spurious media_file annotation, and a lookup failure on one id no longer aborts the whole batch. Also drop a duplicate empty-ids guard. * refactor(playlists): drop no-op reclassify/backfill from migration The average_rating migration carried two data-fix UPDATEs that are no-ops on any real database: - The media_file->playlist reclassification only matches rows no released build ever created: playlists were never annotatable, so star/setRating of a playlist id was never written as item_type='playlist'. Any stray media_file-typed row for a playlist id is already removed by the media_file annotation GC sweep (item_id not in media_file). - The average_rating backfill runs before any item_type='playlist' row can exist, so it can only ever write the default 0. Going forward SetRating keeps average_rating current via updateAvgRating. Reduce the migration to the column add/drop. * refactor(persistence): bind annotation join params, derive idField from tableName Address PR review: use Squirrel parameter binding for item_type/user_id in the shared withAnnotation join instead of string concatenation, and pass r.tableName+".id" from selectPlaylist so the join field stays consistent with the surrounding r.tableName usage. * fix(subsonic): surface datastore errors in setStar instead of skipping Address PR review: setStar swallowed every GetEntityByID error and continued, so a real datastore failure would still commit the transaction and emit a refresh event as if the star succeeded. Skip only on model.ErrNotFound (an unknown id); return any other error so the request fails and rolls back. * test(subsonic): assert absent JSON keys instead of substring matches Address PR review: substring checks are brittle ("starred" matches "starredAt", "rating" matches "userRating"). Unmarshal the response and assert the annotation keys are absent. * fix(subsonic): skip refresh broadcast when a star request changes nothing Address PR review (Codex): once setStar began skipping unknown ids, a request containing only unresolvable ids left the RefreshResource empty, which SendMessage serializes as a {*:*} wildcard that forces every client to refresh. Only broadcast when at least one id was actually starred. * fix(db): rebase playlist average_rating migration timestamp past master The 20260708011823 migration predated the newest migration merged to master (20260712211040_add_primary_key...), which Goose would silently skip on already-upgraded databases. Rename it to a current timestamp so it applies in order. --- ...0714120000_add_playlist_average_rating.sql | 5 + model/playlist.go | 3 + persistence/persistence.go | 1 + persistence/playlist_repository.go | 3 +- persistence/playlist_repository_test.go | 95 +++++++++++++++++++ persistence/sql_annotations.go | 3 +- server/subsonic/media_annotation.go | 62 ++++++------ server/subsonic/media_annotation_test.go | 58 +++++++++++ server/subsonic/playlists_test.go | 22 +++++ tests/mock_playlist_repo.go | 57 +++++++++++ 10 files changed, 279 insertions(+), 30 deletions(-) create mode 100644 db/migrations/20260714120000_add_playlist_average_rating.sql diff --git a/db/migrations/20260714120000_add_playlist_average_rating.sql b/db/migrations/20260714120000_add_playlist_average_rating.sql new file mode 100644 index 000000000..5db642986 --- /dev/null +++ b/db/migrations/20260714120000_add_playlist_average_rating.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE playlist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE playlist DROP COLUMN average_rating; diff --git a/model/playlist.go b/model/playlist.go index dc549f039..262774aa7 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -10,6 +10,8 @@ import ( ) type Playlist struct { + Annotations `structs:"-"` + ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` Comment string `structs:"comment" json:"comment"` @@ -121,6 +123,7 @@ type Playlists []Playlist type PlaylistRepository interface { ResourceRepository + AnnotatedRepository CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) Put(pls *Playlist, cols ...string) error diff --git a/persistence/persistence.go b/persistence/persistence.go index 1164eb70f..93f0e3e71 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -193,6 +193,7 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }), trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }), trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }), + trace(ctx, "clean playlist annotations", func() error { return s.Playlist(ctx).(*playlistRepository).cleanAnnotations() }), trace(ctx, "clean media file bookmarks", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() }), trace(ctx, "purge non used tags", func() error { return s.Tag(ctx).(*tagRepository).purgeUnused() }), trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }), diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 4152505d2..fe1f50689 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -203,8 +203,9 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, } func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...).Join("user on user.id = owner_id"). + sel := r.newSelect(options...).Join("user on user.id = owner_id"). Columns(r.tableName+".*", "user.user_name as owner_name") + return r.withAnnotation(sel, r.tableName+".id") } func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index cfabd0983..c5b16b88f 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,11 +1,14 @@ package persistence import ( + "slices" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { @@ -71,6 +74,98 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("Annotations", func() { + var plsID string + + BeforeEach(func() { + pls := model.Playlist{Name: "Annotated", OwnerID: "userid"} + Expect(repo.Put(&pls)).To(Succeed()) + plsID = pls.ID + }) + + countAnnotations := func() int { + var count int + Expect(GetDBXBuilder().NewQuery( + "SELECT count(*) FROM annotation WHERE item_type = 'playlist' AND item_id = {:id}"). + Bind(dbx.Params{"id": plsID}).Row(&count)).To(Succeed()) + return count + } + + It("stores and reads back starred", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeTrue()) + Expect(p.StarredAt).ToNot(BeNil()) + }) + + It("stores and reads back rating and average_rating", func() { + Expect(repo.SetRating(4, plsID)).To(Succeed()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Rating).To(Equal(4)) + Expect(p.RatedAt).ToNot(BeNil()) + Expect(p.AverageRating).To(Equal(4.0)) + }) + + It("keeps annotations isolated per user", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()), + model.User{ID: "otheruser", UserName: "otheruser", IsAdmin: true}) + otherRepo := NewPlaylistRepository(otherCtx, GetDBXBuilder()) + + p, err := otherRepo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeFalse()) + }) + + It("reads starred back through GetAll", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + idx := slices.IndexFunc(all, func(p model.Playlist) bool { return p.ID == plsID }) + Expect(idx).To(BeNumerically(">=", 0)) + Expect(all[idx].Starred).To(BeTrue()) + }) + + It("does not leak an annotation row of another item_type sharing the playlist id", func() { + // Older builds (and the star fallthrough) can leave a media_file-typed row + // under a playlist id; the item_type-scoped join must not surface or dupe it. + _, err := GetDBXBuilder().NewQuery( + "INSERT INTO annotation (user_id, item_id, item_type, starred) VALUES ({:uid}, {:id}, 'media_file', 1)"). + Bind(dbx.Params{"uid": "userid", "id": plsID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeFalse()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + matches := 0 + for _, pl := range all { + if pl.ID == plsID { + matches++ + } + } + Expect(matches).To(Equal(1)) + }) + + It("relies on the annotation sweep, not Delete, to clean up annotations", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + Expect(repo.Delete(plsID)).To(Succeed()) + Expect(countAnnotations()).To(Equal(1)) + + Expect(repo.(*playlistRepository).cleanAnnotations()).To(Succeed()) + Expect(countAnnotations()).To(Equal(0)) + }) + }) + It("Put/Exists/Delete", func() { By("saves the playlist to the DB") newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 78b7938a1..46ad6a0de 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -67,7 +67,8 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec query = query. LeftJoin("annotation on ("+ "annotation.item_id = "+idField+ - " AND annotation.user_id = '"+userID+"')"). + " AND annotation.item_type = ?"+ + " AND annotation.user_id = ?)", r.tableName, userID). Columns( "coalesce(starred, 0) as starred", "coalesce(rating, 0) as rating", diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index e8b0278c1..27170c11b 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "errors" "fmt" "math" "net/http" @@ -52,6 +53,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { case *model.Album: repo = api.ds.Album(ctx) resource = "album" + case *model.Playlist: + repo = api.ds.Playlist(ctx) + resource = "playlist" default: repo = api.ds.MediaFile(ctx) resource = "song" @@ -104,48 +108,50 @@ func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error { - if len(ids) == 0 { - return nil - } - log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) if len(ids) == 0 { log.Warn(ctx, "Cannot star/unstar an empty list of ids") return nil } - event := &events.RefreshResource{} + log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) err := api.ds.WithTxImmediate(func(tx model.DataStore) error { + event := &events.RefreshResource{} + changed := false for _, id := range ids { - exist, err := tx.Album(ctx).Exists(id) + var repo model.AnnotatedRepository + var resource string + entity, err := model.GetEntityByID(ctx, tx, id) if err != nil { - return err - } - if exist { - err = tx.Album(ctx).SetStar(star, id) - if err != nil { + if !errors.Is(err, model.ErrNotFound) { return err } - event = event.With("album", id) + log.Warn(ctx, "Cannot star/unstar unknown id, skipping", "id", id) continue } - exist, err = tx.Artist(ctx).Exists(id) - if err != nil { + switch entity.(type) { + case *model.Artist: + repo = tx.Artist(ctx) + resource = "artist" + case *model.Album: + repo = tx.Album(ctx) + resource = "album" + case *model.Playlist: + repo = tx.Playlist(ctx) + resource = "playlist" + default: + repo = tx.MediaFile(ctx) + resource = "song" + } + if err := repo.SetStar(star, id); err != nil { return err } - if exist { - err = tx.Artist(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("artist", id) - continue - } - err = tx.MediaFile(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("song", id) + event = event.With(resource, id) + changed = true + } + // Skip the broadcast when nothing changed: an empty RefreshResource + // serializes as a "{*:*}" wildcard, forcing every client to refresh. + if changed { + api.broker.SendMessage(ctx, event) } - api.broker.SendMessage(ctx, event) return nil }) if err != nil { diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 487335d1a..1b16dfc68 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -185,6 +185,64 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) }) }) + + Describe("Star/Unstar playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("stars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", true)) + }) + + It("unstars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Unstar(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", false)) + }) + }) + + Describe("SetRating playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("rates a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1", "rating=4") + + _, err := router.SetRating(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", 4)) + }) + }) + + Describe("Star with an unresolvable id", func() { + It("skips the id without broadcasting an empty (wildcard) refresh", func() { + r := newGetRequest("id=does-not-exist") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.Events).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 1d5f6a70a..697dd5852 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "encoding/json" "time" "github.com/navidrome/navidrome/conf" @@ -248,6 +249,27 @@ var _ = Describe("buildPlaylist", func() { }) }) }) + + Describe("annotation leakage", func() { + It("does not serialize starred/rating even when the model carries them", func() { + p := model.Playlist{ID: "pl-1", Name: "My Playlist"} + p.Starred = true + p.Rating = 5 + + resp := router.buildPlaylist(ctx, p) + + data, err := json.Marshal(resp) + Expect(err).ToNot(HaveOccurred()) + var fields map[string]any + Expect(json.Unmarshal(data, &fields)).To(Succeed()) + Expect(fields).ToNot(HaveKey("starred")) + Expect(fields).ToNot(HaveKey("starredAt")) + Expect(fields).ToNot(HaveKey("rating")) + Expect(fields).ToNot(HaveKey("userRating")) + Expect(fields).ToNot(HaveKey("averageRating")) + Expect(fields).ToNot(HaveKey("playCount")) + }) + }) }) var _ = Describe("UpdatePlaylist", func() { diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9b38ea5b5..b7df5361f 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -2,6 +2,7 @@ package tests import ( "errors" + "time" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" @@ -19,8 +20,11 @@ type MockPlaylistRepo struct { model.PlaylistRepository Data map[string]*model.Playlist // keyed by ID PathMap map[string]*model.Playlist // keyed by path + All model.Playlists Last *model.Playlist Deleted []string + Starred map[string]bool // itemID -> starred + Ratings map[string]int // itemID -> rating Err bool TracksRepo model.PlaylistTrackRepository } @@ -29,6 +33,14 @@ func (m *MockPlaylistRepo) SetError(err bool) { m.Err = err } +func (m *MockPlaylistRepo) SetData(pls model.Playlists) { + m.Data = make(map[string]*model.Playlist, len(pls)) + m.All = pls + for i, p := range m.All { + m.Data[p.ID] = &m.All[i] + } +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") @@ -45,6 +57,13 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } +func (m *MockPlaylistRepo) GetAll(_ ...model.QueryOptions) (model.Playlists, error) { + if m.Err { + return nil, errors.New("error") + } + return m.All, nil +} + func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") @@ -79,6 +98,44 @@ func (m *MockPlaylistRepo) Delete(id string) error { return nil } +func (m *MockPlaylistRepo) SetStar(starred bool, ids ...string) error { + if m.Err { + return errors.New("error") + } + if m.Starred == nil { + m.Starred = map[string]bool{} + } + for _, id := range ids { + m.Starred[id] = starred + } + return nil +} + +func (m *MockPlaylistRepo) SetRating(rating int, id string) error { + if m.Err { + return errors.New("error") + } + if m.Ratings == nil { + m.Ratings = map[string]int{} + } + m.Ratings[id] = rating + return nil +} + +func (m *MockPlaylistRepo) IncPlayCount(string, time.Time) error { + if m.Err { + return errors.New("error") + } + return nil +} + +func (m *MockPlaylistRepo) ReassignAnnotation(string, string) error { + if m.Err { + return errors.New("error") + } + return nil +} + func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { return m.TracksRepo } From 3cd4f1eb24a0fb743bad0f1ad011168d6d50ffe3 Mon Sep 17 00:00:00 2001 From: fxj368 <62541194+fxj368@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:40:55 +0800 Subject: [PATCH 22/56] fix(ui): update Chinese Simplified translation (#5779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update Chinese (Simplified) translation * Update Chinese (Simplified) translation --------- Co-authored-by: Deluan Quintão --- resources/i18n/zh-Hans.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index 63ea5cf60..21778506a 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -6,7 +6,7 @@ "fields": { "albumArtist": "专辑艺人", "duration": "时长", - "trackNumber": "音轨号", + "trackNumber": "曲目序号", "playCount": "播放次数", "title": "标题", "artist": "艺人", @@ -22,6 +22,8 @@ "bitRate": "比特率", "bitDepth": "位深度", "sampleRate": "采样率", + "albumGain": "专辑增益", + "trackGain": "曲目增益", "channels": "声道", "disc": "碟片 %{discNumber}", "discSubtitle": "碟片副标题", @@ -142,7 +144,7 @@ "name": "用户", "fields": { "userName": "用户名", - "isAdmin": "是否管理员", + "isAdmin": "是否为管理员", "lastLoginAt": "上次登录", "lastAccessAt": "上次访问", "updatedAt": "更新于", @@ -623,11 +625,11 @@ "lastfmScrobbling": "启用 Last.fm 的个性化记录", "listenBrainzScrobbling": "启用 ListenBrainz 的个性化记录", "replaygain": "回放增益", - "preAmp": "前置放大器 (dB)", + "preAmp": "回放增益 - 前置放大 (dB)", "gain": { - "none": "禁用增益", - "album": "使用专辑增益信息", - "track": "使用歌曲增益信息" + "none": "禁用", + "album": "使用专辑增益", + "track": "使用曲目增益" } } }, From fe6ac2e577f14090a2fb33a9e4c91c1bee6196a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 14 Jul 2026 11:46:37 -0400 Subject: [PATCH 23/56] feat(jellyfin): experimental Jellyfin Music API support (#5730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sharing): enable sharing by default Flip the EnableSharing default from false to true so new installations have the sharing feature available out of the box. Users can still disable it via the EnableSharing config option. The native API only registers the /share route when sharing is enabled, so the nativeapi tests that build the router without wiring a share service now explicitly disable sharing in their setup to avoid registering a route backed by a nil service. * feat(jellyfin): add config flag and URL path constant Adds the disabled-by-default Server.Jellyfin config option (Enabled, ServerName) and consts.URLPathJellyfinAPI, following the existing LastFM/ListenBrainz patterns. Later tasks will use these to mount the Jellyfin-compatible API router. * fix(jellyfin): default Jellyfin ServerName to "Navidrome" * feat(jellyfin): package skeleton with System handshake endpoints Adds server/jellyfin: the Router (mirrors server/subsonic and server/public), its Wire-friendly New(...) constructor, a chi routes() table, and the ok() JSON response helper. Implements the unauthenticated handshake surface Jellyfin clients probe first: GET /System/Info/Public, GET+POST /System/Ping, and GET /QuickConnect/Enabled (quick connect is unsupported, so it always reports disabled). The public info payload's Id must be stable across restarts (Jellyfin clients cache ServerId), so it reuses the get-or-create Property pattern already used for InsightsID in core/metrics/insights.go: fetch consts.JellyfinServerIDKey from the Property repository, generating and persisting a new UUID on first read. A dedicated key (rather than the existing InsightsID) keeps the anonymous telemetry identifier from being exposed on this unauthenticated endpoint. * test(jellyfin): cover ping and quickConnectEnabled handlers Both were added alongside the System/Info/Public handshake endpoint but had no direct test coverage. * fix(jellyfin): memoize stable server Id and cover get-or-create path * feat(jellyfin): wire and mount the router behind Jellyfin.Enabled Add jellyfin.New to the shared Wire provider set and a CreateJellyfinAPIRouter injector, then mount the router at /jellyfin in startServer(), gated by conf.Server.Jellyfin.Enabled, mirroring the existing LastFM/ListenBrainz router mounts. * feat(jellyfin): add Jellyfin DTOs and model mappers Adds BaseItemDto, QueryResult, UserItemDataDto, UserDto, AuthenticationResult, MediaSourceInfo, PlaybackInfoResponse, NameGuidPair and SessionInfo DTOs to server/jellyfin/dto, plus model-to-DTO mappers for songs, albums, artists and genres that later browse/stream/write endpoints will consume. * test(jellyfin): cover GenreToBaseItem mapper * feat(jellyfin): advertise Jellyfin-compatible version, brand ServerName with Navidrome version * fix(jellyfin): map LastPlayedDate and omit zero track/disc numbers * feat(jellyfin): authentication middleware and AuthenticateByName login * fix(jellyfin): reject empty login password, wire ServerName, add negative auth tests * refactor(jellyfin): use new(x) builtin instead of intPtr helper * feat(jellyfin): user views and current-user endpoints * feat(jellyfin): Items query engine, item detail, and latest Adds the /Items universal query endpoint, dispatching by IncludeItemTypes over albums/artists/songs/genres with ParentId, SearchTerm, Filters=IsFavorite, SortBy/SortOrder and StartIndex/Limit support, plus GET /Items/{itemId} and /Users/{userId}/Items/Latest. Reuses server/subsonic/filter builders (by artist/album/starred) instead of hand-rolled squirrel filters, and resolves sort keys per item type since each repository maps sort names to different real/aliased columns. Also adds the missing CountAll to tests.MockArtistRepo, exposed by this task (the mock embedded a nil ArtistRepository for it and would panic on use). * refactor(server): extract shared query filter builders to server/filter Moves server/subsonic/filter to server/filter so server/jellyfin can use the shared query-option builders without importing server/subsonic, enforcing the rule that no API package imports another API's package. * feat(jellyfin): full multi-library support with per-user access scoping Replace the single hardcoded "music" UserView with one CollectionFolder view per library the user can access, and scope every /Items browse query (albums, songs, artists, /Latest) to those libraries via server/filter's ApplyLibraryFilter/ApplyArtistLibraryFilter. ParentId is now disambiguated: a numeric value the user has access to is treated as a library scope (browsing a UserView), otherwise it falls through as an entity id (artist/album), which safely matches nothing rather than leaking another library's content. getItem now 404s when fetching an album or song outside the user's accessible libraries; artists are skipped (they can span multiple libraries) with a TODO. Genres remain unscoped since they're global tags, not per-library entities. * test(jellyfin): cover admin library-access path and drop nil test contexts * feat(jellyfin): Artists and Genres endpoints Add /Artists, /Artists/AlbumArtists, /Genres and /MusicGenres. Artists listing is library-scoped (defaulting to the user's accessible libraries, narrowed by an accessible ParentId), delegating access control to listArtists/ApplyArtistLibraryFilter. Genres are global and unscoped, matching the ML decision already made for listGenres. * refactor(jellyfin): share resolveLibraryScope between items and artists * feat(jellyfin): item image endpoint via artwork service * fix(jellyfin): let net/http sniff image Content-Type instead of forcing jpeg * feat(jellyfin): audio streaming and PlaybackInfo with library access control * feat(jellyfin): favorites and rating write-back with library access control Adds POST/DELETE handlers for /Users/{userId}/FavoriteItems/{itemId} and /Users/{userId}/Items/{itemId}/Rating. A shared resolveAnnotated helper probes album/artist/media file (mirroring getItem's order) and 404s before writing if the user lacks access to the album/song's library; artists are exempt since they span multiple libraries. Ratings are halved coming in (Jellyfin 0-10 -> Navidrome 0-5) to match the doubling in dto.UserData. Also teaches MockMediaFileRepo and MockArtistRepo's SetStar/SetRating (and MockAlbumRepo's, previously a no-op) to actually mutate the backing data so write-back can be asserted in tests. * feat(jellyfin): playback reporting and scrobbling Add /Sessions/Playing[/Progress|/Stopped] and /Sessions/Capabilities[/Full] handlers, backed by core/scrobbler.PlayTracker. A new withPlayer middleware resolves/registers a model.Player from the Emby DeviceId header (used directly as the stable player id, unlike Subsonic's cookie fallback) and injects it into the request context for scrobbling. The Stopped report calls ReportPlayback with IgnoreScrobble to end the now-playing session without double-counting, then calls Submit as the single source of the play-count increment and external scrobble. * feat(jellyfin): playlist read and write-back Adds POST /Playlists, GET/POST/DELETE /Playlists/{id}/Items. Tags each playlist item with PlaylistItemId (the entry's position within the playlist) so DELETE .../Items?EntryIds=... can remove a specific occurrence by the id core/playlists.RemoveTracks actually expects, rather than the song id used everywhere else. * test(jellyfin): cover empty Ids/EntryIds in playlist add/remove * feat(jellyfin): unknown-route logging, generic 500s, rating clamp, docs Hardening pass ahead of real client testing: unmatched routes and unsupported methods now return a logged, JSON 404 instead of chi's default plain-text response, so a missing endpoint a client needs is easy to spot in the logs. Internal errors (ffmpeg output, file paths, etc.) no longer leak into 500 response bodies -- a shared internalError helper logs the real error server-side and always returns a generic message. Inbound Jellyfin ratings are clamped to 0-10 before being halved into Navidrome's 0-5 scale, and /System/Ping now replies with a bare plain-text body as real Jellyfin servers do. Adds a README with an enable/curl walkthrough and known limitations. * docs(jellyfin): clarify public image endpoint and accessibleLibraryIDs comments * fix(jellyfin): case-insensitive path routing for Jellyfin client compatibility * refactor(server): extract case-insensitive path routing to a shared helper * fix(jellyfin): return User Policy and Configuration so Finamp completes login * fix(jellyfin): support Playlist type, multi-type Items queries, and PlayCount/DatePlayed sort Real Finamp requests break against three /Items query engine bugs: Playlist requests fell through to albums, multi-type IncludeItemTypes (e.g. Finamp's favorites screen) only returned the first requested type, and comma-separated SortBy lists (e.g. "DateCreated,SortName") were matched as one opaque string so they always fell through to the repo default. Also extends tests/mock_playlist_repo.go with SetData/GetAll so playlist listing is testable like the other mock repos. * feat(jellyfin): implement /socket WebSocket for real-time client sessions * fix(jellyfin): resolve library-view ids in getItem so clients can load the library * fix(jellyfin): serve direct file at /Items/{id}/File and accept ApiKey query param * fix(jellyfin): resolve playlist ids in getItem * fix(jellyfin): read lowercase ids/entryIds playlist params and add playlist-users endpoints * fix(jellyfin): include MediaSources with Size/Bitrate on tracks so clients show download size * fix(jellyfin): populate all required MediaSourceInfo bool/array fields to match Jellyfin * fix(jellyfin): hex-encode item ids at the API boundary for Jellyfin client compatibility Finamp (and presumably other clients) parses ids as radix-16, but Navidrome's base62 nanoids aren't valid hex and crash its queue packing. Hex-encode every id emitted by the Jellyfin API and decode every id received, keeping the transform reversible and stateless at the boundary rather than touching any model id. * fix(jellyfin): populate MediaStreams with the audio stream so clients can size/transcode * fix(jellyfin): support Ids batch-fetch in /Items so clients can fetch items by id * fix(jellyfin): do not disable Jellyfin server when disabling external services * fix(jellyfin): emit per-image ImageBlurHashes so clients de-dupe images and stop warning * fix(jellyfin): support playlist cover upload/delete and display via item image endpoints * feat(jellyfin): implement GET /Playlists/{id} for playlist visibility Finamp's playlist edit screen calls GET /Playlists/{id} to read the OpenAccess (public visibility) flag; we only had the sub-routes, so it 404'd and the edit screen failed to load. Return the Jellyfin PlaylistDto shape (OpenAccess from Public, empty Shares, media item ids). * fix(jellyfin): display playlist cover art Uploaded playlist covers never showed in clients for two reasons: the playlist BaseItemDto advertised no Primary ImageTag (so clients didn't know to fetch a cover), and the public image endpoint resolved artwork under the request's anonymous context, so a private playlist failed its visibility filter and fell back to the placeholder. Advertise the Primary image tag/blurhash on playlists, and resolve artwork under an elevated context (as core/artwork's cache warmer does). * fix(jellyfin): expand album/artist/playlist ids when building playlists Jellyfin clients (Finamp) send container ids — an album, artist or playlist — in a playlist's Ids list and expect the server to expand each into its child tracks. core/playlists only understands media file ids, so creating or adding with an album id silently produced an empty playlist. Expand container ids to their tracks (in order) before create/add; bare song ids still pass through. Adds filter.SongsByArtistID. * fix(jellyfin): support playlist deletion via DELETE /Items/{id} Finamp deletes a playlist with DELETE /Items/{id}, which we didn't route, so deletion silently failed. Implement it via core/playlists.Delete (which enforces ownership and removes the cover file). Only playlists are deletable through this API; non-playlist ids return 404, non-owners 403. * test(jellyfin): add e2e suite harness + smoke tests * test(jellyfin): e2e for system, auth, routing, browsing, annotations * test(jellyfin): e2e for playlists (CRUD, expansion, cover) and item images * test(jellyfin): e2e for streaming, sessions, and multi-user access control * fix(jellyfin): artist search 500 (library filter leaked into FTS query) getArtists/listArtists reused the browse-path filters (notMissing + ApplyArtistLibraryFilter) for the search path, but artist Search expects a sole Eq{library_id} filter it can consume as a scope — artists have no library_id column, so the compound/join filter leaked into the FTS query and 500'd. Every Finamp artist search failed (the filter is applied even unscoped, since admins resolve to all library ids). Build search filters separately. Adds e2e search coverage. * feat(jellyfin): implement POST /Playlists/{id} to update name, visibility, tracks Finamp edits a playlist (make public, rename, reorder) via POST /Playlists/{id}, which we didn't route, so every edit 404'd. Implement it: Ids present -> replace track list (Create with existing id, preserving name); otherwise update Name/IsPublic via core/playlists.Update. Adds a shared playlistError helper (403/404/500) reused by deleteItem, plus e2e coverage. * docs(jellyfin): update README for playlists, images, id encoding, and e2e * fix(jellyfin): default album track listing to track order Browsing an album's tracks (Items?ParentId=&IncludeItemTypes=Audio) took only SongsByAlbum's filters and dropped its Sort, so tracks came back in arbitrary order. Default opts.Sort to the album (disc+track) order when browsing an album without an explicit SortBy — matching Subsonic's GetAlbum and real Jellyfin. An explicit SortBy still wins. * fix(jellyfin): filter items by AlbumArtistIds/ArtistIds An artist's page in Finamp sends ParentId= (scoping) plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist itself, but queryItems only honored ParentId, so an artist's albums and tracks came back unfiltered (every artist's content). Parse the artist-id params and apply AlbumsByArtistID (albums) / SongsByArtistID (tracks). Adds e2e coverage. * fix(jellyfin): only count a play past the scrobble threshold reportPlaybackStopped set IgnoreScrobble and force-submitted a play on every Stopped report, so a briefly-played track (e.g. an immediate skip) was marked played. Finamp sends Stopped on every track switch, so the threshold must be applied server-side. Let ReportPlayback's StateStopped logic decide (play + scrobble only past 50% of the track / 4-minute cap) instead. Subsonic differs because there the client gates submission. * fix(jellyfin): sort album tracks by track number when client sends IndexNumber SortBy Finamp's album view requests SortBy=ParentIndexNumber,IndexNumber,SortName (disc, track, name), but applySort didn't recognize ParentIndexNumber or IndexNumber and fell through to SortName, sorting tracks alphabetically by title. Map both keys to the album (disc+track) sort. The reversed-title fixture lets the e2e tell track order from title order. * fix(jellyfin): honor the isFavorite query param for favorites filtering Finamp's artist 'Favourite tracks' widget requests favorites via the standalone isFavorite=true query param, not Filters=IsFavorite, so the filter was ignored and non-favorited tracks were returned. Detect both forms. (The widget's reshuffling is Finamp's explicit SortBy=Random.) * feat(jellyfin): emit DateCreated (Date Added) on items BaseItemDto had no DateCreated, so clients showed 'No Date Added' and had nothing to sort 'Recently Added' by. Emit it as ISO 8601 from each entity's CreatedAt (the same field the recently_added sort uses) for songs, albums and artists. * fix(jellyfin): set ArtistItems/AlbumArtists on songs (Now Playing artist) SongToBaseItem only set Artists (names) and AlbumArtist, not the structured ArtistItems/AlbumArtists. Finamp's Now Playing screen reads ArtistItems and shows 'Unknown Artist' when it's absent. Populate both (track artist and album artist) as name+id pairs, mirroring AlbumToBaseItem. * docs(jellyfin): note synthetic blurhash as a follow-up Document that ImageBlurHashes are derived from the item id (a solid-color placeholder), not computed from the cover art like real Jellyfin, and outline what a proper implementation would take. * docs(jellyfin): note WebSocket events and favourited playlists as follow-ups * fix(jellyfin): filter the Artists page by role (album artist vs performer) /Artists and /Artists/AlbumArtists both called the same role-agnostic handler, so Navidrome's per-role artist entries (composers, arrangers, performers) all showed as Album Artists, and the two tabs were identical. Filter /Artists/AlbumArtists to RoleAlbumArtist and /Artists to RoleArtist (and the MusicArtist browse to album artists), via filter.ArtistsByRole. Verified live: Beatles Singles' album-artists dropped 138->5, composers excluded, performers (Billy Preston) show only under /Artists. * fix(jellyfin): sort playlists by name (missing Playlist sort mapping) applySort had no sortColumnsByType entry for the Playlist type, so SortBy=SortName was ignored and the Playlists screen showed them in the repo's default order. Map SortName/Name -> name (as Subsonic does) and DateCreated -> created_at. Verified live: case-insensitive alphabetical. * fix(jellyfin): read query params case-insensitively (Jellify support) Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase (parentId, albumArtistIds, artistIds, includeItemTypes), where Finamp sends PascalCase. Our handlers read fixed-case keys, so every Jellify filter/sort/paging param was silently dropped: - an artist's page listed albums and tracks from all artists - opening an album listed every album instead of its tracks Real Jellyfin binds query params case-insensitively (ASP.NET model binding), so add a normalizeQueryKeys middleware that folds every query key to lowercase once, and read params by their lowercase name. This also removes the scattered dual-case hacks (Ids/ids, IsFavorite, ContributingArtistIds, api_key/ApiKey, queryParam) that let this bug class through. Also infer the child type from an album parent: Jellify browses an album with only parentId (no IncludeItemTypes), and Jellyfin infers Audio from the parent; without it we fell back to listing all albums. Verified live against Finamp+Jellify and with 4 new e2e specs replaying Jellify's exact request shapes. * fix(jellyfin): advertise LocalAddress in the public system info handshake Jellify (and other @jellyfin/sdk clients) that connect by raw address fall back to HTTP when TLS isn't available, then adopt the handshake's LocalAddress as their server base URL. We never populated it, so Jellify's SDK `api` object was undefined and sign-in crashed with "Cannot read property 'configuration' of undefined" (getUserApi(api!) with an undefined api). Over an HTTPS connection Jellify uses connectionType=hostname and never reads LocalAddress, which is why it worked while an HTTPS proxy was in front. Populate LocalAddress from the request — scheme + host (honoring X-Forwarded-*), plus the /jellyfin mount path — matching real Jellyfin, which always sends it. Export server.ServerAddress for the resolution. * fix(jellyfin): separate "Featured On" from an artist's own discography Jellify's artist page fetches the discography via albumArtistIds and the "Featured On" section via contributingArtistIds, relying on the server to return disjoint sets. We collapsed albumArtistIds/artistIds/ contributingArtistIds into one AlbumsByArtistID filter, so an artist's own albums appeared in both sections. Add AlbumsByContributingArtistID — albums where the artist is a track artist but NOT the album artist — matching Jellyfin's ContributingArtistIds (in Artists, not in AlbumArtists), and route contributingArtistIds to it. * fix(jellyfin): accept a bare Authorization token for audio streaming Jellify's native player (react-native-nitro-player) authenticates the audio stream by setting a bare Authorization header carrying the raw access token ({ AUTHORIZATION: api.accessToken }), not the "MediaBrowser ... Token=" scheme parseEmbyAuth understands. tokenFromRequest didn't recognize it, so every /Audio/{id}/stream request from the native player 401'd and no audio played (the JS client still optimistically posted playback progress, masking it). Accept a bare (or "Bearer ") Authorization header, as real Jellyfin does. Verified live: the native player's exact request now streams 200. * fix(jellyfin): embed a self-authenticating stream URL in PlaybackInfo Jellify's native audio player (react-native-nitro-player / ExoPlayer) fetches the stream without forwarding any auth — captured request headers were only Connection/Icy-Metadata/Accept-Encoding/User-Agent, no Authorization and no api_key — so every /Audio/{id}/stream request 401'd and nothing played (the JS client still optimistically posted progress, masking it). Real Jellyfin returns stream URLs with the token embedded; we returned none, so Jellify fell back to a token-less DirectPlay URL. Populate MediaSources[0].TranscodingUrl with /Audio/{id}/universal?api_key=, which Jellify's player uses verbatim. Direct-play clients (Finamp builds its own /Items/{id}/File?ApiKey URL) ignore the field, so they're unaffected. * feat(jellyfin): serve per-item UserData (GET /UserItems/{id}/UserData) Jellify fetches this per item to render played/favourite indicators; we 404'd it. Resolve the item via the existing getItem resolver (which loads the caller's annotations and enforces the same library-access gate) and return its UserData, falling back to an empty-but-valid object for items without annotations (e.g. playlists). Also registers the legacy /Users/{userId}/Items/{itemId}/UserData spelling. * refactor(jellyfin): drop unused bare-Authorization token support Added mid-troubleshooting on the theory that Jellify's native player sends a bare Authorization header, but the debug capture showed it sends no auth header at all — the real fix was embedding api_key in PlaybackInfo's TranscodingUrl. No client reaches this path (Jellify JS uses the MediaBrowser scheme, Finamp uses X-Emby-Token, the native player uses the api_key URL), so remove it and its tests per YAGNI. Effectively reverts 4f8ac1e0. * feat(jellyfin): implement Similar endpoints via the external provider Add GET /Artists/{id}/Similar (related artists) and GET /Items/{id}/Similar (similar songs for a track, similar albums for an album, related artists for an artist), sourced from the same external.Provider (Last.fm etc.) that powers Subsonic's getArtistInfo2/getSimilarSongs. Only library-present artists are returned so each is navigable; provider errors and unknown ids degrade to an empty 200 result, so clients (Jellify) stop hammering these with 404 retries. Injects external.Provider into the Router (regenerated via make wire). * perf(jellyfin): make Similar endpoints non-blocking (bounded quick wait) Each /Similar request fetched from Last.fm synchronously (500ms-1.4s), and Jellify requests Similar for many items at once on the home/artist screens, so the whole screen stalled — a home pull-to-refresh dragged for seconds. Run the external lookup on a background context and return within a 500ms quick wait: a cached artist resolves instantly, a cold one returns empty now while the lookup finishes caching in the background, so a later load is fast and populated. Verified live: cold calls bounded at ~500ms (was up to 1.4s), warm calls 3-8ms. * fix(jellyfin): answer ManualPlaylistsFolder so the home stops stalling Jellify resolves its "playlists library" via IncludeItemTypes= ManualPlaylistsFolder, then lists playlists with ParentId set to that folder's id. We didn't recognize the type, so parseTypes fell back to MusicAlbum and returned the album list. Jellify's query then found no item with CollectionType=playlists and resolved undefined — which React Query rejects, retrying it in a backoff loop that stalled the home pull-to-refresh for ~5s (every response was fast server-side; the delay was the client's retries). Return a synthetic "playlists" folder (CollectionType=playlists) for the ManualPlaylistsFolder query, resolve ParentId= to the user's playlists, and give playlists a Path under "data" (Jellify drops playlists whose Path lacks it). Adds a Path field to BaseItemDto. * docs(jellyfin): note lyrics and InstantMix/sonic-similarity as follow-ups * fix(jellyfin): genre paging params and same-key casing collisions getGenres read StartIndex/Limit in PascalCase, which normalizeQueryKeys had already folded to lowercase, so genre paging was silently ignored; totals now come from the full (small) genre list instead of the page length. normalizeQueryKeys now merges values when two casings of a key collide, instead of nondeterministically keeping one. * fix(jellyfin): round ratings to the nearest star instead of truncating Rating is a nullable double 0-10 in Jellyfin's contract. Truncating integer division stored 9 as 4 stars, and both Rating=1 and fractional values (which failed integer parsing) became 0 — silently deleting the rating. Parse as float, round, and floor nonzero input at one star. * fix(jellyfin): apply Name/IsPublic sent together with a track replacement Jellyfin's UpdatePlaylist applies every provided field, but the Ids branch returned early, silently discarding a rename or visibility change sent in the same body (core/playlists.Create with an existing id ignores the name). * fix(jellyfin): don't rotate the stored server id on transient DB errors serverID treated any Property.Get error as "no id yet" and persisted a fresh UUID over JellyfinServerID, with sync.Once pinning it for the process lifetime — a busy DB or canceled request context on the first request would break every client's cached ServerId. Only ErrNotFound mints a new id now, and failures yield an uncached temporary value so the next request retries. * fix(jellyfin): report real search totals instead of page length or unfiltered counts Artist search returned TotalRecordCount = len(page), so clients stopped after the first page; album/song search counted via CountAll, which can't see the search term, so clients paged through phantom results. The repos' Search API has no match count, so fetch one row beyond the page: offset+len is exact on the last page and a strictly growing lower bound before it — paging clients terminate exactly at the last match. * fix(jellyfin): browse playlists via the generic /Items path and resolve the playlists folder by id A typeless /Items?ParentId= (legal in real Jellyfin, used by generic clients) fell through to the MusicAlbum default and returned an empty list; it now returns the playlist's tracks, paginated, with visibility enforced by GetWithTracks. resolveItemByID also answers the synthetic playlists-folder id the server itself advertises instead of 404ing it. * perf(jellyfin): cap per-type queries in multi-type /Items requests The multi-type merge path queried each type with a zero-value QueryOptions — no LIMIT in SQL — materializing every matching row (each with embedded MediaSources) just to slice out one page in memory. Each type now fetches at most StartIndex+Limit rows, the worst case one type can contribute to the merged window; totals still come from CountAll. * fix(jellyfin): dedupe and bound the background Similar fetches awaitSimilar spawned a detached, deadline-free goroutine per request; clients re-polling after the empty quick-wait response piled up duplicate provider chains (no singleflight anywhere below) racing writes on the same artist row. Identical in-flight requests now share one fetch — keyed per user, since the mapped items embed the user's annotations — and the background context gets a one-minute deadline so a hung provider can't hold goroutines forever. * fix(jellyfin): gate private playlist covers on the public image route The unauthenticated image endpoint elevated every request to an admin context, so anyone who knew or guessed a playlist id could fetch another user's private uploaded cover. Library artwork still resolves elevated (Jellyfin clients fetch images without auth headers), but playlist covers are now served only when the playlist is public or the request's optional token identifies its owner or an admin; everyone else gets the placeholder. * fix(log): redact full api_key values, including JWTs The api_key pattern matched only word characters, stopping at a JWT's first '.' — the Jellyfin API embeds the session JWT as api_key in TranscodingUrl, so request logs kept its payload and signature, enough to reconstruct a replayable token by prepending the constant header. Match to the next query separator instead, like the sibling s=/p=/jwt= patterns. * fix(jellyfin): record LastLoginAt on Jellyfin logins authenticateByName re-implements credential validation and skipped the UpdateLastLoginAt call the web UI's validateLogin makes, so users who only log in via Jellyfin clients showed a never/stale Last Login in the admin UI. * perf(jellyfin): batch song resolution in /Items?ids= and playlist expansion Both paths probed up to four repositories per client-supplied id. Songs — the common case — now resolve via chunked media_file.id IN queries (same pattern as playqueue's loadTracks); only the residue pays the container probes. * fix(jellyfin): wait for the real Similar result instead of answering a cacheable empty list The 500ms quick wait returned an empty 200 for any cold lookup — indistinguishable from "no similar items exist", so clients cached the wrong answer until an app restart. With fetches deduplicated, wait up to the agents' HTTP timeout for the actual result; only a hung provider now yields the empty fallback, and its fetch still warms the cache in the background. Also makes the dedup test deterministic (the old one raced its release channel). * refactor(tests): extract the shared e2e harness into tests/harness The Subsonic and Jellyfin e2e suites each carried their own copy of the golden-DB lifecycle (boot, seed users/library, scan, WAL snapshot), the ATTACH-DATABASE restore, fixture-FS registration, and the SpyStreamer / NoopFFmpeg doubles. Those now live in one importable package (same pattern as core/storage/storagetest); fixture libraries and request helpers stay per-suite since they encode each API's test expectations. * docs(jellyfin): make comments concise Compress the narrative comments accumulated during live client testing into short why-only notes; keep the client quirks, security rationales and gotchas, drop the exposition. Comments-only change (net -141 lines). * fix(tests): silence gosec taint false-positive in harness snapshot write The write moved from a _test.go file (which gosec skips) into the importable harness package; the path derives from GinkgoT().TempDir(). * fix(jellyfin): route the current /UserFavoriteItems favorite endpoint @jellyfin/sdk 0.13.0 (used by Jellify) posts favorites to POST/DELETE /UserFavoriteItems/{itemId}, while we only routed the legacy /Users/{userId}/FavoriteItems/{itemId} (Finamp). Jellify favorites 404'd ("Failed to add favourite"). Route both spellings to the same handlers. * feat(jellyfin): honor Fields on /Items and add missing conformance fields Match real Jellyfin's response shape: gate MediaSources (and MediaStreams) behind Fields=MediaSources instead of always embedding them — clients that need Size request it, as Finamp does — which cuts a 46-track artist response from ~87KB to a fraction. Also emit the always-present fields Jellyfin sets: ServerId (stamped centrally in ok), LocationType, HasLyrics, and SortName (when Fields=SortName). PlaybackInfo still carries MediaSources (its purpose). * fix(jellyfin): address code review security and correctness findings Applies the reviewed findings from PR #5730: - Gate similar songs/albums on the caller's library access, so the external provider can't surface metadata from libraries the user cannot see. Also clamp the client-supplied limit before it sizes any allocation or provider fetch (CodeQL user-controlled allocation). - Prepend the /jellyfin mount prefix to the PlaybackInfo TranscodingUrl, so a client resolving it as an absolute host path still reaches the mounted router. - Recognize WebP and GIF magic numbers on raw cover uploads, matching the formats Navidrome already supports. - Bound the playlist cover upload: honor EnableArtworkUpload for non-admins and cap the body with MaxBytesReader/MaxImageUploadSize, mirroring the native image endpoint. - Propagate non-not-found repository errors from resolveAnnotated as 500 instead of silently returning 404. - Normalize the literal prefix of mixed literal.param path segments (e.g. STREAM.mp3) so case-insensitive routing reaches stream.{container}. - Rate-limit POST /Users/AuthenticateByName with the same per-IP limiter as /auth/login when AuthRequestLimit is set. - Guard against a nil user in authenticateByName. * fix(jellyfin): correct playlist track browsing and multi-id edits Fixes three playlist issues, two found testing against Jellify and one from the PR review: - Resolve a playlist ParentId to its tracks even when the client sends IncludeItemTypes=Audio. Jellify opens a playlist with ParentId=&IncludeItemTypes=Audio; the id was routed through listSongs as an album id, returning an empty list. - Read repeated id query params (ids=X&ids=Y), not just the first value. Jellify's @jellyfin/sdk serializes id arrays as repeated params, so adding an album (which it expands client-side into many ids) only added the first track. Applies to both add and remove; the comma-separated form other clients use still works. - Let an explicit empty Ids array clear a playlist. Ids is now a pointer so an omitted field still means 'leave unchanged', while an empty list clears the tracks (via RemoveTracks, since the repository skips track writes for an empty list). * fix(jellyfin): register clients as players on any authenticated request Jellyfin clients did not appear in the players list. Unlike Subsonic, whose getPlayer middleware runs on every authenticated endpoint, the Jellyfin router only registered a player on the /Sessions/Playing reports, so browsing or streaming never created one. Apply withPlayer to the whole authenticated group, mirroring Subsonic, so the calling device registers (and scrobbling has a player) as soon as it makes any authenticated request. Two follow-ups found while testing: - Skip registration when the request carries no client/device info (no X-Emby-Authorization, e.g. the /socket handshake that auths via ?api_key= only), which otherwise created a junk player named ' []'. - URL-decode the X-Emby-Authorization field values. Jellify's @jellyfin/sdk percent-encodes them (Device='Pixel%208%20Pro') while Finamp sends them raw, so the player name showed as 'Jellify [Pixel%208%20Pro]'. * refactor(jellyfin): move case-insensitive routing into the jellyfin package The case-insensitive path normalization lived in the server package but was only ever used by the Jellyfin router (its whole purpose is that Jellyfin clients route case-insensitively while chi does not). Move it into server/jellyfin and unexport it, so it sits with its only caller and no longer needs to be exported across a package boundary. * docs(jellyfin): document player registration, playlist and image behavior Updates the package README for the behavior added/fixed this round: - New 'Players and sessions' section: any authenticated request now registers the device as a player (like Subsonic), with the Client [Device] naming, URL-decoding of the Emby auth fields, and the /socket skip that avoids a nameless player. - Authentication: note AuthenticateByName is rate-limited per IP. - Playlists: repeated vs comma-separated id params, and that an explicit empty Ids clears the playlist while an omitted Ids leaves it untouched. - Cover art: WebP/GIF magic-number detection plus the MaxImageUploadSize and EnableArtworkUpload gates. - Endpoints table: add the /Similar and /UserFavoriteItems / /UserItems/.../UserData routes that were already served but unlisted. * feat(jellyfin): expose configured users on the login user-picker Adds Jellyfin.ExposedPublicUsers, a comma-separated allowlist of usernames that GET /Users/Public advertises so Jellyfin clients (Finamp, Jellify) can show a login user-picker instead of a blank username field. The endpoint is unauthenticated, so it defaults to exposing no users and never lists the full user table: only the admin-configured names are returned, resolved live per request (a name that doesn't exist is skipped and logged). Each entry is a minimal DTO (Name, Id) with no Policy/Configuration, so admin status isn't leaked pre-login, and no avatar since Navidrome has no per-user profile images. * style(jellyfin): trim redundant comments Remove comments that restated the code or duplicated an explanation already given nearby, keeping the ones that capture non-obvious rationale (client-specific quirks, gotchas). Comment-only change; no behavior difference. * perf(db): keep query planner statistics trustworthy with full ANALYZE PRAGMA optimize's internal ANALYZE runs with a limited analysis budget (~2000 rows) that writes wrong sqlite_stat1 entries for low-cardinality indexes: on a 96K-track library it claimed (missing, library_id) narrows to ~2000 rows when it matches the whole table. The planner then prefers that index over the sort index and falls back to a full-table temp B-tree sort per request, turning paginated song listings into multi-second queries (reproduced at 5.5s on real hardware; ~90x slower than with correct stats). Every index-creating migration re-triggered the poisoning via the post-migration optimize, and the daily optimizer could re-trigger it on large library changes. Setting analysis_limit on the connection does not help: optimize ignores it. Run a plain full ANALYZE instead: after migrations with schema changes, and in db.Optimize (daily schedule and scan-end). Stats are stored in the database file, so one connection suffices and the per-connection pool loop is gone. The Optimize call at shutdown is removed: stats are maintained at migration/scan/daily points, and an ANALYZE during shutdown only delays it and races container stop timeouts. * perf(db): add covering index for title-sorted song listings Deep pagination over songs sorted by title (WHERE missing/library_id, ORDER BY order_title LIMIT/OFFSET - the shape Jellyfin clients use to enumerate the library, and non-admin native/Subsonic song lists share) walked media_file_order_title and fetched the table row for every skipped entry just to evaluate the filter and the annotation/bookmark join keys: offset+limit random reads, seconds per page on cold spinning disks. The new (missing, library_id, order_title, id) index makes the offset skip fully index-resident: filter columns and the join key come from the index, and only the emitted page touches table rows. Measured on a 96K-track library: offset 50000 drops from ~6.5s (poisoned stats) / ~100ms (good stats, warm) to 24ms, and cold deep pages on NAS hardware from ~7s to ~0.5s. * feat(jellyfin): support transcoding via HLS playlist and server-forced player format - withPlayer now propagates the player's configured transcoding into the request context (like Subsonic's getPlayer), so a format forced in Settings > Players applies to the Jellyfin stream endpoints - new GET /Audio/{itemId}/main.m3u8, the endpoint Finamp plays through when its transcoding setting is enabled: a single-segment HLS VOD playlist pointing at the existing progressive transcode endpoint - streamAudio: treat audioBitRate as bits/sec (Jellyfin convention) and fall back to audioCodec as target format when no container is given * fix(jellyfin): honor GenreIds when browsing genre albums and tracks Finamp's genre screen sends ParentId= plus GenreIds=, but /Items ignored the param, so every genre returned the whole library. - filter.ByGenreID delegates to persistence.TagIDFilter (exported, was tagIDFilter), the same mechanism behind the native API's genre_id filter - id lists are read via queryIDs, covering both spellings clients use (comma-separated and repeated params); /Items?ids= gains the repeated form too * feat(jellyfin): filter album artists by GenreIds Finamp's artist tab sends GenreIds to /Artists/AlbumArtists when a genre filter is active; the param was ignored, returning all artists. filter.ArtistsByGenreID matches artists credited as album artist on an album with the genre, via a non-correlated semi-join over album participants (86ms on a 29K-artist library; the correlated EXISTS form takes 11 minutes). Applied on the browse path of /Artists* and /Items?IncludeItemTypes=MusicArtist. The performers variant (/Artists?GenreIds=) still ignores the filter: it needs the same semi-join against media_file.participants, unmeasured on large libraries. * fix(jellyfin): version playlist image tag so clients refresh uploaded covers Uploads were stored and served correctly, but Finamp kept showing the old cover: it caches covers keyed by blurHash (its imageId is the item id, which never changes), and both our image tag and synthetic blurhash were derived from the playlist id alone. The tag is now - (SetImage/RemoveImage go through a full Put, which bumps UpdatedAt), and the blurhash derives from the tag, so every cover change rotates both. UpdatedAt over-invalidates (any playlist edit busts the cover cache), which only costs a refetch; hashing the actual image remains the proper long-term tag. An e2e test guards the whole chain, since a partial Put(pls, cols...) would silently stop bumping UpdatedAt. * fix(jellyfin): align cover upload limit and validation with the native endpoint Cover uploads of large photos failed with a silent 400: the MaxImageUploadSize cap was applied to the wire body, which Jellyfin clients base64-encode (4/3 inflation), so the effective raw-image limit was only ~7.5MB — and Finamp uploads picked photos uncompressed. Align with the native endpoint: - the limit caps the decoded image; the read cap allows for base64 inflation - validate by decoding (image.DecodeConfig) and take the storage extension from the real format instead of the Content-Type header, which clients get wrong (Finamp falls back to image/jpeg); a HEIC or corrupt file is now rejected instead of stored as a broken cover - rejected uploads log the reason (size/limit/decode error); diagnosing this from production logs previously required guesswork * fix(jellyfin): optimize SongsByArtistID filter to improve performance at library scale Signed-off-by: Deluan * fix(jellyfin): sort tracks by release year for SortBy=PremiereDate Finamp's "Latest Releases" artist section sends SortBy=PremiereDate,Album,... descending; PremiereDate wasn't in the Audio sort map, so applySort fell through to Album and the view came back in reverse album-name order (a 2002 remix album first, the 2013 release last). Map premieredate/productionyear to the year sort key, matching the ProductionYear the DTO exposes for songs and the existing MusicAlbum mapping (premieredate -> max_year). * feat(jellyfin): expose PremiereDate on tracks and albums Finamp's "Latest Releases" artist/genre sections re-sort the merged server responses client-side by PremiereDate and keep the top 5. Without the field every comparison returns equal and Dart's unstable sort leaves the picks in arbitrary order — a 2007 remix could lead the list even with the server sorting by year correctly. Serialize PremiereDate as ISO 8601 from the date tag (padding partial "2007"/"2007-02" values so DateTime.tryParse accepts them), falling back to the year; omitted when neither exists. * feat(jellyfin): resolve Finamp-truncated item ids (saved queue restore) Finamp persists its play queue by packing every item id into exactly 16 bytes (packIds assumes Jellyfin's 32-hex GUID ids), so our longer ids come back truncated after an app restart and "Failed to restore queue" loops forever: the /Items?ids= batch resolves nothing. Navidrome ids can't be made GUID-shaped (nanoid ids can exceed 128 bits), so compensate server-side: a 16-char id — a length no Navidrome id family uses — is resolved by unique-prefix range scan, with ambiguity failing safe. The ids= batch echoes the id as requested (Finamp matches restored items by its stored ids), and stream, image, item, user-data, favorite, rating and playback-report endpoints accept truncated ids transparently. The proper fix belongs upstream in Finamp's packIds; documented in the README so this layer can be removed once that ships. * feat(jellyfin): implement Items/{id}/InstantMix Finamp requests an instant mix on every track tap when its "start instant mix for individual tracks" setting is on (plus the long-press menus); the 404 made those taps fail with an error and play nothing. A track seed returns itself first — Finamp plays exactly what comes back — followed by the external provider's similar songs, capped at the requested limit and filtered to the caller's libraries. Container seeds (artist/album) return the provider's similar-songs blend. Provider errors and unknown seeds degrade to seed-only/empty results instead of 404s, reusing the Similar endpoints' bounded-wait singleflight (with a distinct cache key, since mixes and similar lists answer different shapes). Sonic-similarity backing stays a follow-up (see README). * style(jellyfin): trim wordy comments * refactor(jellyfin): apply cleanup review findings - batch truncated-id resolution in /Items?ids=: one chunked range query for all media-file prefixes instead of a query per id (a restored queue sends hundreds of truncated ids) - resolve truncated ids on the /Similar endpoints too, matching the neighboring InstantMix; document which entry points don't resolve - hoist maxImageUploadSize to core, deleting the byte-identical copies in nativeapi and jellyfin (tests moved to core) - drop the unused type parameter on premiereDate * fix(jellyfin): never drop the instant mix seed on a slow provider The seed track was built inside the awaited provider fetch, so when the external agent was slow or unreachable the request hit the 10s wait and answered a fully empty mix — Finamp then played nothing on tap, even though the seed needs no provider at all (seen live: Last.fm unreachable from the server, responseSize=49). Build the seed outside the await: only the similar-songs tail is fetched and bounded, and a timeout now degrades to a seed-only mix. Also folds instantMixForSong into getInstantMix, since the tail is exactly similarSongs. * fix(jellyfin): prefer the recommended Authorization scheme when picking a token Jellyfin's authorization guidance deprecates X-Emby-Token, X-MediaBrowser-Token, X-Emby-Authorization and api_key; the Authorization MediaBrowser scheme is the recommended form. All spellings stay accepted, but when a client sends several, the recommended one now wins. Adds coverage for the canonical Authorization header, which no test exercised directly. * refactor(jellyfin): rename parseEmbyAuth to parseMediaBrowserAuth The scheme is named MediaBrowser; the old name evoked the deprecated X-Emby-* spellings even though the function also parses the recommended Authorization header. * fix(jellyfin): prefer the Authorization header over X-Emby-Authorization The recommended header now wins when both carry MediaBrowser data — but only when it actually parses as MediaBrowser: a reverse proxy may inject Basic/Digest credentials into Authorization while the client sends the deprecated header, and those must not swallow the client's auth. * fix(jellyfin): require the MediaBrowser scheme when parsing auth headers The parser extracted key="value" pairs from any Authorization value; a foreign scheme whose parameters happened to use our field names would have been misread as client auth. Validate the scheme word instead (case-insensitively, per HTTP), accepting the legacy "Emby" spelling like real Jellyfin. Replaces the any-recognized-field heuristic for detecting proxy-injected Basic/Digest credentials. * Revert "perf(db): keep query planner statistics trustworthy with full ANALYZE" This reverts commit 118563e1053196f0e2e42dd4bee54e39a04ab145. The planner-statistics work now lives in its own PR (#5740); this branch keeps only the covering-index migration. * fix(db): renumber jellyfin covering-index migration after master's latest * fix(db): renumber Jellyfin covering-index migration * docs(jellyfin): document playlist annotations * refactor(log): clarify comments on external services query params * refactor(e2e): move Subsonic e2e suite to server/subsonic/e2e All files in server/e2e were Subsonic tests, so relocate the package under server/subsonic/e2e to sit alongside the API it exercises. The package name stays 'e2e'; only doc/comment references are updated. * refactor(filter): consolidate genre filters and unexport tagIDFilter Route filterByGenre, ByGenreID, and ArtistsByGenreID through a single genreTagFilter helper so the EXISTS json_tree(tags,"$.genre") predicate lives in one place. With server/filter no longer using it, persistence's TagIDFilter is only referenced within the package, so unexport it. --------- Signed-off-by: Deluan --- cmd/root.go | 3 + cmd/wire_gen.go | 26 +- cmd/wire_injectors.go | 8 + conf/configuration.go | 11 + consts/consts.go | 5 + core/image_upload.go | 13 + core/image_upload_test.go | 26 + ...d_media_file_title_sort_covering_index.sql | 22 + log/log.go | 6 +- log/log_test.go | 5 + persistence/playlist_repository.go | 7 +- persistence/playlist_repository_test.go | 14 + persistence/sql_tags.go | 1 + server/{subsonic => }/filter/filters.go | 65 +- server/jellyfin/README.md | 329 ++++++++++ server/jellyfin/annotations.go | 131 ++++ server/jellyfin/annotations_test.go | 257 ++++++++ server/jellyfin/api.go | 200 ++++++ server/jellyfin/api_test.go | 87 +++ server/jellyfin/auth.go | 134 ++++ server/jellyfin/auth_test.go | 103 +++ server/jellyfin/browsing.go | 53 ++ server/jellyfin/browsing_test.go | 161 +++++ server/jellyfin/case_insensitive_routes.go | 68 ++ .../jellyfin/case_insensitive_routes_test.go | 90 +++ server/jellyfin/dto/blurhash.go | 36 ++ server/jellyfin/dto/blurhash_test.go | 27 + server/jellyfin/dto/dto.go | 258 ++++++++ server/jellyfin/dto/dto_suite_test.go | 17 + server/jellyfin/dto/fields.go | 24 + server/jellyfin/dto/ids.go | 23 + server/jellyfin/dto/ids_test.go | 35 + server/jellyfin/dto/mappers.go | 256 ++++++++ server/jellyfin/dto/mappers_test.go | 280 ++++++++ server/jellyfin/e2e/annotations_test.go | 142 ++++ server/jellyfin/e2e/auth_test.go | 120 ++++ server/jellyfin/e2e/browsing_test.go | 389 +++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 365 +++++++++++ server/jellyfin/e2e/images_test.go | 74 +++ server/jellyfin/e2e/multiuser_test.go | 64 ++ server/jellyfin/e2e/playlists_test.go | 311 +++++++++ server/jellyfin/e2e/routing_test.go | 31 + server/jellyfin/e2e/search_test.go | 76 +++ server/jellyfin/e2e/sessions_test.go | 62 ++ server/jellyfin/e2e/similar_test.go | 134 ++++ server/jellyfin/e2e/smoke_test.go | 49 ++ server/jellyfin/e2e/streaming_test.go | 128 ++++ server/jellyfin/e2e/system_test.go | 55 ++ server/jellyfin/images.go | 170 +++++ server/jellyfin/images_test.go | 394 ++++++++++++ server/jellyfin/items.go | 577 +++++++++++++++++ server/jellyfin/items_test.go | 608 ++++++++++++++++++ server/jellyfin/jellyfin_suite_test.go | 25 + server/jellyfin/library.go | 44 ++ server/jellyfin/middlewares.go | 187 ++++++ server/jellyfin/middlewares_test.go | 254 ++++++++ server/jellyfin/playlists.go | 257 ++++++++ server/jellyfin/playlists_test.go | 424 ++++++++++++ server/jellyfin/routing_test.go | 56 ++ server/jellyfin/sessions.go | 115 ++++ server/jellyfin/sessions_test.go | 216 +++++++ server/jellyfin/similar.go | 190 ++++++ server/jellyfin/similar_test.go | 131 ++++ server/jellyfin/socket.go | 55 ++ server/jellyfin/socket_test.go | 125 ++++ server/jellyfin/stream.go | 164 +++++ server/jellyfin/stream_test.go | 316 +++++++++ server/jellyfin/system.go | 96 +++ server/jellyfin/system_test.go | 124 ++++ server/jellyfin/truncated_ids.go | 113 ++++ server/jellyfin/users.go | 61 ++ server/jellyfin/users_test.go | 130 ++++ server/middlewares.go | 8 +- server/nativeapi/image_upload.go | 13 +- server/nativeapi/image_upload_test.go | 34 - server/subsonic/album_lists.go | 2 +- server/subsonic/browsing.go | 2 +- server/{ => subsonic}/e2e/doc.go | 2 +- server/{ => subsonic}/e2e/e2e_suite_test.go | 174 +---- .../e2e/subsonic_album_lists_test.go | 0 .../e2e/subsonic_bookmarks_test.go | 0 .../e2e/subsonic_browsing_test.go | 0 .../e2e/subsonic_lyrics_test.go | 0 .../e2e/subsonic_media_annotation_test.go | 0 .../e2e/subsonic_media_retrieval_test.go | 0 .../e2e/subsonic_multilibrary_test.go | 0 .../e2e/subsonic_multiuser_test.go | 0 .../e2e/subsonic_playlists_test.go | 0 .../{ => subsonic}/e2e/subsonic_radio_test.go | 0 .../{ => subsonic}/e2e/subsonic_scan_test.go | 0 .../e2e/subsonic_searching_test.go | 0 .../e2e/subsonic_sharing_test.go | 0 .../e2e/subsonic_sonic_similarity_test.go | 5 +- .../e2e/subsonic_stream_test.go | 0 .../e2e/subsonic_system_test.go | 0 .../e2e/subsonic_transcode_test.go | 0 .../{ => subsonic}/e2e/subsonic_users_test.go | 0 tests/harness/harness.go | 178 +++++ tests/mock_album_repo.go | 8 + tests/mock_artist_repo.go | 29 + tests/mock_mediafile_repo.go | 22 + tests/mock_playlist_repo.go | 24 +- 102 files changed, 9880 insertions(+), 234 deletions(-) create mode 100644 db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql rename server/{subsonic => }/filter/filters.go (59%) create mode 100644 server/jellyfin/README.md create mode 100644 server/jellyfin/annotations.go create mode 100644 server/jellyfin/annotations_test.go create mode 100644 server/jellyfin/api.go create mode 100644 server/jellyfin/api_test.go create mode 100644 server/jellyfin/auth.go create mode 100644 server/jellyfin/auth_test.go create mode 100644 server/jellyfin/browsing.go create mode 100644 server/jellyfin/browsing_test.go create mode 100644 server/jellyfin/case_insensitive_routes.go create mode 100644 server/jellyfin/case_insensitive_routes_test.go create mode 100644 server/jellyfin/dto/blurhash.go create mode 100644 server/jellyfin/dto/blurhash_test.go create mode 100644 server/jellyfin/dto/dto.go create mode 100644 server/jellyfin/dto/dto_suite_test.go create mode 100644 server/jellyfin/dto/fields.go create mode 100644 server/jellyfin/dto/ids.go create mode 100644 server/jellyfin/dto/ids_test.go create mode 100644 server/jellyfin/dto/mappers.go create mode 100644 server/jellyfin/dto/mappers_test.go create mode 100644 server/jellyfin/e2e/annotations_test.go create mode 100644 server/jellyfin/e2e/auth_test.go create mode 100644 server/jellyfin/e2e/browsing_test.go create mode 100644 server/jellyfin/e2e/e2e_suite_test.go create mode 100644 server/jellyfin/e2e/images_test.go create mode 100644 server/jellyfin/e2e/multiuser_test.go create mode 100644 server/jellyfin/e2e/playlists_test.go create mode 100644 server/jellyfin/e2e/routing_test.go create mode 100644 server/jellyfin/e2e/search_test.go create mode 100644 server/jellyfin/e2e/sessions_test.go create mode 100644 server/jellyfin/e2e/similar_test.go create mode 100644 server/jellyfin/e2e/smoke_test.go create mode 100644 server/jellyfin/e2e/streaming_test.go create mode 100644 server/jellyfin/e2e/system_test.go create mode 100644 server/jellyfin/images.go create mode 100644 server/jellyfin/images_test.go create mode 100644 server/jellyfin/items.go create mode 100644 server/jellyfin/items_test.go create mode 100644 server/jellyfin/jellyfin_suite_test.go create mode 100644 server/jellyfin/library.go create mode 100644 server/jellyfin/middlewares.go create mode 100644 server/jellyfin/middlewares_test.go create mode 100644 server/jellyfin/playlists.go create mode 100644 server/jellyfin/playlists_test.go create mode 100644 server/jellyfin/routing_test.go create mode 100644 server/jellyfin/sessions.go create mode 100644 server/jellyfin/sessions_test.go create mode 100644 server/jellyfin/similar.go create mode 100644 server/jellyfin/similar_test.go create mode 100644 server/jellyfin/socket.go create mode 100644 server/jellyfin/socket_test.go create mode 100644 server/jellyfin/stream.go create mode 100644 server/jellyfin/stream_test.go create mode 100644 server/jellyfin/system.go create mode 100644 server/jellyfin/system_test.go create mode 100644 server/jellyfin/truncated_ids.go create mode 100644 server/jellyfin/users.go create mode 100644 server/jellyfin/users_test.go delete mode 100644 server/nativeapi/image_upload_test.go rename server/{ => subsonic}/e2e/doc.go (98%) rename server/{ => subsonic}/e2e/e2e_suite_test.go (75%) rename server/{ => subsonic}/e2e/subsonic_album_lists_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_bookmarks_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_browsing_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_lyrics_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_media_annotation_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_media_retrieval_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_multilibrary_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_multiuser_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_playlists_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_radio_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_scan_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_searching_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_sharing_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_sonic_similarity_test.go (98%) rename server/{ => subsonic}/e2e/subsonic_stream_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_system_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_transcode_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_users_test.go (100%) create mode 100644 tests/harness/harness.go diff --git a/cmd/root.go b/cmd/root.go index b231aae0d..9e2b38cd8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -124,6 +124,9 @@ func startServer(ctx context.Context) func() error { if conf.Server.ListenBrainz.Enabled { a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter()) } + if conf.Server.Jellyfin.Enabled { + a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx)) + } if conf.Server.Prometheus.Enabled { p := CreatePrometheus() // blocking call because takes <100ms but useful if fails diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d6ffc44d4..4a2b46289 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -31,6 +31,7 @@ import ( "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -116,6 +117,29 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { return router } +func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + players := core.NewPlayers(dataStore) + playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider) + return router +} + func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -221,7 +245,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, 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(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))) +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.New, 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(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))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index bb5c5b5f3..0f6b73891 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -23,6 +23,7 @@ import ( "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -33,6 +34,7 @@ var allProviders = wire.NewSet( artwork.Set, server.New, subsonic.New, + jellyfin.New, nativeapi.New, public.New, persistence.New, @@ -79,6 +81,12 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { )) } +func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { + panic(wire.Build( + allProviders, + )) +} + func CreatePublicRouter() *public.Router { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index e939ebb7e..562f52465 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -117,6 +117,7 @@ type configOptions struct { LastFM lastfmOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` + Jellyfin jellyfinOptions `json:",omitzero"` EnableScrobbleHistory bool Tags map[string]TagConf `json:",omitempty"` Agents string @@ -218,6 +219,14 @@ type listenBrainzOptions struct { TrackAlgorithm string } +type jellyfinOptions struct { + Enabled bool + ServerName string + // 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 +} + type httpHeaderOptions struct { FrameOptions string } @@ -849,6 +858,8 @@ func setViperDefaults() { viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL) viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm) viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) + viper.SetDefault("jellyfin.enabled", false) + viper.SetDefault("jellyfin.servername", "") viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") diff --git a/consts/consts.go b/consts/consts.go index 73f89b450..f453ac125 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -49,6 +49,11 @@ const ( URLPathSubsonicAPI = "/rest" URLPathPublic = "/share" URLPathPublicImages = URLPathPublic + "/img" + URLPathJellyfinAPI = "/jellyfin" + + // JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the + // Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts. + JellyfinServerIDKey = "JellyfinServerID" // DefaultUILoginBackgroundURL uses Navidrome curated background images collection, // available at https://unsplash.com/collections/20072696/navidrome diff --git a/core/image_upload.go b/core/image_upload.go index c2432b647..eb61b225a 100644 --- a/core/image_upload.go +++ b/core/image_upload.go @@ -7,6 +7,9 @@ import ( "os" "path/filepath" + "github.com/dustin/go-humanize" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -17,6 +20,16 @@ type ImageUploadService interface { RemoveImage(ctx context.Context, path string) error } +// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default +// when it's unset/invalid. Shared by every API that accepts image uploads. +func MaxImageUploadSize() int64 { + if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return int64(size) + } + size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + return int64(size) +} + type imageUploadService struct{} func NewImageUploadService() ImageUploadService { diff --git a/core/image_upload_test.go b/core/image_upload_test.go index 265f60a95..e7648df34 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -97,3 +97,29 @@ var _ = Describe("ImageUploadService", func() { }) }) }) + +var _ = Describe("MaxImageUploadSize", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns the configured size when valid", func() { + conf.Server.MaxImageUploadSize = "20MB" + Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000))) + }) + + It("returns the default size when config is empty", func() { + conf.Server.MaxImageUploadSize = "" + Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("returns the default size when config is invalid", func() { + conf.Server.MaxImageUploadSize = "not-a-size" + Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("parses raw byte values", func() { + conf.Server.MaxImageUploadSize = "52428800" + Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800))) + }) +}) diff --git a/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql new file mode 100644 index 000000000..18666eef9 --- /dev/null +++ b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin + +-- Covering index for the title-sorted, library-scoped song listing: +-- WHERE missing = ? AND library_id = ? ORDER BY order_title LIMIT n OFFSET m +-- (Jellyfin clients page through the whole library this way; non-admin native and +-- Subsonic song lists produce the same shape.) +-- +-- Without it, SQLite walks media_file_order_title and must fetch the table row for +-- every *skipped* entry just to evaluate the WHERE, so a deep page costs offset+limit +-- random row reads (seconds on cold spinning disks). With the filter columns in the +-- index the skip is index-only. `id` is included because the annotation/bookmark +-- LEFT JOINs run per candidate row and need the join key; without it each skipped +-- entry still triggers a row fetch. +create index if not exists media_file_missing_library_order_title + on media_file(missing, library_id, order_title, id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop index if exists media_file_missing_library_order_title; +-- +goose StatementEnd diff --git a/log/log.go b/log/log.go index eaea75fb9..1c4ee3b4b 100644 --- a/log/log.go +++ b/log/log.go @@ -45,8 +45,10 @@ var redacted = &Hook{ "([^\\w]p=)[^&]+", "([^\\w]jwt=)[^&]+", - // External services query params - "([^\\w]api_key=)[\\w]+", + // External services query params. Values can be JWTs (dots, dashes), so match everything up + // to the next query separator or whitespace, not just word chars. A [\w]+ class would stop + // at a JWT's first '.' and leak its payload and signature. + "([^\\w]api_key=)[^&\\s]+", }, } diff --git a/log/log_test.go b/log/log_test.go index 7e1f3f3cc..7b6ecfc32 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -259,5 +259,10 @@ var _ = Describe("Logger", func() { msg := "getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=first%20and%20other%20words&title=Title" Expect(Redact(msg)).To(Equal("getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=[REDACTED]&title=Title")) }) + + It("redacts a whole JWT in api_key, not just up to its first dot", func() { + msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1" + Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1")) + }) }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index fe1f50689..573f43c10 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -85,8 +85,11 @@ func (r *playlistRepository) userFilter() Sqlizer { } func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) { - sq := Select().Where(r.userFilter()) - return r.count(sq, options...) + query := Select().Where(r.userFilter()) + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "playlist.id") + } + return r.count(query, options...) } func (r *playlistRepository) Exists(id string) (bool, error) { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index c5b16b88f..831e24453 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "slices" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -132,6 +133,19 @@ var _ = Describe("PlaylistRepository", func() { Expect(all[idx].Starred).To(BeTrue()) }) + It("counts playlists using annotation filters", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + options := model.QueryOptions{Filters: squirrel.Eq{"starred": true}} + starred, err := repo.GetAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(starred).To(ContainElement(HaveField("ID", plsID))) + + count, err := repo.CountAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(len(starred)))) + }) + It("does not leak an annotation row of another item_type sharing the playlist id", func() { // Older builds (and the star fallthrough) can leave a media_file-typed row // under a playlist id; the item_type-scoped join must not surface or dupe it. diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index 88acebb7f..5177bc8e4 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -48,6 +48,7 @@ func marshalTags(tags model.Tags) string { return string(res) } +// tagIDFilter matches rows whose tags JSON contains the tag id(s); a "_id" key maps to "$.". func tagIDFilter(name string, idValue any) Sqlizer { name = strings.TrimSuffix(name, "_id") return Exists( diff --git a/server/subsonic/filter/filters.go b/server/filter/filters.go similarity index 59% rename from server/subsonic/filter/filters.go rename to server/filter/filters.go index d19e163dd..e149dbced 100644 --- a/server/subsonic/filter/filters.go +++ b/server/filter/filters.go @@ -61,6 +61,19 @@ func AlbumsByArtistID(artistId string) Options { }) } +// AlbumsByContributingArtistID matches albums where the artist performs on a track but is not the +// album artist — Jellyfin's "Featured On". The disjoint complement of AlbumsByArtistID, so an +// artist's own discography never leaks into it. +func AlbumsByContributingArtistID(artistId string) Options { + return addDefaultFilters(Options{ + Sort: "max_year", + Filters: And{ + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), + persistence.NotExists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), + }, + }) +} + func AlbumsByYear(fromYear, toYear int) Options { orderOption := "" if fromYear > toYear { @@ -90,6 +103,17 @@ func SongsByAlbum(albumId string) Options { }) } +// SongsByArtistID matches media files where the artist participates as album or track artist, in +// album order. Semi-joins media_file_artists; scanning the participants JSON is ~10x slower at scale. +func SongsByArtistID(artistId string) Options { + return addDefaultFilters(Options{ + Sort: "album", + Filters: Expr( + "media_file.id IN (SELECT media_file_id FROM media_file_artists WHERE artist_id = ? AND role IN (?, ?))", + artistId, model.RoleArtist.String(), model.RoleAlbumArtist.String()), + }) +} + func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { options := Options{} ff := And{} @@ -138,6 +162,21 @@ func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options { return opts } +// ArtistsByRole restricts an artist query to artists appearing in the given role (album artist, +// performer, composer, ...) via library_artist.stats. An unknown role is ignored (no filter). +func ArtistsByRole(opts Options, role model.Role) Options { + if _, ok := model.AllRoles[role.String()]; !ok { + return opts + } + roleFilter := Expr("JSON_EXTRACT(library_artist.stats, '$." + role.String() + ".m') IS NOT NULL") + if opts.Filters == nil { + opts.Filters = roleFilter + } else { + opts.Filters = And{opts.Filters, roleFilter} + } + return opts +} + func ByGenre(genre string) Options { return addDefaultFilters(Options{ Sort: "name", @@ -145,11 +184,29 @@ func ByGenre(genre string) Options { }) } +// ByGenreID matches items (albums or songs) tagged with any of the given genre tag ids. +func ByGenreID(genreIds []string) Sqlizer { + return genreTagFilter(Eq{"value": genreIds}) +} + +// ArtistsByGenreID matches artists credited as album artist on an album with any of the given +// genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. +func ArtistsByGenreID(genreIds []string) Sqlizer { + return Expr( + `artist.id IN (SELECT jt.value FROM album, json_tree(album.participants, '$.albumartist') jt + WHERE jt.atom IS NOT NULL AND ?)`, + genreTagFilter(Eq{"value": genreIds}), + ) +} + +// genreTagFilter builds an EXISTS over the genre entries in the tags JSON, matching each entry +// against cond (its name via Like, or its tag id via Eq/IN). Shared by the name- and id-based lookups. +func genreTagFilter(cond Sqlizer) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.genre")`, And{NotEq{"atom": nil}, cond}) +} + func filterByGenre(genre string) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.genre")`, And{ - Like{"value": genre}, - NotEq{"atom": nil}, - }) + return genreTagFilter(Like{"value": genre}) } func ByRating() Options { diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md new file mode 100644 index 000000000..fb5c4a637 --- /dev/null +++ b/server/jellyfin/README.md @@ -0,0 +1,329 @@ +# Jellyfin API + +This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of +Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets +Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp), +[jftui](https://github.com/dylanmtaylor/jftui)) browse and stream a Navidrome library without +requiring a real Jellyfin server. + +It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music +library, stream audio, manage favorites/ratings for songs, albums, artists, and playlists, report +playback, and manage playlists are implemented. Video, live TV, plugins, and Jellyfin's +admin/dashboard APIs are out of scope. + +## Enabling + +The Jellyfin API is disabled by default. Enable it via `navidrome.toml`: + +```toml +[Jellyfin] +Enabled = true +# Optional: override the server name reported to clients (defaults to "Navidrome ") +ServerName = "My Music Server" +# Optional: usernames to show in the client login user-picker (default: none). See "Public user list". +ExposedPublicUsers = "alice, bob" +``` + +or via environment variables: + +```bash +ND_JELLYFIN_ENABLED=true +ND_JELLYFIN_SERVERNAME="My Music Server" +ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" +``` + +Once enabled, the API is mounted at: + +``` +http://:/jellyfin +``` + +All the paths below are relative to that base URL (e.g. `System/Info/Public` means +`http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**, +since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths. + +## Authentication + +Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome +username/password, and get back an `AccessToken` (a Navidrome JWT). That token is then sent on +every subsequent request as the `X-Emby-Token` header (or embedded in the +`X-Emby-Authorization`/`Authorization` header's `Token="..."` field, or as an `api_key`/`ApiKey` +query param — all forms are accepted, matching what different clients do). + +`POST /Users/AuthenticateByName` is rate-limited per IP with the same limiter as the native +`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force +surface. + +### Public user list (login picker) + +`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the +password) instead of a blank username field. It's **unauthenticated**, so by default it exposes +**no** users. Set `Jellyfin.ExposedPublicUsers` to a comma-separated list of usernames to advertise: + +```toml +[Jellyfin] +ExposedPublicUsers = "alice, bob" +``` + +Only the named users are listed (never the full user table), resolved live per request; a configured +name that doesn't exist is skipped and logged at `Warn`. Each entry is a minimal DTO (`Name`, `Id`) +with no `Policy`/`Configuration`, so admin status isn't leaked to unauthenticated callers, and no +avatar (`PrimaryImageTag` omitted — Navidrome has no per-user profile images). + +## Players and sessions + +Every authenticated request registers (or refreshes) the calling device as a Navidrome player, +mirroring Subsonic's `getPlayer` — so a Jellyfin client shows up in the players list (and scrobbling +has a player) as soon as it makes any authenticated call, not only when it reports playback. The +player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the player name is +`Client [Device]`. Those field values are URL-decoded, since some clients percent-encode them +(Jellify sends `Device="Pixel%208%20Pro"`, Finamp sends it raw). A request that carries no +client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is +skipped, so it doesn't create a nameless player. + +## ID encoding + +Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id +is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients +parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`, +which chokes on Navidrome's base-62 nanoids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a raw MD5 id +from an old migrated library is itself valid hex, correctness depends on every emit path encoding +and every receive path decoding — see `dto/ids.go`. + +## Multi-library behavior + +Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each +Navidrome library the current user can access is exposed as its own top-level Jellyfin +"CollectionFolder" view (`GET /UserViews`), instead of merging every library into a single view. +Browsing (`/Items`), artists, and the "Latest" list are all scoped to the libraries the +authenticated user has access to; a library (or item within it) the user cannot access returns +`404`, never `403`, so ids can't be used as an existence oracle. + +### Browsing filters + +`GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id +for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into +an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or +tracks — Finamp's artist screen sends these *alongside* `ParentId=`); `GenreIds` (a +genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` +and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); +`SearchTerm`; +favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`; +`StartIndex`/`Limit`; and `Ids` (batch fetch by id). + +## Implemented endpoints + +| Area | Endpoints | +|---|---| +| Handshake / system | `GET System/Info/Public`, `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | +| 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) | +| 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` | +| 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` | +| 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}]` | +| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | + +Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level +as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint +that isn't in the table above, check the server logs for these lines to see exactly what it's +requesting. + +## Playlist management + +Playlists are the main writable surface of this API: + +- **Container expansion.** When creating (`POST Playlists`), adding to (`POST Playlists/{id}/Items`) + or replacing (`POST Playlists/{id}`) a playlist, the `Ids` may contain **containers** — album, + artist or playlist ids — not just song ids. Each is expanded into its tracks (in order) before + the write, matching how Jellyfin clients populate these lists. A bare song id passes through. +- **Id list encoding.** `POST`/`DELETE Playlists/{id}/Items` accept the id list both ways clients + spell it: repeated params (`ids=X&ids=Y`, how Jellify's `@jellyfin/sdk` serializes arrays) and a + single comma-separated value (`ids=X,Y`, Finamp). Reading only the first value would add just one + track of an expanded album. +- **Update** (`POST Playlists/{id}`): with `Ids` present, the track list is **replaced** (Finamp + uses this for reordering) — an explicit empty `Ids` (`[]`) **clears** the playlist, while an + omitted `Ids` leaves the tracks untouched and only updates `Name`/`IsPublic`. `IsPublic` maps to + Navidrome's `Public` flag, surfaced to clients as `OpenAccess` on `GET Playlists/{id}`. +- **Cover art**: `POST Items/{id}/Images/Primary` uploads a playlist cover (raw or base64 body, + JPEG/PNG/WebP/GIF detected by magic number, extension from `Content-Type`); `DELETE` removes it. + Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning, + so a non-playlist id returns `501`. Uploads honor the same gates as the native endpoint: they're + bounded by `MaxImageUploadSize` and require `EnableArtworkUpload` for non-admins. +- **`PlaylistItemId`**: `GET Playlists/{id}/Items` tags each entry with `PlaylistItemId` (the + playlist-track row id, distinct from the song id) so a client can echo it back via + `DELETE Playlists/{id}/Items?EntryIds=...` to remove one occurrence of a song that appears more + than once in the same playlist. + +Ownership is enforced by `core/playlists`: a non-owner editing/deleting a playlist gets `403` if +it is visible to them (public) or `404` if it is not (private) — the API never reveals that +someone else's private playlist exists. + +## Images + +The `GET Items/{itemId}/Images/{type}` route is intentionally **public** (artwork isn't sensitive, +matching Jellyfin's lenient image handling), so it carries no authenticated user. Artwork is +therefore resolved under an **elevated admin context** — the same approach `core/artwork`'s cache +warmer uses — so user-scoped items like private playlists still resolve their cover instead of +falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to +their Navidrome `ArtworkID`. + +## Finamp saved-queue id truncation + +Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that +when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16 +bytes. Navidrome ids are longer (nanoid ids can exceed 128 bits, so they cannot be mapped into +GUIDs), which means Finamp silently stores only the first 16 characters of each id and asks for +those **truncated ids** back when restoring the queue — item lookups, then streaming, images, +favorites and playback reports for the restored tracks. + +This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome +id family uses — is resolved to the full id by unique-prefix lookup (an indexed range scan; +ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as +requested**, because Finamp matches restored items back to its stored ids, and the other item +endpoints accept truncated ids transparently. + +**Proper fix (upstream):** Finamp's `packIds()`/`_unpackIds()` (`lib/models/finamp_models.dart`) +should handle ids that aren't 32-hex GUIDs — e.g. store variable-length ids when any id in the +queue doesn't match the GUID shape. Jellyfin-compatible servers aren't guaranteed to use GUID ids, +so this is worth a Finamp issue/PR; once a fixed release is widespread, this compatibility layer +can be removed. + +## Streaming and transcoding + +The stream endpoints reuse the same transcode-decision pipeline as the Subsonic `/stream` endpoint: + +- **`GET Audio/{id}/stream[.{container}]` / `universal`** — the target format comes from the + `.{container}` path suffix, the `container` param, or (when neither is present) `audioCodec`. + `audioBitRate`/`maxStreamingBitrate` are bits/sec, per Jellyfin convention. `static=true` + forces direct play (raw), never a transcode. +- **`GET Items/{id}/File` / `Download`** — always the original file bytes, matching real Jellyfin. + Finamp plays through `File` when its transcoding setting is off, so an undecodable format (e.g. + DSF) can't be rescued server-side on this path. +- **`GET Audio/{id}/main.m3u8`** — the endpoint Finamp plays through when its transcoding setting + is on. Implemented as a single-segment HLS VOD playlist whose one segment is the progressive + transcode endpoint above, so the whole pipeline (decision, cache, forced transcoding) is reused. + Segment codec honors `audioCodec` but is limited to what HLS packed-audio can carry (`aac`, + `mp3`); anything else falls back to `aac`. Seeking re-reads from the start, like Subsonic + transcoded streams. +- **Server-forced transcoding.** A format/bitrate configured on the registered player (Settings → + Players) is applied to `stream`, `universal` and `main.m3u8` — same override semantics as + Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are + advertised and served but packed-audio players won't decode them. + +## curl walkthrough + +This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the +library hierarchy, fetch playback info, stream, favorite, report playback, and manage a playlist. + +```bash +BASE=http://localhost:4533/jellyfin + +# 1. Handshake (no auth required) +curl -s "$BASE/System/Info/Public" | jq . + +# 2. Login - capture the AccessToken +TOKEN=$(curl -s -X POST "$BASE/Users/AuthenticateByName" \ + -H 'Content-Type: application/json' \ + -d '{"Username":"admin","Pw":"password"}' | jq -r .AccessToken) + +AUTH=(-H "X-Emby-Token: $TOKEN") + +# 3. List the user's views (one per accessible library) +curl -s "${AUTH[@]}" "$BASE/UserViews" | jq . + +# 4. Browse artists +curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist" | jq . +ARTIST_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist&Limit=1" | jq -r '.Items[0].Id') + +# 5. Drill into that artist's albums (ParentId with no IncludeItemTypes defaults to MusicAlbum) +ALBUM_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?ParentId=$ARTIST_ID" | jq -r '.Items[0].Id') + +# 6. List the album's songs +USER_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/Me" | jq -r .Id) +SONG_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/$USER_ID/Items?ParentId=$ALBUM_ID&IncludeItemTypes=Audio" \ + | jq -r '.Items[0].Id') + +# 7. Ask for playback info, then stream the song +curl -s -X POST "${AUTH[@]}" "$BASE/Items/$SONG_ID/PlaybackInfo" | jq . +curl -s "${AUTH[@]}" "$BASE/Audio/$SONG_ID/stream" -o /tmp/song.audio + +# 8. Favorite the song +curl -s -X POST "${AUTH[@]}" "$BASE/Users/$USER_ID/FavoriteItems/$SONG_ID" | jq . + +# 9. Report playback start/stop (also drives scrobbling) +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":0}" "$BASE/Sessions/Playing" +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":1200000000}" "$BASE/Sessions/Playing/Stopped" + +# 10. Create a playlist from a whole album (the album id is expanded to its tracks) +PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"Name\":\"My Playlist\",\"Ids\":[\"$ALBUM_ID\"]}" "$BASE/Playlists" | jq -r .Id) + +# 11. Make it public, then remove one entry +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d '{"IsPublic":true}' "$BASE/Playlists/$PLAYLIST_ID" +ENTRY_ID=$(curl -s "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items" | jq -r '.Items[0].PlaylistItemId') +curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENTRY_ID" + +# 12. Delete the playlist +curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID" +``` + +## Testing + +Handler-level unit tests live alongside each file (`*_test.go`). A full end-to-end suite in +[`e2e/`](e2e) exercises every endpoint through the real router against a real SQLite database and +real repositories (only artwork/streaming/ffmpeg are stubbed), with per-`Describe` snapshot +isolation — mirroring the Subsonic `server/subsonic/e2e` suite. Run it with: + +```bash +make test PKG=./server/jellyfin/... +``` + +## Known limitations + +- **Genres are global.** `GET Genres`/`MusicGenres` is not scoped to the current user's + libraries (genre tags aren't per-library entities in Navidrome's model). +- **Artist item-access relies on list-time scoping.** Unlike albums and songs (which each + belong to exactly one library and are checked against `user.HasLibraryAccess` on every + fetch), an artist can have content across multiple libraries via `library_artist`, so there's + no single library id to gate a direct `GET Items/{artistId}` or favorite/rating call against. + Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` + *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client + that already has an artist id from elsewhere is not re-checked against library membership. +- **MD5-hash ids from old migrated libraries.** The hex id codec assumes ids are opaque; a raw + 32-char MD5 id is itself valid hex and so must be encoded/decoded symmetrically like any other. + This is handled, but is the most fragile id case — see the note in `dto/ids.go`. +- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is + populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** + blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a + multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and + stores it per image, so its placeholder approximates the art. Ours satisfies the protocol + (Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash + warning) but renders as a flat color while art loads. A proper implementation would compute the + real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it + keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback + for art that hasn't been rendered yet. +- **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a + `ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a + working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up + would broadcast real session/playstate and library-change events over it (via `server/events`), + mirroring Jellyfin's session messages. +- **No lyrics endpoint (follow-up).** `GET Audio/{id}/Lyrics` is unimplemented (404), but Finamp + and Jellify both request it. Navidrome already has line-synced lyrics, so a follow-up would serve + Jellyfin's `LyricsResponse` (`Lyrics: [{Text, Start}]`, `Start` in 100ns ticks) — enough for both + clients' synced view. (Finamp also renders word-level `Cues`, but Navidrome has only line-level + timing, so word-sync is out of scope.) +- **No sonic similarity (follow-up).** `Items/{id}/InstantMix` and the `/Similar` endpoints are + backed only by external metadata agents (Last.fm), not sonic analysis: an instant mix is the seed + track followed by the provider's similar songs (with agents disabled it degrades to a seed-only + mix). A follow-up would back them with Navidrome's `core/sonic` provider — the same one behind + the OpenSubsonic `sonicSimilarity` extension (`getSonicSimilarTracks`) that AudioMuse-AI feeds + via its Navidrome plugin, and the exact endpoint AudioMuse's own Jellyfin plugin overrides. + Needs the `core/sonic.Sonic` service injected into the `Router` (wire change). diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go new file mode 100644 index 000000000..8f900ee87 --- /dev/null +++ b/server/jellyfin/annotations.go @@ -0,0 +1,131 @@ +package jellyfin + +import ( + "errors" + "math" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// resolveAnnotated finds which annotated repo owns id. Albums and songs 404 when the user can't +// access their library; artists span libraries (library_artist), so have no single LibraryID to +// gate on and rely on list-time scoping. PlaylistRepository.Get enforces playlist visibility. +// When ok is false the response has already been written, so callers must return without writing +// the annotation. +func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, ok bool) { + ctx := r.Context() + u, _ := request.UserFrom(ctx) + if al, err := api.ds.Album(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(al.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return api.ds.Album(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + if _, err := api.ds.Artist(ctx).Get(id); err == nil { + return api.ds.Artist(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return api.ds.MediaFile(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + playlistRepo := api.ds.Playlist(ctx) + if _, err := playlistRepo.Get(id); err == nil { + return playlistRepo, true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false +} + +// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify +// fetches this per item to render played/favourite indicators; resolveItemByID enforces the +// library-access gate. +func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + item, ok := api.resolveItemByID(r.Context(), id, nil) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + data := item.UserData + if data == nil { + // Items without annotations still return a valid empty UserData. + data = dto.UserData(model.Annotations{}, id) + } + api.ok(w, r, data) +} + +func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + repo, ok := api.resolveAnnotated(w, r, id) + if !ok { + return + } + if err := repo.SetStar(starred, id); err != nil { + api.internalError(w, r, err) + return + } + encodedID := dto.EncodeID(id) + api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) +} + +func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) } +func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { + api.setFavorite(w, r, false) +} + +func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + repo, ok := api.resolveAnnotated(w, r, id) + if !ok { + return + } + if err := repo.SetRating(rating, id); err != nil { + api.internalError(w, r, err) + return + } + encodedID := dto.EncodeID(id) + d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} + if rating > 0 { + jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData + d.Rating = &jfRating + } + api.ok(w, r, d) +} + +// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to +// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since +// SetRating(0) is the delete path. +func (api *Router) setRating(w http.ResponseWriter, r *http.Request) { + jfRating := req.Params(r).Float64Or("rating", 0) + jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating + rating := int(math.Round(jfRating / 2)) + if jfRating > 0 { + rating = max(rating, 1) + } + api.setItemRating(w, r, rating) +} + +func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) { + api.setItemRating(w, r, 0) +} diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go new file mode 100644 index 000000000..4a7efabd7 --- /dev/null +++ b/server/jellyfin/annotations_test.go @@ -0,0 +1,257 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "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" +) + +var _ = Describe("Annotations", func() { + var api *Router + var ds *tests.MockDataStore + // alice has access to library 1 only. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + api = &Router{ds: ds} + }) + + Describe("markFavorite / unmarkFavorite", func() { + It("stars a song and returns IsFavorite=true", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(mfRepo.Data["s1"].Starred).To(BeTrue()) + }) + + It("stars an album and returns IsFavorite=true", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(albumRepo.Data["a1"].Starred).To(BeTrue()) + }) + + It("stars an artist without checking library access (artists span multiple libraries)", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + // alice only has access to library 1, but artists aren't gated per-library. + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "ar1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(artistRepo.Data["ar1"].Starred).To(BeTrue()) + }) + + It("stars a visible playlist", func() { + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(playlistRepo.Starred["p1"]).To(BeTrue()) + }) + + It("unstars a song and returns IsFavorite=false", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.unmarkFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeFalse()) + Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + }) + + It("returns 404 and does not star an album in a library the user can't access", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(albumRepo.Data["a1"].Starred).To(BeFalse()) + }) + + It("returns 404 and does not star a song in a library the user can't access", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any entity", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 (not 404) when a repository lookup fails for a reason other than not-found", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "x1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("setRating / removeRating", func() { + It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.Rating).NotTo(BeNil()) + Expect(*d.Rating).To(Equal(8.0)) + }) + + It("rates an album", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Data["a1"].Rating).To(Equal(5)) + }) + + It("rates a visible playlist", func() { + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("p1")+"/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(playlistRepo.Ratings["p1"]).To(Equal(4)) + }) + + It("removes a rating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.removeRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.Rating).To(BeNil()) + }) + + It("returns 404 and does not rate an album in a library the user can't access", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(albumRepo.Data["a1"].Rating).To(Equal(0)) + }) + + It("rounds an odd rating to the nearest star instead of truncating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + }) + + It("stores the minimum star for Rating=1 instead of clearing the rating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(1)) + }) + + It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + }) + + It("clamps a Rating above 10 to Navidrome's max (5)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + }) + + It("clamps a negative Rating to Navidrome's min (0)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + }) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go new file mode 100644 index 000000000..fd523d154 --- /dev/null +++ b/server/jellyfin/api.go @@ -0,0 +1,200 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "golang.org/x/sync/singleflight" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +type Router struct { + http.Handler + ds model.DataStore + artwork artwork.Artwork + streamer stream.MediaStreamer + transcodeDecider stream.TranscodeDecider + players core.Players + scrobbler scrobbler.PlayTracker + playlists playlists.Playlists + provider external.Provider + similarFlight singleflight.Group + serverIDMu sync.Mutex + 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) *Router { + r := &Router{ + ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, + players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, + } + r.Handler = r.routes() + return r +} + +func (api *Router) routes() http.Handler { + inner := chi.NewRouter() + + // Read query params case-insensitively, like real Jellyfin. Must precede all routes so every + // handler and the api_key check see folded keys. + inner.Use(normalizeQueryKeys) + + // Public (no auth): handshake + login. + inner.Get("/System/Info/Public", api.getPublicSystemInfo) + inner.Get("/System/Ping", api.ping) + inner.Post("/System/Ping", api.ping) + inner.Get("/QuickConnect/Enabled", api.quickConnectEnabled) + // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated + // brute-force surface, so it must share the same per-IP throttle when one is configured. + if conf.Server.AuthRequestLimit > 0 { + limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + inner.With(limiter).Post("/Users/AuthenticateByName", api.authenticateByName) + } else { + inner.Post("/Users/AuthenticateByName", api.authenticateByName) + } + inner.Get("/Users/Public", api.getPublicUsers) + + // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. + inner.Get("/Items/{itemId}/Images/{type}", api.getItemImage) + inner.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + + inner.Group(func(r chi.Router) { + r.Use(api.authenticate) + // Register/refresh the calling device as a player on every authenticated request, like + // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a + // player) even before the first playback report. + r.Use(api.withPlayer) + r.Get("/UserViews", api.getUserViews) + r.Get("/Users/{userId}/Views", api.getUserViews) + r.Get("/Users/Me", api.getCurrentUser) + r.Get("/Users/{userId}", api.getCurrentUser) + + r.Get("/Items", api.getItems) + r.Get("/Users/{userId}/Items", api.getItems) + r.Get("/Items/{itemId}", api.getItem) + r.Get("/Users/{userId}/Items/{itemId}", api.getItem) + r.Delete("/Items/{itemId}", api.deleteItem) + r.Get("/Users/{userId}/Items/Latest", api.getLatest) + + // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the + // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. + r.Post("/UserFavoriteItems/{itemId}", api.markFavorite) + r.Delete("/UserFavoriteItems/{itemId}", api.unmarkFavorite) + r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite) + r.Delete("/Users/{userId}/FavoriteItems/{itemId}", api.unmarkFavorite) + r.Post("/Users/{userId}/Items/{itemId}/Rating", api.setRating) + r.Delete("/Users/{userId}/Items/{itemId}/Rating", api.removeRating) + + // Per-item play/favorite/rating state. Jellify uses the /UserItems form; + // /Users/{userId}/Items is the legacy spelling. + r.Get("/UserItems/{itemId}/UserData", api.getUserItemData) + r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData) + + r.Get("/Artists", api.getArtists) + r.Get("/Artists/AlbumArtists", api.getAlbumArtists) + r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists) + r.Get("/Items/{itemId}/Similar", api.getSimilarItems) + r.Get("/Items/{itemId}/InstantMix", api.getInstantMix) + r.Get("/Genres", api.getGenres) + r.Get("/MusicGenres", api.getGenres) + + r.Post("/Playlists", api.createPlaylist) + r.Get("/Playlists/{playlistId}", api.getPlaylist) + r.Post("/Playlists/{playlistId}", api.updatePlaylist) + r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) + r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist) + r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist) + r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers) + r.Get("/Playlists/{playlistId}/Users/{userId}", api.getPlaylistUser) + + // Cover upload/delete: only playlists are writable (see postItemImage); the GET routes + // above stay public. + r.Post("/Items/{itemId}/Images/{type}", api.postItemImage) + r.Delete("/Items/{itemId}/Images/{type}", api.deleteItemImage) + + r.Get("/Audio/{itemId}/stream", api.streamAudio) + r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio) + r.Get("/Audio/{itemId}/universal", api.streamAudio) + r.Get("/Audio/{itemId}/main.m3u8", api.streamHls) + r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of + // /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.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) + + // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. + r.Get("/socket", api.handleSocket) + }) + + // Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected + // traffic, and this just surfaces what's missing. + inner.NotFound(api.notFound) + inner.MethodNotAllowed(api.notFound) + + // Real Jellyfin clients route case-insensitively; chi does not. + return caseInsensitivePaths(inner) +} + +// ok writes payload as JSON, stamping ServerId on any item(s) in it — real Jellyfin always sets it, +// and it's the same value for every item, so it's applied here rather than threaded through mappers. +func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { + switch p := payload.(type) { + case dto.QueryResult: + api.stampServerID(r.Context(), p.Items) + case []dto.BaseItemDto: + api.stampServerID(r.Context(), p) + case dto.BaseItemDto: + p.ServerId = api.serverID(r.Context()) + payload = p + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Error(r.Context(), "Jellyfin API: error encoding response", err) + } +} + +func (api *Router) stampServerID(ctx context.Context, items []dto.BaseItemDto) { + sid := api.serverID(ctx) + for i := range items { + items[i].ServerId = sid + } +} + +// notFound handles unmatched routes and unsupported methods, logging them so unimplemented +// endpoints surface instead of returning chi's default plain-text 404/405. +func (api *Router) notFound(w http.ResponseWriter, r *http.Request) { + log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{}`)) +} + +// internalError logs the real error and writes a generic 500, so internal detail (ffmpeg output, +// file paths) never reaches the client. +func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) { + log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) +} diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go new file mode 100644 index 000000000..23504c73e --- /dev/null +++ b/server/jellyfin/api_test.go @@ -0,0 +1,87 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +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) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("returns 404 JSON for unknown routes", func() { + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + Expect(w.Body.String()).To(Equal("{}")) + }) + + It("returns 404 JSON for a known path with an unsupported method", func() { + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Body.String()).To(Equal("{}")) + }) + + It("registers a player on a general authenticated request, not just playback reports", func() { + ds := &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(GinkgoT().Context()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + token, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + Expect(err).ToNot(HaveOccurred()) + + fp := &fakePlayers{} + api := New(ds, nil, nil, nil, fp, nil, nil, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Jellify", Device="Phone", DeviceId="dev-1", Version="1.0"`) + r.Header.Set("X-Emby-Token", token) + api.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fp.registerCalls).To(Equal(1)) + Expect(fp.lastClient).To(Equal("Jellify")) + }) + + It("rate-limits AuthenticateByName by IP when a login limit is configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + + login := func() int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) + r.RemoteAddr = "10.0.0.1:1234" + api.ServeHTTP(w, r) + return w.Code + } + // The bad credentials would be 401; the limiter cuts in on the 3rd attempt with 429. + Expect(login()).To(Equal(http.StatusUnauthorized)) + Expect(login()).To(Equal(http.StatusUnauthorized)) + Expect(login()).To(Equal(http.StatusTooManyRequests)) + }) +}) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go new file mode 100644 index 000000000..ecd129222 --- /dev/null +++ b/server/jellyfin/auth.go @@ -0,0 +1,134 @@ +package jellyfin + +import ( + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +type authenticateByNameRequest struct { + Username string `json:"Username"` + Pw string `json:"Pw"` +} + +func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var body authenticateByNameRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + // Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path. + usr, err := api.ds.User(ctx).FindByUsernameWithPassword(body.Username) + if body.Pw == "" || err != nil || usr == nil || usr.Password != body.Pw { + log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + // Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a + // never/stale "Last Login" in the admin UI. + if err := api.ds.User(ctx).UpdateLastLoginAt(usr.ID); err != nil { + log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err) + } + + token, err := auth.CreateToken(usr) + if err != nil { + api.internalError(w, r, err) + return + } + + // SessionInfo is omitted, not partially filled: a stub {Id, UserId} could fail a strict client's + // parse, and Finamp's login doesn't need it (its AuthenticationResult.sessionInfo is nullable). + api.ok(w, r, dto.AuthenticationResult{ + User: userToDto(usr, api.serverName(), api.serverID(ctx)), + AccessToken: token, + ServerId: api.serverID(ctx), + }) +} + +// userToDto builds the User object clients expect. Finamp reads Policy and Configuration right after +// login and null-crashes if absent, so both are filled with Navidrome-appropriate defaults. +func userToDto(u *model.User, serverName, serverID string) *dto.UserDto { + return &dto.UserDto{ + Name: u.UserName, + Id: u.ID, + ServerId: serverID, + ServerName: serverName, + HasPassword: true, + HasConfiguredPassword: true, + Policy: userPolicy(u), + Configuration: userConfiguration(), + } +} + +func userPolicy(u *model.User) *dto.UserPolicy { + return &dto.UserPolicy{ + IsAdministrator: u.IsAdmin, + IsHidden: false, + EnableCollectionManagement: false, + EnableSubtitleManagement: false, + EnableLyricManagement: false, + IsDisabled: false, + BlockedTags: []string{}, + AllowedTags: []string{}, + EnableUserPreferenceAccess: true, + AccessSchedules: []string{}, + BlockUnratedItems: []string{}, + EnableRemoteControlOfOtherUsers: false, + EnableSharedDeviceControl: false, + EnableRemoteAccess: true, + EnableLiveTvManagement: false, + EnableLiveTvAccess: false, + EnableMediaPlayback: true, + EnableAudioPlaybackTranscoding: true, + EnableVideoPlaybackTranscoding: true, + EnablePlaybackRemuxing: true, + ForceRemoteSourceTranscoding: false, + EnableContentDeletion: false, + EnableContentDeletionFromFolders: []string{}, + EnableContentDownloading: true, + EnableSyncTranscoding: true, + EnableMediaConversion: true, + EnabledDevices: []string{}, + EnableAllDevices: true, + EnabledChannels: []string{}, + EnableAllChannels: false, + EnabledFolders: []string{}, + EnableAllFolders: true, + InvalidLoginAttemptCount: 0, + LoginAttemptsBeforeLockout: -1, + MaxActiveSessions: 0, + EnablePublicSharing: true, + BlockedMediaFolders: []string{}, + BlockedChannels: []string{}, + RemoteClientBitrateLimit: 0, + AuthenticationProviderId: "", + PasswordResetProviderId: "", + SyncPlayAccess: "CreateAndJoinGroups", + } +} + +func userConfiguration() *dto.UserConfiguration { + return &dto.UserConfiguration{ + PlayDefaultAudioTrack: true, + SubtitleLanguagePreference: "", + DisplayMissingEpisodes: false, + GroupedFolders: []string{}, + SubtitleMode: "Default", + DisplayCollectionsView: false, + EnableLocalPassword: false, + OrderedViews: []string{}, + LatestItemsExcludes: []string{}, + MyMediaExcludes: []string{}, + HidePlayedInLatest: true, + RememberAudioSelections: true, + RememberSubtitleSelections: true, + EnableNextEpisodeAutoPlay: true, + CastReceiverId: "", + } +} diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go new file mode 100644 index 000000000..b51420f1a --- /dev/null +++ b/server/jellyfin/auth_test.go @@ -0,0 +1,103 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AuthenticateByName", func() { + var api *Router + var ds *tests.MockDataStore + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + It("issues a token for valid credentials", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User.Name).To(Equal("alice")) + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Subject).To(Equal("alice")) + + // Finamp reads Policy/Configuration right after login and null-crashes if they're absent. + Expect(res.User.Policy).ToNot(BeNil()) + Expect(res.User.Policy.IsAdministrator).To(BeFalse()) + Expect(res.User.Policy.EnableAllFolders).To(BeTrue()) + Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue()) + Expect(res.User.Configuration).ToNot(BeNil()) + + // Ours is a partial SessionInfo; a strict client may fail to parse it, and Finamp's + // login doesn't require it, so it should be omitted entirely rather than sent partial. + Expect(res.SessionInfo).To(BeNil()) + }) + + It("records the login time, like the web UI login does", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + usr, err := ur.FindByUsername("alice") + Expect(err).ToNot(HaveOccurred()) + Expect(usr.LastLoginAt).ToNot(BeNil()) + }) + + It("reflects an administrator in the User.Policy", func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "admin1", UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"root","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.User.Policy).ToNot(BeNil()) + Expect(res.User.Policy.IsAdministrator).To(BeTrue()) + }) + + It("rejects invalid credentials with 401", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"wrong"}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an empty password even for a user with an empty stored password with 401", func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "e", UserName: "empty", NewPassword: ""})).To(Succeed()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"empty","Pw":""}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go new file mode 100644 index 000000000..bf77c4594 --- /dev/null +++ b/server/jellyfin/browsing.go @@ -0,0 +1,53 @@ +package jellyfin + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists +// handles GET /Artists/AlbumArtists (album artists only). Distinct roles, so composers/arrangers +// don't appear identically in both. +func (api *Router) getArtists(w http.ResponseWriter, r *http.Request) { + api.listArtistsByRole(w, r, model.RoleArtist) +} + +func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) { + api.listArtistsByRole(w, r, model.RoleAlbumArtist) +} + +// listArtistsByRole is the shared body of the /Artists* handlers, scoping to ParentId's library +// when accessible (like queryItems) or all accessible libraries otherwise. +func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} + applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) + + scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + // Finamp's artist tab sends GenreIds when a genre filter is active. + genreIds := decodedQueryIDs(r, "genreids") + + res, err := api.listArtists(ctx, opts, genreIds, scopeIDs, p.StringOr("searchterm", ""), false, role) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} + +// getGenres handles /Genres and /MusicGenres. Genres are global, so no library scoping applies. +func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} + res, err := api.listGenres(ctx, opts) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go new file mode 100644 index 000000000..7f50355e1 --- /dev/null +++ b/server/jellyfin/browsing_test.go @@ -0,0 +1,161 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "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" +) + +var _ = Describe("Browsing", func() { + var api *Router + var ds *tests.MockDataStore + ctxUser := func(libs model.Libraries) context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + } + + // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. + ctxAdmin := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + api = &Router{ds: ds} + }) + + Describe("getArtists", func() { + It("lists artists via /Artists", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("handles /Artists/AlbumArtists the same way", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("scopes results to the user's accessible libraries", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes to a single library when ParentId is an accessible library id", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists?ParentId=2", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(2)) + Expect(args).NotTo(ContainElement(1)) + }) + + It("does not let ParentId= narrow the scope", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}} // no access to library 99 + r := httptest.NewRequest("GET", "/Artists?ParentId=99", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(1)) + Expect(args).NotTo(ContainElement(99)) + }) + + It("forwards SearchTerm to the repo's Search method", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("forwards StartIndex/Limit as Offset/Max", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Offset).To(Equal(5)) + Expect(artistRepo.Options.Max).To(Equal(10)) + }) + + It("does not restrict results for an admin user", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin()) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // accessibleLibraryIDs is empty for an admin (Libraries is nil), so + // ApplyArtistLibraryFilter([]) is a no-op: no library_id restriction is added. + if artistRepo.Options.Filters == nil { + return + } + sql, _, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) + }) + }) + + Describe("getGenres", func() { + It("lists genres via /Genres", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Genres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getGenres, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).NotTo(BeNil()) + }) + + It("handles /MusicGenres the same way", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/MusicGenres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getGenres, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) +}) diff --git a/server/jellyfin/case_insensitive_routes.go b/server/jellyfin/case_insensitive_routes.go new file mode 100644 index 000000000..cc95b526f --- /dev/null +++ b/server/jellyfin/case_insensitive_routes.go @@ -0,0 +1,68 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" +) + +// caseInsensitivePaths normalizes each request path's literal segments to the case they were +// registered with before delegating to r, since Jellyfin clients route case-insensitively but +// chi matches case-sensitively. Param placeholders (e.g. "{itemId}") aren't literals, so id +// segments pass through untouched. +func caseInsensitivePaths(r chi.Router) http.Handler { + canon := canonicalRouteSegments(r) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + normalizeRequestPath(req, canon) + r.ServeHTTP(w, req) + }) +} + +// canonicalRouteSegments walks every registered route and records, for each literal (non-param) +// "/"-separated segment, the case it was registered with, keyed by its lower-cased form (e.g. +// "audio" -> "Audio"). +func canonicalRouteSegments(router chi.Router) map[string]string { + canon := map[string]string{} + _ = chi.Walk(router, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + for seg := range strings.SplitSeq(route, "/") { + if seg == "" || strings.Contains(seg, "{") { + continue + } + canon[strings.ToLower(seg)] = seg + } + return nil + }) + return canon +} + +// normalizeRequestPath rewrites literal path segments to the case routes were registered with. +// It must run before chi's matching. When the router is mounted under a parent, chi has already +// stripped the mount prefix and matches against RouteContext.RoutePath rather than r.URL.Path, so +// that's what must be normalized here. +func normalizeRequestPath(r *http.Request, canon map[string]string) { + if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { + rctx.RoutePath = normalizeCase(rctx.RoutePath, canon) + return + } + r.URL.Path = normalizeCase(r.URL.Path, canon) +} + +// normalizeCase rewrites each "/"-separated literal segment of path to the case it was +// registered with in canon. Segments with no match (e.g. case-sensitive ids) are left untouched. +// A segment like "STREAM.mp3" comes from a mixed literal+param route (e.g. "stream.{container}"), +// whose literal prefix ("stream") is registered separately: normalize that prefix and lower-case +// the extension so chi's case-sensitive match still hits. +func normalizeCase(path string, canon map[string]string) string { + segs := strings.Split(path, "/") + for i, seg := range segs { + if canonical, ok := canon[strings.ToLower(seg)]; ok { + segs[i] = canonical + } else if prefix, suffix, found := strings.Cut(seg, "."); found { + if canonical, ok := canon[strings.ToLower(prefix)]; ok { + segs[i] = canonical + "." + strings.ToLower(suffix) + } + } + } + return strings.Join(segs, "/") +} diff --git a/server/jellyfin/case_insensitive_routes_test.go b/server/jellyfin/case_insensitive_routes_test.go new file mode 100644 index 000000000..4c9140f83 --- /dev/null +++ b/server/jellyfin/case_insensitive_routes_test.go @@ -0,0 +1,90 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("caseInsensitivePaths", func() { + var handler http.Handler + var gotID string + + var gotContainer string + + BeforeEach(func() { + gotID = "" + gotContainer = "" + r := chi.NewRouter() + r.Get("/Foo/{id}/Bar", func(w http.ResponseWriter, req *http.Request) { + gotID = chi.URLParam(req, "id") + w.WriteHeader(http.StatusOK) + }) + // A mixed literal+param segment (like Jellyfin's /Audio/{id}/stream.{container}): the "stream" + // literal prefix is registered separately via the bare /Foo/{id}/stream route below. + r.Get("/Foo/{id}/stream", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) + r.Get("/Foo/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { + gotContainer = chi.URLParam(req, "container") + w.WriteHeader(http.StatusOK) + }) + handler = caseInsensitivePaths(r) + }) + + It("normalizes the literal prefix of a mixed literal.param segment", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/STREAM.mp3", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotContainer).To(Equal("mp3")) + }) + + It("matches a lower-cased request path against mixed-case registered literals", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/bar", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("preserves the id segment's original casing", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/bar", nil) + handler.ServeHTTP(w, r) + Expect(gotID).To(Equal("ID")) + }) + + It("leaves a real mixed-case id untouched while still matching literals", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/cjsFeXbNOaaSjASu3DM93g/bar", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotID).To(Equal("cjsFeXbNOaaSjASu3DM93g")) + }) +}) + +var _ = Describe("normalizeCase", func() { + It("rewrites known literal segments to their canonical case", func() { + canon := map[string]string{ + "audio": "Audio", + "stream": "stream", + } + got := normalizeCase("/audio/XyZ123NotARoute/STREAM", canon) + Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream")) + }) + + It("normalizes the literal prefix of a mixed literal.extension segment", func() { + canon := map[string]string{"audio": "Audio", "stream": "stream"} + got := normalizeCase("/audio/XyZ123NotARoute/STREAM.MP3", canon) + Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream.mp3")) + }) + + It("leaves a dotted segment untouched when its prefix isn't a known literal", func() { + canon := map[string]string{"audio": "Audio"} + got := normalizeCase("/audio/some.file.id", canon) + Expect(got).To(Equal("/Audio/some.file.id")) + }) +}) diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go new file mode 100644 index 000000000..aaf6ff2af --- /dev/null +++ b/server/jellyfin/dto/blurhash.go @@ -0,0 +1,36 @@ +package dto + +import "hash/fnv" + +// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec. +const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// base83 encodes value as a fixed-width, big-endian base83 string of the given length. +func base83(value, length int) string { + b := make([]byte, length) + for i := 1; i <= length; i++ { + digit := (value / pow83(length-i)) % 83 + b[i-1] = base83Alphabet[digit] + } + return string(b) +} + +func pow83(n int) int { + result := 1 + for range n { + result *= 83 + } + return result +} + +// blurHash returns a valid 6-char blurhash for a solid color derived from seed. Finamp only needs a +// well-formed, per-tag-stable value (it uses this as a download de-dup key and blur placeholder), so +// a solid color unique to the tag satisfies both without decoding cover art. +func blurHash(seed string) string { + h := fnv.New32a() + _, _ = h.Write([]byte(seed)) + sum := h.Sum(nil) + r, g, b := int(sum[0]), int(sum[1]), int(sum[2]) + dc := (r << 16) | (g << 8) | b + return "00" + base83(dc, 4) +} diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go new file mode 100644 index 000000000..a6e36131d --- /dev/null +++ b/server/jellyfin/dto/blurhash_test.go @@ -0,0 +1,27 @@ +package dto + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("blurHash", func() { + It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() { + h := blurHash("x") + Expect(h).To(HaveLen(6)) + Expect(h).To(HavePrefix("00")) + for _, c := range h { + Expect(strings.ContainsRune(base83Alphabet, c)).To(BeTrue(), "unexpected char %q", c) + } + }) + + It("is deterministic for the same seed", func() { + Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1"))) + }) + + It("differs for different seeds", func() { + Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2"))) + }) +}) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go new file mode 100644 index 000000000..7720640c5 --- /dev/null +++ b/server/jellyfin/dto/dto.go @@ -0,0 +1,258 @@ +package dto + +// PublicSystemInfo is the unauthenticated handshake payload (GET /System/Info/Public). +type PublicSystemInfo struct { + LocalAddress string `json:"LocalAddress,omitempty"` + ServerName string `json:"ServerName"` + Version string `json:"Version"` + ProductName string `json:"ProductName"` + OperatingSystem string `json:"OperatingSystem,omitempty"` + Id string `json:"Id"` + StartupWizardCompleted bool `json:"StartupWizardCompleted"` +} + +// SystemInfo is the authenticated variant (GET /System/Info). +type SystemInfo struct { + PublicSystemInfo + HasPendingRestart bool `json:"HasPendingRestart"` + IsShuttingDown bool `json:"IsShuttingDown"` + SupportsLibraryMonitor bool `json:"SupportsLibraryMonitor"` + CachePath string `json:"CachePath,omitempty"` +} + +type NameGuidPair struct { + Name string `json:"Name"` + Id string `json:"Id"` +} + +type UserItemDataDto struct { + Rating *float64 `json:"Rating,omitempty"` + PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` + PlayCount int `json:"PlayCount"` + IsFavorite bool `json:"IsFavorite"` + Played bool `json:"Played"` + Key string `json:"Key"` + ItemId string `json:"ItemId,omitempty"` + LastPlayedDate *string `json:"LastPlayedDate,omitempty"` +} + +type BaseItemDto struct { + Name string `json:"Name"` + ServerId string `json:"ServerId,omitempty"` + Id string `json:"Id"` + // PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items), + // distinct from Id so a song appearing more than once can be removed by occurrence + // (DELETE .../Items?EntryIds=...) rather than by song id. + PlaylistItemId string `json:"PlaylistItemId,omitempty"` + Type string `json:"Type"` + IsFolder bool `json:"IsFolder"` + MediaType string `json:"MediaType,omitempty"` + CollectionType string `json:"CollectionType,omitempty"` + LocationType string `json:"LocationType,omitempty"` + HasLyrics bool `json:"HasLyrics,omitempty"` + SortName string `json:"SortName,omitempty"` + Path string `json:"Path,omitempty"` + ParentId string `json:"ParentId,omitempty"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + IndexNumber *int `json:"IndexNumber,omitempty"` + ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` + ProductionYear *int `json:"ProductionYear,omitempty"` + // PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side. + PremiereDate *string `json:"PremiereDate,omitempty"` + // DateCreated is the ISO 8601 date the item was added to the library; clients show it as + // "Date Added" and sort "Recently Added" by it. + DateCreated string `json:"DateCreated,omitempty"` + Album string `json:"Album,omitempty"` + AlbumId string `json:"AlbumId,omitempty"` + AlbumArtist string `json:"AlbumArtist,omitempty"` + AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` + AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` + Artists []string `json:"Artists,omitempty"` + ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` + Genres []string `json:"Genres,omitempty"` + ChildCount *int `json:"ChildCount,omitempty"` + SongCount *int `json:"SongCount,omitempty"` + AlbumCount *int `json:"AlbumCount,omitempty"` + ImageTags map[string]string `json:"ImageTags,omitempty"` + // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a + // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't + // calculating blurhashes. + ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` + BackdropImageTags []string `json:"BackdropImageTags"` + UserData *UserItemDataDto `json:"UserData,omitempty"` + MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` + Container string `json:"Container,omitempty"` + CanDownload bool `json:"CanDownload"` +} + +// PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which +// Finamp probes before allowing playlist edits. +type PlaylistUserPermissions struct { + UserId string `json:"UserId"` + CanEdit bool `json:"CanEdit"` +} + +// PlaylistInfo is the response shape for GET /Playlists/{id}. ItemIds are media item ids, not +// playlist-entry ids (matching real Jellyfin); Finamp reads OpenAccess for the public-visibility toggle. +type PlaylistInfo struct { + OpenAccess bool `json:"OpenAccess"` + Shares []PlaylistUserPermissions `json:"Shares"` + ItemIds []string `json:"ItemIds"` +} + +type QueryResult struct { + Items []BaseItemDto `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` + StartIndex int `json:"StartIndex"` +} + +type UserDto struct { + Name string `json:"Name"` + ServerId string `json:"ServerId,omitempty"` + ServerName string `json:"ServerName,omitempty"` + Id string `json:"Id"` + HasPassword bool `json:"HasPassword"` + HasConfiguredPassword bool `json:"HasConfiguredPassword"` + HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"` + PrimaryImageTag string `json:"PrimaryImageTag,omitempty"` + Policy *UserPolicy `json:"Policy,omitempty"` + Configuration *UserConfiguration `json:"Configuration,omitempty"` +} + +// UserPolicy mirrors real Jellyfin's User.Policy. Finamp reads it right after login and crashes if +// it's absent, so every field must be present even though Navidrome lacks most of these concepts. +type UserPolicy struct { + IsAdministrator bool `json:"IsAdministrator"` + IsHidden bool `json:"IsHidden"` + EnableCollectionManagement bool `json:"EnableCollectionManagement"` + EnableSubtitleManagement bool `json:"EnableSubtitleManagement"` + EnableLyricManagement bool `json:"EnableLyricManagement"` + IsDisabled bool `json:"IsDisabled"` + BlockedTags []string `json:"BlockedTags"` + AllowedTags []string `json:"AllowedTags"` + EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"` + AccessSchedules []string `json:"AccessSchedules"` + BlockUnratedItems []string `json:"BlockUnratedItems"` + EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"` + EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"` + EnableRemoteAccess bool `json:"EnableRemoteAccess"` + EnableLiveTvManagement bool `json:"EnableLiveTvManagement"` + EnableLiveTvAccess bool `json:"EnableLiveTvAccess"` + EnableMediaPlayback bool `json:"EnableMediaPlayback"` + EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"` + EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"` + EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"` + ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"` + EnableContentDeletion bool `json:"EnableContentDeletion"` + EnableContentDeletionFromFolders []string `json:"EnableContentDeletionFromFolders"` + EnableContentDownloading bool `json:"EnableContentDownloading"` + EnableSyncTranscoding bool `json:"EnableSyncTranscoding"` + EnableMediaConversion bool `json:"EnableMediaConversion"` + EnabledDevices []string `json:"EnabledDevices"` + EnableAllDevices bool `json:"EnableAllDevices"` + EnabledChannels []string `json:"EnabledChannels"` + EnableAllChannels bool `json:"EnableAllChannels"` + EnabledFolders []string `json:"EnabledFolders"` + EnableAllFolders bool `json:"EnableAllFolders"` + InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"` + LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"` + MaxActiveSessions int `json:"MaxActiveSessions"` + EnablePublicSharing bool `json:"EnablePublicSharing"` + BlockedMediaFolders []string `json:"BlockedMediaFolders"` + BlockedChannels []string `json:"BlockedChannels"` + RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"` + AuthenticationProviderId string `json:"AuthenticationProviderId"` + PasswordResetProviderId string `json:"PasswordResetProviderId"` + SyncPlayAccess string `json:"SyncPlayAccess"` +} + +// UserConfiguration mirrors real Jellyfin's User.Configuration. Like UserPolicy, clients expect it +// always present, even though most settings don't apply to Navidrome's audio-only library. +type UserConfiguration struct { + PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"` + SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"` + DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"` + GroupedFolders []string `json:"GroupedFolders"` + SubtitleMode string `json:"SubtitleMode"` + DisplayCollectionsView bool `json:"DisplayCollectionsView"` + EnableLocalPassword bool `json:"EnableLocalPassword"` + OrderedViews []string `json:"OrderedViews"` + LatestItemsExcludes []string `json:"LatestItemsExcludes"` + MyMediaExcludes []string `json:"MyMediaExcludes"` + HidePlayedInLatest bool `json:"HidePlayedInLatest"` + RememberAudioSelections bool `json:"RememberAudioSelections"` + RememberSubtitleSelections bool `json:"RememberSubtitleSelections"` + EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"` + CastReceiverId string `json:"CastReceiverId"` +} + +type SessionInfo struct { + Id string `json:"Id"` + UserId string `json:"UserId"` +} + +type AuthenticationResult struct { + User *UserDto `json:"User"` + SessionInfo *SessionInfo `json:"SessionInfo,omitempty"` + AccessToken string `json:"AccessToken"` + ServerId string `json:"ServerId"` +} + +// MediaStream mirrors real Jellyfin's MediaStream. Finamp declares several bools as non-nullable, so +// they must always be emitted (no omitempty). Finamp also does MediaStreams.firstWhere((s) => s.type +// == 'Audio'), so MediaSourceInfo must include at least one Audio stream or that lookup throws. +type MediaStream struct { + Codec string `json:"Codec,omitempty"` + Type string `json:"Type"` + Index int `json:"Index"` + BitRate int `json:"BitRate,omitempty"` + Channels int `json:"Channels,omitempty"` + SampleRate int `json:"SampleRate,omitempty"` + ChannelLayout string `json:"ChannelLayout,omitempty"` + IsInterlaced bool `json:"IsInterlaced"` + IsDefault bool `json:"IsDefault"` + IsForced bool `json:"IsForced"` + IsExternal bool `json:"IsExternal"` + IsTextSubtitleStream bool `json:"IsTextSubtitleStream"` + SupportsExternalStream bool `json:"SupportsExternalStream"` +} + +// MediaSourceInfo mirrors real Jellyfin's MediaSourceInfo. Finamp declares several bools/arrays as +// non-nullable, so a missing field deserializes to null and throws a cast error that aborts parsing +// of the whole item list; emit them always (no omitempty on bools). +type MediaSourceInfo struct { + Id string `json:"Id"` + Path string `json:"Path,omitempty"` + Protocol string `json:"Protocol"` + Container string `json:"Container,omitempty"` + TranscodingUrl string `json:"TranscodingUrl,omitempty"` + TranscodingSubProtocol string `json:"TranscodingSubProtocol,omitempty"` + Size int64 `json:"Size,omitempty"` + Name string `json:"Name,omitempty"` + IsRemote bool `json:"IsRemote"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + Bitrate int `json:"Bitrate,omitempty"` + SupportsTranscoding bool `json:"SupportsTranscoding"` + SupportsDirectStream bool `json:"SupportsDirectStream"` + SupportsDirectPlay bool `json:"SupportsDirectPlay"` + Type string `json:"Type"` + ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"` + IgnoreDts bool `json:"IgnoreDts"` + IgnoreIndex bool `json:"IgnoreIndex"` + GenPtsInput bool `json:"GenPtsInput"` + IsInfiniteStream bool `json:"IsInfiniteStream"` + UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"` + RequiresOpening bool `json:"RequiresOpening"` + RequiresClosing bool `json:"RequiresClosing"` + RequiresLooping bool `json:"RequiresLooping"` + SupportsProbing bool `json:"SupportsProbing"` + HasSegments bool `json:"HasSegments"` + MediaStreams []MediaStream `json:"MediaStreams"` + MediaAttachments []any `json:"MediaAttachments"` + Formats []string `json:"Formats"` +} + +type PlaybackInfoResponse struct { + MediaSources []MediaSourceInfo `json:"MediaSources"` + PlaySessionId string `json:"PlaySessionId"` +} diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go new file mode 100644 index 000000000..1d8ec47e4 --- /dev/null +++ b/server/jellyfin/dto/dto_suite_test.go @@ -0,0 +1,17 @@ +package dto + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDto(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin DTO Suite") +} diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go new file mode 100644 index 000000000..faa31ec0d --- /dev/null +++ b/server/jellyfin/dto/fields.go @@ -0,0 +1,24 @@ +package dto + +import "strings" + +// Fields is the parsed set of a Jellyfin request's Fields param (lowercased). It controls which +// conditional fields a mapped item carries — chiefly MediaSources — matching real Jellyfin, which +// omits those unless the client asks for them. +type Fields map[string]struct{} + +// ParseFields splits the comma-separated Fields param into a lowercased set. +func ParseFields(csv string) Fields { + f := Fields{} + for name := range strings.SplitSeq(csv, ",") { + if name = strings.TrimSpace(strings.ToLower(name)); name != "" { + f[name] = struct{}{} + } + } + return f +} + +func (f Fields) Has(name string) bool { + _, ok := f[strings.ToLower(name)] + return ok +} diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go new file mode 100644 index 000000000..3490ba260 --- /dev/null +++ b/server/jellyfin/dto/ids.go @@ -0,0 +1,23 @@ +package dto + +import "encoding/hex" + +// EncodeID renders a Navidrome id as lowercase hex; Jellyfin clients parse ids as radix-16 (e.g. +// Finamp's queue packing) and crash on Navidrome's base62 nanoids if emitted as-is. +func EncodeID(id string) string { + if id == "" { + return "" + } + return hex.EncodeToString([]byte(id)) +} + +// DecodeID reverses EncodeID; non-hex input is returned unchanged, so it's safe on any inbound id. +func DecodeID(id string) string { + if id == "" { + return "" + } + if b, err := hex.DecodeString(id); err == nil && len(b) > 0 { + return string(b) + } + return id +} diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go new file mode 100644 index 000000000..26a957604 --- /dev/null +++ b/server/jellyfin/dto/ids_test.go @@ -0,0 +1,35 @@ +package dto + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("id codec", func() { + It("round-trips a base62 nanoid through Encode/Decode", func() { + id := "5QFKvMsJrd57QE2Le2dKKo" + Expect(DecodeID(EncodeID(id))).To(Equal(id)) + }) + + It("passes a raw (non-hex) id through DecodeID unchanged", func() { + Expect(DecodeID("5QFKvMsJrd57QE2Le2dKKo")).To(Equal("5QFKvMsJrd57QE2Le2dKKo")) + }) + + It("produces valid lowercase hex", func() { + encoded := EncodeID("song-1") + Expect(encoded).To(MatchRegexp("^[0-9a-f]+$")) + Expect(encoded).To(HaveLen(len("song-1") * 2)) + }) + + It("round-trips the empty string", func() { + Expect(EncodeID("")).To(Equal("")) + Expect(DecodeID("")).To(Equal("")) + }) + + It("decodes a hex-looking raw id incorrectly only when re-encoded consistently (encode/decode is always internally consistent)", func() { + // "a1" happens to be valid hex on its own; DecodeID can't tell a coincidental hex + // string apart from one we encoded. Callers must always encode ids on emission and + // decode them on receipt so this ambiguity never surfaces in practice. + Expect(DecodeID(EncodeID("a1"))).To(Equal("a1")) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go new file mode 100644 index 000000000..bd817e432 --- /dev/null +++ b/server/jellyfin/dto/mappers.go @@ -0,0 +1,256 @@ +package dto + +import ( + "cmp" + "fmt" + "time" + + "github.com/navidrome/navidrome/model" +) + +func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) } + +// premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601 +// PremiereDate clients parse, falling back to year; nil when neither exists. +func premiereDate(date string, year int) *string { + d := date + switch len(d) { + case 4: + d += "-01-01" + case 7: + d += "-01" + case 10: // already yyyy-mm-dd + default: + if year <= 0 { + return nil + } + d = fmt.Sprintf("%04d-01-01", year) + } + s := d + "T00:00:00Z" + return &s +} + +// jellyfinDate formats t as the ISO 8601 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 { + if t == nil || t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream. +func channelLayout(n int) string { + switch n { + case 1: + return "mono" + case 2: + return "stereo" + case 6: + return "5.1" + case 8: + return "7.1" + default: + return "" + } +} + +// MediaSourceFromMediaFile builds the MediaSourceInfo for direct playback of mf's source file. +// Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo +// responses (Finamp's download dialog reads MediaSources[0].Size from the browse response). +func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { + return MediaSourceInfo{ + Id: EncodeID(mf.ID), + Protocol: "Http", + Container: mf.Suffix, + Size: mf.Size, + Name: mf.Title, + Type: "Default", + RunTimeTicks: TicksFromSeconds(mf.Duration), + Bitrate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's Bitrate is bps. + SupportsDirectPlay: true, + SupportsDirectStream: true, + SupportsTranscoding: true, + IsRemote: false, + SupportsProbing: true, + MediaStreams: []MediaStream{{ + Type: "Audio", + Index: 0, + Codec: mf.Codec, + BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. + Channels: mf.Channels, + SampleRate: mf.SampleRate, + ChannelLayout: channelLayout(mf.Channels), + }}, + MediaAttachments: []any{}, + Formats: []string{}, + } +} + +func UserData(a model.Annotations, itemID string) *UserItemDataDto { + // Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto. + encodedID := EncodeID(itemID) + d := &UserItemDataDto{ + PlayCount: int(a.PlayCount), + IsFavorite: a.Starred, + Played: a.PlayCount > 0, + Key: encodedID, + ItemId: encodedID, + } + if a.Rating > 0 { + r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 + d.Rating = &r + } + if a.PlayDate != nil { + s := a.PlayDate.UTC().Format(time.RFC3339) + d.LastPlayedDate = &s + } + return d +} + +// SongToBaseItem maps a media file to an Audio BaseItemDto. MediaSources and SortName are attached +// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a +// plain list response); a nil fields set means neither. +func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { + item := BaseItemDto{ + Name: mf.Title, + Id: EncodeID(mf.ID), + Type: "Audio", + MediaType: "Audio", + IsFolder: false, + LocationType: "FileSystem", + HasLyrics: mf.Lyrics != "", + ParentId: EncodeID(mf.AlbumID), + Album: mf.Album, + AlbumId: EncodeID(mf.AlbumID), + AlbumArtist: mf.AlbumArtist, + Artists: []string{mf.Artist}, + RunTimeTicks: TicksFromSeconds(mf.Duration), + DateCreated: jellyfinDate(&mf.CreatedAt), + Container: mf.Suffix, + CanDownload: true, + BackdropImageTags: []string{}, + UserData: UserData(mf.Annotations, mf.ID), + } + if fields.Has("MediaSources") { + item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)} + } + if fields.Has("SortName") { + item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title) + } + // Finamp's Now Playing screen reads ArtistItems for the displayed artist (falling back to "Unknown + // Artist" if absent), even though Artists carries the same name. ArtistItems is the track artist; + // AlbumArtists the album artist. + if mf.ArtistID != "" { + item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + } + if mf.AlbumArtistID != "" { + item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} + } + if mf.Year > 0 { + item.ProductionYear = new(mf.Year) + } + item.PremiereDate = premiereDate(mf.Date, mf.Year) + if mf.TrackNumber > 0 { + item.IndexNumber = new(mf.TrackNumber) + } + if mf.DiscNumber > 0 { + item.ParentIndexNumber = new(mf.DiscNumber) + } + if len(mf.Genres) > 0 { + for _, g := range mf.Genres { + item.Genres = append(item.Genres, g.Name) + } + } else if mf.Genre != "" { + item.Genres = []string{mf.Genre} + } + // Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag. + if mf.AlbumID != "" { + item.AlbumPrimaryImageTag = mf.AlbumID + item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}} + } + return item +} + +func AlbumToBaseItem(al model.Album) BaseItemDto { + item := BaseItemDto{ + Name: al.Name, + Id: EncodeID(al.ID), + Type: "MusicAlbum", + IsFolder: true, + ParentId: EncodeID(al.AlbumArtistID), + AlbumArtist: al.AlbumArtist, + Album: al.Name, + ChildCount: new(al.SongCount), + SongCount: new(al.SongCount), + RunTimeTicks: TicksFromSeconds(al.Duration), + DateCreated: jellyfinDate(&al.CreatedAt), + ImageTags: map[string]string{"Primary": al.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}}, + BackdropImageTags: []string{}, + UserData: UserData(al.Annotations, al.ID), + } + if al.AlbumArtistID != "" { + item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} + item.ArtistItems = item.AlbumArtists + } + if al.MaxYear > 0 { + item.ProductionYear = new(al.MaxYear) + } + item.PremiereDate = premiereDate(al.Date, al.MaxYear) + if len(al.Genres) > 0 { + for _, g := range al.Genres { + item.Genres = append(item.Genres, g.Name) + } + } + return item +} + +func ArtistToBaseItem(ar model.Artist) BaseItemDto { + return BaseItemDto{ + Name: ar.Name, + Id: EncodeID(ar.ID), + Type: "MusicArtist", + IsFolder: true, + AlbumCount: new(ar.AlbumCount), + SongCount: new(ar.SongCount), + DateCreated: jellyfinDate(ar.CreatedAt), + ImageTags: map[string]string{"Primary": ar.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}}, + BackdropImageTags: []string{}, + UserData: UserData(ar.Annotations, ar.ID), + } +} + +func GenreToBaseItem(g model.Genre) BaseItemDto { + return BaseItemDto{ + Name: g.Name, + Id: EncodeID(g.ID), + Type: "MusicGenre", + IsFolder: true, + BackdropImageTags: []string{}, + } +} + +// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. +func PlaylistToBaseItem(p model.Playlist) BaseItemDto { + // Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover. + // UpdatedAt versions it (Put bumps it on upload); over-invalidation only costs a refetch. + tag := fmt.Sprintf("%s-%x", p.ID, p.UpdatedAt.UnixMilli()) + return BaseItemDto{ + Name: p.Name, + Id: EncodeID(p.ID), + Type: "Playlist", + // Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin + // stores them under its data folder), so without this its Playlists tab hides them all. + Path: "/data/playlists/" + p.ID, + IsFolder: true, + MediaType: "Audio", + ChildCount: new(p.SongCount), + RunTimeTicks: TicksFromSeconds(p.Duration), + ImageTags: map[string]string{"Primary": tag}, + ImageBlurHashes: map[string]map[string]string{"Primary": {tag: blurHash(tag)}}, + BackdropImageTags: []string{}, + UserData: UserData(p.Annotations, p.ID), + } +} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go new file mode 100644 index 000000000..bd351bee9 --- /dev/null +++ b/server/jellyfin/dto/mappers_test.go @@ -0,0 +1,280 @@ +package dto + +import ( + "encoding/json" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("mappers", func() { + It("maps a song to an Audio BaseItemDto", func() { + mf := model.MediaFile{ + ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, + Year: 1999, Duration: 60, Size: 2_500_000, + } + mf.PlayCount = 2 + mf.Starred = true + item := SongToBaseItem(mf, nil) + Expect(item.Type).To(Equal("Audio")) + Expect(item.MediaType).To(Equal("Audio")) + Expect(item.IsFolder).To(BeFalse()) + Expect(item.LocationType).To(Equal("FileSystem")) + Expect(item.Id).To(Equal(EncodeID("song-1"))) + Expect(item.AlbumId).To(Equal(EncodeID("alb-1"))) + Expect(item.ParentId).To(Equal(EncodeID("alb-1"))) + Expect(item.RunTimeTicks).To(Equal(int64(600_000_000))) + Expect(*item.IndexNumber).To(Equal(3)) + Expect(item.UserData.IsFavorite).To(BeTrue()) + Expect(item.UserData.PlayCount).To(Equal(2)) + Expect(item.UserData.Played).To(BeTrue()) + Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) + Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) + Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + }) + + Describe("Fields gating (matches real Jellyfin)", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, + SortTitle: "sort song", Lyrics: `[{"line":"la"}]`} + + It("omits MediaSources and SortName when Fields does not ask for them", func() { + item := SongToBaseItem(mf, nil) + Expect(item.MediaSources).To(BeNil()) + Expect(item.SortName).To(BeEmpty()) + }) + + It("includes MediaSources only when Fields=MediaSources", func() { + item := SongToBaseItem(mf, ParseFields("ChildCount,MediaSources,SortName")) + Expect(item.MediaSources).To(HaveLen(1)) + Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000))) + }) + + It("includes SortName (from the sort title) only when Fields=SortName", func() { + Expect(SongToBaseItem(mf, ParseFields("SortName")).SortName).To(Equal("sort song")) + }) + + It("sets HasLyrics from the media file's lyrics", func() { + Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) + Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) + }) + }) + + It("omits ImageBlurHashes when a song has no album", func() { + mf := model.MediaFile{ID: "song-noalbum", Title: "Song", Duration: 60} + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("sets DateCreated from the media file's CreatedAt", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} + Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z")) + }) + + It("omits DateCreated when CreatedAt is the zero time", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).DateCreated).To(BeEmpty()) + }) + + It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() { + mf := model.MediaFile{ + ID: "s1", Title: "Song", + Artist: "The Band", ArtistID: "ar-1", + AlbumArtist: "Various", AlbumArtistID: "ar-2", + } + item := SongToBaseItem(mf, nil) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID("ar-1")}})) + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID("ar-2")}})) + }) + + It("omits ArtistItems when the track has no artist id", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) + }) + + It("builds a MediaSourceInfo from a media file", func() { + mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + src := MediaSourceFromMediaFile(mf) + Expect(src.Id).To(Equal(EncodeID("s1"))) + Expect(src.Size).To(Equal(int64(5242880))) + Expect(src.Container).To(Equal("mp3")) + Expect(src.Bitrate).To(Equal(320_000)) + Expect(src.RunTimeTicks).To(Equal(int64(1_000_000_000))) + Expect(src.Protocol).To(Equal("Http")) + Expect(src.SupportsDirectPlay).To(BeTrue()) + }) + + It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() { + mf := model.MediaFile{ + ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100, + Channels: 2, SampleRate: 44100, Codec: "mp3", + } + src := MediaSourceFromMediaFile(mf) + Expect(src.MediaStreams).To(HaveLen(1)) + stream := src.MediaStreams[0] + Expect(stream.Type).To(Equal("Audio")) + Expect(stream.Channels).To(Equal(2)) + Expect(stream.SampleRate).To(Equal(44100)) + Expect(stream.BitRate).To(Equal(320_000)) + Expect(stream.Codec).To(Equal("mp3")) + Expect(stream.ChannelLayout).To(Equal("stereo")) + }) + + It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() { + mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + src := MediaSourceFromMediaFile(mf) + b, err := json.Marshal(src) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"SupportsProbing":true`)) + Expect(j).To(ContainSubstring(`"IsInfiniteStream":false`)) + Expect(j).To(ContainSubstring(`"RequiresOpening":false`)) + Expect(j).To(ContainSubstring(`"MediaAttachments":[]`)) + Expect(j).To(ContainSubstring(`"Formats":[]`)) + }) + + It("serializes MediaStream's required non-nullable bools, never omitted", func() { + stream := MediaStream{Type: "Audio", Index: 0} + b, err := json.Marshal(stream) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"Type":"Audio"`)) + Expect(j).To(ContainSubstring(`"IsDefault":false`)) + Expect(j).To(ContainSubstring(`"IsInterlaced":false`)) + Expect(j).To(ContainSubstring(`"IsForced":false`)) + Expect(j).To(ContainSubstring(`"IsExternal":false`)) + Expect(j).To(ContainSubstring(`"IsTextSubtitleStream":false`)) + Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`)) + }) + + It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { + mf := model.MediaFile{ + ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0, + Duration: 60, + } + item := SongToBaseItem(mf, nil) + Expect(item.IndexNumber).To(BeNil()) + Expect(item.ParentIndexNumber).To(BeNil()) + }) + + It("maps PlayDate to UserData.LastPlayedDate", func() { + playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC) + mf := model.MediaFile{ + ID: "song-3", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", Duration: 60, + } + mf.PlayDate = &playDate + item := SongToBaseItem(mf, nil) + Expect(item.UserData.LastPlayedDate).NotTo(BeNil()) + Expect(*item.UserData.LastPlayedDate).To(Equal(playDate.Format(time.RFC3339))) + }) + + It("maps an album to a MusicAlbum folder item", func() { + al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10} + item := AlbumToBaseItem(al) + Expect(item.Type).To(Equal("MusicAlbum")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("alb-1"))) + Expect(item.ParentId).To(Equal(EncodeID("art-1"))) + Expect(item.AlbumArtists).To(HaveLen(1)) + Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID("art-1"))) + Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) + Expect(*item.ProductionYear).To(Equal(1999)) + Expect(*item.ChildCount).To(Equal(10)) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) + Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) + }) + + It("maps an artist to a MusicArtist folder item", func() { + ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} + item := ArtistToBaseItem(ar) + Expect(item.Type).To(Equal("MusicArtist")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("art-1"))) + Expect(*item.AlbumCount).To(Equal(2)) + }) + + It("maps a genre to a MusicGenre folder item", func() { + g := model.Genre{ID: "genre-1", Name: "Rock"} + item := GenreToBaseItem(g) + Expect(item.Type).To(Equal("MusicGenre")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("genre-1"))) + Expect(item.Name).To(Equal("Rock")) + }) + + Describe("premiereDate", func() { + // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. + It("serializes a full date", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02-01", Year: 2007} + item := SongToBaseItem(mf, nil) + Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + }) + + It("pads a year-only date so clients can parse it", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007", Year: 2007} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z")) + }) + + It("pads a year-month date", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02"} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + }) + + It("falls back to the year when no date tag exists", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Year: 1999} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z")) + }) + + It("is omitted when the track has no date at all", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).PremiereDate).To(BeNil()) + }) + + It("is set on albums from their date, falling back to MaxYear", func() { + Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: "a3"}).PremiereDate).To(BeNil()) + }) + }) + + It("maps a playlist to a Playlist BaseItemDto", func() { + p := model.Playlist{ + ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120, + Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, + } + item := PlaylistToBaseItem(p) + Expect(item.Type).To(Equal("Playlist")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("pl-1"))) + Expect(item.Name).To(Equal("Chill")) + Expect(item.MediaType).To(Equal("Audio")) + Expect(*item.ChildCount).To(Equal(7)) + Expect(item.RunTimeTicks).To(Equal(int64(1_200_000_000))) + Expect(item.UserData.IsFavorite).To(BeTrue()) + Expect(item.UserData.PlayCount).To(Equal(2)) + Expect(*item.UserData.Rating).To(Equal(8.0)) + tag := item.ImageTags["Primary"] + Expect(tag).ToNot(BeEmpty()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(tag)) + Expect(item.ImageBlurHashes["Primary"][tag]).To(HaveLen(6)) + }) + + It("changes the playlist image tag and blurhash when the playlist is updated (cover upload)", func() { + p := model.Playlist{ID: "pl-1", Name: "Chill", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + before := PlaylistToBaseItem(p) + p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) + after := PlaylistToBaseItem(p) + + // Finamp caches covers keyed by blurHash, so tag and blurhash must change with the cover. + Expect(after.ImageTags["Primary"]).ToNot(Equal(before.ImageTags["Primary"])) + Expect(after.ImageBlurHashes["Primary"]).ToNot(Equal(before.ImageBlurHashes["Primary"])) + }) + + It("keeps the playlist image tag stable when nothing changed", func() { + p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags)) + }) +}) diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go new file mode 100644 index 000000000..b1ad850e3 --- /dev/null +++ b/server/jellyfin/e2e/annotations_test.go @@ -0,0 +1,142 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Annotations", func() { + BeforeEach(func() { setupTestDB() }) + + itemUserData := func(id string) *dto.UserItemDataDto { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(id)), &item) + return item.UserData + } + + Describe("favorites", func() { + It("marks and unmarks an album as favorite", func() { + id := albumID("Abbey Road") + + var marked dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &marked) + Expect(marked.IsFavorite).To(BeTrue()) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + var unmarked dto.UserItemDataDto + parseInto(del("/Users/admin-1/FavoriteItems/"+enc(id)), &unmarked) + Expect(unmarked.IsFavorite).To(BeFalse()) + Expect(itemUserData(id).IsFavorite).To(BeFalse()) + }) + + It("marks a song as favorite", func() { + id := songID("So What") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &data) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + }) + + It("marks and unmarks via the current SDK endpoint /UserFavoriteItems/{id} (Jellify)", func() { + id := songID("Come Together") + + var marked dto.UserItemDataDto + parseInto(post("/UserFavoriteItems/"+enc(id), ""), &marked) + Expect(marked.IsFavorite).To(BeTrue()) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + var unmarked dto.UserItemDataDto + parseInto(del("/UserFavoriteItems/"+enc(id)), &unmarked) + Expect(unmarked.IsFavorite).To(BeFalse()) + Expect(itemUserData(id).IsFavorite).To(BeFalse()) + }) + + It("filters items to favorites only", func() { + post("/Users/admin-1/FavoriteItems/"+enc(albumID("Abbey Road")), "") + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Filters=IsFavorite")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Abbey Road")) + }) + + It("marks and lists a playlist as favorite", func() { + id := createPlaylist("Favorite Mix", nil) + Expect(post("/Users/admin-1/FavoriteItems/"+enc(id), "").Code).To(Equal(http.StatusOK)) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&Filters=IsFavorite")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Favorite Mix")) + }) + + It("filters to favorites via the isFavorite query param (Finamp's artist widget form)", func() { + // Finamp's "Favourite tracks" widget sends isFavorite=true as a query param (not + // Filters=IsFavorite), combined with ArtistIds. + post("/Users/admin-1/FavoriteItems/"+enc(songID("Help!")), "") + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("The Beatles")) + "&isFavorite=true")) + Expect(names(q.Items)).To(ConsistOf("Help!")) + }) + + It("returns 404 when favoriting an unknown item", func() { + Expect(post("/Users/admin-1/FavoriteItems/"+enc("nope"), "").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /UserItems/{id}/UserData", func() { + It("returns per-item favorite/played state (Jellify's played/favourite indicators)", func() { + id := songID("So What") + post("/Users/admin-1/FavoriteItems/"+enc(id), "") + + var data dto.UserItemDataDto + parseInto(get("/UserItems/"+enc(id)+"/UserData?userId=admin-1"), &data) + Expect(data.IsFavorite).To(BeTrue()) + Expect(data.ItemId).To(Equal(enc(id))) + }) + + It("returns a valid (unfavorited) UserData for an item with no annotations", func() { + var data dto.UserItemDataDto + parseInto(get("/UserItems/"+enc(albumID("Kind of Blue"))+"/UserData"), &data) + Expect(data.IsFavorite).To(BeFalse()) + Expect(data.ItemId).To(Equal(enc(albumID("Kind of Blue")))) + }) + + It("returns 404 for an unknown item", func() { + Expect(get("/UserItems/" + enc("nope") + "/UserData").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("ratings", func() { + It("sets and clears an album rating (Jellyfin 0-10 scale)", func() { + id := albumID("IV") + + var set dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=10", ""), &set) + Expect(set.Rating).ToNot(BeNil()) + Expect(*set.Rating).To(Equal(float64(10))) + Expect(*itemUserData(id).Rating).To(Equal(float64(10))) + + // Fresh struct: the DELETE response omits the (now-nil) Rating field, so reusing `set` + // would leave the stale value. + var cleared dto.UserItemDataDto + parseInto(del("/Users/admin-1/Items/"+enc(id)+"/Rating"), &cleared) + Expect(cleared.Rating).To(BeNil()) + Expect(itemUserData(id).Rating).To(BeNil()) + }) + + It("sets and reads a playlist rating", func() { + id := createPlaylist("Rated Mix", nil) + Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK)) + Expect(*itemUserData(id).Rating).To(Equal(float64(8))) + }) + + It("clamps an out-of-range rating to the valid domain", func() { + id := albumID("Help!") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data) + // 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out. + Expect(data.Rating).ToNot(BeNil()) + Expect(*data.Rating).To(Equal(float64(10))) + }) + }) +}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go new file mode 100644 index 000000000..f66833af2 --- /dev/null +++ b/server/jellyfin/e2e/auth_test.go @@ -0,0 +1,120 @@ +package e2e + +import ( + "net/http" + "net/http/httptest" + + "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("Authentication", func() { + BeforeEach(func() { setupTestDB() }) + + authenticate := func(username, pw string) *httptest.ResponseRecorder { + body := `{"Username":"` + username + `","Pw":"` + pw + `"}` + return rawReq("POST", "/Users/AuthenticateByName", body) + } + + Describe("POST /Users/AuthenticateByName", func() { + It("authenticates a valid user and returns a usable token", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User).ToNot(BeNil()) + Expect(res.User.Name).To(Equal("admin")) + Expect(res.User.Id).To(Equal("admin-1")) + Expect(res.User.Policy.IsAdministrator).To(BeTrue()) + Expect(res.ServerId).ToNot(BeEmpty()) + + // The returned token must actually authenticate a protected request. + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw := httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusOK)) + }) + + It("marks a non-admin user's policy as non-administrator", func() { + w := authenticate("regular", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.User.Policy.IsAdministrator).To(BeFalse()) + }) + + It("rejects a wrong password", func() { + Expect(authenticate("admin", "wrong").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an empty password", func() { + Expect(authenticate("admin", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an unknown user", func() { + Expect(authenticate("nobody", "password").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a malformed body", func() { + Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("GET /Users/Public", func() { + publicUsers := func() []dto.UserDto { + w := rawReq("GET", "/Users/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var users []dto.UserDto + parseInto(w, &users) + return users + } + + It("returns an empty list when no users are exposed", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ExposedPublicUsers = "" + Expect(publicUsers()).To(BeEmpty()) + }) + + It("lists the configured users to an unauthenticated caller, without policy", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ExposedPublicUsers = "regular" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("regular")) + Expect(users[0].Id).To(Equal("regular-1")) + Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login + }) + }) + + Describe("current user", func() { + It("returns the caller from GET /Users/Me", func() { + var u dto.UserDto + parseInto(getAs(regularUser, "/Users/Me"), &u) + Expect(u.Name).To(Equal("regular")) + Expect(u.Id).To(Equal("regular-1")) + }) + + It("returns the caller from GET /Users/{userId}", func() { + var u dto.UserDto + parseInto(get("/Users/admin-1"), &u) + Expect(u.Name).To(Equal("admin")) + }) + }) + + Describe("auth enforcement", func() { + It("rejects a protected request with no token", func() { + Expect(rawReq("GET", "/Users/Me", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a protected request with a bogus token", func() { + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", "not-a-valid-jwt") + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go new file mode 100644 index 000000000..af639ed29 --- /dev/null +++ b/server/jellyfin/e2e/browsing_test.go @@ -0,0 +1,389 @@ +package e2e + +import ( + "net/http" + "time" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func names(items []dto.BaseItemDto) []string { + out := make([]string, len(items)) + for i, it := range items { + out[i] = it.Name + } + return out +} + +var _ = Describe("Browsing", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /UserViews", func() { + It("returns the user's libraries as CollectionFolders", func() { + q := queryResult(get("/UserViews")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Music Library")) + Expect(q.Items[0].Type).To(Equal("CollectionFolder")) + Expect(q.Items[0].CollectionType).To(Equal("music")) + }) + }) + + Describe("GET /Items by type", func() { + It("lists all albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("lists all songs with Audio type and an AlbumId", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(7)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Audio")) + Expect(it.MediaType).To(Equal("Audio")) + Expect(it.LocationType).To(Equal("FileSystem")) + Expect(it.ServerId).ToNot(BeEmpty()) // real Jellyfin always sets it + Expect(it.AlbumId).ToNot(BeEmpty()) + } + }) + + // Real Jellyfin omits MediaSources from a plain list response, returning it only when the + // client asks via Fields=MediaSources (Finamp's download dialog does). + It("omits MediaSources unless Fields=MediaSources is requested", func() { + plain := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) + for _, it := range plain.Items { + Expect(it.MediaSources).To(BeEmpty()) + } + withSources := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=MediaSources")) + for _, it := range withSources.Items { + Expect(it.MediaSources).To(HaveLen(1)) + } + }) + + It("lists all album artists", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(4)) + Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) + }) + + It("lists all genres", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(3)) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + + It("returns no playlists when none exist", func() { + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + Expect(q.Items).To(BeEmpty()) + }) + + It("defaults to albums when IncludeItemTypes is unrecognized", func() { + q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + }) + + Describe("ParentId browsing", func() { + It("browses an artist's albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("browses an album's tracks in track order by default", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")))) + Expect(q.TotalRecordCount).To(Equal(2)) + // Track order (Something=1, Come Together=2) differs from alphabetical title order, + // proving the sort is by track number, not name. + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + Expect(*q.Items[0].IndexNumber).To(Equal(1)) + Expect(*q.Items[1].IndexNumber).To(Equal(2)) + }) + + // "Latest Releases": if PremiereDate isn't recognized, applySort falls through to album-name order. + It("sorts an artist's tracks by release year for SortBy=PremiereDate (Latest Releases)", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumArtistIds=" + enc(artistID("The Beatles")) + + "&SortBy=PremiereDate%2CAlbum%2CParentIndexNumber%2CIndexNumber%2CSortName&SortOrder=Descending")) + got := names(q.Items) + Expect(got).To(HaveLen(3)) + Expect(got[:2]).To(ConsistOf("Come Together", "Something")) + Expect(got[2]).To(Equal("Help!")) + }) + + It("respects Finamp's explicit ParentIndexNumber/IndexNumber SortBy on an album", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&SortBy=ParentIndexNumber,IndexNumber,SortName")) + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + }) + }) + + // Finamp's artist screen sends ParentId= (scoping) plus AlbumArtistIds/ArtistIds + // for the actual artist filter, not ParentId=. + Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { + lib1 := enc("1") + + It("filters albums by AlbumArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("filters songs by ArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) + }) + + It("filters albums by a single-album artist", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&AlbumArtistIds=" + enc(artistID("Led Zeppelin")))) + Expect(names(q.Items)).To(ConsistOf("IV")) + }) + + It("filters songs by a single-track artist", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("Miles Davis")))) + Expect(names(q.Items)).To(ConsistOf("So What")) + }) + + // contributingArtistIds is Jellify's "Featured On" section: albums the artist only appears + // on, which must exclude their own discography (albums where they are the album artist). + It("lists Featured On albums (contributingArtistIds) a performer only guests on", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("Featured Guest")))) + Expect(names(q.Items)).To(ConsistOf("Singles")) + }) + + It("excludes an album artist's own discography from Featured On (contributingArtistIds)", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).ToNot(ContainElement("Abbey Road")) + Expect(names(q.Items)).ToNot(ContainElement("Help!")) + }) + }) + + // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. + Describe("genre filtering (GenreIds)", func() { + lib1 := enc("1") + + It("filters albums by GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue")) + Expect(q.TotalRecordCount).To(Equal(1)) + }) + + It("filters songs by GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Rock")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!", "Stairway To Heaven")) + Expect(q.TotalRecordCount).To(Equal(4)) + }) + + It("matches any of multiple comma-separated GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) + }) + + It("matches any of multiple repeated GenreIds params (@jellyfin/sdk spelling)", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "&GenreIds=" + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) + }) + + It("returns nothing for an unknown genre id", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc("no-such-genre"))) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + + It("filters album artists by GenreIds on /Artists/AlbumArtists", func() { + q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) + Expect(names(q.Items)).To(ConsistOf("Miles Davis")) + Expect(q.TotalRecordCount).To(Equal(1)) + }) + + It("matches album artists of any of multiple GenreIds", func() { + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Miles Davis", "Solo Artist")) + }) + + It("filters album artists by GenreIds via /Items?IncludeItemTypes=MusicArtist", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true&GenreIds=" + enc(genreID("Rock")))) + Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin")) + }) + + It("returns no artists for an unknown genre id", func() { + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc("no-such-genre"))) + Expect(q.Items).To(BeEmpty()) + }) + }) + + // Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase + // (parentId, includeItemTypes, albumArtistIds), where Finamp sends PascalCase. Real Jellyfin + // binds them case-insensitively; these guard that our dispatcher does too, and that browsing an + // album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks. + Describe("camelCase query params (Jellify / JS SDK)", func() { + lib1 := enc("1") + + It("filters albums by camelCase albumArtistIds", func() { + q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("filters songs by camelCase artistIds", func() { + q := queryResult(get("/Items?includeItemTypes=Audio&recursive=true&parentId=" + lib1 + "&artistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) + }) + + It("browses an album's tracks with only camelCase parentId (no IncludeItemTypes)", func() { + q := queryResult(get("/Items?parentId=" + enc(albumID("Abbey Road")) + "&sortBy=ParentIndexNumber&sortBy=IndexNumber&sortBy=SortName")) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + }) + + It("browses an artist's albums with only camelCase parentId (no IncludeItemTypes)", func() { + q := queryResult(get("/Items?parentId=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + }) + + Describe("search, batch and pagination", func() { + It("searches albums by term", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("batch-fetches specific items by Ids", func() { + ids := enc(albumID("Abbey Road")) + "," + enc(albumID("IV")) + q := queryResult(get("/Items?ids=" + ids)) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV")) + }) + + // Finamp restores its saved queue with ids truncated to 16 bytes (see README). + Describe("Finamp-truncated ids (saved queue restore)", func() { + It("resolves a truncated id by unique prefix and echoes the requested id", func() { + full := songID("Come Together") + truncated := full[:16] + q := queryResult(get("/Items?ids=" + enc(truncated))) + Expect(names(q.Items)).To(ConsistOf("Come Together")) + // Finamp matches restored items by its stored ids, so the requested id must be echoed. + Expect(q.Items[0].Id).To(Equal(enc(truncated))) + }) + + It("batch-resolves a mixed list of truncated and full ids, keeping order", func() { + ids := enc(songID("Come Together")[:16]) + "," + enc(songID("So What")) + "," + enc(songID("Help!")[:16]) + q := queryResult(get("/Items?ids=" + ids)) + Expect(names(q.Items)).To(Equal([]string{"Come Together", "So What", "Help!"})) + }) + + It("streams a track by its truncated id", func() { + full := songID("So What") + w := get("/Audio/" + enc(full[:16]) + "/stream") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(full)) + }) + + It("still 404s for a truncated id matching nothing", func() { + Expect(get("/Audio/" + enc("zzzzzzzzzzzzzzzz") + "/stream").Code).To(Equal(http.StatusNotFound)) + }) + }) + + It("applies Limit while reporting the full TotalRecordCount", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2")) + Expect(q.Items).To(HaveLen(2)) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("pages distinct items via StartIndex", func() { + p1 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=0")) + p2 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=2")) + Expect(p1.Items).To(HaveLen(2)) + Expect(p2.Items).To(HaveLen(2)) + Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) + }) + + It("merges multiple types into one paginated result", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs + }) + }) + + Describe("GET /Items/{id}", func() { + It("resolves an album", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(albumID("Kind of Blue"))), &item) + Expect(item.Name).To(Equal("Kind of Blue")) + Expect(item.Type).To(Equal("MusicAlbum")) + }) + + It("resolves a song", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.Type).To(Equal("Audio")) + }) + + It("includes a parseable DateCreated (Date Added) on a song", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.DateCreated).ToNot(BeEmpty()) + _, err := time.Parse(time.RFC3339, item.DateCreated) + Expect(err).ToNot(HaveOccurred()) + }) + + It("includes structured ArtistItems and AlbumArtists on a song (now-playing artist)", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.ArtistItems).ToNot(BeEmpty()) + Expect(item.ArtistItems[0].Name).To(Equal("Miles Davis")) + Expect(item.ArtistItems[0].Id).ToNot(BeEmpty()) + Expect(item.AlbumArtists).ToNot(BeEmpty()) + Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) + }) + + It("resolves an artist", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) + Expect(item.Type).To(Equal("MusicArtist")) + }) + + It("returns 404 for an unknown id", func() { + Expect(get("/Items/" + enc("does-not-exist")).Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /Users/{userId}/Items/Latest", func() { + It("returns recent albums as a bare array, respecting Limit", func() { + var items []dto.BaseItemDto + parseInto(get("/Users/admin-1/Items/Latest?Limit=3"), &items) + Expect(items).To(HaveLen(3)) + for _, it := range items { + Expect(it.Type).To(Equal("MusicAlbum")) + } + }) + }) + + Describe("GET /Artists and /Genres", func() { + It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { + names := names(queryResult(get("/Artists/AlbumArtists")).Items) + Expect(names).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) + Expect(names).ToNot(ContainElement("Featured Guest")) + }) + + It("lists performing artists on /Artists (includes a track's guest artist)", func() { + names := names(queryResult(get("/Artists")).Items) + Expect(names).To(ContainElement("Featured Guest")) + Expect(names).To(ContainElement("Solo Artist")) + }) + + It("returns different lists for album artists and performing artists", func() { + aa := names(queryResult(get("/Artists/AlbumArtists")).Items) + ar := names(queryResult(get("/Artists")).Items) + Expect(aa).ToNot(Equal(ar)) + }) + + It("lists genres", func() { + q := queryResult(get("/Genres")) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + + It("pages genres with StartIndex/Limit and still reports the full total", func() { + q := queryResult(get("/Genres?StartIndex=1&Limit=1")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(3)) + }) + }) +}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go new file mode 100644 index 000000000..02c5183f8 --- /dev/null +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -0,0 +1,365 @@ +// Package e2e provides end-to-end integration tests for the Navidrome Jellyfin API. +// +// These tests exercise the full HTTP request/response cycle through the Jellyfin API router, +// using a real SQLite database and real repository implementations while stubbing out external +// services (artwork, streaming, transcoding) with spy/noop implementations. +// +// The harness mirrors server/subsonic/e2e (the Subsonic suite): BeforeSuite creates a temporary SQLite +// database, seeds two users (admin + regular) and one library backed by a fake in-memory +// filesystem, runs the scanner, and snapshots the golden DB. Each top-level Describe restores +// that snapshot and builds a fresh jellyfin.Router. +// +// # Seeded library (see buildTestFS) +// +// Rock/The Beatles/Abbey Road/ 01 Something (1969), 02 Come Together (1969) +// Rock/The Beatles/Help!/ 01 Help! (1965) +// Rock/Led Zeppelin/IV/ 01 Stairway To Heaven (1971) +// Jazz/Miles Davis/Kind of Blue/01 So What (1959) +// Pop/Solo Artist/Singles/ 01 Standalone Track (2020), 02 Duet (artist "Featured Guest") +// +// Totals: 7 songs, 5 albums, 4 album artists (+ 1 performer-only "Featured Guest" = 5 artists), +// 3 genres (Rock=4, Jazz=1, Pop=2). +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJellyfinE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin API E2E Suite") +} + +// Easy aliases for the storagetest package +type _t = map[string]any + +var ( + template = storagetest.Template + track = storagetest.Track +) + +// Shared test state +var ( + ctx context.Context + ds *tests.MockDataStore + router http.Handler + streamerSpy *harness.SpyStreamer + artworkSpy *spyArtwork + providerFake *fakeExternalProvider + goldenDB *harness.DB + dataFolder string + + adminUser = model.User{ + ID: "admin-1", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + } + + regularUser = model.User{ + ID: "regular-1", + UserName: "regular", + Name: "Regular User", + IsAdmin: false, + } +) + +// buildTestFS creates the seeded test filesystem (see package doc for totals). +func buildTestFS() storagetest.FakeFS { + abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) + ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) + + return harness.CreateFS(fstest.MapFS{ + // Track numbers are deliberately reversed vs. alphabetical title order (Something=1, + // Come Together=2) so tests can tell track-order sorting apart from title sorting. + "Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")), + "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), + "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), + "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), + // "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a + // performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists. + "Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})), + }) +} + +// --- Request helpers --- + +// jReq performs a full HTTP round-trip as the given user (token auth) and returns the recorder. +func jReq(user model.User, method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + r := httptest.NewRequest(method, path, reader) + token, err := auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("X-Emby-Token", token) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + router.ServeHTTP(w, r) + return w +} + +// rawReq performs a request with no authentication (for public routes). +func rawReq(method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + r := httptest.NewRequest(method, path, reader) + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + router.ServeHTTP(w, r) + return w +} + +func get(path string) *httptest.ResponseRecorder { return jReq(adminUser, "GET", path, "") } +func getAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "GET", path, "") } +func post(path, body string) *httptest.ResponseRecorder { return jReq(adminUser, "POST", path, body) } +func postAs(u model.User, path, body string) *httptest.ResponseRecorder { + return jReq(u, "POST", path, body) +} +func del(path string) *httptest.ResponseRecorder { return jReq(adminUser, "DELETE", path, "") } +func delAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "DELETE", path, "") } + +// upload performs an authenticated POST with a custom Content-Type and raw body (image upload). +func upload(user model.User, path, contentType string, body []byte) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", path, bytes.NewReader(body)) + token, err := auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("X-Emby-Token", token) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) + r.Header.Set("Content-Type", contentType) + router.ServeHTTP(w, r) + return w +} + +// parseInto asserts a 200 and unmarshals the JSON body into target. +func parseInto(w *httptest.ResponseRecorder, target any) { + Expect(w.Code).To(Equal(http.StatusOK), "body: %s", w.Body.String()) + Expect(json.Unmarshal(w.Body.Bytes(), target)).To(Succeed()) +} + +// queryResult asserts a 200 and returns the parsed QueryResult. +func queryResult(w *httptest.ResponseRecorder) dto.QueryResult { + var q dto.QueryResult + parseInto(w, &q) + return q +} + +// createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a +// client would send) and returns its decoded Navidrome id. +func createPlaylist(name string, encodedIds []string) string { + return createPlaylistAs(adminUser, name, encodedIds...) +} + +// createPlaylistAs creates a playlist owned by the given user and returns its decoded id. +func createPlaylistAs(user model.User, name string, encodedIds ...string) string { + if encodedIds == nil { + encodedIds = []string{} + } + body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds}) + Expect(err).ToNot(HaveOccurred()) + var res map[string]string + parseInto(postAs(user, "/Playlists", string(body)), &res) + Expect(res["Id"]).ToNot(BeEmpty()) + return dto.DecodeID(res["Id"]) +} + +// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) --- + +func enc(id string) string { return dto.EncodeID(id) } + +// The seeded library is tiny, so the id lookups fetch-all and match by name in Go rather than +// guessing repository filter column names. + +func albumID(name string) string { + albums, err := ds.Album(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range albums { + if a.Name == name { + return a.ID + } + } + Fail("album not found: " + name) + return "" +} + +func songID(title string) string { + mfs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range mfs { + if mf.Title == title { + return mf.ID + } + } + Fail("song not found: " + title) + return "" +} + +func artistID(name string) string { + artists, err := ds.Artist(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range artists { + if a.Name == name { + return a.ID + } + } + Fail("artist not found: " + name) + return "" +} + +func genreID(name string) string { + genres, err := ds.Genre(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, g := range genres { + if g.Name == name { + return g.ID + } + } + Fail("genre not found: " + name) + return "" +} + +// --- Suite lifecycle --- + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + dataFolder = filepath.Join(GinkgoT().TempDir(), "data") + Expect(os.MkdirAll(dataFolder, 0o755)).To(Succeed()) + + conf.Server.MusicFolder = "fake:///music" + conf.Server.DataFolder = conf.NewDir(dataFolder) + conf.Server.DevExternalScanner = false + + buildTestFS() + goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) +}) + +var _ = AfterSuite(func() { + db.Close(ctx) +}) + +// setupTestDB restores the golden snapshot and builds a fresh jellyfin.Router. Call from +// BeforeEach in each test container. +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DataFolder = conf.NewDir(dataFolder) + conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false + + goldenDB.Restore() + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + streamerSpy = &harness.SpyStreamer{} + artworkSpy = &spyArtwork{} + providerFake = &fakeExternalProvider{} + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + router = jellyfin.New( + ds, + artworkSpy, + streamerSpy, + decider, + core.NewPlayers(ds), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), + playlists.NewPlaylists(ds, core.NewImageUploadService()), + providerFake, + ) +} + +// fakeExternalProvider is a configurable stand-in for external.Provider. Tests set the return +// values they need; unset fields yield empty similar lists. Only the methods the Jellyfin API uses +// are overridden — the embedded interface panics for anything else, flagging unexpected calls. +type fakeExternalProvider struct { + external.Provider + similarArtists model.Artists + similarSongs model.MediaFiles +} + +func (f *fakeExternalProvider) UpdateArtistInfo(_ context.Context, id string, _ int, _ bool) (*model.Artist, error) { + return &model.Artist{ID: id, SimilarArtists: f.similarArtists}, nil +} + +func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return f.similarSongs, nil +} + +// --- Spy/noop dependencies (shared ones live in tests/harness) --- + +// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the +// resolved ArtworkID and that resolution runs under an elevated (admin) context. +type spyArtwork struct { + lastID string + lastCtx context.Context + data []byte +} + +func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { + return nil, time.Time{}, model.ErrNotFound +} + +func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) { + s.lastID = id + s.lastCtx = c + d := s.data + if d == nil { + d = []byte("IMG") + } + return io.NopCloser(bytes.NewReader(d)), time.Time{}, nil +} + +var _ artwork.Artwork = &spyArtwork{} diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go new file mode 100644 index 000000000..0c53d75f3 --- /dev/null +++ b/server/jellyfin/e2e/images_test.go @@ -0,0 +1,74 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The image endpoint is public and resolves artwork under an elevated (admin) context. The suite +// wires a spyArtwork that captures the resolved ArtworkID and the context, so these tests assert +// resolution and elevation without needing real image processing. +var _ = Describe("Item images", func() { + BeforeEach(func() { setupTestDB() }) + + It("resolves an album's Primary image", func() { + id := albumID("Abbey Road") + w := get("/Items/" + enc(id) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves an artist's Primary image", func() { + id := artistID("Miles Davis") + Expect(get("/Items/" + enc(id) + "/Images/Primary").Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves a private playlist's cover for its owner under an elevated context", func() { + // The route carries no user in ctx (public); the owner is identified by the request token, + // and resolution then runs elevated so the visibility filter doesn't eat the cover. + plID := createPlaylist("Private Mix", nil) + w := get("/Items/" + enc(plID) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) + + u, ok := request.UserFrom(artworkSpy.lastCtx) + Expect(ok).To(BeTrue()) + Expect(u.IsAdmin).To(BeTrue()) + }) + + It("serves images without authentication (public route)", func() { + id := albumID("IV") + w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + }) + + Describe("private playlist covers", func() { + It("does not resolve a private playlist's cover for an unauthenticated caller", func() { + plID := createPlaylist("Secret Mix", nil) // owned by admin, private + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) // placeholder, not an auth error + Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) + }) + + It("does not resolve a private playlist's cover for another user", func() { + plID := createPlaylist("Secret Mix", nil) + w := getAs(regularUser, "/Items/"+enc(plID)+"/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) + }) + + It("resolves a public playlist's cover for anyone", func() { + plID := createPlaylist("Shared Mix", nil) + Expect(post("/Playlists/"+enc(plID), `{"IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) + }) + }) +}) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go new file mode 100644 index 000000000..015d82d91 --- /dev/null +++ b/server/jellyfin/e2e/multiuser_test.go @@ -0,0 +1,64 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Multi-user access control", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("library scoping", func() { + It("lets a library member browse its content", func() { + q := queryResult(getAs(regularUser, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("hides all content from a user with no library access", func() { + noAccess := model.User{ID: "noaccess-1", UserName: "noaccess", Name: "No Access", NewPassword: "password"} + Expect(ds.User(ctx).Put(&noAccess)).To(Succeed()) + loaded, err := ds.User(ctx).FindByUsername("noaccess") + Expect(err).ToNot(HaveOccurred()) + + q := queryResult(getAs(*loaded, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + + Describe("private playlists", func() { + It("does not expose another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + + // Owner sees it. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + // A different user does not. + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + // And can't read its items. + Expect(getAs(regularUser, "/Playlists/"+enc(adminPl)+"/Items").Code).To(Equal(http.StatusNotFound)) + }) + + It("does not let a non-owner delete another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + // The playlist is invisible to the regular user, so delete resolves to 404 (not 403) — + // the API never reveals that someone else's private playlist exists. + Expect(delAs(regularUser, "/Items/"+enc(adminPl)).Code).To(Equal(http.StatusNotFound)) + // Still present for the owner. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + }) + + It("does not let a non-owner annotate another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound)) + Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound)) + }) + + It("lets each user manage their own playlist", func() { + regularPl := createPlaylistAs(regularUser, "Regular's Mix") + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + Expect(delAs(regularUser, "/Items/"+enc(regularPl)).Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go new file mode 100644 index 000000000..73b76e3d8 --- /dev/null +++ b/server/jellyfin/e2e/playlists_test.go @@ -0,0 +1,311 @@ +package e2e + +import ( + "bytes" + "image" + jpeglib "image/jpeg" + "net/http" + "os" + "time" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlists", func() { + BeforeEach(func() { setupTestDB() }) + + playlistItems := func(plID string) dto.QueryResult { + return queryResult(get("/Playlists/" + enc(plID) + "/Items")) + } + + Describe("create", func() { + It("creates an empty playlist", func() { + plID := createPlaylist("Empty", nil) + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + Expect(info.OpenAccess).To(BeFalse()) + Expect(info.Shares).To(BeEmpty()) + Expect(info.ItemIds).To(BeEmpty()) + }) + + It("creates a playlist from song ids", func() { + plID := createPlaylist("Songs", []string{enc(songID("Come Together")), enc(songID("So What"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) + }) + + It("expands an album id into its tracks", func() { + plID := createPlaylist("From Album", []string{enc(albumID("Abbey Road"))}) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + }) + + It("expands an artist id into its tracks", func() { + plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) + }) + }) + + Describe("items", func() { + It("tags each entry with a PlaylistItemId", func() { + plID := createPlaylist("Tagged", []string{enc(songID("Help!"))}) + q := playlistItems(plID) + Expect(q.Items).To(HaveLen(1)) + Expect(q.Items[0].Type).To(Equal("Audio")) + Expect(q.Items[0].PlaylistItemId).ToNot(BeEmpty()) + }) + }) + + Describe("add and remove", func() { + It("adds a song by id", func() { + plID := createPlaylist("Add", nil) + Expect(post("/Playlists/"+enc(plID)+"/Items?ids="+enc(songID("So What")), "").Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + + It("adds an album (expanding to its tracks)", func() { + plID := createPlaylist("AddAlbum", []string{enc(songID("So What"))}) + post("/Playlists/"+enc(plID)+"/Items?ids="+enc(albumID("Abbey Road")), "") + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // 1 + Abbey Road (2) + }) + + // Jellify's @jellyfin/sdk serializes id arrays as repeated params (ids=X&ids=Y), not a + // comma-joined value; all ids must be added, not just the first. + It("adds multiple songs sent as repeated ids params", func() { + plID := createPlaylist("Multi", nil) + url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + + "&ids=" + enc(songID("Come Together")) + "&ids=" + enc(songID("Help!")) + Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) + }) + + 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 + Expect(del("/Playlists/" + enc(plID) + "/Items?entryIds=" + entryID).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + + It("removes multiple entries sent as repeated entryIds params", func() { + plID := createPlaylist("MultiRemove", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) + items := playlistItems(plID).Items + url := "/Playlists/" + enc(plID) + "/Items?entryIds=" + items[0].PlaylistItemId + "&entryIds=" + items[1].PlaylistItemId + Expect(del(url).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + }) + + Describe("users", func() { + It("reports the current user as an editor", func() { + plID := createPlaylist("Perms", nil) + var perms []dto.PlaylistUserPermissions + parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) + Expect(perms).To(HaveLen(1)) + Expect(perms[0].UserId).To(Equal("admin-1")) + Expect(perms[0].CanEdit).To(BeTrue()) + }) + }) + + Describe("listing", func() { + It("lists a created playlist advertising a Primary image tag", func() { + createPlaylist("Listed", nil) + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Listed")) + Expect(q.Items[0].ImageTags).To(HaveKey("Primary")) + }) + + It("sorts playlists by name when SortBy=SortName", func() { + createPlaylist("Charlie", nil) + createPlaylist("Alpha", nil) + createPlaylist("Bravo", nil) + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&SortBy=SortName")) + Expect(names(q.Items)).To(Equal([]string{"Alpha", "Bravo", "Charlie"})) + }) + }) + + // Jellify resolves the "playlists library" via a ManualPlaylistsFolder query, then lists + // playlists with ParentId set to that folder's id (no IncludeItemTypes). Without a folder item + // whose CollectionType is "playlists", its query resolves undefined and React Query retries in a + // backoff loop that stalls the home screen. + Describe("playlists library folder (ManualPlaylistsFolder)", func() { + It("returns a synthetic playlists folder with CollectionType=playlists", func() { + q := queryResult(get("/Items?includeItemTypes=ManualPlaylistsFolder&excludeItemTypes=CollectionFolder")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.Items[0].CollectionType).To(Equal("playlists")) + Expect(q.Items[0].Id).To(Equal(enc("playlists"))) + }) + + It("lists the user's playlists when browsing the folder by ParentId (no IncludeItemTypes)", func() { + createPlaylist("My Mix", nil) + q := queryResult(get("/Items?parentId=" + enc("playlists"))) + Expect(names(q.Items)).To(ContainElement("My Mix")) + Expect(q.Items[0].Type).To(Equal("Playlist")) + // Jellify keeps only playlists whose Path contains "data". + Expect(q.Items[0].Path).To(ContainSubstring("data")) + }) + + It("resolves the synthetic playlists folder by its own advertised id", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc("playlists")), &item) + Expect(item.Type).To(Equal("ManualPlaylistsFolder")) + Expect(item.CollectionType).To(Equal("playlists")) + Expect(item.Id).To(Equal(enc("playlists"))) + }) + }) + + // Real Jellyfin returns a playlist's children for /Items?ParentId= with no + // IncludeItemTypes; generic clients (not Finamp/Jellify) browse playlists this way. + Describe("browsing a playlist via the generic /Items path", func() { + It("lists the playlist's tracks for a typeless ParentId query", func() { + plID := createPlaylist("Browse Me", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID))) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("pages the playlist's tracks", func() { + plID := createPlaylist("Browse Paged", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID) + "&startIndex=1&limit=1")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(2)) + }) + + // Jellify opens a playlist with ParentId=&IncludeItemTypes=Audio&Recursive=false. + // The playlist id must resolve to its tracks, not be treated as an album id (which returns none). + It("lists the playlist's tracks even when IncludeItemTypes=Audio is set", func() { + plID := createPlaylist("Typed Browse", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID) + "&includeItemTypes=Audio&recursive=false")) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) + }) + }) + + Describe("cover art", func() { + // A real (decodable) image: the upload endpoint validates by decoding, like the native one. + var jpeg []byte + BeforeEach(func() { + var buf bytes.Buffer + Expect(jpeglib.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + jpeg = buf.Bytes() + }) + + It("uploads and removes a playlist cover", func() { + plID := createPlaylist("Cover", nil) + + Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNoContent)) + + pls, err := ds.Playlist(ctx).Get(plID) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.UploadedImage).ToNot(BeEmpty()) + _, statErr := os.Stat(pls.UploadedImagePath()) + Expect(statErr).ToNot(HaveOccurred(), "cover file should exist on disk") + + Expect(del("/Items/" + enc(plID) + "/Images/Primary").Code).To(Equal(http.StatusNoContent)) + pls, _ = ds.Playlist(ctx).Get(plID) + Expect(pls.UploadedImage).To(BeEmpty()) + }) + + It("rejects cover upload for a non-playlist item", func() { + Expect(upload(adminUser, "/Items/"+enc(albumID("IV"))+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNotImplemented)) + }) + + // Guards the whole chain: SetImage must go through a full Put (which bumps UpdatedAt), and the + // tag must be versioned by it, or clients keep their blurhash-keyed cover cache forever. + It("rotates the playlist's image tag and blurhash after a cover upload", func() { + plID := createPlaylist("Cover Tag", nil) + imageTag := func() string { + q := queryResult(get("/Items?ids=" + enc(plID))) + Expect(q.Items).To(HaveLen(1)) + return q.Items[0].ImageTags["Primary"] + } + before := imageTag() + Expect(before).ToNot(BeEmpty()) + + time.Sleep(2 * time.Millisecond) // UpdatedAt has millisecond resolution in the tag + Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNoContent)) + + after := imageTag() + Expect(after).ToNot(Equal(before)) + q := queryResult(get("/Items?ids=" + enc(plID))) + Expect(q.Items[0].ImageBlurHashes["Primary"]).To(HaveKey(after)) + }) + }) + + Describe("update", func() { + It("makes a playlist public", func() { + plID := createPlaylist("Make Public", nil) + Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + Expect(info.OpenAccess).To(BeTrue()) + // Now visible to other users. + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + }) + + It("renames a playlist", func() { + plID := createPlaylist("Old Name", nil) + Expect(post("/Playlists/"+enc(plID), `{"Name":"New Name"}`).Code).To(Equal(http.StatusNoContent)) + pls, _ := ds.Playlist(ctx).Get(plID) + Expect(pls.Name).To(Equal("New Name")) + }) + + It("replaces the track list when Ids are provided", func() { + plID := createPlaylist("Reorder", []string{enc(songID("Come Together")), enc(songID("Something"))}) + // Replace with a single different track. + Expect(post("/Playlists/"+enc(plID), `{"Ids":["`+enc(songID("So What"))+`"]}`).Code).To(Equal(http.StatusNoContent)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("So What")) + }) + + It("clears the track list when an explicit empty Ids array is sent", func() { + plID := createPlaylist("Clear Me", []string{enc(songID("Come Together")), enc(songID("Something"))}) + Expect(post("/Playlists/"+enc(plID), `{"Ids":[]}`).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(0)) + }) + + It("leaves the track list intact when Ids is omitted (metadata-only update)", func() { + plID := createPlaylist("Keep Tracks", []string{enc(songID("Come Together")), enc(songID("Something"))}) + Expect(post("/Playlists/"+enc(plID), `{"Name":"Renamed"}`).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) + }) + + It("applies Name and IsPublic sent together with a track replacement", func() { + plID := createPlaylist("Combo", []string{enc(songID("Come Together"))}) + body := `{"Name":"Combo Renamed","IsPublic":true,"Ids":["` + enc(songID("So What")) + `"]}` + Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNoContent)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("So What")) + pls, _ := ds.Playlist(ctx).Get(plID) + Expect(pls.Name).To(Equal("Combo Renamed")) + Expect(pls.Public).To(BeTrue()) + }) + + It("forbids a non-owner from updating a public playlist", func() { + plID := createPlaylist("Owned", nil) + post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user + Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("delete", func() { + It("deletes a playlist", func() { + plID := createPlaylist("ToDelete", nil) + Expect(del("/Items/" + enc(plID)).Code).To(Equal(http.StatusNoContent)) + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + }) + + It("returns 404 when deleting a non-playlist item", func() { + Expect(del("/Items/" + enc(albumID("IV"))).Code).To(Equal(http.StatusNotFound)) + }) + }) +}) diff --git a/server/jellyfin/e2e/routing_test.go b/server/jellyfin/e2e/routing_test.go new file mode 100644 index 000000000..49faad342 --- /dev/null +++ b/server/jellyfin/e2e/routing_test.go @@ -0,0 +1,31 @@ +package e2e + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Routing", func() { + BeforeEach(func() { setupTestDB() }) + + It("routes authenticated endpoints case-insensitively", func() { + // Lowercase path variant of GET /Items — real clients (jellyfin-apiclient-python) send these. + lower := queryResult(get("/items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(lower.TotalRecordCount).To(Equal(5)) + }) + + It("returns a JSON 404 for an unknown route", func() { + w := get("/Nonexistent/Route") + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) + Expect(w.Body.String()).To(ContainSubstring("{}")) + }) + + It("returns 404 for an unsupported method on a known path", func() { + // PUT isn't registered for /Items; the MethodNotAllowed handler maps to the same JSON 404. + w := jReq(adminUser, "PUT", "/Items", "") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go new file mode 100644 index 000000000..6c26569fc --- /dev/null +++ b/server/jellyfin/e2e/search_test.go @@ -0,0 +1,76 @@ +package e2e + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Search with a ParentId library scope is how Finamp drives its search screen. Artists are the +// tricky case: they have no library_id column, so the repo's Search does its own scope handling. +var _ = Describe("Search", func() { + BeforeEach(func() { setupTestDB() }) + + lib1 := func() string { return enc("1") } // Library id 1 encodes to "31" + + Describe("artists", func() { + It("searches all album artists", func() { + q := queryResult(get("/Artists/AlbumArtists?SearchTerm=Beatles")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("searches album artists scoped to a library (ParentId)", func() { + q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1() + "&SearchTerm=Beatles&Recursive=true&SortBy=SortName")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("returns an empty result for a non-matching term", func() { + q := queryResult(get("/Artists?ParentId=" + lib1() + "&SearchTerm=nonexistentxyz")) + Expect(q.Items).To(BeEmpty()) + }) + }) + + Describe("albums and songs", func() { + It("searches albums scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("searches songs scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Stairway")) + Expect(names(q.Items)).To(ContainElement("Stairway To Heaven")) + }) + }) + + Describe("pagination totals", func() { + It("reports the search match count, not the unfiltered library count", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey&Limit=50")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(1)) // not the 5-album library total + }) + + It("reaches the true total when paging song search results", func() { + // "So" prefix-matches several songs (titles and Solo Artist's tracks); learn the true + // count from an unpaged query, then walk one-item pages: the reported total must keep + // the client paging until the last match and stop it exactly there. + all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So")) + total := all.TotalRecordCount + Expect(total).To(Equal(len(all.Items))) + Expect(total).To(BeNumerically(">=", 2)) + + var collected []string + for start := range total { + page := queryResult(get(fmt.Sprintf("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So&Limit=1&StartIndex=%d", start))) + Expect(page.Items).To(HaveLen(1)) + if start+1 < total { + Expect(page.TotalRecordCount).To(BeNumerically(">", start+1)) // more remain: keep paging + } else { + Expect(page.TotalRecordCount).To(Equal(total)) // last page: exact, so the client stops + } + collected = append(collected, page.Items[0].Name) + } + Expect(collected).To(ConsistOf(names(all.Items))) + }) + }) +}) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go new file mode 100644 index 000000000..2057b43c6 --- /dev/null +++ b/server/jellyfin/e2e/sessions_test.go @@ -0,0 +1,62 @@ +package e2e + +import ( + "net/http" + "strconv" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sessions", func() { + BeforeEach(func() { setupTestDB() }) + + ticks := func(ms int64) int64 { return ms * 10_000 } + reportBody := func(itemID string, positionTicks int64) string { + return `{"ItemId":"` + enc(itemID) + `","PositionTicks":` + strconv.FormatInt(positionTicks, 10) + `}` + } + + Describe("playback reporting", func() { + It("accepts a playback start report", func() { + Expect(post("/Sessions/Playing", reportBody(songID("Come Together"), 0)).Code).To(Equal(http.StatusNoContent)) + }) + + It("accepts a playback progress report", func() { + Expect(post("/Sessions/Playing/Progress", reportBody(songID("Come Together"), ticks(5000))).Code).To(Equal(http.StatusNoContent)) + }) + + It("counts a play stopped past the threshold", func() { + id := songID("So What") + mf, err := ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + // Report a stop at the end of the track — comfortably past 50% / the 4-minute cap. + Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(int64(mf.Duration*1000)))).Code).To(Equal(http.StatusNoContent)) + + mf, err = ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.PlayCount).To(BeNumerically(">=", 1)) + }) + + 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% + // threshold is always well above 1s. + id := songID("Help!") + Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(1000))).Code).To(Equal(http.StatusNoContent)) + + mf, err := ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.PlayCount).To(Equal(int64(0))) + }) + }) + + Describe("capabilities", func() { + It("acknowledges POST /Sessions/Capabilities", func() { + Expect(post("/Sessions/Capabilities", "{}").Code).To(Equal(http.StatusNoContent)) + }) + + It("acknowledges POST /Sessions/Capabilities/Full", func() { + Expect(post("/Sessions/Capabilities/Full", "{}").Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go new file mode 100644 index 000000000..43ef857fa --- /dev/null +++ b/server/jellyfin/e2e/similar_test.go @@ -0,0 +1,134 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Similar", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /Artists/{id}/Similar", func() { + It("returns the provider's similar artists, excluding ones not in the library", func() { + providerFake.similarArtists = model.Artists{ + {ID: "z", Name: "Led Zeppelin"}, + {ID: "", Name: "Not In Library"}, // no id -> not present -> excluded + } + q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) + Expect(names(q.Items)).To(ConsistOf("Led Zeppelin")) + Expect(q.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("returns an empty result (not 404) when the provider has nothing", func() { + q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + + Describe("GET /Items/{id}/Similar", func() { + It("returns similar songs for a track", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(songID("So What")) + "/Similar")) + Expect(names(q.Items)).To(ConsistOf("Similar Song")) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("excludes similar songs from libraries the user can't access", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "In Library", LibraryID: 1}, + {ID: "x2", Title: "Other Library", LibraryID: 2}, // regularUser has no access + } + q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/Similar")) + Expect(names(q.Items)).To(ConsistOf("In Library")) + }) + + It("returns similar albums (derived from similar songs, de-duplicated) for an album", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", AlbumID: albumID("IV")}, + {ID: "x2", AlbumID: albumID("IV")}, // same album -> counted once + {ID: "x3", AlbumID: albumID("Kind of Blue")}, + } + q := queryResult(get("/Items/" + enc(albumID("Abbey Road")) + "/Similar")) + Expect(names(q.Items)).To(Equal([]string{"IV", "Kind of Blue"})) + Expect(q.Items[0].Type).To(Equal("MusicAlbum")) + }) + + It("excludes similar albums from libraries the user can't access", func() { + // Seed an album in a second library the regular user has no access to, and point a + // provider similar-song at it. + otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} + Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + otherAlbum := model.Album{ID: "other-album", Name: "Other Album", LibraryID: 2} + Expect(ds.Album(ctx).Put(&otherAlbum)).To(Succeed()) + + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", AlbumID: albumID("IV")}, // library 1 -> visible + {ID: "x2", AlbumID: "other-album"}, // library 2 -> filtered for regularUser + } + q := queryResult(getAs(regularUser, "/Items/"+enc(albumID("Abbey Road"))+"/Similar")) + Expect(names(q.Items)).To(ConsistOf("IV")) + }) + + It("returns an empty result (not 404) for an unknown item, so the client stops retrying", func() { + q := queryResult(get("/Items/" + enc("does-not-exist") + "/Similar")) + Expect(q.Items).To(BeEmpty()) + }) + }) + + // Finamp plays exactly what InstantMix returns, so a track seed must lead its own mix. + Describe("GET /Items/{id}/InstantMix", func() { + It("returns the seed track first, followed by similar songs", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=19")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("does not duplicate the seed when the provider returns it", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: songID("So What"), Title: "So What", LibraryID: 1}, + {ID: "x1", Title: "Similar Song", LibraryID: 1}, + } + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) + }) + + It("caps the mix at the requested limit", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "S1", LibraryID: 1}, + {ID: "x2", Title: "S2", LibraryID: 1}, + {ID: "x3", Title: "S3", LibraryID: 1}, + } + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=2")) + Expect(names(q.Items)).To(Equal([]string{"So What", "S1"})) + }) + + It("excludes similar songs from libraries the user can't access", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "In Library", LibraryID: 1}, + {ID: "x2", Title: "Other Library", LibraryID: 2}, + } + q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "In Library"})) + }) + + It("returns a mix of the provider's similar songs for an artist seed", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Artist Mix Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(artistID("Miles Davis")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"Artist Mix Song"})) + }) + + It("returns an empty result (not 404) for an unknown item", func() { + w := get("/Items/" + enc("does-not-exist") + "/InstantMix") + Expect(w.Code).To(Equal(200)) + Expect(queryResult(w).Items).To(BeEmpty()) + }) + + It("returns only the seed when the provider has nothing", func() { + q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"Help!"})) + }) + }) +}) diff --git a/server/jellyfin/e2e/smoke_test.go b/server/jellyfin/e2e/smoke_test.go new file mode 100644 index 000000000..b26955632 --- /dev/null +++ b/server/jellyfin/e2e/smoke_test.go @@ -0,0 +1,49 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Smoke test: proves the harness boots (DB, scan, snapshot, router, token auth) and the seeded +// library is queryable end-to-end. Broader per-endpoint coverage lives in the sibling files. +var _ = Describe("Smoke", func() { + BeforeEach(func() { setupTestDB() }) + + It("serves public system info without auth", func() { + w := rawReq("GET", "/System/Info/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info).To(HaveKey("ServerName")) + Expect(info).To(HaveKey("Version")) + }) + + It("rejects an authenticated endpoint without a token", func() { + w := rawReq("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("lists the seeded albums for an authenticated user", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + names := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("MusicAlbum")) + names = append(names, it.Name) + } + Expect(names).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("resolves a seeded album id round-trip (encoded in the URL)", func() { + id := albumID("Abbey Road") + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(id)), &item) + Expect(item.Id).To(Equal(enc(id))) + Expect(item.Name).To(Equal("Abbey Road")) + Expect(item.Type).To(Equal("MusicAlbum")) + }) +}) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go new file mode 100644 index 000000000..c096d904a --- /dev/null +++ b/server/jellyfin/e2e/streaming_test.go @@ -0,0 +1,128 @@ +package e2e + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Streaming", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /Audio/{id}/stream", func() { + It("streams the requested track", func() { + id := songID("Come Together") + w := get("/Audio/" + enc(id) + "/stream") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("fake audio data")) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("streams via the /universal endpoint", func() { + id := songID("So What") + Expect(get("/Audio/" + enc(id) + "/universal").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("serves the stream.{container} path form", func() { + id := songID("Help!") + Expect(get("/Audio/" + enc(id) + "/stream.mp3").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("forces raw format when static=true", func() { + // With ffmpeg unavailable the decider direct-plays regardless, but static=true must + // never resolve to a transcode. + id := songID("Help!") + get("/Audio/" + enc(id) + "/stream?static=true") + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("returns 404 for an unknown track", func() { + Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() { + It("returns a VOD playlist whose segment streams through the transcode pipeline", func() { + id := songID("Come Together") + w := get("/Audio/" + enc(id) + "/main.m3u8?audioCodec=aac&audioBitRate=320000") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) + body := w.Body.String() + Expect(body).To(HavePrefix("#EXTM3U\n")) + Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) + + // Fetch the advertised segment like an HLS player would. + var segment string + for _, line := range strings.Split(body, "\n") { + if line != "" && !strings.HasPrefix(line, "#") { + segment = line + } + } + Expect(segment).To(HavePrefix("stream.aac?")) + Expect(get("/Audio/" + enc(id) + "/" + segment).Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + Expect(streamerSpy.LastRequest.Format).To(Equal("aac")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(320)) + }) + + It("is reachable with Jellyfin's case-insensitive routing", func() { + id := songID("Come Together") + Expect(get("/audio/" + enc(id) + "/Main.m3u8").Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("direct-file endpoints", func() { + It("serves /Items/{id}/File as direct play (raw)", func() { + id := songID("Something") + w := get("/Items/" + enc(id) + "/File") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("serves /Items/{id}/Download", func() { + id := songID("Something") + Expect(get("/Items/" + enc(id) + "/Download").Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("PlaybackInfo", func() { + It("returns a single direct-play MediaSource via GET", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + Expect(info.MediaSources[0].Id).ToNot(BeEmpty()) + Expect(info.PlaySessionId).ToNot(BeEmpty()) + }) + + It("returns a MediaSource via POST", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(post("/Items/"+enc(id)+"/PlaybackInfo", "{}"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + }) + + It("embeds a self-authenticating TranscodingUrl (for native players that omit auth headers)", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + streamURL := info.MediaSources[0].TranscodingUrl + // The URL includes the /jellyfin mount prefix so a client resolving it as an absolute + // host path hits the mounted router. + Expect(streamURL).To(HavePrefix(consts.URLPathJellyfinAPI + "/Audio/" + enc(id) + "/universal")) + Expect(streamURL).To(ContainSubstring("api_key=")) + // The embedded api_key alone must authenticate the stream — no auth header sent. The e2e + // router is mounted at the root, so strip the /jellyfin prefix before replaying. + replayURL := strings.TrimPrefix(streamURL, consts.URLPathJellyfinAPI) + w := rawReq("GET", replayURL, "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + }) +}) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go new file mode 100644 index 000000000..d4d2f2777 --- /dev/null +++ b/server/jellyfin/e2e/system_test.go @@ -0,0 +1,55 @@ +package e2e + +import ( + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /System/Info/Public", func() { + It("returns public server info without authentication", func() { + w := rawReq("GET", "/System/Info/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info["ServerName"]).To(HavePrefix("Navidrome")) + Expect(info["ProductName"]).To(Equal("Jellyfin Server")) + Expect(info["StartupWizardCompleted"]).To(BeTrue()) + Expect(info["Id"]).ToNot(BeEmpty()) + Expect(info["Version"]).ToNot(BeEmpty()) + }) + + It("routes case-insensitively (lowercase path)", func() { + w := rawReq("GET", "/system/info/public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("GET/POST /System/Ping", func() { + It("answers GET with a plain-text server name", func() { + w := rawReq("GET", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("text/plain")) + Expect(w.Body.String()).To(HavePrefix("Navidrome")) + }) + + It("answers POST identically", func() { + w := rawReq("POST", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + 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/images.go b/server/jellyfin/images.go new file mode 100644 index 000000000..722e828c5 --- /dev/null +++ b/server/jellyfin/images.go @@ -0,0 +1,170 @@ +package jellyfin + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "strconv" + + "github.com/dustin/go-humanize" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + _ "golang.org/x/image/webp" +) + +func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { + // Public endpoint (no user in ctx): library artwork isn't user-sensitive, so resolution runs + // under an elevated context to bypass the persistence visibility filter; playlist access is + // gated inside resolveArtworkID. + ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) + itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) + + artID := api.resolveArtworkID(ctx, r, itemId) + reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) + switch { + case errors.Is(err, context.Canceled): + return + case err != nil: + log.Warn(ctx, "Error retrieving artwork", "id", itemId, err) + http.Error(w, "Not Found", http.StatusNotFound) + return + } + defer reader.Close() + // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). + _, _ = io.Copy(w, reader) +} + +// resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing +// album -> artist -> media file -> playlist. +func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId string) string { + if al, err := api.ds.Album(ctx).Get(itemId); err == nil { + return al.CoverArtID().String() + } + if ar, err := api.ds.Artist(ctx).Get(itemId); err == nil { + return ar.CoverArtID().String() + } + if mf, err := api.ds.MediaFile(ctx).Get(itemId); err == nil { + return mf.CoverArtID().String() + } + if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil { + // Playlist covers are user-scoped: serve a private one only for a public playlist or a + // token identifying its owner/an admin, so this public route can't probe others' covers. + u, ok := api.userFromToken(r) + if pl.Public || (ok && (u.IsAdmin || pl.OwnerID == u.ID)) { + return pl.CoverArtID().String() + } + } + return (model.ArtworkID{}).String() +} + +// postItemImage handles cover upload. Only playlists are writable here; album/artist covers come +// from scanning. The body is always drained first (even on the not-implemented path) because +// Finamp writes it synchronously and sees a broken pipe if we respond before reading it. +func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + + // Honor the same artwork-upload gate and size cap as the native endpoint. + u, _ := request.UserFrom(ctx) + if !conf.Server.EnableArtworkUpload && !u.IsAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + // The limit caps the decoded image (native endpoint semantics); Jellyfin clients base64-encode + // the wire body (4/3 bigger), so the read cap allows for inflation. + limit := core.MaxImageUploadSize() + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit*4/3+4)) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: body exceeds MaxImageUploadSize", + "playlistId", id, "limit", humanize.Bytes(uint64(limit)), err) + http.Error(w, "file too large", http.StatusBadRequest) + return + } + + if _, err := api.playlists.Get(ctx, id); err != nil { + http.Error(w, "Not Implemented", http.StatusNotImplemented) + return + } + + imgBytes, err := decodeImageBody(body) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: body is neither an image nor base64", "playlistId", id, err) + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + if int64(len(imgBytes)) > limit { + log.Warn(ctx, "Jellyfin API: cover upload rejected: image exceeds MaxImageUploadSize", + "playlistId", id, "size", humanize.Bytes(uint64(len(imgBytes))), "limit", humanize.Bytes(uint64(limit))) + http.Error(w, "file too large", http.StatusBadRequest) + return + } + // Validate by decoding and derive the extension from the real format — clients lie in Content-Type. + _, format, err := image.DecodeConfig(bytes.NewReader(imgBytes)) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: not a valid image", "playlistId", id, err) + http.Error(w, "invalid image file", http.StatusBadRequest) + return + } + ext := "." + format + + if err := api.playlists.SetImage(ctx, id, bytes.NewReader(imgBytes), ext); err != nil { + api.internalError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// deleteItemImage removes a playlist's uploaded cover. Only playlists are supported. +func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + + if _, err := api.playlists.Get(ctx, id); err != nil { + http.Error(w, "Not Implemented", http.StatusNotImplemented) + return + } + + if err := api.playlists.RemoveImage(ctx, id); err != nil { + api.internalError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// decodeImageBody returns the raw image bytes. Jellyfin base64-encodes the body, but some clients +// send raw bytes, so input already starting with an image magic number is passed through as-is. +func decodeImageBody(body []byte) ([]byte, error) { + if isImageMagic(body) { + return body, nil + } + trimmed := bytes.TrimSpace(body) + return base64.StdEncoding.DecodeString(string(trimmed)) +} + +func isImageMagic(b []byte) bool { + switch { + case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8: // JPEG + return true + case bytes.HasPrefix(b, []byte{0x89, 'P', 'N', 'G'}): // PNG + return true + case bytes.HasPrefix(b, []byte("GIF8")): // GIF (GIF87a/GIF89a) + return true + case len(b) >= 12 && bytes.HasPrefix(b, []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")): // WebP + return true + default: + return false + } +} diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go new file mode 100644 index 000000000..8099fcf94 --- /dev/null +++ b/server/jellyfin/images_test.go @@ -0,0 +1,394 @@ +package jellyfin + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/artwork" + "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 fakeArtwork struct { + artwork.Artwork + recvId string + recvCtx context.Context + data []byte +} + +func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { + f.recvId = id + f.recvCtx = ctx + data := f.data + if data == nil { + data = []byte("IMG") + } + return io.NopCloser(bytes.NewReader(data)), time.Now(), nil +} + +func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+itemId+"/Images/Primary", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("itemId", itemId) + rctx.URLParams.Add("type", "Primary") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + return w, r +} + +var _ = Describe("Images", func() { + It("streams album artwork", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(fa.recvId).To(ContainSubstring("a1")) + }) + + It("sniffs the Content-Type instead of hardcoding it", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + + png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) + fa := &fakeArtwork{data: png} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) + }) + + It("resolves a public playlist id to its cover artwork", func() { + ds := &tests.MockDataStore{} + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", Public: true}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("pl1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvId).To(ContainSubstring("pl1")) + }) + + It("serves the placeholder, not the cover, for a private playlist and an anonymous caller", func() { + ds := &tests.MockDataStore{} + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("pl1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvId).ToNot(ContainSubstring("pl1")) + }) + + // This endpoint is public (no user in the request), so artwork must be resolved under an + // elevated context; otherwise a private playlist's cover fails its visibility filter and + // silently falls back to the placeholder. + It("resolves artwork under an elevated admin context", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + u, ok := request.UserFrom(fa.recvCtx) + Expect(ok).To(BeTrue()) + Expect(u.IsAdmin).To(BeTrue()) + }) +}) + +// Real image fixtures: postItemImage validates uploads by decoding them. +func pngBytes() []byte { + var b bytes.Buffer + Expect(png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)))).To(Succeed()) + return b.Bytes() +} + +func jpegBytes() []byte { + var b bytes.Buffer + Expect(jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + return b.Bytes() +} + +func gifBytes() []byte { + var b bytes.Buffer + Expect(gif.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + return b.Bytes() +} + +// 1x1 WebP (Go's webp support is decode-only, so this one is pre-encoded). +func webpBytes() []byte { + b, err := base64.StdEncoding.DecodeString( + "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA=") + Expect(err).ToNot(HaveOccurred()) + return b +} + +var _ = Describe("postItemImage", func() { + var api *Router + var fp *fakePlaylists + + BeforeEach(func() { + fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + api = &Router{playlists: fp} + }) + + It("uploads a raw JPEG body and returns 204", func() { + body := jpegBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImagePlaylistID).To(Equal("pl1")) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".jpeg")) + }) + + It("base64-decodes the body and derives the extension from the actual format, not Content-Type", func() { + raw := pngBytes() + encoded := base64.StdEncoding.EncodeToString(raw) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader([]byte(encoded))) + r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(raw)) + Expect(fp.setImageExt).To(Equal(".png")) + }) + + It("returns 501 for a non-playlist item, draining the body first", func() { + fp.getByIDPls = nil + fp.getByIDErr = model.ErrNotFound + bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained")) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", bodyReader) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotImplemented)) + Expect(bodyReader.Len()).To(Equal(0)) + }) + + It("returns 500 when the service fails", func() { + fp.setImageErr = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("accepts a raw WebP body", func() { + body := webpBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/webp") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".webp")) + }) + + It("accepts a raw GIF body", func() { + body := gifBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/gif") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".gif")) + }) + + It("rejects an oversized body with 400, like the native endpoint", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.MaxImageUploadSize = "16" // 16 bytes + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty(), "must not persist an over-limit upload") + }) + + It("applies the size limit to the decoded image, not the base64 body", func() { + DeferCleanup(configtest.SetupConfig()) + img := pngBytes() + // The raw image is exactly at the limit; its base64 form is 4/3 bigger. + conf.Server.MaxImageUploadSize = strconv.Itoa(len(img)) + body := []byte(base64.StdEncoding.EncodeToString(img)) + Expect(len(body)).To(BeNumerically(">", len(img))) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/png") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(img)) + }) + + It("rejects a base64 body whose decoded image exceeds the limit with 400", func() { + DeferCleanup(configtest.SetupConfig()) + img := pngBytes() + conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1) + body := []byte(base64.StdEncoding.EncodeToString(img)) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/png") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("rejects a body that is neither an image nor base64 with 400", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", strings.NewReader("!!not base64!!")) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() { + body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("forbids a non-admin upload when artwork upload is disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableArtworkUpload = false + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "u1", IsAdmin: false})) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("still allows an admin upload when artwork upload is disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableArtworkUpload = false + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin", IsAdmin: true})) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + }) +}) + +var _ = Describe("deleteItemImage", func() { + It("removes the playlist image and returns 204", func() { + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removeImagePlaylistID).To(Equal("pl1")) + }) + + It("returns 501 for a non-playlist item", func() { + fp := &fakePlaylists{getByIDErr: model.ErrNotFound} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotImplemented)) + }) + + It("returns 500 when the service fails", func() { + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}, removeImageErr: errors.New("boom")} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) +}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go new file mode 100644 index 000000000..f8158bca4 --- /dev/null +++ b/server/jellyfin/items.go @@ -0,0 +1,577 @@ +package jellyfin + +import ( + "context" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// notMissing excludes items whose backing files are all gone ("missing" is a real column on +// album, artist and media_file). +var notMissing = squirrel.Eq{"missing": false} + +func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { + res, err := api.queryItems(r.Context(), r) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} + +// queryItems is the /Items dispatcher: it parses entity types from IncludeItemTypes (defaulting to +// MusicAlbum), queries each via the matching listXxx, and merges multi-type results into one +// paginated list (as Finamp's favorites screen requests). +func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryResult, error) { + p := req.Params(r) + // Query keys are read lowercase because normalizeQueryKeys folded them (Jellyfin binds + // case-insensitively). /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch below. + fields := dto.ParseFields(p.StringOr("fields", "")) + if ids := decodedQueryIDs(r, "ids"); len(ids) > 0 { + return api.itemsByIDs(ctx, ids, fields), nil + } + parentId := dto.DecodeID(p.StringOr("parentid", "")) + search := p.StringOr("searchterm", "") + // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone + // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). + favOnly := strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false) + sortBy := p.StringOr("sortby", "") + sortOrder := p.StringOr("sortorder", "") + offset := p.IntOr("startindex", 0) + limit := p.IntOr("limit", 0) + rawTypes := p.StringOr("includeitemtypes", "") + // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. + if strings.Contains(rawTypes, "ManualPlaylistsFolder") { + return result([]dto.BaseItemDto{playlistsFolder()}, 1, 0), nil + } + types := parseTypes(rawTypes) + // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping + // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistIds/artistIds + // select the artist's own discography; contributingArtistIds alone means albums they merely appear + // on (Jellyfin's "Featured On"), which must exclude that discography. + albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) + contributingScope := p.StringOr("contributingartistids", "") + artistId := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + contributingOnly := albumArtistScope == "" && contributingScope != "" + // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. + genreIds := decodedQueryIDs(r, "genreids") + + scopeIDs, isLibraryParent := resolveLibraryScope(ctx, parentId) + // A playlist parent always resolves to its tracks, whatever IncludeItemTypes says. Jellify opens + // a playlist with ParentId=&IncludeItemTypes=Audio; routing that through listSongs would + // treat the playlist id as an album id and return nothing. + if parentId != "" && !isLibraryParent && parentId != playlistsFolderID { + if pls, err := api.playlists.GetWithTracks(ctx, parentId); err == nil { + // GetWithTracks enforces visibility (public or owned by the current user). + items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) + return result(paginate(items, offset, limit), len(items), offset), nil + } + } + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks + // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse + // its albums). + if rawTypes == "" && parentId != "" && !isLibraryParent { + if parentId == playlistsFolderID { + // Browsing into the synthetic playlists folder lists the user's playlists. + types = []string{"Playlist"} + } else if _, err := api.ds.Album(ctx).Get(parentId); err == nil { + types = []string{"Audio"} + } + } + entityParent := parentId + // ParentId-as-entity-id (artist for MusicAlbum, album for Audio) only makes sense for a single + // type; a multi-type query has no natural parent entity, so ParentId is only library scoping there. + if isLibraryParent || len(types) > 1 { + entityParent = "" + } + + if len(types) == 1 { + opts := model.QueryOptions{Offset: offset, Max: limit} + applySort(&opts, types[0], sortBy, sortOrder) + return api.queryItemsOfType(ctx, types[0], opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) + } + + var items []dto.BaseItemDto + total := 0 + for _, itemType := range types { + var opts model.QueryOptions + // Each per-type query needs at most offset+limit rows (the worst case where one type fills the + // whole [offset, offset+limit) window); without this cap each would fetch its whole table. + // Totals are unaffected — they come from CountAll. + if limit > 0 { + opts.Max = offset + limit + } + applySort(&opts, itemType, sortBy, sortOrder) + res, err := api.queryItemsOfType(ctx, itemType, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) + if err != nil { + return dto.QueryResult{}, err + } + items = append(items, res.Items...) + total += res.TotalRecordCount + } + return result(paginate(items, offset, limit), total, offset), nil +} + +func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, entityParent, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, favOnly bool, fields dto.Fields) (dto.QueryResult, error) { + switch itemType { + case "Audio": + return api.listSongs(ctx, opts, entityParent, artistId, genreIds, scopeIDs, search, favOnly, fields) + case "MusicArtist": + // The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists. + return api.listArtists(ctx, opts, genreIds, scopeIDs, search, favOnly, model.RoleAlbumArtist) + case "MusicGenre": + return api.listGenres(ctx, opts) + case "Playlist": + return api.listPlaylists(ctx, opts, favOnly) + default: // MusicAlbum + return api.listAlbums(ctx, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly) + } +} + +// firstNonEmpty returns the first non-empty string, or "". +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list. +func firstDecodedID(s string) string { + if s == "" { + return "" + } + first, _, _ := strings.Cut(s, ",") + return dto.DecodeID(strings.TrimSpace(first)) +} + +// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs), decoding each id. +func decodedQueryIDs(r *http.Request, key string) []string { + return slice.Map(queryIDs(r, key), dto.DecodeID) +} + +// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to +// {"MusicAlbum"} when none are recognized (so ParentId= browses that artist's albums). +func parseTypes(types string) []string { + var recognized []string + for t := range strings.SplitSeq(types, ",") { + t = strings.TrimSpace(t) + switch t { + case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist": + recognized = append(recognized, t) + } + } + if len(recognized) == 0 { + return []string{"MusicAlbum"} + } + return recognized +} + +// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only +// (single-type queries push Offset/Max down to SQL instead). +func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { + if offset >= len(items) { + return []dto.BaseItemDto{} + } + items = items[offset:] + if limit > 0 && limit < len(items) { + items = items[:limit] + } + return items +} + +// searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the +// Search API returns no match count and CountAll can't see the search term. offset+len(rows) is +// exact once matches end (and a growing lower bound before), so paging terminates at the last match. +func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) { + fetch := opts + if fetch.Max > 0 { + fetch.Max++ + } + rows, err := search(fetch) + if err != nil { + return nil, 0, err + } + total := opts.Offset + len(rows) + if opts.Max > 0 && len(rows) > opts.Max { + rows = rows[:opts.Max] + } + return rows, total, nil +} + +func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) { + repo := api.ds.Album(ctx) + filters := squirrel.And{} + // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's + // albums"; contributingArtistIds means "albums they only appear on" (Featured On). + switch { + case contributingOnly && artistId != "": + filters = append(filters, filter.AlbumsByContributingArtistID(artistId).Filters) + case firstNonEmpty(artistId, parentId) != "": + filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(artistId, parentId)).Filters) + default: + filters = append(filters, notMissing) + } + if len(genreIds) > 0 { + filters = append(filters, filter.ByGenreID(genreIds)) + } + if fav { + filters = append(filters, filter.ByStarred().Filters) + } + opts.Filters = filters + opts = filter.ApplyLibraryFilter(opts, scopeIDs) + + if search != "" { + albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { + return repo.Search(search, o) + }) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset), nil + } + albums, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + return result(slice.Map(albums, dto.AlbumToBaseItem), int(total), opts.Offset), nil +} + +func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, parentId, artistId string, genreIds []string, scopeIDs []int, search string, fav bool, fields dto.Fields) (dto.QueryResult, error) { + toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, fields) } + repo := api.ds.MediaFile(ctx) + filters := squirrel.And{} + // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. + switch { + case artistId != "": + filters = append(filters, filter.SongsByArtistID(artistId).Filters) + case parentId != "": + filters = append(filters, filter.SongsByAlbum(parentId).Filters) + default: + filters = append(filters, notMissing) + } + if len(genreIds) > 0 { + filters = append(filters, filter.ByGenreID(genreIds)) + } + if fav { + filters = append(filters, filter.ByStarred().Filters) + } + opts.Filters = filters + opts = filter.ApplyLibraryFilter(opts, scopeIDs) + + if search != "" { + mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { + return repo.Search(search, o) + }) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(mfs, toItem), total, opts.Offset), nil + } + // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an + // explicit client SortBy still wins, since applySort already set opts.Sort. + if artistId == "" && parentId != "" && opts.Sort == "" { + opts.Sort = filter.SongsByAlbum(parentId).Sort + } + mfs, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + return result(slice.Map(mfs, toItem), int(total), opts.Offset), nil +} + +// listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views, +// RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. +// genreIds isn't applied to search — a name lookup, like role (see below). +func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, genreIds []string, scopeIDs []int, search string, fav bool, role model.Role) (dto.QueryResult, error) { + repo := api.ds.Artist(ctx) + + // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a + // search scope (artists have no library_id column). A compound or join-based filter + // (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build + // filters differently. Role isn't applied to search for the same reason — it's a name lookup. + if search != "" { + if len(scopeIDs) > 0 { + opts.Filters = squirrel.Eq{"library_id": scopeIDs} + } + artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { + return repo.Search(search, o) + }) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset), nil + } + + if fav { + opts.Filters = filter.ArtistsByStarred().Filters + } else { + opts.Filters = notMissing + } + if len(genreIds) > 0 { + opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(genreIds)} + } + opts = filter.ArtistsByRole(opts, role) + opts = filter.ApplyArtistLibraryFilter(opts, scopeIDs) + artists, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + return result(slice.Map(artists, dto.ArtistToBaseItem), int(total), opts.Offset), nil +} + +// listGenres is intentionally unscoped: genres are global tags, not per-library entities. Paging is +// in-memory (GenreRepository has no CountAll, lists are small) so TotalRecordCount is the real total. +func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (dto.QueryResult, error) { + genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) + if err != nil { + return dto.QueryResult{}, err + } + items := slice.Map(genres, dto.GenreToBaseItem) + return result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset), nil +} + +// listPlaylists lists playlists visible to the current user. Visibility (public or owned) is +// enforced by playlistRepository, not scopeIDs. +func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, favOnly bool) (dto.QueryResult, error) { + if favOnly { + starred := squirrel.Eq{"starred": true} + if opts.Filters == nil { + opts.Filters = starred + } else { + opts.Filters = squirrel.And{opts.Filters, starred} + } + } + repo := api.ds.Playlist(ctx) + playlists, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(playlists, dto.PlaylistToBaseItem), int(total), opts.Offset), nil +} + +// resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, +// artist, song and playlist in turn. Albums and songs report not-found when the user lacks access +// to their library, so an id can't probe content outside the user's libraries. +func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) { + // The synthetic playlists folder must resolve by the id we advertised, not 404. + if id == playlistsFolderID { + return playlistsFolder(), true + } + u, _ := request.UserFrom(ctx) + // Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this + // the home screen and library tabs 404. + if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) { + for _, lib := range u.Libraries { + if lib.ID == libID { + return libraryView(lib), true + } + } + // Admin bypass: Libraries is empty but all access is granted, so fetch the real library. + if lib, err := api.ds.Library(ctx).Get(libID); err == nil { + return libraryView(*lib), true + } + } + if al, err := api.ds.Album(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(al.LibraryID) { + return dto.BaseItemDto{}, false + } + return dto.AlbumToBaseItem(*al), true + } + if ar, err := api.ds.Artist(ctx).Get(id); err == nil { + // TODO: an artist spans multiple libraries (library_artist), so there's no single + // LibraryID to gate here; artist access relies on list-time scoping and persistence. + return dto.ArtistToBaseItem(*ar), true + } + if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(mf.LibraryID) { + return dto.BaseItemDto{}, false + } + return dto.SongToBaseItem(*mf, fields), true + } + // api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through. + if pl, err := api.playlists.Get(ctx, id); err == nil { + return dto.PlaylistToBaseItem(*pl), true + } + return dto.BaseItemDto{}, false +} + +// songsByIDs fetches the media files among ids with chunked IN queries instead of a Get per id. +func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]model.MediaFile { + songs := make(map[string]model.MediaFile, len(ids)) + // Chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, like playqueue's loadTracks. + for chunk := range slice.CollectChunks(slices.Values(ids), 500) { + mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}}) + if err != nil { + log.Error(ctx, "Jellyfin API: error fetching songs by id", err) + continue + } + for _, mf := range mfs { + songs[mf.ID] = mf + } + } + return songs +} + +// itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids. +// A Finamp-truncated id is resolved by prefix but echoed as requested — Finamp matches restored +// queue items against its stored (truncated) ids. +func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult { + u, _ := request.UserFrom(ctx) + fullIDs := api.resolveItemIDs(ctx, ids) + songs := api.songsByIDs(ctx, fullIDs) + var items []dto.BaseItemDto + for i, id := range fullIDs { + var item dto.BaseItemDto + if mf, ok := songs[id]; ok { + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + item = dto.SongToBaseItem(mf, fields) + } else if item, ok = api.resolveItemByID(ctx, id, fields); !ok { + continue + } + if id != ids[i] { + item.Id = dto.EncodeID(ids[i]) + } + items = append(items, item) + } + return result(items, len(items), 0) +} + +func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { + api.ok(w, r, item) + return + } + http.Error(w, "Not Found", http.StatusNotFound) +} + +// deleteItem handles DELETE /Items/{id}. Only playlists are deletable here (albums/songs come from +// scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership. +func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + if err := api.playlists.Delete(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := filter.AlbumsByNewest() + opts.Max = req.Params(r).IntOr("limit", 20) + opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) + albums, err := api.ds.Album(ctx).GetAll(opts) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, slice.Map(albums, dto.AlbumToBaseItem)) // /Latest returns a bare array +} + +func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { + if items == nil { + items = []dto.BaseItemDto{} + } + return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} +} + +// applySort translates Jellyfin's SortBy/SortOrder into a valid model.QueryOptions sort key for the +// item type. Clients send SortBy as a comma-separated fallback list (e.g. "DateCreated,SortName"); +// this uses the first recognized key. An unrecognized SortBy is left untouched (the repo's default), +// not passed through raw where it could produce an invalid ORDER BY. +func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { + for key := range strings.SplitSeq(sortBy, ",") { + if col, ok := sortColumn(itemType, strings.TrimSpace(key)); ok { + opts.Sort = col + break + } + } + if strings.EqualFold(order, "Descending") { + opts.Order = "desc" + } +} + +// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type. Each repository maps +// logical fields to different real columns (e.g. media_file has "title" not "name"; artist has no +// "random"). +var sortColumnsByType = map[string]map[string]string{ + "Audio": { + "sortname": "title", "name": "title", + "album": "album", + // Finamp's album view sorts by ParentIndexNumber,IndexNumber (disc, track); Navidrome's + // "album" sort key is disc+track order within an album, so map both to it. + "indexnumber": "album", + "parentindexnumber": "album", + "artist": "artist", + "albumartist": "album_artist", + "datecreated": "recently_added", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + "random": "random", + // Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear. + "premieredate": "year", + "productionyear": "year", + }, + "MusicArtist": { + "sortname": "name", "name": "name", + "albumcount": "album_count", + "songcount": "song_count", + "datecreated": "created_at", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + }, + "MusicAlbum": { + "sortname": "name", "name": "name", "album": "name", + "artist": "artist", + "albumartist": "album_artist", + "datecreated": "recently_added", + "random": "random", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + "premieredate": "max_year", "productionyear": "max_year", + }, + "MusicGenre": { + "sortname": "name", "name": "name", + }, + "Playlist": { + "sortname": "name", "name": "name", + "datecreated": "created_at", + }, +} + +// sortColumn maps a single (non comma-list) Jellyfin SortBy key to the repo sort key for +// itemType, reporting false when it isn't recognized for that type. +func sortColumn(itemType, sortBy string) (string, bool) { + col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)] + return col, ok +} diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go new file mode 100644 index 000000000..8bd1bf052 --- /dev/null +++ b/server/jellyfin/items_test.go @@ -0,0 +1,608 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "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" +) + +// withChiURLParam simulates chi's routing having captured a path parameter, since these +// tests call handlers directly instead of going through the full router. +func withChiURLParam(r *http.Request, key, value string) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add(key, value) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +var _ = Describe("Items", func() { + var api *Router + var ds *tests.MockDataStore + var fp *fakePlaylists + // alice has access to library 1 only; used by tests that don't care about scoping. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + ctxUserWithLibraries := func(libs model.Libraries) context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + } + // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. + ctxAdmin := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + } + BeforeEach(func() { + ds = &tests.MockDataStore{} + fp = &fakePlaylists{} + api = &Router{ds: ds, playlists: fp} + }) + + Describe("getItems", func() { + It("lists albums when IncludeItemTypes=MusicAlbum", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Type).To(Equal("MusicAlbum")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("lists an album's songs when ParentId is an album and type is Audio", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", AlbumID: "a1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("Audio")) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("ar1")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("json_tree")) + }) + + It("lists artists when IncludeItemTypes=MusicArtist", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("lists genres when IncludeItemTypes=MusicGenre", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicGenre", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).NotTo(BeNil()) + }) + + It("lists playlists when IncludeItemTypes=Playlist", func() { + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix", SongCount: 5}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("Playlist")) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("p1"))) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + + It("merges results from every requested type in IncludeItemTypes", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + types := []string{res.Items[0].Type, res.Items[1].Type} + Expect(types).To(ConsistOf("Audio", "MusicAlbum")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("merges favorite songs, albums, and playlists", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(3)) + types := []string{res.Items[0].Type, res.Items[1].Type} + types = append(types, res.Items[2].Type) + Expect(types).To(ConsistOf("Audio", "MusicAlbum", "Playlist")) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + playlistSQL, _, err := playlistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(playlistSQL).To(ContainSubstring("starred")) + }) + + It("applies StartIndex/Limit to the merged multi-type result set", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.TotalRecordCount).To(Equal(4)) + Expect(res.StartIndex).To(Equal(1)) + }) + + It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // The merged window is [1, 3): each type needs at most its first 3 rows, not the table. + Expect(mfRepo.Options.Max).To(Equal(3)) + Expect(albumRepo.Options.Max).To(Equal(3)) + }) + + It("applies a starred filter when Filters=IsFavorite", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + }) + + It("forwards SearchTerm to the repo's Search method", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("reports a search total beyond the fetched page instead of the page length", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ + {ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.TotalRecordCount).To(Equal(3)) + }) + + It("forwards StartIndex/Limit as Offset/Max", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Offset).To(Equal(5)) + Expect(albumRepo.Options.Max).To(Equal(10)) + }) + + Describe("Ids batch-fetch", func() { + // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=; without + // this, queryItems ignored Ids and returned the default type-dispatched list instead. + It("returns exactly the requested item when Ids has a single id", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Name).To(Equal("Song")) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + + It("returns items of different types for a lowercase ids param with multiple ids", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + ids := []string{res.Items[0].Id, res.Items[1].Id} + Expect(ids).To(ConsistOf(dto.EncodeID("a1"), dto.EncodeID("s1"))) + types := []string{res.Items[0].Type, res.Items[1].Type} + Expect(types).To(ConsistOf("MusicAlbum", "Audio")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("resolves song ids with one batched IN query, not a Get per id", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}, {ID: "s2", Title: "Song2", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("s1")+","+dto.EncodeID("s2"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.id IN")) + Expect(args).To(ConsistOf("s1", "s2")) + }) + + It("omits an id in a library the user can't access, without erroring the whole batch", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) // alice only has access to library 1 + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("a1"))) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + }) + + Describe("sorting", func() { + It("maps SortBy=PlayCount to the play_count column", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("play_count")) + }) + + It("maps SortBy=DatePlayed to the play_date column", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("play_date")) + }) + + It("uses the first recognized key in a comma-separated SortBy list", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("recently_added")) + }) + + It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("title")) + }) + + It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("album")) + }) + + It("leaves Sort at the repo default when no SortBy key is recognized", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("")) + }) + }) + + Describe("library scoping", func() { + It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes a MusicArtist listing to the user's accessible libraries", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("2")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("json_tree")) // not treated as an artist-parent filter + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElement(2)) + }) + + It("does not let ParentId= scope results to that library", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}} // no access to library 99 + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("99")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + // Falls back to treating "99" as an (empty-matching) artist-parent id... + Expect(sql).To(ContainSubstring("json_tree")) + // ...while still scoping to the user's own accessible libraries. + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElement(1)) + Expect(args).NotTo(ContainElement(99)) + }) + + It("does not restrict a default MusicAlbum listing for an admin user", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}, {ID: "a2", Name: "Two", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // accessibleLibraryIDs is empty for an admin (Libraries is nil), so + // ApplyLibraryFilter([]) is a no-op: no library_id restriction is added. + if albumRepo.Options.Filters == nil { + return + } + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("library_id")) + }) + }) + }) + + Describe("getItem", func() { + It("returns an album by id", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + Expect(item.Type).To(Equal("MusicAlbum")) + }) + + It("returns 404 when the id doesn't match any entity", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for an album in a library the user can't access", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for a song in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns an album to an admin even when it's outside their (empty) Libraries", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxAdmin()) // admin, Libraries: nil + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + }) + + // Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the + // library node before it can load the home screen or any library tab. + It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1, Name: "Music Library"}} + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Name).To(Equal("Music Library")) + Expect(item.Type).To(Equal("CollectionFolder")) + Expect(item.CollectionType).To(Equal("music")) + Expect(item.IsFolder).To(BeTrue()) + }) + + It("does not resolve a library-view id the user has no access to", func() { + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1 + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + // Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it + // 404s with "Could not fetch BaseItemDto from server." + It("resolves a playlist id via the playlists service", func() { + fp.getByIDPls = &model.Playlist{ID: "p1", Name: "My Mix", SongCount: 5} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("p1"))) + Expect(item.Name).To(Equal("My Mix")) + Expect(item.Type).To(Equal("Playlist")) + }) + + It("returns 404 for a non-owned or absent playlist id", func() { + fp.getByIDErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { + ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxAdmin()) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Name).To(Equal("Music Library")) + Expect(item.Type).To(Equal("CollectionFolder")) + }) + }) + + Describe("getLatest", func() { + It("returns a bare array of the newest albums", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) + invoke(api.getLatest, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var items []dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed()) + Expect(items).To(HaveLen(1)) + Expect(items[0].Id).To(Equal(dto.EncodeID("a1"))) + }) + + It("scopes to the user's accessible libraries", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getLatest, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + }) +}) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go new file mode 100644 index 000000000..aab9628a0 --- /dev/null +++ b/server/jellyfin/jellyfin_suite_test.go @@ -0,0 +1,25 @@ +package jellyfin + +import ( + "net/http" + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJellyfinApi(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin API Suite") +} + +// invoke runs a handler through normalizeQueryKeys, mirroring the router. These unit tests call +// handlers directly (with withChiURLParam for path params) instead of routing, so without this the +// case-insensitive query folding real requests get would be skipped and PascalCase params dropped. +func invoke(h http.HandlerFunc, w http.ResponseWriter, r *http.Request) { + normalizeQueryKeys(h).ServeHTTP(w, r) +} diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go new file mode 100644 index 000000000..2e486c36f --- /dev/null +++ b/server/jellyfin/library.go @@ -0,0 +1,44 @@ +package jellyfin + +import ( + "context" + "strconv" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty +// slice (non-admin with no libraries) is treated as a no-op/unrestricted by the library filters. +func accessibleLibraryIDs(ctx context.Context) []int { + u, _ := request.UserFrom(ctx) + return u.Libraries.IDs() +} + +// resolveLibraryScope handles ParentId's ambiguity: a library id (browsing a UserView) or an +// entity id (artist/album). It's treated as a library only when the user has access; otherwise +// isLibraryParent is false and callers fall through to entity-id handling. +func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, isLibraryParent bool) { + if parentId != "" { + if id, err := strconv.Atoi(parentId); err == nil { + if u, _ := request.UserFrom(ctx); u.HasLibraryAccess(id) { + return []int{id}, true + } + } + } + return accessibleLibraryIDs(ctx), false +} + +// libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. +// Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. +func libraryView(lib model.Library) dto.BaseItemDto { + return dto.BaseItemDto{ + Id: dto.EncodeID(strconv.Itoa(lib.ID)), + Name: lib.Name, + Type: "CollectionFolder", + CollectionType: "music", + IsFolder: true, + BackdropImageTags: []string{}, + } +} diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go new file mode 100644 index 000000000..2941d3c32 --- /dev/null +++ b/server/jellyfin/middlewares.go @@ -0,0 +1,187 @@ +package jellyfin + +import ( + "net" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +// normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params +// case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, +// Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one +// client's filters, sort and paging. Only keys are folded — values keep their case. The original +// request is left untouched (a rewritten copy goes downstream) so logging shows the client's casing. +func normalizeQueryKeys(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + folded := make(url.Values, len(q)) + changed := false + for k, vs := range q { + lk := strings.ToLower(k) + // Append, don't assign: two casings of the same key must merge, not overwrite. + folded[lk] = append(folded[lk], vs...) + if lk != k { + changed = true + } + } + if changed { + r2 := *r + u := *r.URL + u.RawQuery = folded.Encode() + r2.URL = &u + r = &r2 + } + next.ServeHTTP(w, r) + }) +} + +type mediaBrowserAuth struct { + Client, Device, DeviceId, Version, Token string +} + +var mediaBrowserAuthField = regexp.MustCompile(`(\w+)="([^"]*)"`) + +// parseMediaBrowserAuth reads the MediaBrowser-scheme authorization header, e.g. +// `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="abc", Version="1.0", Token="jwt"`. +// The recommended Authorization header is preferred, but only when it actually carries +// MediaBrowser data — a reverse proxy may inject Basic/Digest credentials there while the client +// sends the deprecated X-Emby-Authorization. Field values are URL-decoded: Jellify (@jellyfin/sdk) +// percent-encodes them (Device="Pixel%208%20Pro"), while Finamp sends them raw; unescapeField +// leaves a raw value untouched. +func parseMediaBrowserAuth(r *http.Request) mediaBrowserAuth { + if a, ok := parseAuthHeader(r.Header.Get("Authorization")); ok { + return a + } + a, _ := parseAuthHeader(r.Header.Get("X-Emby-Authorization")) + return a +} + +// parseAuthHeader extracts the MediaBrowser fields from one header value; ok reports whether the +// value uses the MediaBrowser scheme ("Emby" is the legacy spelling real Jellyfin also accepts). +func parseAuthHeader(h string) (mediaBrowserAuth, bool) { + var a mediaBrowserAuth + scheme, params, found := strings.Cut(h, " ") + if !found || (!strings.EqualFold(scheme, "MediaBrowser") && !strings.EqualFold(scheme, "Emby")) { + return a, false + } + for _, m := range mediaBrowserAuthField.FindAllStringSubmatch(params, -1) { + switch m[1] { + case "Client": + a.Client = unescapeField(m[2]) + case "Device": + a.Device = unescapeField(m[2]) + case "DeviceId": + a.DeviceId = unescapeField(m[2]) + case "Version": + a.Version = unescapeField(m[2]) + case "Token": + a.Token = unescapeField(m[2]) + } + } + return a, true +} + +// unescapeField percent-decodes a header field value, falling back to the raw value when it isn't +// valid encoding (Finamp sends raw values that may contain a literal '%'). PathUnescape, not +// QueryUnescape, so a literal '+' in a value is preserved rather than turned into a space. +func unescapeField(v string) string { + if decoded, err := url.PathUnescape(v); err == nil { + return decoded + } + return v +} + +// tokenFromRequest prefers the recommended Authorization scheme; the rest are legacy spellings +// deprecated by Jellyfin but still sent by clients. +func tokenFromRequest(r *http.Request) string { + if t := parseMediaBrowserAuth(r).Token; t != "" { + return t + } + if t := r.Header.Get("X-Emby-Token"); t != "" { + return t + } + if t := r.Header.Get("X-MediaBrowser-Token"); t != "" { + return t + } + // api_key and apikey differ by an underscore, not case, so normalizeQueryKeys' folding doesn't + // merge them; both are checked (Finamp's just_audio engine fetches direct-file URLs with ?ApiKey=). + if t := r.URL.Query().Get("api_key"); t != "" { + return t + } + return r.URL.Query().Get("apikey") +} + +// userFromToken resolves the user for the request's token; ok is false for a missing/invalid token +// or unknown subject. Used by authenticate and by public routes that optionally identify the caller. +func (api *Router) userFromToken(r *http.Request) (model.User, bool) { + token := tokenFromRequest(r) + if token == "" { + return model.User{}, false + } + claims, err := auth.Validate(token) + if err != nil || claims.Subject == "" { + return model.User{}, false + } + usr, err := api.ds.User(r.Context()).FindByUsername(claims.Subject) + if err != nil { + log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) + return model.User{}, false + } + return *usr, true +} + +func (api *Router) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + usr, ok := api.userFromToken(r) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + ctx := request.WithUser(r.Context(), usr) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// withPlayer resolves/registers a model.Player for the calling device into the context, mirroring +// Subsonic's getPlayer. Jellyfin clients always send a DeviceId in the auth header (unlike Subsonic), +// so it's used directly as the player id and reports from the same install share a player/scrobbling +// session. +func (api *Router) withPlayer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if api.players == nil { // fail open when players isn't wired (e.g. in unit tests) + next.ServeHTTP(w, r) + return + } + ctx := r.Context() + a := parseMediaBrowserAuth(r) + // Skip registration when the request can't identify a client (no X-Emby-Authorization, e.g. + // the /socket handshake that authenticates via ?api_key= only). Otherwise Register would + // create a junk player with an empty name (" []"). + if a.Client == "" && a.DeviceId == "" { + next.ServeHTTP(w, r) + return + } + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + player, trc, err := api.players.Register(ctx, a.DeviceId, a.Client, a.Device, ip) + if err != nil { + // Fail open, like Subsonic's getPlayer: proceed without a player; reporting handlers + // degrade gracefully. + log.Warn(ctx, "Jellyfin API: could not register player", "client", a.Client, "device", a.Device, err) + next.ServeHTTP(w, r) + return + } + ctx = request.WithPlayer(ctx, *player) + // Like Subsonic's getPlayer: the forced transcoding must reach ResolveRequest's override. + if trc != nil { + ctx = request.WithTranscoding(ctx, *trc) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go new file mode 100644 index 000000000..2c0761834 --- /dev/null +++ b/server/jellyfin/middlewares_test.go @@ -0,0 +1,254 @@ +package jellyfin + +import ( + "context" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("authenticate middleware", func() { + var api *Router + var ds *tests.MockDataStore + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + tokenFor := func(name string) string { + t, err := auth.CreateToken(&model.User{ID: "u1", UserName: name}) + Expect(err).ToNot(HaveOccurred()) + return t + } + + It("passes with a valid X-Emby-Token and injects the user", func() { + var gotUser model.User + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, _ = request.UserFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", tokenFor("alice")) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotUser.UserName).To(Equal("alice")) + }) + + It("passes with the recommended Authorization: MediaBrowser scheme and injects the user", func() { + var gotUser model.User + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, _ = request.UserFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="`+tokenFor("alice")+`", Client="Test", DeviceId="dev1"`) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotUser.UserName).To(Equal("alice")) + }) + + It("rejects a missing token with 401", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a garbage token with 401 and does not call next", func() { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", "not-a-jwt") + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(nextCalled).To(BeFalse()) + }) + + It("rejects a valid token whose subject user does not exist with 401", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + t, err := auth.CreateToken(&model.User{ID: "x", UserName: "ghost"}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", t) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) + +var _ = Describe("withPlayer middleware", func() { + var api *Router + var players *fakePlayers + + BeforeEach(func() { + players = &fakePlayers{} + api = &Router{ds: &tests.MockDataStore{}, players: players} + }) + + callWith := func() (model.Player, model.Transcoding, bool) { + var gotPlayer model.Player + var gotTrc model.Transcoding + var hasTrc bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPlayer, _ = request.PlayerFrom(r.Context()) + gotTrc, hasTrc = request.TranscodingFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + api.withPlayer(next).ServeHTTP(w, r) + return gotPlayer, gotTrc, hasTrc + } + + It("injects the registered player into the context", func() { + player, _, hasTrc := callWith() + Expect(player.ID).To(Equal("dev1")) + Expect(hasTrc).To(BeFalse()) + }) + + It("injects the player's server-forced transcoding into the context", func() { + players.trc = &model.Transcoding{ID: "t1", TargetFormat: "opus"} + _, trc, hasTrc := callWith() + Expect(hasTrc).To(BeTrue()) + Expect(trc.TargetFormat).To(Equal("opus")) + }) +}) + +var _ = Describe("tokenFromRequest", func() { + It("accepts the recommended Authorization: MediaBrowser scheme", func() { + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="tok123", Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("prefers the Authorization scheme token over deprecated token headers", func() { + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="scheme-token"`) + r.Header.Set("X-Emby-Token", "legacy-token") + Expect(tokenFromRequest(r)).To(Equal("scheme-token")) + }) + + It("accepts the lowercase api_key query param", func() { + r := httptest.NewRequest("GET", "/Items/s1/File?api_key=tok123", nil) + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("accepts a PascalCase ApiKey query param once normalizeQueryKeys has folded it", func() { + r := httptest.NewRequest("GET", "/Items/s1/File?ApiKey=tok123", nil) + var got string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = tokenFromRequest(r) }, httptest.NewRecorder(), r) + Expect(got).To(Equal("tok123")) + }) +}) + +var _ = Describe("parseMediaBrowserAuth", func() { + authFor := func(header string) mediaBrowserAuth { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-Emby-Authorization", header) + return parseMediaBrowserAuth(r) + } + + It("reads Finamp's raw (unencoded) field values", func() { + a := authFor(`MediaBrowser Client="Finamp", Device="Pixel 8 Pro", DeviceId="dev1", Version="1.0", Token="tok"`) + Expect(a.Client).To(Equal("Finamp")) + Expect(a.Device).To(Equal("Pixel 8 Pro")) + Expect(a.DeviceId).To(Equal("dev1")) + }) + + It("percent-decodes Jellify's URL-encoded field values", func() { + a := authFor(`MediaBrowser Client="Jellify", Device="Pixel%208%20Pro", DeviceId="dev1", Version="1.0", Token="tok"`) + Expect(a.Client).To(Equal("Jellify")) + Expect(a.Device).To(Equal("Pixel 8 Pro")) + }) + + It("keeps a literal '%' that isn't valid percent-encoding", func() { + a := authFor(`MediaBrowser Client="100% Player", Device="d"`) + Expect(a.Client).To(Equal("100% Player")) + }) + + It("prefers the recommended Authorization header over the deprecated X-Emby-Authorization", func() { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `MediaBrowser Client="New", DeviceId="dev-new"`) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Old", DeviceId="dev-old"`) + a := parseMediaBrowserAuth(r) + Expect(a.Client).To(Equal("New")) + Expect(a.DeviceId).To(Equal("dev-new")) + }) + + It("falls back to X-Emby-Authorization when Authorization carries a foreign scheme", func() { + // A reverse proxy may inject Basic/Digest credentials; the client's MediaBrowser data must + // still be honored. + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `Digest username="proxy", realm="site"`) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", DeviceId="dev1", Token="tok"`) + a := parseMediaBrowserAuth(r) + Expect(a.Client).To(Equal("Finamp")) + Expect(a.Token).To(Equal("tok")) + }) + + It("rejects a foreign scheme even when its parameters mimic MediaBrowser fields", func() { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `Custom Token="not-for-us"`) + Expect(parseMediaBrowserAuth(r).Token).To(BeEmpty()) + }) + + It("accepts the legacy Emby scheme spelling, like real Jellyfin", func() { + a := authFor(`Emby Client="OldClient", DeviceId="dev1", Token="tok"`) + Expect(a.Client).To(Equal("OldClient")) + Expect(a.Token).To(Equal("tok")) + }) + + It("matches the scheme case-insensitively (HTTP auth schemes are)", func() { + a := authFor(`mediabrowser Token="tok"`) + Expect(a.Token).To(Equal("tok")) + }) +}) + +var _ = Describe("normalizeQueryKeys", func() { + // keyFor runs a request through normalizeQueryKeys and reports the value the handler sees for + // the given (lowercase) key — i.e. what a case-insensitive read would find. + keyFor := func(rawQuery, key string) string { + r := httptest.NewRequest("GET", "/Items?"+rawQuery, nil) + var got string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get(key) }, httptest.NewRecorder(), r) + return got + } + + It("folds PascalCase (Finamp) and camelCase (Jellify) keys to lowercase", func() { + Expect(keyFor("ParentId=abc", "parentid")).To(Equal("abc")) + Expect(keyFor("parentId=abc", "parentid")).To(Equal("abc")) + }) + + It("leaves values untouched", func() { + Expect(keyFor("IncludeItemTypes=MusicAlbum,Audio", "includeitemtypes")).To(Equal("MusicAlbum,Audio")) + }) + + It("passes already-lowercase keys through unchanged", func() { + Expect(keyFor("container=mp3", "container")).To(Equal("mp3")) + }) + + It("merges values when two keys fold to the same name instead of dropping one", func() { + r := httptest.NewRequest("GET", "/Items?Ids=aaa&ids=bbb", nil) + var got []string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query()["ids"] }, httptest.NewRecorder(), r) + Expect(got).To(ConsistOf("aaa", "bbb")) + }) +}) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go new file mode 100644 index 000000000..ffc2c6543 --- /dev/null +++ b/server/jellyfin/playlists.go @@ -0,0 +1,257 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// playlistsFolderID is the reserved id of the synthetic "playlists library" folder. Clients resolve +// it via a ManualPlaylistsFolder query, then list playlists with ParentId set to it. The literal +// can't collide with real ids (those are hashes). +const playlistsFolderID = "playlists" + +// playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be +// "playlists" — how the client identifies it; without it Jellify's playlist-library query loops. +func playlistsFolder() dto.BaseItemDto { + return dto.BaseItemDto{ + Id: dto.EncodeID(playlistsFolderID), + Name: "Playlists", + Type: "ManualPlaylistsFolder", + CollectionType: "playlists", + IsFolder: true, + } +} + +// playlistError maps core/playlists write errors to HTTP status: ownership -> 403, missing/invisible +// -> 404 (never revealing another user's private playlist), else -> 500. +func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, model.ErrNotAuthorized): + http.Error(w, "Forbidden", http.StatusForbidden) + case errors.Is(err, model.ErrNotFound): + http.Error(w, "Not Found", http.StatusNotFound) + default: + api.internalError(w, r, err) + } +} + +type createPlaylistRequest struct { + Name string `json:"Name"` + Ids []string `json:"Ids"` + MediaType string `json:"MediaType"` +} + +// createPlaylist always creates a new playlist (playlistId "" tells core/playlists.Create not to +// replace an existing one), owned by the authenticated user. +func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) { + var body createPlaylistRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + ids := api.expandContainerIDs(r.Context(), slice.Map(body.Ids, dto.DecodeID)) + id, err := api.playlists.Create(r.Context(), "", body.Name, ids) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)}) +} + +// updatePlaylistRequest mirrors Jellyfin's NewPlaylist body. Pointers so an absent field means +// "leave unchanged", distinguishing an omitted Ids (no change) from an explicit empty list (clear). +type updatePlaylistRequest struct { + Name *string `json:"Name"` + Ids *[]string `json:"Ids"` + IsPublic *bool `json:"IsPublic"` +} + +func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + var body updatePlaylistRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + // A present Ids replaces the track list. An empty list must clear it explicitly, since Create + // can't persist an empty track list (the repository skips track writes when the list is empty). + if body.Ids != nil { + if len(*body.Ids) == 0 { + if err := api.clearPlaylist(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + } else { + ids := api.expandContainerIDs(ctx, slice.Map(*body.Ids, dto.DecodeID)) + if _, err := api.playlists.Create(ctx, id, "", ids); err != nil { + api.playlistError(w, r, err) + return + } + } + } + if body.Ids == nil || body.Name != nil || body.IsPublic != nil { + if err := api.playlists.Update(ctx, id, body.Name, nil, body.IsPublic, nil, nil); err != nil { + api.playlistError(w, r, err) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + +// clearPlaylist removes every track from a playlist. RemoveTracks enforces ownership. +func (api *Router) clearPlaylist(ctx context.Context, id string) error { + pls, err := api.playlists.GetWithTracks(ctx, id) + if err != nil { + return err + } + if len(pls.Tracks) == 0 { + return nil + } + entryIDs := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.ID }) + return api.playlists.RemoveTracks(ctx, id, entryIDs) +} + +// trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId (the +// entry's id, model.PlaylistTrack.ID, not the song id). Clients echo it back via +// DELETE .../Items?EntryIds= to remove a specific occurrence, so duplicates of the same song remain +// individually removable. +func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { + item := dto.SongToBaseItem(t.MediaFile, fields) + item.PlaylistItemId = dto.EncodeID(t.ID) + return item +} + +// getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the +// edit screen). GetWithTracks enforces visibility; any error maps to 404 so private playlists can't +// be probed. +func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + pls, err := api.playlists.GetWithTracks(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + itemIds := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return dto.EncodeID(t.MediaFileID) }) + api.ok(w, r, dto.PlaylistInfo{ + OpenAccess: pls.Public, + Shares: []dto.PlaylistUserPermissions{}, + ItemIds: itemIds, + }) +} + +// getPlaylistItems relies on GetWithTracks to enforce visibility; any error maps to a generic 404 so +// a playlist id can't probe for private playlists. +func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + pls, err := api.playlists.GetWithTracks(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) + api.ok(w, r, dto.QueryResult{Items: items, TotalRecordCount: len(items)}) +} + +// queryIDs reads an id-list query param that clients spell two ways: comma-separated in a single +// param (Finamp: ids=X,Y) or as repeated params (Jellify's @jellyfin/sdk: ids=X&ids=Y). It returns +// the flattened, non-empty ids across both forms. +func queryIDs(r *http.Request, key string) []string { + var ids []string + for _, v := range r.URL.Query()[key] { + for id := range strings.SplitSeq(v, ",") { + if id != "" { + ids = append(ids, id) + } + } + } + return ids +} + +// expandContainerIDs expands the container ids (albums, artists, playlists) a client sends when +// building a playlist into their track ids, in order, since core/playlists only understands media +// file ids. Unknown ids pass through unchanged. Songs are classified with one batched query; only +// the rest pays per-id container probes. +func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []string { + songs := api.songsByIDs(ctx, ids) + out := make([]string, 0, len(ids)) + for _, id := range ids { + if _, ok := songs[id]; ok { + out = append(out, id) // already a song + } else if _, err := api.ds.Album(ctx).Get(id); err == nil { + out = append(out, api.songIDs(ctx, filter.SongsByAlbum(id))...) + } else if _, err := api.ds.Artist(ctx).Get(id); err == nil { + out = append(out, api.songIDs(ctx, filter.SongsByArtistID(id))...) + } else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil { + out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...) + } else { + out = append(out, id) // unknown id — pass through unchanged + } + } + return out +} + +func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []string { + mfs, err := api.ds.MediaFile(ctx).GetAll(opts) + if err != nil { + log.Error(ctx, "Jellyfin: error expanding container to tracks", err) + return nil + } + 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; any error maps to 404. +func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + ids := api.expandContainerIDs(ctx, slice.Map(queryIDs(r, "ids"), dto.DecodeID)) + if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// 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; any error maps to 404. +func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + ids := slice.Map(queryIDs(r, "entryids"), dto.DecodeID) + if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// getPlaylistUsers and getPlaylistUser answer client probes (e.g. Finamp) made before allowing +// edits. Navidrome has no per-playlist ACL, so every user is reported CanEdit; ownership is still +// enforced by AddTracks/RemoveTracks. +func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: u.ID, CanEdit: true}}) +} + +func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { + userId := chi.URLParam(r, "userId") + api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: true}) +} diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go new file mode 100644 index 000000000..804123003 --- /dev/null +++ b/server/jellyfin/playlists_test.go @@ -0,0 +1,424 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakePlaylists is a local fake for core/playlists.Playlists. It embeds the interface so +// unimplemented methods aren't needed here; only the ones this test exercises are overridden. +type fakePlaylists struct { + playlists.Playlists + + createdName string + createdIds []string + createErr error + + getPls *model.Playlist + getErr error + + getByIDPls *model.Playlist + getByIDErr error + + addPlaylistID string + addIds []string + addErr error + + removePlaylistID string + removeIds []string + removeErr error + + setImagePlaylistID string + setImageBytes []byte + setImageExt string + setImageErr error + + removeImagePlaylistID string + removeImageErr error + + deletePlaylistID string + deleteErr error +} + +func (f *fakePlaylists) Delete(_ context.Context, id string) error { + f.deletePlaylistID = id + return f.deleteErr +} + +func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) { + f.createdName = name + f.createdIds = ids + if f.createErr != nil { + return "", f.createErr + } + return "pl-new", nil +} + +// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real +// service's behavior for a missing or inaccessible playlist and letting getItem tests that don't +// care about playlists leave it unconfigured. +func (f *fakePlaylists) Get(_ context.Context, _ string) (*model.Playlist, error) { + if f.getByIDErr != nil { + return nil, f.getByIDErr + } + if f.getByIDPls == nil { + return nil, model.ErrNotFound + } + return f.getByIDPls, nil +} + +func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.getPls == nil { + return nil, model.ErrNotFound // mirror the real repo: never (nil, nil) + } + return f.getPls, nil +} + +func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) { + f.addPlaylistID = playlistID + f.addIds = ids + return len(ids), f.addErr +} + +func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, trackIds []string) error { + f.removePlaylistID = playlistID + f.removeIds = trackIds + return f.removeErr +} + +func (f *fakePlaylists) SetImage(_ context.Context, playlistID string, reader io.Reader, ext string) error { + f.setImagePlaylistID = playlistID + f.setImageExt = ext + if reader != nil { + f.setImageBytes, _ = io.ReadAll(reader) + } + return f.setImageErr +} + +func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error { + f.removeImagePlaylistID = playlistID + return f.removeImageErr +} + +var _ = Describe("Playlists", func() { + var api *Router + var fp *fakePlaylists + + BeforeEach(func() { + fp = &fakePlaylists{} + api = &Router{ds: &tests.MockDataStore{}, playlists: fp} + }) + + Describe("createPlaylist", func() { + It("creates a playlist and returns its id", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["s1","s2"]}`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res map[string]string + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res["Id"]).To(Equal(dto.EncodeID("pl-new"))) + Expect(fp.createdName).To(Equal("Mix")) + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + }) + + It("returns 400 on an invalid JSON body", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`not json`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 500 when the service fails", func() { + fp.createErr = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix"}`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("getPlaylistItems", func() { + It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1", Title: "Song One"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2", Title: "Song Two"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + api.getPlaylistItems(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(2)) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Type).To(Equal("Audio")) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[1].Id).To(Equal(dto.EncodeID("s2"))) + Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2"))) + }) + + It("returns 404 for a non-owned or absent playlist", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "missing") + api.getPlaylistItems(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("container id expansion", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + api = &Router{ds: ds, playlists: fp} + }) + + createWith := func(id string) { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)). + WithContext(ctx) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + } + + It("passes a bare song id through unchanged", func() { + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}}) + createWith("s1") + Expect(fp.createdIds).To(Equal([]string{"s1"})) + }) + + It("expands an album id into its songs, filtered by album", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"}, + }) + createWith("al1") + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum("al1").Filters)) + }) + + It("expands an artist id into its songs", func() { + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1"}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}, {ID: "s2"}}) + createWith("ar1") + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID("ar1").Filters)) + }) + + It("expands a playlist id into its tracks' media file ids", func() { + fp.getPls = &model.Playlist{ID: "pl9", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s3"}, {ID: "2", MediaFileID: "s4"}, + }} + createWith("pl9") + Expect(fp.createdIds).To(Equal([]string{"s3", "s4"})) + }) + }) + + Describe("getPlaylist", func() { + It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Public: true, + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.getPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistInfo + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.OpenAccess).To(BeTrue()) + Expect(res.Shares).To(BeEmpty()) + Expect(res.ItemIds).To(Equal([]string{dto.EncodeID("s1"), dto.EncodeID("s2")})) + }) + + It("returns 404 for a non-owned or absent playlist", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "missing") + invoke(api.getPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("deleteItem", func() { + deleteReq := func(id string) *http.Request { + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(id), nil).WithContext(context.Background()) + return withChiURLParam(r, "itemId", dto.EncodeID(id)) + } + + It("deletes the playlist and returns 204", func() { + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.deletePlaylistID).To(Equal("pl1")) + }) + + It("returns 403 when the user doesn't own the playlist", func() { + fp.deleteErr = model.ErrNotAuthorized + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("returns 404 for a missing playlist or non-playlist id", func() { + fp.deleteErr = model.ErrNotFound + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("al1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 on an unexpected error", func() { + fp.deleteErr = errors.New("boom") + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("addToPlaylist", func() { + It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1,s2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + }) + + It("accepts a PascalCase Ids param (case-folded by the middleware)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?Ids=s1,s2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + }) + + It("returns 404 when the service rejects the request (not found/not owned)", func() { + fp.addErr = model.ErrNotAuthorized + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("passes no ids (not a spurious empty string) when the ids param is absent", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addIds).To(BeEmpty()) + }) + }) + + Describe("removeFromPlaylist", func() { + It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1,2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removeIds).To(Equal([]string{"1", "2"})) + }) + + It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?EntryIds=1,2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removeIds).To(Equal([]string{"1", "2"})) + }) + + It("returns 404 when the service rejects the request (not found/not owned)", func() { + fp.removeErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removeIds).To(BeEmpty()) + }) + }) + + Describe("getPlaylistUsers", func() { + It("returns the current user with CanEdit true", func() { + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/pl1/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", "pl1") + api.getPlaylistUsers(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res []dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: "u1", CanEdit: true}})) + }) + }) + + Describe("getPlaylistUser", func() { + It("returns CanEdit true for the requested user", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Users/u1", nil).WithContext(context.Background()) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("playlistId", "pl1") + rctx.URLParams.Add("userId", "u1") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + api.getPlaylistUser(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: "u1", CanEdit: true})) + }) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go new file mode 100644 index 000000000..e3c9903a8 --- /dev/null +++ b/server/jellyfin/routing_test.go @@ -0,0 +1,56 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Real Jellyfin servers route path segments case-insensitively, but chi's default matching is +// case-sensitive. Jellyfin wires up server.CaseInsensitivePaths (see server/case_insensitive_routes.go +// for the unit-level tests of that helper) to work around this. These tests are an end-to-end proof +// that requests using non-canonical casing are still routed correctly, both when the router is used +// directly and when mounted under a parent (as it is in production via server.MountRouter). +var _ = Describe("Case-insensitive routing", func() { + var api *Router + + BeforeEach(func() { + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + }) + + It("serves a fully lowercase path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/system/info/public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("serves a mixed/weird-case path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/SYSTEM/Info/PUBLIC", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("serves a lowercase login path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/users/authenticatebyname", nil) + api.ServeHTTP(w, r) + // MockDataStore has no users, so authentication itself may fail downstream, but the + // route must be found (not a 404) to prove case-insensitive matching worked. + Expect(w.Code).ToNot(Equal(http.StatusNotFound)) + }) + + It("serves a lowercase path when mounted under a parent router, replicating production", func() { + parent := chi.NewRouter() + parent.Mount("/jellyfin", api) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/jellyfin/system/info/public", nil) + parent.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) +}) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go new file mode 100644 index 000000000..f462f283a --- /dev/null +++ b/server/jellyfin/sessions.go @@ -0,0 +1,115 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// playbackReport is the subset of Jellyfin's PlaybackStartInfo/PlaybackProgressInfo +// fields Navidrome needs to keep its playback/scrobbling state in sync. +type playbackReport struct { + ItemId string `json:"ItemId"` + PositionTicks int64 `json:"PositionTicks"` + IsPaused bool `json:"IsPaused"` +} + +// decodeReport reads the playback report body. ItemId falls back to a query param (some clients send +// it there) and is decoded here since it flows straight into scrobbler lookups by media file id. +// Finamp reports restored-queue playback with truncated ids, hence resolveItemID. +func (api *Router) decodeReport(r *http.Request) playbackReport { + var body playbackReport + _ = json.NewDecoder(r.Body).Decode(&body) + if body.ItemId == "" { + body.ItemId = r.URL.Query().Get("itemid") + } + body.ItemId = api.resolveItemID(r.Context(), dto.DecodeID(body.ItemId)) + return body +} + +// clientIdentity returns the scrobbler cache key/display name for the caller's +// player. Both are zero values if withPlayer could not resolve a player. +func clientIdentity(ctx context.Context) (id, name string) { + player, _ := request.PlayerFrom(ctx) + return player.ID, player.Client +} + +// reportPlaybackStart handles POST /Sessions/Playing, sent once when a client starts an item. +// +// These Sessions endpoints report only the caller's own playback and never expose content, so unlike +// browse/stream they are intentionally not library-access-gated. +func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback start failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackProgress handles POST /Sessions/Playing/Progress, sent periodically +// (and on pause/resume/seek) while a client keeps playing an item. +func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + state := scrobbler.StatePlaying + if body.IsPaused { + state = scrobbler.StatePaused + } + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: state, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback progress failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackStopped handles POST /Sessions/Playing/Stopped, sent once when playback ends. +// +// Jellyfin clients (Finamp) send a Stopped report on *every* stop, even an immediate track switch, +// so the play threshold is applied server-side: ReportPlayback's StateStopped logic counts the play +// only past 50% (capped at 4 minutes). Force-submitting here would mark a one-second skip as played. +func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + clientId, clientName := clientIdentity(ctx) + + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StateStopped, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback stopped failed", "id", body.ItemId, err) + } + 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) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go new file mode 100644 index 000000000..24f945efd --- /dev/null +++ b/server/jellyfin/sessions_test.go @@ -0,0 +1,216 @@ +package jellyfin + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakePlayTracker is a local double for scrobbler.PlayTracker, mirroring +// server/subsonic's fakePlayTracker. +type fakePlayTracker struct { + scrobbler.PlayTracker + reported []scrobbler.ReportPlaybackParams + submitted []scrobbler.Submission +} + +func (f *fakePlayTracker) ReportPlayback(_ context.Context, p scrobbler.ReportPlaybackParams) error { + f.reported = append(f.reported, p) + return nil +} + +func (f *fakePlayTracker) Submit(_ context.Context, s []scrobbler.Submission) error { + f.submitted = append(f.submitted, s...) + return nil +} + +// fakePlayers is a local double for core.Players, used to exercise withPlayer. +type fakePlayers struct { + core.Players + err error + registerCalls int + lastClient string + trc *model.Transcoding +} + +func (f *fakePlayers) Register(_ context.Context, id, client, _, _ string) (*model.Player, *model.Transcoding, error) { + f.registerCalls++ + f.lastClient = client + if f.err != nil { + return nil, nil, f.err + } + return &model.Player{ID: id, Client: client}, f.trc, nil +} + +var _ = Describe("Sessions", func() { + var api *Router + var pt *fakePlayTracker + + authed := func(r *http.Request) *http.Request { + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", Client: "Finamp"}) + return r.WithContext(ctx) + } + + BeforeEach(func() { + pt = &fakePlayTracker{} + api = &Router{ds: &tests.MockDataStore{}, scrobbler: pt} + }) + + Describe("reportPlaybackStart", func() { + It("reports playback start with the item id and position", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"s1","PositionTicks":10000000}`))) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].PositionMs).To(Equal(int64(1000))) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(pt.reported[0].ClientId).To(Equal("p1")) + Expect(pt.reported[0].ClientName).To(Equal("Finamp")) + }) + + It("falls back to the ItemId query param when the body has none", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId=s2", nil)) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s2")) + }) + }) + + Describe("reportPlaybackProgress", func() { + It("reports the playing state when not paused", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":false}`))) + + invoke(api.reportPlaybackProgress, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(pt.reported[0].PositionMs).To(Equal(int64(2000))) + }) + + It("reports the paused state when IsPaused is true", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":true}`))) + + invoke(api.reportPlaybackProgress, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePaused)) + }) + }) + + Describe("reportPlaybackStopped", func() { + It("reports the stopped state and lets the scrobbler apply its play threshold", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"s1","PositionTicks":600000000}`))) + + invoke(api.reportPlaybackStopped, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped)) + Expect(pt.reported[0].PositionMs).To(Equal(int64(60000))) + // IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides + // whether the play counts; we no longer force a Submit that would bypass it. + Expect(pt.reported[0].IgnoreScrobble).To(BeFalse()) + Expect(pt.submitted).To(BeEmpty()) + }) + }) + + Describe("postCapabilities", 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) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(BeEmpty()) + Expect(pt.submitted).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("withPlayer middleware", func() { + var api *Router + var fp *fakePlayers + + BeforeEach(func() { + fp = &fakePlayers{} + api = &Router{ds: &tests.MockDataStore{}, players: fp} + }) + + It("registers a player from the Emby device info and injects it into the context", func() { + var gotPlayer model.Player + var gotOk bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPlayer, gotOk = request.PlayerFrom(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Sessions/Playing", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(gotOk).To(BeTrue()) + Expect(gotPlayer.ID).To(Equal("dev1")) + Expect(gotPlayer.Client).To(Equal("Finamp")) + }) + + It("fails open (no player in context) when registration errors", func() { + fp.err = errors.New("boom") + var gotOk bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, gotOk = request.PlayerFrom(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Sessions/Playing", nil) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(gotOk).To(BeFalse()) + }) + + // The /socket handshake authenticates via ?api_key= with no X-Emby-Authorization header, so it + // carries no client/device info; registering it would create a junk player named " []". + It("skips registration when the request has no client or device info", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/socket?api_key=tok", nil) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.registerCalls).To(Equal(0)) + }) +}) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go new file mode 100644 index 000000000..db3671fe4 --- /dev/null +++ b/server/jellyfin/similar.go @@ -0,0 +1,190 @@ +package jellyfin + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// similarWait bounds how long a Similar request waits for the provider fetch. Returning the real +// result beats an instant empty list, which clients cache as "no similar items exist". A var so +// tests can shorten it. +var similarWait = 10 * time.Second + +const maxSimilarLimit = 100 + +// similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine +// indefinitely. +const similarFetchTimeout = time.Minute + +// awaitSimilar runs fetch on a detached background context (so it completes and caches even if the +// request times out or the client disconnects), waiting up to similarWait then answering empty. +// Identical concurrent requests share one fetch via singleflight; the key includes the user since +// mapped items embed that user's annotations. +func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch func(context.Context) dto.QueryResult) dto.QueryResult { + u, _ := request.UserFrom(ctx) + key := fmt.Sprintf("%s|%s|%d", u.ID, id, limit) + ch := api.similarFlight.DoChan(key, func() (any, error) { + bgCtx, cancel := context.WithTimeout(request.WithUser(context.Background(), u), similarFetchTimeout) + defer cancel() + return fetch(bgCtx), nil + }) + select { + case res := <-ch: + return res.Val.(dto.QueryResult) + case <-time.After(similarWait): + return result(nil, 0, 0) + } +} + +// getSimilarArtists answers GET /Artists/{itemId}/Similar with related artists from the same +// external.Provider that powers Subsonic's getArtistInfo2. Only artists present in the library are +// returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. +func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 20)) + api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarArtists(ctx, id, limit) + })) +} + +// getSimilarItems answers GET /Items/{itemId}/Similar with items of the target's kind: similar +// songs for a track, albums for an album, artists for an artist. An unresolvable id yields an empty +// result (not 404) so the client stops retrying. +func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 20)) + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + api.ok(w, r, result(nil, 0, 0)) + return + } + api.ok(w, r, api.awaitSimilar(ctx, id, limit, func(ctx context.Context) dto.QueryResult { + switch entity.(type) { + case *model.Artist: + return api.similarArtists(ctx, id, limit) + case *model.Album: + return api.similarAlbums(ctx, id, limit) + default: // *model.MediaFile + return api.similarSongs(ctx, id, limit) + } + })) +} + +// getInstantMix answers GET /Items/{itemId}/InstantMix. Finamp plays exactly what is returned, so +// 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 := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 20)) + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + api.ok(w, r, result(nil, 0, 0)) + return + } + mf, isSong := entity.(*model.MediaFile) + if isSong { + if u, _ := request.UserFrom(ctx); !u.HasLibraryAccess(mf.LibraryID) { + api.ok(w, r, result(nil, 0, 0)) + return + } + } + // Prefixed key: a mix must not share the singleflight/cache slot with a Similar request. + tail := api.awaitSimilar(ctx, "mix|"+id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarSongs(ctx, id, limit) + }) + if !isSong { + // Container seeds: the provider's similar songs already blend the seed's own tracks. + api.ok(w, r, tail) + return + } + // The seed leads the mix and must not depend on the provider: a slow or failing provider times + // the await out with an empty tail, but the tapped track still plays. + items := []dto.BaseItemDto{dto.SongToBaseItem(*mf, nil)} + for _, it := range tail.Items { + if len(items) >= limit { + break + } + if it.Id != items[0].Id { + items = append(items, it) + } + } + api.ok(w, r, result(items, len(items), 0)) +} + +func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto.QueryResult { + artist, err := api.provider.UpdateArtistInfo(ctx, id, limit, false) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar artists", "id", id, err) + return result(nil, 0, 0) + } + present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" }) + items := slice.Map(present, dto.ArtistToBaseItem) + return result(items, len(items), 0) +} + +// clampLimit bounds a client-supplied limit so it can't drive an oversized allocation or provider +// fetch (flagged by CodeQL as a user-controlled allocation size). +func clampLimit(limit int) int { + if limit <= 0 { + return 20 + } + return min(limit, maxSimilarLimit) +} + +func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult { + songs, err := api.provider.SimilarSongs(ctx, id, limit) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar songs", "id", id, err) + return result(nil, 0, 0) + } + // Filter to the caller's libraries; the provider can return songs from any library. + u, _ := request.UserFrom(ctx) + var items []dto.BaseItemDto + for _, mf := range songs { + if u.HasLibraryAccess(mf.LibraryID) { + items = append(items, dto.SongToBaseItem(mf, nil)) + } + } + return result(items, len(items), 0) +} + +// similarAlbums derives similar albums from the provider's similar-songs signal (there's no direct +// "similar albums" source), keeping each album once in first-seen order and resolving it to a full +// model.Album for cover art and metadata. +func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto.QueryResult { + songs, err := api.provider.SimilarSongs(ctx, id, limit*5) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar albums", "id", id, err) + return result(nil, 0, 0) + } + u, _ := request.UserFrom(ctx) + seen := make(map[string]bool, limit) + var items []dto.BaseItemDto + for _, s := range songs { + if s.AlbumID == "" || seen[s.AlbumID] { + continue + } + seen[s.AlbumID] = true + if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { + items = append(items, dto.AlbumToBaseItem(*al)) + if len(items) >= limit { + break + } + } + } + return result(items, len(items), 0) +} diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go new file mode 100644 index 000000000..cbb68e5cf --- /dev/null +++ b/server/jellyfin/similar_test.go @@ -0,0 +1,131 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http/httptest" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/core/external" + "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" +) + +var _ = Describe("awaitSimilar", func() { + var api *Router + ctxFor := func(userID string) context.Context { + return request.WithUser(context.Background(), model.User{ID: userID}) + } + shortenWait := func() { + old := similarWait + similarWait = 20 * time.Millisecond + DeferCleanup(func() { similarWait = old }) + } + + BeforeEach(func() { + api = &Router{} + }) + + It("returns the fetch result when it completes within the wait", func() { + res := api.awaitSimilar(ctxFor("u1"), "id1", 20, func(context.Context) dto.QueryResult { + return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0) + }) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("fast")) + }) + + It("returns an empty result when the fetch exceeds the wait", func() { + shortenWait() + release := make(chan struct{}) + DeferCleanup(func() { close(release) }) + res := api.awaitSimilar(ctxFor("u1"), "id2", 20, func(context.Context) dto.QueryResult { + <-release // hung provider; would finish caching in the background + return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0) + }) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(Equal(0)) + }) + + It("dedupes requests into the in-flight fetch", func() { + shortenWait() + var calls atomic.Int32 + release := make(chan struct{}) + fetch := func(context.Context) dto.QueryResult { + calls.Add(1) + <-release + return result(nil, 0, 0) + } + // Both calls time out, but the flight can't complete before release closes, so the + // second call must join it rather than start a new fetch. + api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + close(release) + Eventually(calls.Load).Should(Equal(int32(1))) + Consistently(calls.Load, "50ms").Should(Equal(int32(1))) + }) + + It("does not share fetches across users (items embed the user's annotations)", func() { + var calls atomic.Int32 + fetch := func(context.Context) dto.QueryResult { + calls.Add(1) + return result(nil, 0, 0) + } + api.awaitSimilar(ctxFor("u1"), "id4", 20, fetch) + api.awaitSimilar(ctxFor("u2"), "id4", 20, fetch) + Expect(calls.Load()).To(Equal(int32(2))) + }) + + It("hands the fetch a deadline-bounded background context", func() { + var deadline time.Time + var hasDeadline bool + api.awaitSimilar(ctxFor("u1"), "id5", 20, func(ctx context.Context) dto.QueryResult { + deadline, hasDeadline = ctx.Deadline() + return result(nil, 0, 0) + }) + Expect(hasDeadline).To(BeTrue(), "background fetch must not be able to run forever") + Expect(time.Until(deadline)).To(BeNumerically("<=", similarFetchTimeout)) + }) +}) + +// blockingProvider hangs SimilarSongs until release is closed, simulating a slow/unreachable agent. +type blockingProvider struct { + external.Provider + release chan struct{} +} + +func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + <-p.release + return nil, nil +} + +var _ = Describe("getInstantMix", func() { + It("returns the seed track even when the provider fetch exceeds the wait", func() { + old := similarWait + similarWait = 20 * time.Millisecond + DeferCleanup(func() { similarWait = old }) + + ds := &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Seed Song", LibraryID: 1}, + }) + release := make(chan struct{}) + DeferCleanup(func() { close(release) }) + api := &Router{ds: ds, provider: &blockingProvider{release: release}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getInstantMix(w, r) + + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("Seed Song")) + }) +}) diff --git a/server/jellyfin/socket.go b/server/jellyfin/socket.go new file mode 100644 index 000000000..80a49cd4c --- /dev/null +++ b/server/jellyfin/socket.go @@ -0,0 +1,55 @@ +package jellyfin + +import ( + "net/http" + "time" + + "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/log" +) + +// socketKeepAliveInterval (seconds) is sent in the initial ForceKeepAlive telling the client how +// often to send KeepAlive, and bounds the local read deadline. +const socketKeepAliveInterval = 60 + +// socketReadTimeout is generous relative to socketKeepAliveInterval so a single delayed +// KeepAlive doesn't drop the connection. +const socketReadTimeout = 90 * time.Second + +var socketUpgrader = websocket.Upgrader{ + // Jellyfin clients aren't browsers, so there's no cross-origin risk; the connection is + // already authenticated via api_key. + CheckOrigin: func(*http.Request) bool { return true }, +} + +// handleSocket implements Jellyfin's /socket WebSocket endpoint. Finamp opens it right after login +// and 404-loop-reconnects without it. Minimal: keeps the connection alive and answers KeepAlive +// pings, with no session/playstate push. +func (api *Router) handleSocket(w http.ResponseWriter, r *http.Request) { + conn, err := socketUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Warn(r.Context(), "Jellyfin API: WebSocket upgrade failed", err) + return + } + defer conn.Close() + + if err := conn.WriteJSON(map[string]any{"MessageType": "ForceKeepAlive", "Data": socketKeepAliveInterval}); err != nil { + log.Warn(r.Context(), "Jellyfin API: WebSocket failed to send ForceKeepAlive", err) + return + } + + for { + _ = conn.SetReadDeadline(time.Now().Add(socketReadTimeout)) + var msg struct { + MessageType string `json:"MessageType"` + } + if err := conn.ReadJSON(&msg); err != nil { + return + } + if msg.MessageType == "KeepAlive" { + if err := conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"}); err != nil { + return + } + } + } +} diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go new file mode 100644 index 000000000..91542c111 --- /dev/null +++ b/server/jellyfin/socket_test.go @@ -0,0 +1,125 @@ +package jellyfin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("handleSocket", func() { + var api *Router + + BeforeEach(func() { + api = &Router{} + }) + + // Jellyfin's real-time clients (e.g. Finamp) open a WebSocket right after login; without + // a working handshake here they 404-loop-reconnect instead of settling into a session. + It("upgrades the connection and sends ForceKeepAlive", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var msg map[string]any + Expect(conn.ReadJSON(&msg)).To(Succeed()) + Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) + Expect(msg["Data"]).To(BeNumerically("==", 60)) + }) + + It("replies to a KeepAlive message with a KeepAlive of its own", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var handshake map[string]any + Expect(conn.ReadJSON(&handshake)).To(Succeed()) + Expect(handshake["MessageType"]).To(Equal("ForceKeepAlive")) + + Expect(conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"})).To(Succeed()) + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var reply map[string]any + Expect(conn.ReadJSON(&reply)).To(Succeed()) + Expect(reply["MessageType"]).To(Equal("KeepAlive")) + }) + + It("closes the connection when the client disconnects, without leaving the handler hanging", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var handshake map[string]any + Expect(conn.ReadJSON(&handshake)).To(Succeed()) + + Expect(conn.Close()).To(Succeed()) + }) + + // End-to-end: proves /socket is reachable through the full router (case-insensitive + // wrapper + chi mux + auth middleware) with a real network listener, exactly as Finamp + // hits it in production with ?api_key=. + Context("mounted behind the full router and auth middleware", func() { + var ds *tests.MockDataStore + var token string + + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + + t, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + Expect(err).ToNot(HaveOccurred()) + token = t + + api = New(ds, nil, nil, nil, nil, nil, nil, nil) + }) + + It("upgrades when authenticated via the api_key query parameter", func() { + srv := httptest.NewServer(api) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket?api_key=" + token + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var msg map[string]any + Expect(conn.ReadJSON(&msg)).To(Succeed()) + Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) + }) + + It("rejects the upgrade with no api_key", func() { + srv := httptest.NewServer(api) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket" + _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).To(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go new file mode 100644 index 000000000..28411f9d1 --- /dev/null +++ b/server/jellyfin/stream.go @@ -0,0 +1,164 @@ +package jellyfin + +import ( + "fmt" + "math" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// mediaFileForRequest resolves {itemId} to a MediaFile and verifies the user has access to its +// library, writing 404 (never 403, to avoid an existence oracle) and returning ok=false otherwise. +// Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library. +func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + mf, err := api.ds.MediaFile(ctx).Get(id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + u, _ := request.UserFrom(ctx) + if !u.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return mf, true +} + +// getPlaybackInfo answers /Items/{itemId}/PlaybackInfo with a single MediaSource for direct +// playback. Format negotiation happens later in streamAudio (like Subsonic defers it to /stream). +func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + src := dto.MediaSourceFromMediaFile(*mf) + // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl + // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients + // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so + // a client resolving it as an absolute host path still hits the mounted router. + if token := tokenFromRequest(r); token != "" { + src.TranscodingSubProtocol = "http" + src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) + } + api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID}) +} + +// streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, +// reusing the same transcode-decision + streaming pipeline as the Subsonic /stream endpoint. +func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + ctx := r.Context() + p := req.Params(r) + + format := p.StringOr("container", "") + if format == "" { + // The /stream.{container} route form carries the format as a path segment, not a query param. + format = chi.URLParam(r, "container") + } + if format == "" { + // Jellyfin's audioCodec param names the target codec when no container is given. + format = p.StringOr("audiocodec", "") + } + if p.BoolOr("static", false) { + format = "raw" + } + + // Bitrate params are bits/sec by Jellyfin convention; ResolveRequest expects kbps. + bitRate := p.IntOr("audiobitrate", 0) / 1000 + if bitRate == 0 { + bitRate = p.IntOr("maxstreamingbitrate", 0) / 1000 + } + + streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, format, bitRate, 0) + s, err := api.streamer.NewStream(ctx, mf, streamReq) + if err != nil { + api.internalError(w, r, err) + return + } + defer s.Close() + if _, err := s.Serve(ctx, w, r); err != nil { + log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) + } +} + +// streamHls serves /Audio/{itemId}/main.m3u8 (Finamp's transcoding mode) as a single-segment VOD +// playlist whose one segment is the progressive transcode endpoint, reusing that whole pipeline. +// Trade-off: seeking re-reads from the start, like Subsonic transcoded streams. +func (api *Router) streamHls(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + p := req.Params(r) + + // HLS packed audio can only carry ADTS/AAC or MP3; other codecs fall back to aac. A forced + // transcoding wins verbatim — its override rewrites the segment anyway, and the playlist must match. + codec := strings.ToLower(p.StringOr("audiocodec", "")) + if codec != "mp3" { + codec = "aac" + } + if trc, ok := request.TranscodingFrom(r.Context()); ok && trc.TargetFormat != "" { + codec = strings.ToLower(trc.TargetFormat) + } + + // Relative to the playlist URL. HLS fetches drop auth headers, so the token rides in the query. + segment := "stream." + codec + q := url.Values{} + if token := tokenFromRequest(r); token != "" { + q.Set("api_key", token) + } + if bitRate := p.IntOr("audiobitrate", 0); bitRate > 0 { + q.Set("audioBitRate", strconv.Itoa(bitRate)) + } + if len(q) > 0 { + segment += "?" + q.Encode() + } + + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + //nolint:gosec // not HTML; the only tainted value is query-escaped + fmt.Fprintf(w, "#EXTM3U\n"+ + "#EXT-X-VERSION:3\n"+ + "#EXT-X-PLAYLIST-TYPE:VOD\n"+ + "#EXT-X-TARGETDURATION:%d\n"+ + "#EXT-X-MEDIA-SEQUENCE:0\n"+ + "#EXTINF:%.3f,\n"+ + "%s\n"+ + "#EXT-X-ENDLIST\n", + int(math.Ceil(float64(mf.Duration))), mf.Duration, segment) +} + +// streamFile serves /Items/{itemId}/File and /Download, Jellyfin's direct-file endpoints. Some +// clients (Finamp's just_audio engine) fetch playback audio here instead of /Audio/{id}/stream, so +// it must always resolve to direct play ("raw"), never a forced transcode. +func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + ctx := r.Context() + streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, "raw", 0, 0) + s, err := api.streamer.NewStream(ctx, mf, streamReq) + if err != nil { + api.internalError(w, r, err) + return + } + defer s.Close() + if _, err := s.Serve(ctx, w, r); err != nil { + log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) + } +} diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go new file mode 100644 index 000000000..7063d321a --- /dev/null +++ b/server/jellyfin/stream_test.go @@ -0,0 +1,316 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core/stream" + "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" +) + +var _ = Describe("Stream", func() { + var api *Router + var ds *tests.MockDataStore + var streamer *fakeMediaStreamer + var decider *fakeTranscodeDecider + + // alice has access to library 1 only. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + streamer = &fakeMediaStreamer{} + decider = &fakeTranscodeDecider{} + api = &Router{ds: ds, streamer: streamer, transcodeDecider: decider} + }) + + Describe("getPlaybackInfo", func() { + It("returns a media source for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaybackInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.MediaSources).To(HaveLen(1)) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.MediaSources[0].Container).To(Equal("mp3")) + Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) + Expect(res.PlaySessionId).ToNot(BeEmpty()) + }) + + It("returns 404 for a track in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/s1/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the id doesn't match any media file", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("streamAudio", func() { + It("invokes the transcode decider and streamer for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decider.invoked).To(BeTrue()) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(Equal("audio-bytes")) + }) + + It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/missing/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("converts the bps audioBitRate param to kbps", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream?audiobitrate=320000", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(decider.req.BitRate).To(Equal(320)) + }) + + It("uses the audioCodec param as target format when no container is given", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream?audiocodec=aac", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(decider.req.Format).To(Equal("aac")) + }) + + It("returns 500 and logs when the streamer fails", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.err = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("streamHls", func() { + BeforeEach(func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, + }) + }) + + hls := func(query string, ctx context.Context) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/main.m3u8"+query, nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamHls, w, r) + return w + } + + It("returns a single-segment VOD playlist pointing at the progressive stream endpoint", func() { + w := hls("?audiocodec=aac&audiobitrate=320000&api_key=tok", ctxUser()) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) + body := w.Body.String() + Expect(body).To(HavePrefix("#EXTM3U\n")) + Expect(body).To(ContainSubstring("#EXT-X-PLAYLIST-TYPE:VOD\n")) + Expect(body).To(ContainSubstring("#EXT-X-TARGETDURATION:101\n")) + Expect(body).To(ContainSubstring("#EXTINF:100.500,\n")) + Expect(body).To(ContainSubstring("\nstream.aac?api_key=tok&audioBitRate=320000\n")) + Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) + }) + + It("omits the bitrate param when the client doesn't send one", func() { + w := hls("?audiocodec=aac&api_key=tok", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.aac?api_key=tok\n")) + }) + + It("falls back to aac for codecs HLS packed-audio can't carry", func() { + w := hls("?audiocodec=opus", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.aac\n")) + }) + + It("honors mp3 as segment codec", func() { + w := hls("?audiocodec=mp3", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) + }) + + It("prefers the server-forced transcoding format over the requested codec", func() { + ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "mp3"}) + w := hls("?audiocodec=aac", ctx) + Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) + }) + + It("advertises an HLS-incompatible forced format verbatim, matching what the segment will contain", func() { + ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "opus"}) + w := hls("?audiocodec=aac", ctx) + Expect(w.Body.String()).To(ContainSubstring("\nstream.opus\n")) + }) + + It("returns 404 for a track in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "dsf", LibraryID: 2}, + }) + Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the id doesn't match any media file", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{}) + Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("streamFile", func() { + It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decider.invoked).To(BeTrue()) + Expect(decider.req.Format).To(Equal("raw")) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(Equal("audio-bytes")) + }) + + It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + }) +}) + +// fakeTranscodeDecider is a local test double for stream.TranscodeDecider: it records whether +// (and how) ResolveRequest was invoked, so tests can assert it's never called on the +// access-denied path, without needing a real transcode decision pipeline. +type fakeTranscodeDecider struct { + invoked bool + req stream.Request +} + +func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) { + return &stream.TranscodeDecision{}, nil +} + +func (f *fakeTranscodeDecider) CreateTranscodeParams(*stream.TranscodeDecision) (string, error) { + return "", nil +} + +func (f *fakeTranscodeDecider) ResolveRequestFromToken(context.Context, string, *model.MediaFile, int) (stream.Request, error) { + return stream.Request{}, nil +} + +func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { + f.invoked = true + f.req = stream.Request{Format: format, BitRate: bitRate, Offset: offset} + return f.req +} + +// fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether +// NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed +// by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code. +type fakeMediaStreamer struct { + invoked bool + content string + err error +} + +func (f *fakeMediaStreamer) NewStream(_ context.Context, mf *model.MediaFile, _ stream.Request) (*stream.Stream, error) { + f.invoked = true + if f.err != nil { + return nil, f.err + } + return stream.NewStream(mf, mf.Suffix, 0, io.NopCloser(strings.NewReader(f.content))), nil +} diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go new file mode 100644 index 000000000..baa31fdeb --- /dev/null +++ b/server/jellyfin/system.go @@ -0,0 +1,96 @@ +package jellyfin + +import ( + "context" + "errors" + "fmt" + "net/http" + "path" + + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "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. +const jellyfinVersion = "10.8.13" + +func (api *Router) 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 + } + if api.ds == nil { + api.serverIDVal = uuid.NewString() + return api.serverIDVal + } + id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) + switch { + case errors.Is(err, model.ErrNotFound): + id = uuid.NewString() + if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { + log.Error(ctx, "Jellyfin API: could not persist server id", err) + return id + } + case err != nil: + log.Error(ctx, "Jellyfin API: could not read server id", err) + return uuid.NewString() + } + api.serverIDVal = id + return api.serverIDVal +} + +func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { + return dto.PublicSystemInfo{ + LocalAddress: localAddress(r), + ServerName: api.serverName(), + Version: jellyfinVersion, + ProductName: "Jellyfin Server", + Id: api.serverID(r.Context()), + StartupWizardCompleted: true, + } +} + +// localAddress reconstructs the base URL the client used (scheme/host from the request, honoring +// X-Forwarded-* headers, plus the mount path), advertised as LocalAddress. Jellify adopts it as +// its server base URL; without it its SDK api instance is undefined and sign-in crashes. +func localAddress(r *http.Request) string { + scheme, host := server.ServerAddress(r) + return scheme + "://" + host + path.Join(conf.Server.BasePath, consts.URLPathJellyfinAPI) +} + +func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, api.publicInfo(r)) +} + +// ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's +// server does this and clients parse the raw body. +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())) +} + +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 new file mode 100644 index 000000000..5ea525100 --- /dev/null +++ b/server/jellyfin/system_test.go @@ -0,0 +1,124 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System", func() { + var api *Router + BeforeEach(func() { api = &Router{} }) + + It("returns public system info without auth", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + api.getPublicSystemInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + var info dto.PublicSystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.Id).ToNot(BeEmpty()) + Expect(info.Version).To(Equal(jellyfinVersion)) + Expect(info.ProductName).To(Equal("Jellyfin Server")) + Expect(info.ServerName).To(HavePrefix("Navidrome")) + }) + + It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() { + DeferCleanup(configtest.SetupConfig()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + r.Host = "music.example.com:4599" + api.getPublicSystemInfo(w, r) + + var info dto.PublicSystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + // Jellify connecting over HTTP sets its server base URL from LocalAddress; without it the + // SDK `api` is undefined and sign-in crashes. It must include the /jellyfin mount path. + Expect(info.LocalAddress).To(Equal("http://music.example.com:4599/jellyfin")) + }) + + It("responds to ping with the server name as plain text", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Ping", nil) + api.ping(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain")) + // Plain text, not a JSON-quoted string: Jellyfin clients expect the bare server name. + Expect(w.Body.String()).To(HavePrefix("Navidrome")) + }) + + 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 + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + }) + + It("persists the generated id so it can be read back by another Router sharing the same DataStore", func() { + first := &Router{ds: ds} + id := first.serverID(ctx) + Expect(id).ToNot(BeEmpty()) + + second := &Router{ds: ds} + Expect(second.serverID(ctx)).To(Equal(id)) + }) + + It("memoizes the id across repeated calls on the same Router", func() { + r := &Router{ds: ds} + id := r.serverID(ctx) + Expect(r.serverID(ctx)).To(Equal(id)) + Expect(r.serverID(ctx)).To(Equal(id)) + }) + + It("does not overwrite or pin over a stored id when the property read fails transiently", func() { + Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "stable-id")).To(Succeed()) + + r := &Router{ds: ds} + props := ds.Property(ctx).(*tests.MockedPropertyRepo) + props.Error = errors.New("database is locked") + degraded := r.serverID(ctx) + Expect(degraded).ToNot(BeEmpty()) + Expect(degraded).ToNot(Equal("stable-id")) // temporary value, not the (unreadable) stored one + props.Error = nil + + // Once the DB recovers, the stored id is intact and served again. + Expect(r.serverID(ctx)).To(Equal("stable-id")) + stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) + Expect(err).ToNot(HaveOccurred()) + Expect(stored).To(Equal("stable-id")) + }) + }) +}) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go new file mode 100644 index 000000000..b58296391 --- /dev/null +++ b/server/jellyfin/truncated_ids.go @@ -0,0 +1,113 @@ +package jellyfin + +import ( + "context" + "slices" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +// truncatedIDLen is what Finamp's saved-queue persistence cuts item ids to (16 bytes, assuming +// Jellyfin GUIDs). No Navidrome id family is 16 chars (nanoid=22, legacy MD5=32, playlist +// UUID=36), so the length alone identifies a truncated id. See README. +// +// Handlers taking an item id resolve it via resolveItemID/resolveItemIDs; playlist-write handlers +// and ParentId scoping don't (a restored queue never edits playlists or browses by container id). +const truncatedIDLen = 16 + +// resolveItemID maps a truncated item id back to the full id via unique-prefix lookup. The id is +// returned unchanged when it isn't truncation-shaped, matches nothing, or is ambiguous. +func (api *Router) resolveItemID(ctx context.Context, id string) string { + if len(id) != truncatedIDLen { + return id + } + probes := []func() []string{ + func() []string { return idsMatching(api.ds.MediaFile(ctx).GetAll, "media_file.id", id, mediaFileID) }, + func() []string { return idsMatching(api.ds.Album(ctx).GetAll, "album.id", id, albumID) }, + func() []string { return idsMatching(api.ds.Artist(ctx).GetAll, "artist.id", id, artistID) }, + func() []string { return idsMatching(api.ds.Playlist(ctx).GetAll, "playlist.id", id, playlistID) }, + } + for _, probe := range probes { + switch ids := probe(); len(ids) { + case 0: + continue + case 1: + log.Trace(ctx, "Jellyfin API: resolved truncated item id", "truncated", id, "full", ids[0]) + return ids[0] + default: + log.Warn(ctx, "Jellyfin API: truncated item id is ambiguous", "truncated", id) + return id + } + } + return id +} + +// resolveItemIDs is the batch form of resolveItemID for id lists (queue restore sends hundreds of +// truncated ids): all media-file prefixes are resolved with one chunked range query, and only the +// leftovers (containers, unknowns) fall back to the per-id probes. +func (api *Router) resolveItemIDs(ctx context.Context, ids []string) []string { + var truncated []string + for _, id := range ids { + if len(id) == truncatedIDLen { + truncated = append(truncated, id) + } + } + if len(truncated) == 0 { + return ids + } + + byPrefix := make(map[string][]string, len(truncated)) + for chunk := range slice.CollectChunks(slices.Values(truncated), 100) { + ranges := make(squirrel.Or, len(chunk)) + for i, p := range chunk { + ranges[i] = squirrel.And{squirrel.GtOrEq{"media_file.id": p}, squirrel.Lt{"media_file.id": p + "\x7f"}} + } + mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: ranges}) + if err != nil { + log.Error(ctx, "Jellyfin API: error batch-resolving truncated ids", err) + break + } + for _, mf := range mfs { + p := mf.ID[:truncatedIDLen] + byPrefix[p] = append(byPrefix[p], mf.ID) + } + } + + out := make([]string, len(ids)) + for i, id := range ids { + switch full := byPrefix[id]; { + case len(full) == 1: + out[i] = full[0] + case len(id) == truncatedIDLen: + out[i] = api.resolveItemID(ctx, id) // ambiguous or not a song: per-id probes decide + default: + out[i] = id + } + } + return out +} + +// idsMatching returns the ids of up to two rows whose id starts with prefix (two is enough to +// detect ambiguity). '\x7f' is above every character the id alphabets use. +func idsMatching[S ~[]T, T any](getAll func(...model.QueryOptions) (S, error), column, prefix string, id func(T) string) []string { + rows, err := getAll(model.QueryOptions{ + Filters: squirrel.And{squirrel.GtOrEq{column: prefix}, squirrel.Lt{column: prefix + "\x7f"}}, + Max: 2, + }) + if err != nil { + return nil + } + ids := make([]string, len(rows)) + for i, row := range rows { + ids[i] = id(row) + } + return ids +} + +func mediaFileID(mf model.MediaFile) string { return mf.ID } +func albumID(al model.Album) string { return al.ID } +func artistID(ar model.Artist) string { return ar.ID } +func playlistID(pl model.Playlist) string { return pl.ID } diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go new file mode 100644 index 000000000..5f231e0b5 --- /dev/null +++ b/server/jellyfin/users.go @@ -0,0 +1,61 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// getUserViews returns one CollectionFolder view per accessible library, so clients browse each +// library as its own top-level view rather than one aggregate. +func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + views := make([]dto.BaseItemDto, 0, len(u.Libraries)) + for _, lib := range u.Libraries { + views = append(views, libraryView(lib)) + } + api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0}) +} + +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))) +} + +// getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login +// picker. The route is unauthenticated, so it lists only the configured allowlist (never the full +// user table) and returns a minimal DTO — no Policy/Configuration, which would leak admin status. +func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + serverID := api.serverID(ctx) + seen := make(map[string]bool) + users := []dto.UserDto{} + for name := range strings.SplitSeq(conf.Server.Jellyfin.ExposedPublicUsers, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + key := strings.ToLower(name) + if seen[key] { + continue + } + seen[key] = true + usr, err := api.ds.User(ctx).FindByUsername(name) + if err != nil { + log.Warn(ctx, "Jellyfin API: configured public user not found", "username", name, err) + continue + } + users = append(users, dto.UserDto{ + Name: usr.UserName, + Id: usr.ID, + ServerId: serverID, + HasPassword: true, + }) + } + api.ok(w, r, users) +} diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go new file mode 100644 index 000000000..2b7177044 --- /dev/null +++ b/server/jellyfin/users_test.go @@ -0,0 +1,130 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "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" +) + +var _ = Describe("Users", func() { + var api *Router + authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + return r.WithContext(ctx) + } + BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) + + Describe("getUserViews", func() { + It("returns one view per accessible library", func() { + libs := model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Podcasts"}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.TotalRecordCount).To(Equal(2)) + + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Name).To(Equal("Music")) + Expect(res.Items[0].Type).To(Equal("CollectionFolder")) + Expect(res.Items[0].CollectionType).To(Equal("music")) + Expect(res.Items[0].IsFolder).To(BeTrue()) + + Expect(res.Items[1].Id).To(Equal(dto.EncodeID("2"))) + Expect(res.Items[1].Name).To(Equal("Podcasts")) + }) + + It("returns a single view for a user with one library", func() { + libs := model.Libraries{{ID: 1, Name: "Music"}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + }) + + It("returns no views for a user with no library access", func() { + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(0)) + Expect(res.TotalRecordCount).To(Equal(0)) + }) + }) + + It("returns the current user", func() { + w := httptest.NewRecorder() + api.getCurrentUser(w, authedWithLibraries(httptest.NewRequest("GET", "/Users/Me", nil), nil)) + var u dto.UserDto + Expect(json.Unmarshal(w.Body.Bytes(), &u)).To(Succeed()) + Expect(u.Name).To(Equal("alice")) + Expect(u.Policy).ToNot(BeNil()) + Expect(u.Policy.IsAdministrator).To(BeFalse()) + Expect(u.Configuration).ToNot(BeNil()) + }) + + Describe("getPublicUsers", func() { + var ur *tests.MockedUserRepo + publicUsers := func() []dto.UserDto { + w := httptest.NewRecorder() + api.getPublicUsers(w, httptest.NewRequest("GET", "/Users/Public", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + var users []dto.UserDto + Expect(json.Unmarshal(w.Body.Bytes(), &users)).To(Succeed()) + return users + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ur = api.ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: "u2", UserName: "bob"})).To(Succeed()) + }) + + It("returns an empty list when the config is unset", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "" + Expect(publicUsers()).To(BeEmpty()) + }) + + It("lists the configured users in order, without leaking policy", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "bob, alice" + users := publicUsers() + Expect(users).To(HaveLen(2)) + Expect(users[0].Name).To(Equal("bob")) + Expect(users[0].Id).To(Equal("u2")) + Expect(users[1].Name).To(Equal("alice")) + // The public list must not expose Policy/Configuration to unauthenticated callers. + Expect(users[0].Policy).To(BeNil()) + Expect(users[0].Configuration).To(BeNil()) + }) + + It("skips a configured username that does not exist", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "alice,ghost" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("alice")) + }) + + It("matches usernames case-insensitively and de-duplicates", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "ALICE, alice" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("alice")) + }) + }) +}) diff --git a/server/middlewares.go b/server/middlewares.go index 5d6a1e59c..23e11eaa6 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -202,10 +202,10 @@ func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.H func serverAddressMiddleware(h http.Handler) http.Handler { // Define a new handler function that will be returned by this middleware function. fn := func(w http.ResponseWriter, r *http.Request) { - // Call the serverAddress function to get the scheme and host of the server + // Call the ServerAddress function to get the scheme and host of the server // handling the request. If a host is found, modify the request object to use // that host and scheme instead of the original ones. - if rScheme, rHost := serverAddress(r); rHost != "" { + if rScheme, rHost := ServerAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme } @@ -225,10 +225,10 @@ var ( xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme") ) -// serverAddress is a helper function that returns the scheme and host of the server +// ServerAddress is a helper function that returns the scheme and host of the server // handling the given request, as determined by the presence of X-Forwarded-* headers // or the scheme and host of the request URL. -func serverAddress(r *http.Request) (scheme, host string) { +func ServerAddress(r *http.Request) (scheme, host string) { // Save the original request host for later comparison. origHost := r.Host diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index 5e2d29876..077eac35e 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -13,23 +13,14 @@ import ( "path/filepath" "strings" - "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" _ "golang.org/x/image/webp" ) -func maxImageUploadSize() int64 { - if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { - return int64(size) - } - size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) - return int64(size) -} - func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) if !conf.Server.EnableArtworkUpload && !user.IsAdmin { @@ -40,7 +31,7 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { - maxImageSize := maxImageUploadSize() + maxImageSize := core.MaxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { diff --git a/server/nativeapi/image_upload_test.go b/server/nativeapi/image_upload_test.go deleted file mode 100644 index 291912e67..000000000 --- a/server/nativeapi/image_upload_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package nativeapi - -import ( - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("maxImageUploadSize", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("returns the configured size when valid", func() { - conf.Server.MaxImageUploadSize = "20MB" - Expect(maxImageUploadSize()).To(Equal(int64(20_000_000))) - }) - - It("returns the default size when config is empty", func() { - conf.Server.MaxImageUploadSize = "" - Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("returns the default size when config is invalid", func() { - conf.Server.MaxImageUploadSize = "not-a-size" - Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("parses raw byte values", func() { - conf.Server.MaxImageUploadSize = "52428800" - Expect(maxImageUploadSize()).To(Equal(int64(52_428_800))) - }) -}) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 24bbca960..041a3b8f2 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -9,7 +9,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/filter" + "github.com/navidrome/navidrome/server/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/run" diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 817238aaf..f6a7047c4 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/filter" + "github.com/navidrome/navidrome/server/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" diff --git a/server/e2e/doc.go b/server/subsonic/e2e/doc.go similarity index 98% rename from server/e2e/doc.go rename to server/subsonic/e2e/doc.go index 51ee6f047..9435d1f60 100644 --- a/server/e2e/doc.go +++ b/server/subsonic/e2e/doc.go @@ -103,7 +103,7 @@ // // The e2e tests are included in the standard test suite and can be run with: // -// make test PKG=./server/e2e # Run only e2e tests +// make test PKG=./server/subsonic/e2e # Run only e2e tests // make test # Run all tests including e2e // make test-race # Run with race detector // diff --git a/server/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go similarity index 75% rename from server/e2e/e2e_suite_test.go rename to server/subsonic/e2e/e2e_suite_test.go index ac4aaa5f2..6875b6370 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -4,14 +4,12 @@ import ( "bytes" "context" "encoding/json" - "errors" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" - "strings" "testing" "testing/fstest" "time" @@ -22,7 +20,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" @@ -40,6 +37,7 @@ import ( "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -89,13 +87,10 @@ var ( ctx context.Context ds *tests.MockDataStore router *subsonic.Router - streamerSpy *spyStreamer + streamerSpy *harness.SpyStreamer + goldenDB *harness.DB lib model.Library - // Snapshot paths for fast DB restore - dbFilePath string - snapshotPath string - // Admin user used for most tests adminUser = model.User{ ID: "admin-1", @@ -113,13 +108,6 @@ var ( } ) -func createFS(files fstest.MapFS) storagetest.FakeFS { - fs := storagetest.FakeFS{} - fs.SetFiles(files) - storagetest.Register("fake", &fs) - return fs -} - // buildTestFS creates the full test filesystem matching the plan func buildTestFS() storagetest.FakeFS { abbeyRoad := template(_t{ @@ -145,7 +133,7 @@ func buildTestFS() storagetest.FakeFS { // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) - return createFS(fstest.MapFS{ + return harness.CreateFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), // "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID). @@ -331,61 +319,6 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil } -// spyStreamer captures the Request passed to NewStream for test assertions, -// then returns a minimal fake Stream so the handler completes without error. -type spyStreamer struct { - LastRequest stream.Request - LastMediaFile *model.MediaFile - SimulateError error // When set, NewStream returns this error - SimulateEmptyStream bool // When true, returns a 0-byte stream (simulates ffmpeg producing no output) -} - -func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { - s.LastRequest = req - s.LastMediaFile = mf - if s.SimulateError != nil { - return nil, s.SimulateError - } - format := req.Format - if format == "" || format == "raw" { - format = mf.Suffix - } - content := "fake audio data" - if s.SimulateEmptyStream { - content = "" - } - r := io.NopCloser(strings.NewReader(content)) - return stream.NewStream(mf, format, req.BitRate, r), nil -} - -// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. -type noopFFmpeg struct{} - -func (n noopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: transcode not supported") -} - -func (n noopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: extract image not supported") -} - -func (n noopFFmpeg) Probe(context.Context, []string) (string, error) { - return "", nil -} - -func (n noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { - return nil, errors.New("noop ffmpeg: probe not supported") -} - -func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: convert animated image not supported") -} - -func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } -func (n noopFFmpeg) IsAvailable() bool { return false } -func (n noopFFmpeg) IsProbeAvailable() bool { return true } -func (n noopFFmpeg) Version() string { return "noop" } - // noopArchiver implements core.Archiver type noopArchiver struct{} @@ -434,67 +367,22 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} ) var _ = BeforeSuite(func() { ctx = request.WithUser(GinkgoT().Context(), adminUser) - tmpDir := GinkgoT().TempDir() - dbFilePath = filepath.Join(tmpDir, "test-e2e.db") - snapshotPath = filepath.Join(tmpDir, "test-e2e.db.snapshot") - conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" - db.Db().SetMaxOpenConns(1) - // Initial setup: schema, user, library, and full scan (runs once for the entire suite) conf.Server.MusicFolder = "fake:///music" conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" conf.Server.DevExternalScanner = false - db.Init(ctx) - - initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(initDS) - - adminUserWithPass := adminUser - adminUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed()) - - regularUserWithPass := regularUser - regularUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) - - lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) - - Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) - - loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) - Expect(err).ToNot(HaveOccurred()) - adminUser.Libraries = loadedUser.Libraries - - loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName) - Expect(err).ToNot(HaveOccurred()) - regularUser.Libraries = loadedRegular.Libraries - - ctx = request.WithUser(GinkgoT().Context(), adminUser) - buildTestFS() - s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) - _, err = s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - // Checkpoint WAL and snapshot the golden DB state - _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") - Expect(err).ToNot(HaveOccurred()) - data, err := os.ReadFile(dbFilePath) - Expect(err).ToNot(HaveOccurred()) - Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) + goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) + lib = goldenDB.Library + ctx = request.WithUser(GinkgoT().Context(), adminUser) }) // Close the database before the suite's TempDir cleanup runs. Required on @@ -520,14 +408,14 @@ func setupTestDB() { conf.Server.DevEnableMediaFileProbe = false // Restore DB to golden state (no scan needed) - restoreDB() + goldenDB.Restore() ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} auth.Init(ds) // Create the Subsonic Router with real DS, streamer spy, and real Decider - streamerSpy = &spyStreamer{} - decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + streamerSpy = &harness.SpyStreamer{} + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) router = subsonic.New( @@ -549,39 +437,3 @@ func setupTestDB() { nil, ) } - -// restoreDB restores all table data from the snapshot using ATTACH DATABASE. -// This is much faster than re-running the scanner for each test. -func restoreDB() { - sqlDB := db.Db() - - _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - - _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) - Expect(err).ToNot(HaveOccurred()) - - rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") - Expect(err).ToNot(HaveOccurred()) - var tables []string - for rows.Next() { - var name string - Expect(rows.Scan(&name)).To(Succeed()) - tables = append(tables, name) - } - Expect(rows.Err()).ToNot(HaveOccurred()) - rows.Close() - - for _, table := range tables { - // Table names come from sqlite_master, not user input, so concatenation is safe here - _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - } - - _, err = sqlDB.Exec("DETACH DATABASE snapshot") - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") - Expect(err).ToNot(HaveOccurred()) -} diff --git a/server/e2e/subsonic_album_lists_test.go b/server/subsonic/e2e/subsonic_album_lists_test.go similarity index 100% rename from server/e2e/subsonic_album_lists_test.go rename to server/subsonic/e2e/subsonic_album_lists_test.go diff --git a/server/e2e/subsonic_bookmarks_test.go b/server/subsonic/e2e/subsonic_bookmarks_test.go similarity index 100% rename from server/e2e/subsonic_bookmarks_test.go rename to server/subsonic/e2e/subsonic_bookmarks_test.go diff --git a/server/e2e/subsonic_browsing_test.go b/server/subsonic/e2e/subsonic_browsing_test.go similarity index 100% rename from server/e2e/subsonic_browsing_test.go rename to server/subsonic/e2e/subsonic_browsing_test.go diff --git a/server/e2e/subsonic_lyrics_test.go b/server/subsonic/e2e/subsonic_lyrics_test.go similarity index 100% rename from server/e2e/subsonic_lyrics_test.go rename to server/subsonic/e2e/subsonic_lyrics_test.go diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go similarity index 100% rename from server/e2e/subsonic_media_annotation_test.go rename to server/subsonic/e2e/subsonic_media_annotation_test.go diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/subsonic/e2e/subsonic_media_retrieval_test.go similarity index 100% rename from server/e2e/subsonic_media_retrieval_test.go rename to server/subsonic/e2e/subsonic_media_retrieval_test.go diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go similarity index 100% rename from server/e2e/subsonic_multilibrary_test.go rename to server/subsonic/e2e/subsonic_multilibrary_test.go diff --git a/server/e2e/subsonic_multiuser_test.go b/server/subsonic/e2e/subsonic_multiuser_test.go similarity index 100% rename from server/e2e/subsonic_multiuser_test.go rename to server/subsonic/e2e/subsonic_multiuser_test.go diff --git a/server/e2e/subsonic_playlists_test.go b/server/subsonic/e2e/subsonic_playlists_test.go similarity index 100% rename from server/e2e/subsonic_playlists_test.go rename to server/subsonic/e2e/subsonic_playlists_test.go diff --git a/server/e2e/subsonic_radio_test.go b/server/subsonic/e2e/subsonic_radio_test.go similarity index 100% rename from server/e2e/subsonic_radio_test.go rename to server/subsonic/e2e/subsonic_radio_test.go diff --git a/server/e2e/subsonic_scan_test.go b/server/subsonic/e2e/subsonic_scan_test.go similarity index 100% rename from server/e2e/subsonic_scan_test.go rename to server/subsonic/e2e/subsonic_scan_test.go diff --git a/server/e2e/subsonic_searching_test.go b/server/subsonic/e2e/subsonic_searching_test.go similarity index 100% rename from server/e2e/subsonic_searching_test.go rename to server/subsonic/e2e/subsonic_searching_test.go diff --git a/server/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go similarity index 100% rename from server/e2e/subsonic_sharing_test.go rename to server/subsonic/e2e/subsonic_sharing_test.go diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go similarity index 98% rename from server/e2e/subsonic_sonic_similarity_test.go rename to server/subsonic/e2e/subsonic_sonic_similarity_test.go index 1b8d34eb1..775fefe89 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -32,11 +33,11 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { loader := &mockSonicPluginLoader{provider: provider} m := matcher.New(ds) sonicSvc := sonic.New(ds, loader, m) - decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) return subsonic.New( ds, noopArtwork{}, - &spyStreamer{}, + &harness.SpyStreamer{}, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, diff --git a/server/e2e/subsonic_stream_test.go b/server/subsonic/e2e/subsonic_stream_test.go similarity index 100% rename from server/e2e/subsonic_stream_test.go rename to server/subsonic/e2e/subsonic_stream_test.go diff --git a/server/e2e/subsonic_system_test.go b/server/subsonic/e2e/subsonic_system_test.go similarity index 100% rename from server/e2e/subsonic_system_test.go rename to server/subsonic/e2e/subsonic_system_test.go diff --git a/server/e2e/subsonic_transcode_test.go b/server/subsonic/e2e/subsonic_transcode_test.go similarity index 100% rename from server/e2e/subsonic_transcode_test.go rename to server/subsonic/e2e/subsonic_transcode_test.go diff --git a/server/e2e/subsonic_users_test.go b/server/subsonic/e2e/subsonic_users_test.go similarity index 100% rename from server/e2e/subsonic_users_test.go rename to server/subsonic/e2e/subsonic_users_test.go diff --git a/tests/harness/harness.go b/tests/harness/harness.go new file mode 100644 index 000000000..ff5ce8919 --- /dev/null +++ b/tests/harness/harness.go @@ -0,0 +1,178 @@ +// Package harness holds the pieces shared by the API e2e suites (server/subsonic/e2e and +// server/jellyfin/e2e): golden-database lifecycle, snapshot restore, fixture-FS registration, +// and service doubles. Like core/storage/storagetest, it must only be imported from test code. +package harness + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" //nolint:staticcheck +) + +// DB is a golden e2e database: scanned once in BeforeSuite, restored per test via Restore. +type DB struct { + FilePath string + SnapshotPath string + Library model.Library +} + +// CreateFS registers files under the "fake:" storage scheme the suites use as MusicFolder. +func CreateFS(files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register("fake", &fs) + return fs +} + +// SetupDB boots the golden database: a temp SQLite file, the given users (password "password", +// all with access to the seeded "Music Library"), a full scan of the registered fake FS, and a +// snapshot for per-test restore. Callers must set conf.Server.MusicFolder and register the FS +// first; each user's Libraries field is populated in place. +func SetupDB(ctx context.Context, users ...*model.User) *DB { + tmpDir := ginkgo.GinkgoT().TempDir() + h := &DB{FilePath: filepath.Join(tmpDir, "test-e2e.db")} + h.SnapshotPath = h.FilePath + ".snapshot" + conf.Server.DbPath = h.FilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + db.Init(ctx) + + ds := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + h.Library = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(ds.Library(ctx).Put(&h.Library)).To(Succeed()) + + for _, u := range users { + seeded := *u + seeded.NewPassword = "password" + Expect(ds.User(ctx).Put(&seeded)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(u.ID, []int{h.Library.ID})).To(Succeed()) + loaded, err := ds.User(ctx).FindByUsername(u.UserName) + Expect(err).ToNot(HaveOccurred()) + u.Libraries = loaded.Libraries + } + + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(h.FilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(h.SnapshotPath, data, 0o600)).To(Succeed()) //nolint:gosec // path derives from TempDir + return h +} + +// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a +// rescan. FTS shadow tables are skipped; they are kept in sync by their content tables' triggers. +func (h *DB) Restore() { + sqlDB := db.Db() + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath) + Expect(err).ToNot(HaveOccurred()) + + rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + var tables []string + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + tables = append(tables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + rows.Close() + + for _, table := range tables { + // Table names come from sqlite_master, not user input. + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("DETACH DATABASE snapshot") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} + +// SpyStreamer captures the Request passed to NewStream and returns a minimal fake stream. +type SpyStreamer struct { + LastRequest stream.Request + LastMediaFile *model.MediaFile + SimulateError error // when set, NewStream returns this error + SimulateEmptyStream bool // when true, returns a 0-byte stream (ffmpeg produced no output) +} + +func (s *SpyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { + s.LastRequest = req + s.LastMediaFile = mf + if s.SimulateError != nil { + return nil, s.SimulateError + } + format := req.Format + if format == "" || format == "raw" { + format = mf.Suffix + } + content := "fake audio data" + if s.SimulateEmptyStream { + content = "" + } + return stream.NewStream(mf, format, req.BitRate, io.NopCloser(strings.NewReader(content))), nil +} + +// NoopFFmpeg implements ffmpeg.FFmpeg; transcoding never actually runs in e2e. +type NoopFFmpeg struct{} + +func (NoopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: transcode not supported") +} + +func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: extract image not supported") +} + +func (NoopFFmpeg) Probe(context.Context, []string) (string, error) { return "", nil } + +func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { + return nil, errors.New("noop ffmpeg: probe not supported") +} + +func (NoopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: convert animated image not supported") +} + +func (NoopFFmpeg) CmdPath() (string, error) { return "", nil } +func (NoopFFmpeg) IsAvailable() bool { return false } +func (NoopFFmpeg) IsProbeAvailable() bool { return true } +func (NoopFFmpeg) Version() string { return "noop" } + +var ( + _ stream.MediaStreamer = &SpyStreamer{} + _ ffmpeg.FFmpeg = NoopFFmpeg{} +) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 3428813f6..85765abf8 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -174,6 +174,9 @@ func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { if m.Err { return errors.New("unexpected error") } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } return nil } @@ -182,6 +185,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { if m.Err { return errors.New("unexpected error") } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } return nil } diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index b7a6fb811..748002882 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -73,6 +73,28 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } +func (m *MockArtistRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("error") + } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } + return nil +} + +func (m *MockArtistRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } + return nil +} + func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] @@ -145,6 +167,13 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles . return result, nil } +func (m *MockArtistRepo) CountAll(...model.QueryOptions) (int64, error) { + if m.Err { + return 0, errors.New("mock repo error") + } + return int64(len(m.Data)), nil +} + func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index f15ba1bc6..6ddd77f14 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -154,6 +154,28 @@ func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } +func (m *MockMediaFileRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("error") + } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } + return nil +} + +func (m *MockMediaFileRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } + return nil +} + func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) { if m.Err { return nil, errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index b7df5361f..908d6aab5 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -21,6 +21,7 @@ type MockPlaylistRepo struct { Data map[string]*model.Playlist // keyed by ID PathMap map[string]*model.Playlist // keyed by path All model.Playlists + Options model.QueryOptions Last *model.Playlist Deleted []string Starred map[string]bool // itemID -> starred @@ -33,14 +34,24 @@ func (m *MockPlaylistRepo) SetError(err bool) { m.Err = err } -func (m *MockPlaylistRepo) SetData(pls model.Playlists) { - m.Data = make(map[string]*model.Playlist, len(pls)) - m.All = pls +func (m *MockPlaylistRepo) SetData(playlists model.Playlists) { + m.Data = make(map[string]*model.Playlist, len(playlists)) + m.All = playlists for i, p := range m.All { m.Data[p.ID] = &m.All[i] } } +func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) { + if len(options) > 0 { + m.Options = options[0] + } + if m.Err { + return nil, errors.New("error") + } + return m.All, nil +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") @@ -57,13 +68,6 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } -func (m *MockPlaylistRepo) GetAll(_ ...model.QueryOptions) (model.Playlists, error) { - if m.Err { - return nil, errors.New("error") - } - return m.All, nil -} - func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") From 53d54baef04254c6243b5dd842db239fd0378cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 15 Jul 2026 14:07:00 -0400 Subject: [PATCH 24/56] fix(jellyfin): stream collection responses to prevent OOM on large libraries (#5783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): stream /Items responses to prevent OOM on large libraries Finamp's library sync issues an unbounded GET /Items?IncludeItemTypes=Audio with Fields=MediaSources and no Limit. On a large library this built the whole result set — every MediaFile and every BaseItemDto, each fat with MediaSources — in memory, and json.Encoder then buffered the entire ~200MB response before writing a byte. Measured on a 96k-track library, one such request peaked near 1.6GB RSS; in a memory-limited container the resulting slowness made clients retry, stacking concurrent full-library builds until the process was OOM-killed. Stream the song listing straight from a DB cursor (MediaFileRepository.GetCursor), mapping and encoding one row at a time, so peak memory is bounded to about one item regardless of library size. The cursor is opened lazily, when streaming begins, so it doesn't hold a DB connection across the CountAll and ServerId lookups that run first (ServerId can write on first use and would deadlock against an open reader). Pagination still works — the cursor query carries LIMIT/OFFSET/ORDER BY from StartIndex/Limit/SortBy — and TotalRecordCount stays the full count. Other materialized responses stream their JSON too, avoiding the encoder's buffer-the-whole-output cost. Also bound artwork image concurrency with the same server.ThrottleBacklog that Subsonic's getCoverArt uses, so a burst of image requests during sync can't exhaust memory through concurrent decode/resize. Verified against a copy of a 96k-track production database: byte-for-byte identical response, peak RSS 1593MB -> 53MB, time-to-first-byte 8.1s -> 0.2s. * fix(jellyfin): fail loudly on /Items streaming errors instead of a truncated 200 Addresses code review: a streamed /Items response commits HTTP 200 before an error can occur, so failures were surfacing as misleadingly successful bodies. - A cursor-open failure (e.g. a busy DB under connection pressure) is now surfaced before the first byte is written: the cursor open is deferred into the item source and run in writeItems after the ServerId lookup but before the envelope, returning a clean 500 the client can retry — not a 200 with an empty item list. - A mid-stream (row-scan) error now aborts without closing the JSON envelope, leaving the body malformed. A truncated-but-valid response would let a sync client (Finamp) treat the short list as the whole library and prune local tracks; malformed JSON forces the client's parser to fail and retry. * refactor(jellyfin): stream every collection endpoint from a DB cursor Streaming was applied only to the song listing, which left two write paths, two ServerId stamping mechanisms and a listXxx return-type split ("songs is special") that made the code hard to follow. Add GetCursor to the album, artist, genre and playlist repositories, mirroring MediaFileRepository.GetCursor: same select builder as GetAll, so each cursor yields identical, fully-hydrated rows (hydration happens per-row in PostScan, and toModels is only a deref loop). The playlist cursor keeps GetAll's owner/public visibility filter. Each repository test asserts the cursor yields exactly what GetAll returns, including Max/Offset. With cursors everywhere, every listXxx returns itemsResult and every collection streams through one writer: - listAlbums, listArtists and listPlaylists now stream from their cursors; search paths stay materialized (Search returns a slice). - listGenres stays materialized: its total is the length of the full list and it paginates in memory, so there is nothing for a cursor to page over. - api.ok's QueryResult case delegates to writeItems, so the ~9 inline callers funnel into the same writer; streamQueryResult and wrapResult are gone. - /Items/Latest streams as a bare JSON array (its Jellyfin wire shape) via writeItemsArray, which shares the item loop and stamping. That also closes the same unbounded hole /Items had: limit=0 disabled its LIMIT. - ServerId is now stamped in exactly one place, so api.ok only handles single, non-collection payloads. Verified against a copy of a 96k-track production database: every endpoint byte-for-byte identical to master. Unbounded /Items peak RSS 1691MB -> 54MB; time-to-first-byte 6.3s -> 0.19s, /Artists 1.14s -> 0.13s. * refactor(jellyfin): route every response through api.ok Handlers were split between api.ok and api.writeItems with no clear rule for which to call. api.ok now accepts itemsResult too, so it is the single entry point: callers hand it whatever they have and it routes collections (cursor -backed or materialized) to the streaming writer. writeItems is reached only through api.ok now. The one exception is /Items/Latest, which returns a bare JSON array rather than a QueryResult envelope and so writes directly — noted in both doc comments. Also drop GenreRepository.GetCursor: listGenres derives its total from the length of the full list and paginates in memory, so there is nothing for a cursor to page over, leaving the method unused. * fix(jellyfin): stream the unbounded multi-type /Items merge The multi-type merge capped each per-type query at offset+limit only when a Limit was given. Without one (Finamp's favorites screen sends multi-type), every type ran an unbounded query and collect() drained each cursor into a slice — so /Items?IncludeItemTypes=MusicAlbum,Audio with no Limit materialized every album and every song, the same OOM class this branch set out to fix. The comment claiming the set was "capped at offset+limit per type" was wrong for limit=0. Without a limit the merged page is just each type's rows in order minus the first offset, which is exactly what chaining the per-type cursors yields, so stream that instead of merging in memory. The bounded path is unchanged: with a Limit each type holds at most offset+limit rows, so merging and paginating across the combined list is safe. Cursors are opened one at a time (each pins a DB connection until drained), with the first opened eagerly so the usual failure is still a clean error before any byte is written. Measured on a 96k-track library, /Items?IncludeItemTypes=MusicAlbum,Audio with no Limit (103,393 items): peak RSS 612MB -> 53MB, time-to-first-byte 4.9s -> 0.6s, response byte-for-byte identical. * refactor(jellyfin): parse /Items params into a struct The /Items dispatcher was a single 90-line block that parsed a dozen params and then threaded them positionally through three layers — queryItemsOfType took 11 arguments, listSongs and listAlbums 9 each — so reading any one of them meant decoding a long argument list. Parse once into an itemsQuery and pass that instead. queryItems now reads as the four things it actually does: parse, the id/playlists-folder/playlist-parent special cases, single-type dispatch, multi-type merge — with the playlist-parent and merge bodies moved to playlistTracks and mergeTypes. Every listXxx takes (ctx, opts, q). No behavior change: parsing, ordering and the entityParent rule are unchanged, and /Artists still passes favOnly=false explicitly by building the subset of the query it uses. * docs(jellyfin): trim comments on the streaming path Cut the comments back to the non-obvious why: the deferred cursor open (the ServerId write would deadlock against an open reader), the deliberate malformed JSON on a mid-stream error, why chained opens one cursor at a time, why listGenres stays materialized, and why the cursor helpers take the underlying func type. Dropped the rest — restatements of the code, doc comments that repeated a test's own name, and the GetCursor interface comments that the existing MediaFileRepository.GetCursor does without. * refactor(persistence): fold the cursor wrappers into a generic wrapCursor wrapAlbumCursor, wrapArtistCursor, wrapMediaFileCursor and wrapFolderCursor were the same twelve lines four times over, differing only in the type name, and the playlist cursor had its own inline copy of the loop. Add one generic wrapCursor. It takes an extractor func rather than a method on an interface: a type parameter can't reach an embedded field, and methods that only satisfy a generic constraint are reported by the unused linter. The extractor also lets both type parameters be inferred, so call sites need no explicit instantiation. Each entity keeps its named wrapper as a one-liner, since the model cursor types are defined types and need the conversion — and the existing wrapper tests call them directly. The nil-row error now names the model type via %T ("unexpected nil model.Album") instead of a hand-written per-entity string; the three tests asserting that message are updated. * fix(jellyfin): bound concurrent collection streams A streamed collection holds a DB cursor — and its pooled connection — for the whole client-paced response, where the old materialize-then-write path released it as soon as the query finished. The pool is shared with the scanner, Subsonic, the native API and the UI, so enough slow clients take every connection and everything else blocks waiting for one. Measured against a copy of a 96k-track production DB, 20 concurrent unbounded streams against a pool of 16 stalled a write for 16.2s (the other 14 writes in the run took ~2ms — the signature of connection starvation, not lock contention). Cap concurrent streams at half the pool. Excess requests queue rather than fail, so no client is rejected: the same 20 streams now all complete and the worst write is 2.5ms. - conf.MaxOpenConns() now owns the pool sizing (db calls it). It belongs in conf: the pool is a tunable, db already imports conf, and putting it in db would force server/jellyfin to import db just to size the cap against it. Expressing the cap as MaxOpenConns()/2 also keeps the two from drifting apart. - Uses chi's ThrottleBacklog, not server.ThrottleBacklog: the latter buffers the whole response to release its token early, which is right for artwork but would undo the streaming. chi's panics on a non-positive limit, so throttleStreams guards it — setting MaxConcurrentStreams=0 disables the cap instead of crashing the server at startup. * refactor(jellyfin): move throttleStreams to middlewares.go It's a middleware, so it belongs beside normalizeQueryKeys, authenticate and withPlayer rather than in api.go. Its tests move to middlewares_test.go with it, keeping one test file per production file. * fix(jellyfin): abandon the scan when the client stops reading encodeItems discarded its write errors and relied on the final Flush to report them, so once bufio's buffer filled and the flush failed, the loop still pulled every remaining row through the cursor and serialized it for a client that was gone. A test with a failing writer confirms it: all 20k items were drained. That wastes CPU, and holds the cursor's pooled DB connection and a stream slot (now a capped resource) for the length of a full scan nobody is reading. Check the per-item writes so the first failure ends the scan; the fixed envelope writes stay unchecked, since bufio latches for them anyway. --- conf/configuration.go | 18 + db/db.go | 3 +- model/album.go | 1 + model/artist.go | 4 + model/playlist.go | 4 + persistence/album_repository.go | 21 +- persistence/album_repository_test.go | 18 +- persistence/artist_repository.go | 14 + persistence/artist_repository_test.go | 16 + persistence/folder_repository.go | 12 +- persistence/folder_repository_test.go | 2 +- persistence/mediafile_repository.go | 12 +- persistence/mediafile_repository_test.go | 18 +- persistence/persistence_suite_test.go | 13 + persistence/playlist_repository.go | 16 + persistence/playlist_repository_test.go | 9 + persistence/sql_base_repository.go | 18 + server/jellyfin/api.go | 52 ++- server/jellyfin/browsing.go | 9 +- server/jellyfin/e2e/browsing_test.go | 28 ++ server/jellyfin/items.go | 537 ++++++++++++++++------- server/jellyfin/items_test.go | 8 + server/jellyfin/middlewares.go | 16 + server/jellyfin/middlewares_test.go | 69 +++ server/jellyfin/response.go | 87 ++++ server/jellyfin/response_test.go | 116 +++++ tests/mock_album_repo.go | 14 + tests/mock_artist_repo.go | 14 + tests/mock_mediafile_repo.go | 14 + tests/mock_playlist_repo.go | 14 + 30 files changed, 957 insertions(+), 220 deletions(-) create mode 100644 server/jellyfin/response.go create mode 100644 server/jellyfin/response_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 562f52465..83793bd43 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -225,6 +225,10 @@ 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 + // 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. + MaxConcurrentStreams int } type httpHeaderOptions struct { @@ -889,6 +893,9 @@ func setViperDefaults() { viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) viper.SetDefault("devoffsetoptimize", 50000) + // Half the pool: streams may take up to this many connections, leaving the rest for the scanner, + // scrobbles and the UI. See MaxOpenConns. + viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2)) viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) @@ -959,3 +966,14 @@ func getConfigFile(cfgFile string) string { } return "" } + +// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner, +// Subsonic, Jellyfin, native API, UI). +// +// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so +// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a +// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the +// count is only loosely related to core count, and the floor is what matters on small machines. +func MaxOpenConns() int { + return max(4, runtime.NumCPU()) +} diff --git a/db/db.go b/db/db.go index 3f3f61d71..4ca996fe5 100644 --- a/db/db.go +++ b/db/db.go @@ -5,7 +5,6 @@ import ( "database/sql" "embed" "fmt" - "runtime" "time" "github.com/mattn/go-sqlite3" @@ -44,7 +43,7 @@ func Db() *sql.DB { } log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) db, err := sql.Open(Driver, Path) - db.SetMaxOpenConns(max(4, runtime.NumCPU())) + db.SetMaxOpenConns(conf.MaxOpenConns()) if err != nil { log.Fatal("Error opening database", err) } diff --git a/model/album.go b/model/album.go index 667f4695b..ade7f6ee0 100644 --- a/model/album.go +++ b/model/album.go @@ -141,6 +141,7 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) + GetCursor(...QueryOptions) (AlbumCursor, error) // The following methods are used exclusively by the scanner: Touch(ids ...string) error diff --git a/model/artist.go b/model/artist.go index 2085f0051..f9c4bffd5 100644 --- a/model/artist.go +++ b/model/artist.go @@ -1,6 +1,7 @@ package model import ( + "iter" "maps" "slices" "time" @@ -79,6 +80,8 @@ type ArtistIndex struct { } type ArtistIndexes []ArtistIndex +type ArtistCursor iter.Seq2[Artist, error] + type ArtistRepository interface { CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) @@ -86,6 +89,7 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) + GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) // The following methods are used exclusively by the scanner: diff --git a/model/playlist.go b/model/playlist.go index 262774aa7..f2586f52d 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,6 +1,7 @@ package model import ( + "iter" "slices" "strconv" "time" @@ -121,6 +122,8 @@ func (pls Playlist) UploadedImagePath() string { type Playlists []Playlist +type PlaylistCursor iter.Seq2[Playlist, error] + type PlaylistRepository interface { ResourceRepository AnnotatedRepository @@ -130,6 +133,7 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) + GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 34845be15..6ebbd9202 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -247,6 +247,15 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e return res.toModels(), nil } +func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { + sq := r.selectAlbum(options...) + cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, sq) + if err != nil { + return nil, err + } + return wrapAlbumCursor(cursor), nil +} + func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { var from dbx.NullStringMap err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) @@ -319,17 +328,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) } func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor { - return func(yield func(model.Album, error) bool) { - for a, err := range cursor { - if a.Album == nil { - yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err)) - return - } - if !yield(*a.Album, err) || err != nil { - return - } - } - } + return model.AlbumCursor(wrapCursor(cursor, func(a dbAlbum) *model.Album { return a.Album })) } // RefreshPlayCounts updates the play count and last play date annotations for all albums, based diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index f72f778db..64ff0095e 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -67,6 +67,22 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetCursor", func() { + It("yields the same albums as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) @@ -854,7 +870,7 @@ var _ = Describe("AlbumRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Album")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index f84f410e9..b542dedb4 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "iter" "os" "slices" "strings" @@ -263,6 +264,19 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return res, err } +func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { + sel := r.selectArtist(options...) + cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return wrapArtistCursor(cursor), nil +} + +func wrapArtistCursor(cursor iter.Seq2[dbArtist, error]) model.ArtistCursor { + return model.ArtistCursor(wrapCursor(cursor, func(a dbArtist) *model.Artist { return a.Artist })) +} + func (r *artistRepository) getIndexKey(a model.Artist) string { source := a.OrderArtistName if conf.Server.PreferSortTags { diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index d7b695ade..dc11ede36 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -268,6 +268,22 @@ var _ = Describe("ArtistRepository", func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) }) + Describe("GetCursor", func() { + It("yields the same artists as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + }) + }) + Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 8fb7f0296..5da395a74 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -263,17 +263,7 @@ func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { } func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { - return func(yield func(model.Folder, error) bool) { - for f, err := range cursor { - if f.Folder == nil { - yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err)) - return - } - if !yield(*f.Folder, err) || err != nil { - return - } - } - } + return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder })) } func (r folderRepository) purgeEmpty(libraryIDs ...int) error { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index a8945dfee..8cd45f16b 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -297,7 +297,7 @@ var _ = Describe("FolderRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Folder")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index b4979ca77..ace61610c 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -420,17 +420,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC } func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor { - return func(yield func(model.MediaFile, error) bool) { - for m, err := range cursor { - if m.MediaFile == nil { - yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err)) - return - } - if !yield(*m.MediaFile, err) || err != nil { - return - } - } - } + return model.MediaFileCursor(wrapCursor(cursor, func(m dbMediaFile) *model.MediaFile { return m.MediaFile })) } // FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 80d440c41..f6a744d8d 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -29,6 +29,22 @@ var _ = Describe("MediaRepository", func() { mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) + Describe("GetCursor", func() { + It("yields the same media files as GetAll", func() { + opts := model.QueryOptions{Sort: "title"} + want, err := mr.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1} + want, err := mr.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + }) + }) + It("gets mediafile from the DB", func() { actual, err := mr.Get("1004") Expect(err).ToNot(HaveOccurred()) @@ -1012,7 +1028,7 @@ var _ = Describe("MediaRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.MediaFile")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 4f2fd7fe2..f146cb06b 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -329,3 +329,16 @@ var _ = BeforeSuite(func() { func GetDBXBuilder() *dbx.DB { return dbx.NewFromDB(db.Db(), db.Dialect) } + +// collectCursor takes the cursor's underlying func type so the named cursor types +// (model.AlbumCursor, ...) infer T. +func collectCursor[T any](cursor func(func(T, error) bool), err error) []T { + GinkgoHelper() + Expect(err).ToNot(HaveOccurred()) + var out []T + for item, err := range cursor { + Expect(err).ToNot(HaveOccurred()) + out = append(out, item) + } + return out +} diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 573f43c10..9626aad6a 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "iter" "slices" "time" @@ -186,6 +187,21 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } +func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { + // Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists. + sel := r.selectPlaylist(options...).Where(r.userFilter()) + cursor, err := queryWithStableResults[dbPlaylist](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return wrapPlaylistCursor(cursor), nil +} + +// dbPlaylist embeds a value, not a pointer, so its model is never nil. +func wrapPlaylistCursor(cursor iter.Seq2[dbPlaylist, error]) model.PlaylistCursor { + return model.PlaylistCursor(wrapCursor(cursor, func(p dbPlaylist) *model.Playlist { return &p.Playlist })) +} + func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}). Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id"). diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 831e24453..c51ff6222 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -27,6 +27,15 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetCursor", func() { + It("yields the same playlists as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Playlist(want))) + }) + }) + Describe("Exists", func() { It("returns true for an existing playlist", func() { Expect(repo.Exists(plsCool.ID)).To(BeTrue()) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index ce5221d19..d0cbb2946 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -347,6 +347,24 @@ func (r sqlRepository) queryOne(sq Sqlizer, response any) error { return err } +// wrapCursor adapts a cursor over db rows into one over their models. toModel pulls out the row's +// embedded model, which a type parameter can't reach on its own. +func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + for row, err := range cursor { + m := toModel(row) + if m == nil { + var zero T + yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err)) + return + } + if !yield(*m, err) || err != nil { + return + } + } + } +} + // queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results // from a cursor, guaranteeing that the results will be stable, even if the underlying data changes. func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index fd523d154..d169a4c83 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -1,7 +1,6 @@ package jellyfin import ( - "context" "encoding/json" "net/http" "sync" @@ -19,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/jellyfin/dto" ) @@ -71,8 +71,14 @@ func (api *Router) routes() http.Handler { inner.Get("/Users/Public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. - inner.Get("/Items/{itemId}/Images/{type}", api.getItemImage) - inner.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + // Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy, + // and an unbounded burst (a client fetching artwork across a large library) can exhaust memory. + inner.Group(func(r chi.Router) { + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) + r.Get("/Items/{itemId}/Images/{type}", api.getItemImage) + r.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + }) inner.Group(func(r chi.Router) { r.Use(api.authenticate) @@ -85,12 +91,22 @@ func (api *Router) routes() http.Handler { r.Get("/Users/Me", api.getCurrentUser) r.Get("/Users/{userId}", api.getCurrentUser) - r.Get("/Items", api.getItems) - r.Get("/Users/{userId}/Items", api.getItems) + // 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 + // scanner, scrobbles and the UI. Cap them at half the pool (see conf.MaxOpenConns); excess + // requests queue rather than fail. + r.Group(func(r chi.Router) { + r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) + r.Get("/Items", api.getItems) + r.Get("/Users/{userId}/Items", api.getItems) + r.Get("/Users/{userId}/Items/Latest", api.getLatest) + r.Get("/Artists", api.getArtists) + r.Get("/Artists/AlbumArtists", api.getAlbumArtists) + }) + r.Get("/Items/{itemId}", api.getItem) r.Get("/Users/{userId}/Items/{itemId}", api.getItem) r.Delete("/Items/{itemId}", api.deleteItem) - r.Get("/Users/{userId}/Items/Latest", api.getLatest) // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. @@ -106,8 +122,6 @@ func (api *Router) routes() http.Handler { r.Get("/UserItems/{itemId}/UserData", api.getUserItemData) r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData) - r.Get("/Artists", api.getArtists) - r.Get("/Artists/AlbumArtists", api.getAlbumArtists) r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists) r.Get("/Items/{itemId}/Similar", api.getSimilarItems) r.Get("/Items/{itemId}/InstantMix", api.getInstantMix) @@ -158,14 +172,19 @@ func (api *Router) routes() http.Handler { return caseInsensitivePaths(inner) } -// ok writes payload as JSON, stamping ServerId on any item(s) in it — real Jellyfin always sets it, -// and it's the same value for every item, so it's applied here rather than threaded through mappers. +// ok writes payload as JSON — the single entry point for every handler. Collections are routed to +// the streaming writer, so callers needn't know whether theirs is cursor-backed. ServerId is stamped +// on any item(s): real Jellyfin always sets it, and it's constant per request. +// +// Only /Items/Latest bypasses this, for its bare-array shape (see writeItemsArray). func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { switch p := payload.(type) { + case itemsResult: + api.writeItems(w, r, p) + return case dto.QueryResult: - api.stampServerID(r.Context(), p.Items) - case []dto.BaseItemDto: - api.stampServerID(r.Context(), p) + api.writeItems(w, r, materialized(p)) + return case dto.BaseItemDto: p.ServerId = api.serverID(r.Context()) payload = p @@ -176,13 +195,6 @@ func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { } } -func (api *Router) stampServerID(ctx context.Context, items []dto.BaseItemDto) { - sid := api.serverID(ctx) - for i := range items { - items[i].ServerId = sid - } -} - // notFound handles unmatched routes and unsupported methods, logging them so unimplemented // endpoints surface instead of returning chi's default plain-text 404/405. func (api *Router) notFound(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index bf77c4594..d5a00e492 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -28,10 +28,15 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. // Finamp's artist tab sends GenreIds when a genre filter is active. - genreIds := decodedQueryIDs(r, "genreids") + q := itemsQuery{ + scopeIDs: scopeIDs, + genreIds: decodedQueryIDs(r, "genreids"), + search: p.StringOr("searchterm", ""), + } - res, err := api.listArtists(ctx, opts, genreIds, scopeIDs, p.StringOr("searchterm", ""), false, role) + res, err := api.listArtists(ctx, opts, q, role) if err != nil { api.internalError(w, r, err) return diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index af639ed29..646de4497 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -300,6 +300,34 @@ var _ = Describe("Browsing", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs }) + + // Chaining the per-type cursors must preserve the merged order. + It("streams an unbounded multi-type merge, honoring StartIndex", func() { + all := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) + Expect(all.Items).To(HaveLen(12)) + + skipped := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true&StartIndex=2")) + Expect(skipped.Items).To(HaveLen(10)) + Expect(skipped.TotalRecordCount).To(Equal(12)) + Expect(skipped.StartIndex).To(Equal(2)) + Expect(names(skipped.Items)).To(Equal(names(all.Items)[2:])) + }) + + // Paging must ride on the cursor query's LIMIT/OFFSET, not be applied after materializing. + It("pages songs via StartIndex/Limit while reporting the full total", func() { + all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName")) + Expect(all.TotalRecordCount).To(Equal(7)) + Expect(all.Items).To(HaveLen(7)) + + p1 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=0")) + p2 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=3")) + Expect(p1.Items).To(HaveLen(3)) + Expect(p2.Items).To(HaveLen(3)) + Expect(p1.TotalRecordCount).To(Equal(7)) + // The two pages are distinct and match the head of the unpaged, identically-sorted list. + Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) + Expect(append(names(p1.Items), names(p2.Items)...)).To(Equal(names(all.Items)[:6])) + }) }) Describe("GET /Items/{id}", func() { diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index f8158bca4..0dca48938 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -2,6 +2,8 @@ package jellyfin import ( "context" + "io" + "iter" "net/http" "slices" "strconv" @@ -31,112 +33,328 @@ func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { api.ok(w, r, res) } -// queryItems is the /Items dispatcher: it parses entity types from IncludeItemTypes (defaulting to -// MusicAlbum), queries each via the matching listXxx, and merges multi-type results into one -// paginated list (as Finamp's favorites screen requests). -func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryResult, error) { - p := req.Params(r) - // Query keys are read lowercase because normalizeQueryKeys folded them (Jellyfin binds - // case-insensitively). /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch below. - fields := dto.ParseFields(p.StringOr("fields", "")) - if ids := decodedQueryIDs(r, "ids"); len(ids) > 0 { - return api.itemsByIDs(ctx, ids, fields), nil - } - parentId := dto.DecodeID(p.StringOr("parentid", "")) - search := p.StringOr("searchterm", "") - // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone - // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). - favOnly := strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false) - sortBy := p.StringOr("sortby", "") - sortOrder := p.StringOr("sortorder", "") - offset := p.IntOr("startindex", 0) - limit := p.IntOr("limit", 0) - rawTypes := p.StringOr("includeitemtypes", "") - // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. - if strings.Contains(rawTypes, "ManualPlaylistsFolder") { - return result([]dto.BaseItemDto{playlistsFolder()}, 1, 0), nil - } - types := parseTypes(rawTypes) - // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping - // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistIds/artistIds - // select the artist's own discography; contributingArtistIds alone means albums they merely appear - // on (Jellyfin's "Featured On"), which must exclude that discography. - albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) - contributingScope := p.StringOr("contributingartistids", "") - artistId := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) - contributingOnly := albumArtistScope == "" && contributingScope != "" - // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. - genreIds := decodedQueryIDs(r, "genreids") +// itemsResult is the outcome of a collection query: a materialized page, or a cursor opener so a +// full-library response never builds every DTO at once. Exactly one of items/openCursor is set. +// +// openCursor is deferred rather than opened here: it must run after the ServerId lookup, which +// writes to the DB on first use and would deadlock against an open reader, but before the first +// response byte, so a failed open is still a clean error rather than a truncated 200. +type itemsResult struct { + items []dto.BaseItemDto + openCursor func() (iter.Seq2[dto.BaseItemDto, error], error) + total int + start int +} - scopeIDs, isLibraryParent := resolveLibraryScope(ctx, parentId) - // A playlist parent always resolves to its tracks, whatever IncludeItemTypes says. Jellify opens - // a playlist with ParentId=&IncludeItemTypes=Audio; routing that through listSongs would - // treat the playlist id as an album id and return nothing. - if parentId != "" && !isLibraryParent && parentId != playlistsFolderID { - if pls, err := api.playlists.GetWithTracks(ctx, parentId); err == nil { - // GetWithTracks enforces visibility (public or owned by the current user). - items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) - return result(paginate(items, offset, limit), len(items), offset), nil +func materialized(q dto.QueryResult) itemsResult { + return itemsResult{items: q.Items, total: q.TotalRecordCount, start: q.StartIndex} +} + +func streamed(open func() (iter.Seq2[dto.BaseItemDto, error], error), total, start int) itemsResult { + return itemsResult{openCursor: open, total: total, start: start} +} + +// chained streams several results back to back, skipping the first skip items — the unbounded +// multi-type merge, where paginate(items, offset, 0) is just the concatenation minus its head. +func chained(results []itemsResult, total, skip int) itemsResult { + open := func() (iter.Seq2[dto.BaseItemDto, error], error) { + if len(results) == 0 { + return sliceItems(nil), nil + } + // Only the first opens eagerly (so the usual failure is still a clean error); the rest open as + // the stream reaches them, so only one cursor pins a DB connection at a time. + first, err := results[0].seq() + if err != nil { + return nil, err + } + return func(yield func(dto.BaseItemDto, error) bool) { + n := 0 + emit := func(seq iter.Seq2[dto.BaseItemDto, error]) bool { + for it, err := range seq { + if err != nil { + yield(dto.BaseItemDto{}, err) + return false + } + if n < skip { + n++ + continue + } + if !yield(it, nil) { + return false + } + } + return true + } + if !emit(first) { + return + } + for _, res := range results[1:] { + seq, err := res.seq() + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + if !emit(seq) { + return + } + } + }, nil + } + return streamed(open, total, skip) +} + +// streamCursor builds a deferred opener that maps each row as it's yielded. It takes the cursor's +// underlying func type, so callers wrap repo.GetCursor for the named type to infer T. +func streamCursor[T any](openCursor func() (func(func(T, error) bool), error), toItem func(T) dto.BaseItemDto) func() (iter.Seq2[dto.BaseItemDto, error], error) { + return func() (iter.Seq2[dto.BaseItemDto, error], error) { + cursor, err := openCursor() + if err != nil { + return nil, err + } + return func(yield func(dto.BaseItemDto, error) bool) { + for row, err := range cursor { + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + if !yield(toItem(row), nil) { + return + } + } + }, nil + } +} + +// seq returns the items as one sequence, opening the cursor if there is one. +func (ir itemsResult) seq() (iter.Seq2[dto.BaseItemDto, error], error) { + if ir.openCursor != nil { + return ir.openCursor() + } + return sliceItems(ir.items), nil +} + +// collect drains the result into a slice, for the merge that combines types before paginating. +func (ir itemsResult) collect() ([]dto.BaseItemDto, error) { + if ir.openCursor == nil { + return ir.items, nil + } + seq, err := ir.openCursor() + if err != nil { + return nil, err + } + var out []dto.BaseItemDto + for it, err := range seq { + if err != nil { + return nil, err + } + out = append(out, it) + } + return out, nil +} + +func (api *Router) writeItems(w http.ResponseWriter, r *http.Request, res itemsResult) { + api.streamResult(w, r, res, func(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + return streamItemsEnvelope(w, items, res.total, res.start) + }) +} + +// writeItemsArray writes the bare-array shape (/Items/Latest), which has no QueryResult envelope. +func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res itemsResult) { + api.streamResult(w, r, res, streamItemsArray) +} + +// streamResult stamps every item's ServerId (constant per request, so it's set here rather than in +// each mapper). The cursor opens before the first byte, so a failed open is still a clean 500. +func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult, + write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) { + sid := api.serverID(r.Context()) + seq, err := res.seq() + if err != nil { + api.internalError(w, r, err) + return + } + stamped := func(yield func(dto.BaseItemDto, error) bool) { + for it, err := range seq { + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + it.ServerId = sid + if !yield(it, nil) { + return + } } } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := write(w, stamped); err != nil { + log.Error(r.Context(), "Jellyfin API: error streaming response", err) + } +} + +// itemsQuery is a parsed /Items request, so the dispatch and every listXxx take one value instead +// of a long positional parameter list. +type itemsQuery struct { + fields dto.Fields + ids []string + rawTypes string + types []string + search string + sortBy string + sortOrder string + offset int + limit int + favOnly bool + // parentId scopes the query. entityParent is the same id only when it names an entity (an artist + // for MusicAlbum, an album for Audio) rather than a library. + parentId string + entityParent string + isLibraryParent bool + scopeIDs []int + // artistId selects that artist's own discography; contributingOnly means albums they merely + // appear on (Jellyfin's "Featured On"), which must exclude that discography. + artistId string + contributingOnly bool + genreIds []string +} + +// parseItemsQuery also resolves the entity types (inferring them from the parent when +// IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because +// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). +func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { + p := req.Params(r) + q := itemsQuery{ + fields: dto.ParseFields(p.StringOr("fields", "")), + ids: decodedQueryIDs(r, "ids"), + rawTypes: p.StringOr("includeitemtypes", ""), + search: p.StringOr("searchterm", ""), + sortBy: p.StringOr("sortby", ""), + sortOrder: p.StringOr("sortorder", ""), + offset: p.IntOr("startindex", 0), + limit: p.IntOr("limit", 0), + // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone + // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). + favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), + parentId: dto.DecodeID(p.StringOr("parentid", "")), + // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. + genreIds: decodedQueryIDs(r, "genreids"), + } + // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping + // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. + albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) + contributingScope := p.StringOr("contributingartistids", "") + q.artistId = firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + q.contributingOnly = albumArtistScope == "" && contributingScope != "" + + q.types = parseTypes(q.rawTypes) + q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse // its albums). - if rawTypes == "" && parentId != "" && !isLibraryParent { - if parentId == playlistsFolderID { + if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent { + if q.parentId == playlistsFolderID { // Browsing into the synthetic playlists folder lists the user's playlists. - types = []string{"Playlist"} - } else if _, err := api.ds.Album(ctx).Get(parentId); err == nil { - types = []string{"Audio"} + q.types = []string{"Playlist"} + } else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil { + q.types = []string{"Audio"} } } - entityParent := parentId - // ParentId-as-entity-id (artist for MusicAlbum, album for Audio) only makes sense for a single - // type; a multi-type query has no natural parent entity, so ParentId is only library scoping there. - if isLibraryParent || len(types) > 1 { - entityParent = "" + // ParentId-as-entity-id only makes sense for a single type; a multi-type query has no natural + // parent entity, so there ParentId is only library scoping. + q.entityParent = q.parentId + if q.isLibraryParent || len(q.types) > 1 { + q.entityParent = "" } - - if len(types) == 1 { - opts := model.QueryOptions{Offset: offset, Max: limit} - applySort(&opts, types[0], sortBy, sortOrder) - return api.queryItemsOfType(ctx, types[0], opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) - } - - var items []dto.BaseItemDto - total := 0 - for _, itemType := range types { - var opts model.QueryOptions - // Each per-type query needs at most offset+limit rows (the worst case where one type fills the - // whole [offset, offset+limit) window); without this cap each would fetch its whole table. - // Totals are unaffected — they come from CountAll. - if limit > 0 { - opts.Max = offset + limit - } - applySort(&opts, itemType, sortBy, sortOrder) - res, err := api.queryItemsOfType(ctx, itemType, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) - if err != nil { - return dto.QueryResult{}, err - } - items = append(items, res.Items...) - total += res.TotalRecordCount - } - return result(paginate(items, offset, limit), total, offset), nil + return q } -func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, entityParent, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, favOnly bool, fields dto.Fields) (dto.QueryResult, error) { +// queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via +// the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites +// screen requests). +func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) { + q := api.parseItemsQuery(ctx, r) + switch { + // /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch. + case len(q.ids) > 0: + return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil + // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. + case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"): + return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil + } + if res, ok := api.playlistTracks(ctx, q); ok { + return res, nil + } + if len(q.types) == 1 { + opts := model.QueryOptions{Offset: q.offset, Max: q.limit} + applySort(&opts, q.types[0], q.sortBy, q.sortOrder) + return api.queryItemsOfType(ctx, q.types[0], opts, q) + } + return api.mergeTypes(ctx, q) +} + +// playlistTracks resolves a playlist parent to its tracks, whatever IncludeItemTypes says: Jellify +// opens a playlist with ParentId=&IncludeItemTypes=Audio, and routing that through +// listSongs would treat the playlist id as an album id and return nothing. +func (api *Router) playlistTracks(ctx context.Context, q itemsQuery) (itemsResult, bool) { + if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID { + return itemsResult{}, false + } + pls, err := api.playlists.GetWithTracks(ctx, q.parentId) + if err != nil { + return itemsResult{}, false + } + // GetWithTracks enforces visibility (public or owned by the current user). + items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, q.fields) }) + return materialized(result(paginate(items, q.offset, q.limit), len(items), q.offset)), true +} + +func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { + // Each per-type query needs at most offset+limit rows (the worst case where one type fills the + // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. + var results []itemsResult + total := 0 + for _, itemType := range q.types { + var opts model.QueryOptions + if q.limit > 0 { + opts.Max = q.offset + q.limit + } + applySort(&opts, itemType, q.sortBy, q.sortOrder) + res, err := api.queryItemsOfType(ctx, itemType, opts, q) + if err != nil { + return itemsResult{}, err + } + results = append(results, res) + total += res.total + } + if q.limit == 0 { + // No cap above, so merging in memory would pull every row of every type. The merged page is + // just their rows in order minus the first offset — what chaining the cursors yields. + return chained(results, total, q.offset), nil + } + var items []dto.BaseItemDto + for _, res := range results { + typeItems, err := res.collect() + if err != nil { + return itemsResult{}, err + } + items = append(items, typeItems...) + } + return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil +} + +func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { switch itemType { case "Audio": - return api.listSongs(ctx, opts, entityParent, artistId, genreIds, scopeIDs, search, favOnly, fields) + return api.listSongs(ctx, opts, q) case "MusicArtist": // The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists. - return api.listArtists(ctx, opts, genreIds, scopeIDs, search, favOnly, model.RoleAlbumArtist) + return api.listArtists(ctx, opts, q, model.RoleAlbumArtist) case "MusicGenre": return api.listGenres(ctx, opts) case "Playlist": - return api.listPlaylists(ctx, opts, favOnly) + return api.listPlaylists(ctx, opts, q) default: // MusicAlbum - return api.listAlbums(ctx, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly) + return api.listAlbums(ctx, opts, q) } } @@ -213,145 +431,144 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO return rows, total, nil } -func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) { +func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { repo := api.ds.Album(ctx) filters := squirrel.And{} // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's // albums"; contributingArtistIds means "albums they only appear on" (Featured On). switch { - case contributingOnly && artistId != "": - filters = append(filters, filter.AlbumsByContributingArtistID(artistId).Filters) - case firstNonEmpty(artistId, parentId) != "": - filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(artistId, parentId)).Filters) + case q.contributingOnly && q.artistId != "": + filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters) + case firstNonEmpty(q.artistId, q.entityParent) != "": + filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters) default: filters = append(filters, notMissing) } - if len(genreIds) > 0 { - filters = append(filters, filter.ByGenreID(genreIds)) + if len(q.genreIds) > 0 { + filters = append(filters, filter.ByGenreID(q.genreIds)) } - if fav { + if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, scopeIDs) + opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - if search != "" { + if q.search != "" { albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { - return repo.Search(search, o) + return repo.Search(q.search, o) }) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset), nil - } - albums, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err + return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil } total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) - return result(slice.Map(albums, dto.AlbumToBaseItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.Album, error) bool), error) { + return repo.GetCursor(opts) + }, dto.AlbumToBaseItem) + return streamed(open, int(total), opts.Offset), nil } -func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, parentId, artistId string, genreIds []string, scopeIDs []int, search string, fav bool, fields dto.Fields) (dto.QueryResult, error) { - toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, fields) } +func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) } repo := api.ds.MediaFile(ctx) filters := squirrel.And{} // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. switch { - case artistId != "": - filters = append(filters, filter.SongsByArtistID(artistId).Filters) - case parentId != "": - filters = append(filters, filter.SongsByAlbum(parentId).Filters) + case q.artistId != "": + filters = append(filters, filter.SongsByArtistID(q.artistId).Filters) + case q.entityParent != "": + filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters) default: filters = append(filters, notMissing) } - if len(genreIds) > 0 { - filters = append(filters, filter.ByGenreID(genreIds)) + if len(q.genreIds) > 0 { + filters = append(filters, filter.ByGenreID(q.genreIds)) } - if fav { + if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, scopeIDs) + opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - if search != "" { + if q.search != "" { mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { - return repo.Search(search, o) + return repo.Search(q.search, o) }) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(mfs, toItem), total, opts.Offset), nil + return materialized(result(slice.Map(mfs, toItem), total, opts.Offset)), nil } // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an // explicit client SortBy still wins, since applySort already set opts.Sort. - if artistId == "" && parentId != "" && opts.Sort == "" { - opts.Sort = filter.SongsByAlbum(parentId).Sort - } - mfs, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err + if q.artistId == "" && q.entityParent != "" && opts.Sort == "" { + opts.Sort = filter.SongsByAlbum(q.entityParent).Sort } + // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) - return result(slice.Map(mfs, toItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { + return repo.GetCursor(opts) + }, toItem) + return streamed(open, int(total), opts.Offset), nil } // listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views, // RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. // genreIds isn't applied to search — a name lookup, like role (see below). -func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, genreIds []string, scopeIDs []int, search string, fav bool, role model.Role) (dto.QueryResult, error) { +func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { repo := api.ds.Artist(ctx) // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a // search scope (artists have no library_id column). A compound or join-based filter // (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build // filters differently. Role isn't applied to search for the same reason — it's a name lookup. - if search != "" { - if len(scopeIDs) > 0 { - opts.Filters = squirrel.Eq{"library_id": scopeIDs} + if q.search != "" { + if len(q.scopeIDs) > 0 { + opts.Filters = squirrel.Eq{"library_id": q.scopeIDs} } artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { - return repo.Search(search, o) + return repo.Search(q.search, o) }) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset), nil + return materialized(result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset)), nil } - if fav { + if q.favOnly { opts.Filters = filter.ArtistsByStarred().Filters } else { opts.Filters = notMissing } - if len(genreIds) > 0 { - opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(genreIds)} + if len(q.genreIds) > 0 { + opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(q.genreIds)} } opts = filter.ArtistsByRole(opts, role) - opts = filter.ApplyArtistLibraryFilter(opts, scopeIDs) - artists, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err - } + opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) - return result(slice.Map(artists, dto.ArtistToBaseItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.Artist, error) bool), error) { + return repo.GetCursor(opts) + }, dto.ArtistToBaseItem) + return streamed(open, int(total), opts.Offset), nil } -// listGenres is intentionally unscoped: genres are global tags, not per-library entities. Paging is -// in-memory (GenreRepository has no CountAll, lists are small) so TotalRecordCount is the real total. -func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (dto.QueryResult, error) { +// listGenres is intentionally unscoped: genres are global tags, not per-library entities. It's also +// the one listXxx that stays materialized: GenreRepository has no CountAll, so the total is the +// length of the full list and paging is in-memory — nothing for a cursor to page over. +func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) { genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } items := slice.Map(genres, dto.GenreToBaseItem) - return result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset), nil + return materialized(result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset)), nil } // listPlaylists lists playlists visible to the current user. Visibility (public or owned) is // enforced by playlistRepository, not scopeIDs. -func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, favOnly bool) (dto.QueryResult, error) { - if favOnly { +func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + if q.favOnly { starred := squirrel.Eq{"starred": true} if opts.Filters == nil { opts.Filters = starred @@ -360,15 +577,14 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, f } } repo := api.ds.Playlist(ctx) - playlists, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err - } total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(playlists, dto.PlaylistToBaseItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { + return repo.GetCursor(opts) + }, dto.PlaylistToBaseItem) + return streamed(open, int(total), opts.Offset), nil } // resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, @@ -482,17 +698,18 @@ func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// getLatest returns a bare array, not a QueryResult envelope — real Jellyfin's shape for +// /Items/Latest, and why it writes directly instead of going through api.ok. func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() opts := filter.AlbumsByNewest() opts.Max = req.Params(r).IntOr("limit", 20) opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) - albums, err := api.ds.Album(ctx).GetAll(opts) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, slice.Map(albums, dto.AlbumToBaseItem)) // /Latest returns a bare array + repo := api.ds.Album(ctx) + open := streamCursor(func() (func(func(model.Album, error) bool), error) { + return repo.GetCursor(opts) + }, dto.AlbumToBaseItem) + api.writeItemsArray(w, r, streamed(open, 0, 0)) } func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 8bd1bf052..801db9e3a 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -71,6 +71,14 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) }) + It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=true", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) w := httptest.NewRecorder() diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 2941d3c32..d9e41d252 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -7,12 +7,28 @@ import ( "regexp" "strings" + "github.com/go-chi/chi/v5/middleware" + "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/request" ) +// throttleStreams bounds how many collection responses stream concurrently, so they can't take every +// connection in the shared DB pool: each holds a cursor, and its connection, for the whole +// client-paced response. Excess requests queue rather than fail. limit <= 0 disables it. +// +// Deliberately chi's ThrottleBacklog and not server.ThrottleBacklog: the latter buffers the entire +// response to release its token early, which is right for artwork but would undo the streaming here. +// chi's panics on a non-positive limit, hence the guard. +func throttleStreams(limit int) func(http.Handler) http.Handler { + if limit <= 0 { + return func(next http.Handler) http.Handler { return next } + } + return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout) +} + // normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params // case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, // Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index 2c0761834..c766bff70 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -4,6 +4,9 @@ import ( "context" "net/http" "net/http/httptest" + "sync" + "sync/atomic" + "time" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" @@ -252,3 +255,69 @@ var _ = Describe("normalizeQueryKeys", func() { Expect(got).To(ConsistOf("aaa", "bbb")) }) }) + +var _ = Describe("throttleStreams", func() { + // serve fires n concurrent requests through the middleware and reports the highest number that + // were ever inside the handler at once. + serve := func(limit, n int) int32 { + var inFlight, peak int32 + release := make(chan struct{}) + h := throttleStreams(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cur := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&peak) + if cur <= old || atomic.CompareAndSwapInt32(&peak, old, cur) { + break + } + } + <-release // hold the slot until every request has had a chance to enter + atomic.AddInt32(&inFlight, -1) + })) + + var wg sync.WaitGroup + for range n { + wg.Add(1) + go func() { + defer wg.Done() + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) + }() + } + // Give the admitted requests time to pile up before letting them finish. + time.Sleep(100 * time.Millisecond) + close(release) + wg.Wait() + return atomic.LoadInt32(&peak) + } + + It("admits no more than the limit at once", func() { + Expect(serve(2, 8)).To(Equal(int32(2))) + }) + + It("queues the excess rather than rejecting it", func() { + // All 8 still complete — they wait for a slot instead of getting a 429. + var served int32 + release := make(chan struct{}) + h := throttleStreams(2)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + atomic.AddInt32(&served, 1) + })) + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) + }() + } + close(release) + wg.Wait() + Expect(served).To(Equal(int32(8))) + }) + + // chi's ThrottleBacklog panics on a non-positive limit, so a user disabling the cap must not + // crash the server at startup. + It("is disabled, not panicking, when the limit is zero", func() { + Expect(func() { serve(0, 4) }).ToNot(Panic()) + Expect(serve(0, 4)).To(BeNumerically(">", int32(1))) + }) +}) diff --git a/server/jellyfin/response.go b/server/jellyfin/response.go new file mode 100644 index 000000000..f4b96eda3 --- /dev/null +++ b/server/jellyfin/response.go @@ -0,0 +1,87 @@ +package jellyfin + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "iter" + "strconv" + + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// streamItemsEnvelope writes a QueryResult, byte-identical to json.NewEncoder(w).Encode(q). +// +// A mid-stream error aborts without closing the envelope: the 200 is already committed, so a +// truncated-but-valid body would let a sync client treat the short list as the whole library and +// prune local tracks. Malformed JSON forces its parser to fail instead. Callers open the cursor +// before the first byte, so this only fires on a rare mid-iteration failure. +func streamItemsEnvelope(w io.Writer, items iter.Seq2[dto.BaseItemDto, error], total, start int) error { + bw := bufio.NewWriterSize(w, 64*1024) + _, _ = bw.WriteString(`{"Items":[`) + if err := encodeItems(bw, items); err != nil { + _ = bw.Flush() + return err + } + _, _ = bw.WriteString(`],"TotalRecordCount":`) + _, _ = bw.WriteString(strconv.Itoa(total)) + _, _ = bw.WriteString(`,"StartIndex":`) + _, _ = bw.WriteString(strconv.Itoa(start)) + _, _ = bw.WriteString("}\n") + return bw.Flush() +} + +// streamItemsArray writes a bare JSON array — the shape /Items/Latest returns, with no envelope. +func streamItemsArray(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + bw := bufio.NewWriterSize(w, 64*1024) + _, _ = bw.WriteString("[") + if err := encodeItems(bw, items); err != nil { + _ = bw.Flush() + return err + } + _, _ = bw.WriteString("]\n") + return bw.Flush() +} + +// encodeItems writes items comma-separated. Unlike the fixed envelope writes, these are checked: +// bufio surfaces a latched write error here once a flush fails, and a client that has gone away must +// abandon the scan rather than pull the rest of the library through the cursor — which would hold its +// pooled DB connection and stream slot for a response nobody is reading. +func encodeItems(bw *bufio.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + // One reused buffer+encoder, so per-item JSON doesn't allocate. Encode HTML-escapes like + // json.Marshal, and appends a newline that's dropped below. + var itemBuf bytes.Buffer + enc := json.NewEncoder(&itemBuf) + first := true + for item, err := range items { + if err != nil { + return err + } + if !first { + if _, err := bw.WriteString(","); err != nil { + return err + } + } + first = false + itemBuf.Reset() + if err := enc.Encode(item); err != nil { + return err + } + b := itemBuf.Bytes() + if _, err := bw.Write(b[:len(b)-1]); err != nil { + return err + } + } + return nil +} + +func sliceItems(items []dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { + return func(yield func(dto.BaseItemDto, error) bool) { + for i := range items { + if !yield(items[i], nil) { + return + } + } + } +} diff --git a/server/jellyfin/response_test.go b/server/jellyfin/response_test.go new file mode 100644 index 000000000..c32c566fc --- /dev/null +++ b/server/jellyfin/response_test.go @@ -0,0 +1,116 @@ +package jellyfin + +import ( + "bytes" + "encoding/json" + "errors" + "iter" + "strings" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// deadWriter stands in for a client that went away mid-response. +type deadWriter struct{} + +func (deadWriter) Write([]byte) (int, error) { return 0, errors.New("connection reset by peer") } + +var _ = Describe("streaming a materialized QueryResult", func() { + // The materialized path (api.ok -> writeItems -> sliceItems) must stay byte-for-byte identical to + // what json.Encoder.Encode produced before, so no client sees a different response. + assertIdenticalToEncoder := func(q dto.QueryResult) { + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, sliceItems(q.Items), q.TotalRecordCount, q.StartIndex)).To(Succeed()) + + var want bytes.Buffer + Expect(json.NewEncoder(&want).Encode(q)).To(Succeed()) + + Expect(got.String()).To(Equal(want.String())) + } + + It("encodes an empty item list", func() { + assertIdenticalToEncoder(dto.QueryResult{Items: []dto.BaseItemDto{}}) + }) + + It("encodes a single item", func() { + assertIdenticalToEncoder(dto.QueryResult{ + Items: []dto.BaseItemDto{{Id: "a", Name: "One"}}, + TotalRecordCount: 1, + }) + }) + + It("encodes multiple items, honoring HTML escaping and StartIndex", func() { + assertIdenticalToEncoder(dto.QueryResult{ + Items: []dto.BaseItemDto{ + {Id: "a", Name: "One"}, + {Id: "b", Name: "Two & "}, + }, + TotalRecordCount: 500, + StartIndex: 100, + }) + }) +}) + +var _ = Describe("streamItemsEnvelope", func() { + seqOf := func(items ...dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { + return func(yield func(dto.BaseItemDto, error) bool) { + for _, it := range items { + if !yield(it, nil) { + return + } + } + } + } + + It("produces the same bytes as encoding an equivalent QueryResult", func() { + items := []dto.BaseItemDto{{Id: "a", Name: "One"}, {Id: "b", Name: "Two & "}} + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, seqOf(items...), 500, 100)).To(Succeed()) + + var want bytes.Buffer + Expect(json.NewEncoder(&want).Encode(dto.QueryResult{Items: items, TotalRecordCount: 500, StartIndex: 100})).To(Succeed()) + Expect(got.String()).To(Equal(want.String())) + }) + + It("emits an empty array (not null) for a sequence that yields nothing", func() { + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, seqOf(), 0, 0)).To(Succeed()) + Expect(got.String()).To(Equal("{\"Items\":[],\"TotalRecordCount\":0,\"StartIndex\":0}\n")) + }) + + // A client that goes away must not keep the source (a DB cursor, holding its pooled connection + // and a stream slot) running to the end of the library. + It("stops pulling from the source once writing fails", func() { + const total = 20000 + pulled := 0 + seq := func(yield func(dto.BaseItemDto, error) bool) { + for range total { + pulled++ + if !yield(dto.BaseItemDto{Id: "a", Name: strings.Repeat("x", 200)}, nil) { + return + } + } + } + err := streamItemsEnvelope(deadWriter{}, seq, total, 0) + Expect(err).To(HaveOccurred()) + Expect(pulled).To(BeNumerically("<", total), "should abandon the scan, not drain it") + }) + + It("aborts on a mid-stream error, leaving the envelope open (malformed) so the client fails loudly", func() { + boom := errors.New("scan failed") + first := dto.BaseItemDto{Id: "a", Name: "One"} + seq := func(yield func(dto.BaseItemDto, error) bool) { + if !yield(first, nil) { + return + } + yield(dto.BaseItemDto{}, boom) + } + var got bytes.Buffer + err := streamItemsEnvelope(&got, seq, 7, 0) + Expect(err).To(MatchError(boom)) + firstJSON, _ := json.Marshal(first) + Expect(got.String()).To(Equal("{\"Items\":[" + string(firstJSON))) + }) +}) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 85765abf8..6635881b7 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -75,6 +75,20 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } +func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return func(yield func(model.Album, error) bool) { + for _, a := range res { + if !yield(a, nil) { + return + } + } + }, nil +} + func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("unexpected error") diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index 748002882..e6ea7aea4 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -113,6 +113,20 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } +func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { + res, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return func(yield func(model.Artist, error) bool) { + for _, a := range res { + if !yield(a, nil) { + return + } + } + }, nil +} + func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { if m.Err { return errors.New("mock repo error") diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 6ddd77f14..990b91d7c 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -109,6 +109,20 @@ func (m *MockMediaFileRepo) GetRandom(qo ...model.QueryOptions) (model.MediaFile return res, nil } +func (m *MockMediaFileRepo) GetCursor(qo ...model.QueryOptions) (model.MediaFileCursor, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return func(yield func(model.MediaFile, error) bool) { + for _, mf := range res { + if !yield(mf, nil) { + return + } + } + }, nil +} + func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 908d6aab5..8f8842c8e 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -52,6 +52,20 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } +func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { + res, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return func(yield func(model.Playlist, error) bool) { + for _, p := range res { + if !yield(p, nil) { + return + } + } + }, nil +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") From 3d438b08efc705928a187b51042ca0778feeb900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 15 Jul 2026 18:09:59 -0400 Subject: [PATCH 25/56] fix(jellyfin): close the unbounded playlist and search paths left by #5783 (#5784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): stream playlist tracks instead of loading every one PR #5783 left the playlist paths materializing: all three loaded every track of a playlist, whatever the client asked for. A playlist can be the whole library — a smart playlist matching everything — so this is the same OOM class that PR fixed for the other collections. Measured on a 96k-track smart playlist, /Items?ParentId=&Limit=10 peaked at 1.3GB to return ten tracks. Playlist tracks now stream from a cursor, like every other collection: - PlaylistTrackRepository gains GetCursor and CountAll, sharing the select builder with loadTracks so cursor rows hydrate identically, plus GetMediaFileIDs for callers that need every id but no track data. - playlists.Tracks(ctx, id) exposes that repo to the HTTP layer with visibility enforced, returning ErrNotFound rather than the repo's nil-and-log-a-warning (which /Items would hit on every album browse, since ParentId is usually not a playlist). - /Playlists/{id}/Items now honors StartIndex/Limit, which it silently ignored before — it always returned the whole playlist. Real Jellyfin pages it. - getPlaylist runs an id-only query: PlaylistInfo carries every track id so it can't be paged, but it no longer hydrates rows it discards. - The route joins the throttled group, as it's now cursor-backed. Measured against a copy of a 96k-track production DB, peak RSS over idle, with byte-for-byte identical responses on every endpoint: /Items?ParentId=&Limit=10 1275MB -> 1MB 8.8s -> 3.6s (0.27s warm) /Items?ParentId= unbounded 1353MB -> 8MB 8.7s -> 3.4s /Playlists//Items 1217MB -> 7MB 8.8s -> 3.4s /Playlists/ 1178MB -> 33MB 9.1s -> 3.8s TotalRecordCount now costs a count query where the old path got it from len(tracks): 6ms on the largest real playlist in that library (2638 tracks), 288ms on the synthetic all-96k one. The old path paid 1.3GB and 4s+ instead. * fix(jellyfin): bound unbounded /Items searches The other collections stream, so an unbounded one costs about one item of memory. Search can't: the repositories' Search returns a slice, so it materializes every match. PR #5783 left two ways to reach that. A whitespace-only SearchTerm was the first. " " != "", so it took the search path, where doSearch trims it back to empty and hits its "empty query, return everything in natural order" branch — with no LIMIT, since executeTwoPhase only applies one when Max > 0. The whole library, materialized. Trimming at the two parse sites makes the `search != ""` checks mean what they look like they mean: a blank term is not a search, so it takes the unfiltered streaming path, which still returns everything, exactly as real Jellyfin does for an empty term. A real search with no Limit was the second, and needs an actual bound. Search gets a default of 100 when the client sends no Limit — matching the DefaultSearchLimit in Jellyfin's unreleased SqlSearchProvider — plus a ceiling, without which Limit=999999 would still materialize the library. The default alone wouldn't have closed the hole. An explicit Limit under the ceiling is honored unclamped, as upstream does; truncating a search is safe in a way truncating /Items is not, since nothing syncs a library through searchTerm. Note this is upstream's own bug: v10.11's /Search/Hints returns the entire library for a whitespace-only term, which master fixed by switching to ThrowIfNullOrWhiteSpace. * fix(jellyfin): cap the search Limit the client asked for, not the merge window searchPage sees two different things in opts.Max: the client's Limit for a single-type query, and mergeTypes' internal offset+limit window for a multi-type one. Clamping there hit both, so a multi-type search paging past the ceiling fetched only `ceiling` rows of the first type and the merged page skipped into the next one — IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=2000 &Limit=1 returned an album instead of the 2001st song. The ceiling now applies where the client's Limit is read: queryItems (after the playlist branch, so a playlist parent's page isn't capped by a stray SearchTerm) and getArtists, which reads its own. A limit of 0 stays 0, keeping searchPage's default. What a deep page materializes is then bounded by the client's StartIndex, as it already was for any multi-type query, search or not. Also from review: the playlist-track mock reused the previously stored Options when called without any, so a later no-args call inherited stale paging. * fix(jellyfin): apply the search default to the client's Limit, not per type The default lived in searchPage, which runs per type and after mergeTypes has already picked its branch. So an unbounded multi-type search left q.limit at 0, mergeTypes took its chained branch, and StartIndex was applied to a list each type had already truncated to the default — dropping matches rather than paging them. With 200 matching songs, IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song &StartIndex=150 returned nothing at all, and without StartIndex it returned the default per type instead of in total. clampSearchLimit now applies the default and the ceiling together, to the client's Limit, at the two places it's read (queryItems and getArtists). A search therefore always gives mergeTypes a real window, so it pages the merged result and cuts it once, at the end. * fix(jellyfin): bound the multi-type search window against StartIndex mergeTypes asks each type for offset+limit rows before paginating the merged list, so a search still materialized whatever StartIndex asked for: IncludeItemTypes=Audio,MusicAlbum&SearchTerm=x&StartIndex=500000&Limit=1 pulled ~500001 matches per type. Bounding the window alone isn't enough — below it the merged rows are the client's page, but at it a truncated type is followed by the next one's rows, which is what made a clamped window serve an album where the 2001st song belonged. So the window is capped at maxSearchLimit and the page is clipped to it: pages below the ceiling are served in full and unchanged, a page straddling it is cut at it, and past it the result is empty rather than another type's rows. The total reports what can actually be paged to, so a client stops instead of asking for pages that no longer exist. This is the merge's own limit, not the client's: a single-type search still pages as deep as it likes, since its offset goes to SQL. Non-search multi-type queries keep the unbounded offset+limit window, which predates this and wants the same treatment via CountAll (exact per-type totals let whole types be skipped) rather than a cap. * fix(jellyfin): advertise the pageable search total, not the page window Clipping the multi-type search total to `window` clipped it to StartIndex+Limit on an ordinary page, so a first page of Limit=10 reported TotalRecordCount 10 however many matches there were, and a client paging on the total stopped after one page. The cap belongs at the ceiling — what can be paged to overall — not at the current page. The tests missed it because the only one asserting a total used StartIndex=maxSearchLimit, where the window happens to equal the ceiling. * refactor(jellyfin): fold the search clamp into clampLimit and simplify mergeTypes Cleanup pass over the branch, no behaviour change: - clampSearchLimit was clampLimit (similar.go) with different constants, so the latter takes the default and ceiling as arguments and both call it. Similar's default moves out of three IntOr calls into defaultSimilarLimit. - mergeTypes derived a second `limit` and needed an early return for the empty page, only because paginate reads 0 as "unbounded". Clipping the merged slice to the window instead lets q.limit be passed straight through: window is min(offset+limit, ceiling), so clipping there is the same cut. - playlistTracks returned (result, handled, error) where handled=false always meant error=nil. Splitting the lookup out gives playlistTracksRepo returning (repo, ok), and the nilerr suppression goes with it. - The comment on playlists.Tracks blamed the log warning for its extra Get; the reason is that PlaylistRepository.Tracks discards the error behind a nil. Also drops a stale reference to a renamed variable. --- core/playlists/playlists.go | 16 ++ core/playlists/playlists_test.go | 22 ++ model/playlist.go | 5 + persistence/playlist_repository.go | 12 +- persistence/playlist_track_repository.go | 32 +++ persistence/playlist_track_repository_test.go | 61 +++++ server/jellyfin/api.go | 2 +- server/jellyfin/browsing.go | 5 +- server/jellyfin/browsing_test.go | 17 ++ server/jellyfin/items.go | 84 +++++-- server/jellyfin/items_test.go | 208 ++++++++++++++++++ server/jellyfin/playlists.go | 49 ++++- server/jellyfin/playlists_test.go | 46 +++- server/jellyfin/similar.go | 20 +- tests/mock_album_repo.go | 2 + tests/mock_playlist_track_repo.go | 64 +++++- 16 files changed, 591 insertions(+), 54 deletions(-) create mode 100644 persistence/playlist_track_repository_test.go diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 3da24706c..1ef083bbb 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -22,6 +22,7 @@ type Playlists interface { GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) Get(ctx context.Context, id string) (*model.Playlist, error) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) + Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) // Mutations @@ -98,6 +99,21 @@ func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model return s.ds.Playlist(ctx).GetPlaylists(mediaFileId) } +// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather +// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards +// its error behind a nil (and warns), and this is probed with ids that are usually not playlists. +func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) { + repo := s.ds.Playlist(ctx) + if _, err := repo.Get(id); err != nil { + return nil, err + } + tracks := repo.Tracks(id, true) + if tracks == nil { + return nil, model.ErrNotFound + } + return tracks, nil +} + // --- Mutation operations --- // Create creates a new playlist (when name is provided) or replaces tracks on an existing diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index f849a0a21..0c9674bed 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -73,6 +73,28 @@ var _ = Describe("Playlists", func() { }) }) + Describe("Tracks", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + }) + + It("returns the playlist's track repository", func() { + Expect(ps.Tracks(ctx, "pls-1")).To(BeIdenticalTo(mockTracks)) + }) + + It("returns ErrNotFound for an unknown or invisible playlist", func() { + _, err := ps.Tracks(ctx, "nonexistent") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Describe("Create", func() { BeforeEach(func() { mockPlsRepo.Data = map[string]*model.Playlist{ diff --git a/model/playlist.go b/model/playlist.go index f2586f52d..40adb8d0a 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -157,10 +157,15 @@ func (plt PlaylistTracks) MediaFiles() MediaFiles { return mfs } +type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error] + type PlaylistTrackRepository interface { ResourceRepository + CountAll(options ...QueryOptions) (int64, error) GetAll(options ...QueryOptions) (PlaylistTracks, error) + GetCursor(options ...QueryOptions) (PlaylistTrackCursor, error) GetAlbumIDs(options ...QueryOptions) ([]string, error) + GetMediaFileIDs(options ...QueryOptions) ([]string, error) Add(mediaFileIds []string) (int, error) AddAlbums(albumIds []string) (int, error) AddArtists(artistIds []string) (int, error) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 9626aad6a..e39f0bbd3 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -298,10 +298,11 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { return nil } -func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) { - sel = r.applyLibraryFilter(sel, "f") +// tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically. +func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectBuilder { + query = r.applyLibraryFilter(query, "f") userID := loggedUser(r.ctx).ID - tracksQuery := sel. + return query. Columns( "coalesce(starred, 0) as starred", "starred_at", @@ -321,8 +322,11 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla Join("media_file f on f.id = media_file_id"). Join("library on f.library_id = library.id"). Where(Eq{"playlist_id": id}) +} + +func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.PlaylistTracks, error) { tracks := dbPlaylistTracks{} - err := r.queryAll(tracksQuery, &tracks) + err := r.queryAll(r.tracksQuery(query, id), &tracks) if err != nil { return nil, err } diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 1a7062cc2..e51ff8ea6 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -77,6 +77,14 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool return p } +func (r *playlistTrackRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := Select(). + Join("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") + return r.count(query, options...) +} + func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) { query := Select(). LeftJoin("media_file f on f.id = media_file_id"). @@ -116,6 +124,30 @@ func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.P return tracks, err } +func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { + sel := r.playlistRepo.tracksQuery(r.newSelect(options...), r.playlistId) + cursor, err := queryWithStableResults[dbPlaylistTrack](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return model.PlaylistTrackCursor(wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { + return t.PlaylistTrack + })), nil +} + +// GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data. +func (r *playlistTrackRepository) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { + query := r.newSelect(options...).Columns("media_file_id"). + Join("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") + var ids []string + if err := r.queryAllSlice(query, &ids); err != nil { + return nil, err + } + return ids, nil +} + func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) { query := r.newSelect(options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go new file mode 100644 index 000000000..36f9ae4a9 --- /dev/null +++ b/persistence/playlist_track_repository_test.go @@ -0,0 +1,61 @@ +package persistence + +import ( + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PlaylistTrackRepository", func() { + var repo model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()).Tracks(plsBest.ID, true) + }) + + Describe("GetCursor", func() { + It("yields the same tracks as GetAll", func() { + opts := model.QueryOptions{Sort: "id"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(2)) + + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + }) + + It("honors Max and Offset", func() { + opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(1)) + + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + }) + }) + + Describe("CountAll", func() { + It("returns the number of tracks in the playlist", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + + It("ignores Max and Offset", func() { + Expect(repo.CountAll(model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2))) + }) + }) + + Describe("GetMediaFileIDs", func() { + It("returns the song ids in playlist order", func() { + Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id"})). + To(Equal([]string{songDayInALife.ID, songRadioactivity.ID})) + }) + + It("honors Max and Offset", func() { + Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Max: 1, Offset: 1})). + To(Equal([]string{songRadioactivity.ID})) + }) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index d169a4c83..e94d64e85 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -102,6 +102,7 @@ func (api *Router) routes() http.Handler { r.Get("/Users/{userId}/Items/Latest", api.getLatest) r.Get("/Artists", api.getArtists) r.Get("/Artists/AlbumArtists", api.getAlbumArtists) + r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) }) r.Get("/Items/{itemId}", api.getItem) @@ -131,7 +132,6 @@ func (api *Router) routes() http.Handler { r.Post("/Playlists", api.createPlaylist) r.Get("/Playlists/{playlistId}", api.getPlaylist) r.Post("/Playlists/{playlistId}", api.updatePlaylist) - r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist) r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist) r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index d5a00e492..fae21fc0b 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -33,7 +33,10 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol q := itemsQuery{ scopeIDs: scopeIDs, genreIds: decodedQueryIDs(r, "genreids"), - search: p.StringOr("searchterm", ""), + search: searchTerm(p), + } + if q.search != "" { + opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) } res, err := api.listArtists(ctx, opts, q, role) diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 7f50355e1..660e5d293 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -111,6 +111,23 @@ var _ = Describe("Browsing", func() { Expect(res.Items).To(HaveLen(1)) }) + It("bounds a search the client left unbounded, and clamps an oversized one", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) + + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/Artists?SearchTerm=art&Limit=999999", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + It("forwards StartIndex/Limit as Offset/Max", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 0dca48938..ff38b6491 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -24,6 +24,12 @@ import ( // album, artist and media_file). var notMissing = squirrel.Eq{"missing": false} +// searchTerm trims, so a whitespace-only term is not a search: doSearch would read it as "match +// everything" and materialize the library, where the unfiltered path streams. +func searchTerm(p *req.Values) string { + return strings.TrimSpace(p.StringOr("searchterm", "")) +} + func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { res, err := api.queryItems(r.Context(), r) if err != nil { @@ -226,7 +232,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu fields: dto.ParseFields(p.StringOr("fields", "")), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), - search: p.StringOr("searchterm", ""), + search: searchTerm(p), sortBy: p.StringOr("sortby", ""), sortOrder: p.StringOr("sortorder", ""), offset: p.IntOr("startindex", 0), @@ -281,8 +287,11 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"): return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil } - if res, ok := api.playlistTracks(ctx, q); ok { - return res, nil + if repo, ok := api.playlistTracksRepo(ctx, q); ok { + return api.playlistTrackPage(repo, q.fields, q.offset, q.limit) + } + if q.search != "" { + q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit) } if len(q.types) == 1 { opts := model.QueryOptions{Offset: q.offset, Max: q.limit} @@ -292,32 +301,39 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult return api.mergeTypes(ctx, q) } -// playlistTracks resolves a playlist parent to its tracks, whatever IncludeItemTypes says: Jellify -// opens a playlist with ParentId=&IncludeItemTypes=Audio, and routing that through -// listSongs would treat the playlist id as an album id and return nothing. -func (api *Router) playlistTracks(ctx context.Context, q itemsQuery) (itemsResult, bool) { +// playlistTracksRepo resolves a playlist parent, whatever IncludeItemTypes says: Jellify opens a +// playlist with ParentId=&IncludeItemTypes=Audio, and routing that through listSongs would +// treat the playlist id as an album id and return nothing. +// +// ok is false when ParentId isn't a visible playlist, so the caller falls through to the type +// dispatch: ParentId is usually an album or artist. +func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) { if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID { - return itemsResult{}, false + return nil, false } - pls, err := api.playlists.GetWithTracks(ctx, q.parentId) - if err != nil { - return itemsResult{}, false - } - // GetWithTracks enforces visibility (public or owned by the current user). - items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, q.fields) }) - return materialized(result(paginate(items, q.offset, q.limit), len(items), q.offset)), true + // Tracks enforces visibility. + repo, err := api.playlists.Tracks(ctx, q.parentId) + return repo, err == nil } func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { // Each per-type query needs at most offset+limit rows (the worst case where one type fills the // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. + window := 0 + if q.limit > 0 { + window = q.offset + q.limit + } + // A search can't stream, so the window is what each type materializes and StartIndex would drive + // it without bound. Only below the window are the merged rows the true order, hence the clip + // below too. Non-search stays unbounded in StartIndex: a known gap, fixable with per-type counts. + if q.search != "" { + window = min(window, maxSearchLimit) + } var results []itemsResult total := 0 for _, itemType := range q.types { var opts model.QueryOptions - if q.limit > 0 { - opts.Max = q.offset + q.limit - } + opts.Max = window applySort(&opts, itemType, q.sortBy, q.sortOrder) res, err := api.queryItemsOfType(ctx, itemType, opts, q) if err != nil { @@ -339,6 +355,13 @@ func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, e } items = append(items, typeItems...) } + if q.search != "" { + // Past the window the merged order isn't the true one, so drop it rather than serve another + // type's rows. The total is what's pageable overall, not this page, or a client paging on it + // would stop after the first page. + items = items[:min(window, len(items))] + total = min(total, maxSearchLimit) + } return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil } @@ -412,20 +435,37 @@ func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { return items } +// Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without +// the ceiling, Limit=999999 still materializes every match. +const ( + defaultSearchLimit = 100 + maxSearchLimit = 2000 +) + +// clampLimit bounds a client-supplied limit, 0 or less meaning it sent none, so it can't drive an +// oversized allocation or provider fetch (flagged by CodeQL as a user-controlled allocation size). +// +// Searches clamp their Limit here rather than in searchPage, which also sees mergeTypes' larger +// offset+limit window: bounding that would truncate each type before the merged page is cut. +func clampLimit(limit, def, ceiling int) int { + if limit <= 0 { + return def + } + return min(limit, ceiling) +} + // searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the // Search API returns no match count and CountAll can't see the search term. offset+len(rows) is // exact once matches end (and a growing lower bound before), so paging terminates at the last match. func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) { fetch := opts - if fetch.Max > 0 { - fetch.Max++ - } + fetch.Max++ rows, err := search(fetch) if err != nil { return nil, 0, err } total := opts.Offset + len(rows) - if opts.Max > 0 && len(rows) > opts.Max { + if len(rows) > opts.Max { rows = rows[:opts.Max] } return rows, total, nil diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 801db9e3a..049151651 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -3,6 +3,7 @@ package jellyfin import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" @@ -71,6 +72,59 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) }) + It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { + fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + }} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("pages a playlist parent's tracks in the query, not in memory", func() { + fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + }} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&StartIndex=1&Limit=1", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(3)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) + Expect(fp.tracksRepo.Options.Max).To(Equal(1)) + }) + + It("falls through to the type dispatch when ParentId is not a playlist", func() { + fp.getErr = model.ErrNotFound + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) w := httptest.NewRecorder() @@ -220,6 +274,160 @@ var _ = Describe("Items", func() { Expect(res.Items).To(HaveLen(1)) }) + It("caps a search the client left unbounded", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) + }) + + It("honors an explicit search Limit up to the ceiling", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(501)) + }) + + It("clamps a search Limit that would materialize the library", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + + It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(albumRepo.SearchQuery).To(BeEmpty()) + }) + + It("reports a multi-type search total past the page, so clients keep paging", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(10)) + Expect(res.TotalRecordCount).To(BeNumerically(">", 10)) + }) + + It("bounds the multi-type search window however large StartIndex is", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // Without the bound this asks each type for ~500001 rows. + Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + + It("stops a multi-type search at the ceiling rather than serving another type's rows", func() { + // Bounding the per-type window is what keeps StartIndex from driving it without limit, and + // past that window the merged order is no longer the true one. + songs := make(model.MediaFiles, maxSearchLimit+1) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(Equal(maxSearchLimit)) + }) + + It("serves the last page below the ceiling in full", func() { + songs := make(model.MediaFiles, maxSearchLimit+1) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + // Clipped to the window, and still the real row at that index — not the album behind it. + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-1].ID))) + }) + + It("bounds an unbounded multi-type search to the default in total, not per type", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(defaultSearchLimit)) + }) + + It("pages an unbounded multi-type search past the default without dropping matches", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).ToNot(BeEmpty()) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+50].ID))) + }) + It("reports a search total beyond the fetched page instead of the page length", func() { ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ {ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"}, diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index ffc2c6543..a157aae2e 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -125,6 +125,21 @@ func (api *Router) clearPlaylist(ctx context.Context, id string) error { return api.playlists.RemoveTracks(ctx, id, entryIDs) } +// playlistTrackPage streams one page of a playlist's tracks. Streams because a playlist can be the +// whole library (a smart playlist matching everything) and clients may omit Limit. Excludes missing +// tracks, and counts the same set, like GetWithTracks. +func (api *Router) playlistTrackPage(repo model.PlaylistTrackRepository, fields dto.Fields, offset, limit int) (itemsResult, error) { + total, err := repo.CountAll(model.QueryOptions{Filters: notMissing}) + if err != nil { + return itemsResult{}, err + } + opts := model.QueryOptions{Sort: "id", Offset: offset, Max: limit, Filters: notMissing} + open := streamCursor(func() (func(func(model.PlaylistTrack, error) bool), error) { + return repo.GetCursor(opts) + }, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) + return streamed(open, int(total), offset), nil +} + // trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId (the // entry's id, model.PlaylistTrack.ID, not the song id). Clients echo it back via // DELETE .../Items?EntryIds= to remove a specific occurrence, so duplicates of the same song remain @@ -136,17 +151,28 @@ func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { } // getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the -// edit screen). GetWithTracks enforces visibility; any error maps to 404 so private playlists can't +// edit screen). Get and Tracks enforce visibility; any error maps to 404 so private playlists can't // be probed. func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := dto.DecodeID(chi.URLParam(r, "playlistId")) - pls, err := api.playlists.GetWithTracks(ctx, id) + pls, err := api.playlists.Get(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) return } - itemIds := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return dto.EncodeID(t.MediaFileID) }) + repo, err := api.playlists.Tracks(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + // PlaylistInfo carries every track id, so this can't be paged — but it needs no track data. + trackIDs, err := repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Filters: notMissing}) + if err != nil { + api.internalError(w, r, err) + return + } + itemIds := slice.Map(trackIDs, dto.EncodeID) api.ok(w, r, dto.PlaylistInfo{ OpenAccess: pls.Public, Shares: []dto.PlaylistUserPermissions{}, @@ -154,19 +180,24 @@ func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { }) } -// getPlaylistItems relies on GetWithTracks to enforce visibility; any error maps to a generic 404 so -// a playlist id can't probe for private playlists. +// getPlaylistItems relies on Tracks to enforce visibility; any error maps to a generic 404 so a +// playlist id can't probe for private playlists. func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := dto.DecodeID(chi.URLParam(r, "playlistId")) - pls, err := api.playlists.GetWithTracks(ctx, id) + repo, err := api.playlists.Tracks(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) return } - fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) - items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) - api.ok(w, r, dto.QueryResult{Items: items, TotalRecordCount: len(items)}) + p := req.Params(r) + fields := dto.ParseFields(p.StringOr("fields", "")) + res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) } // queryIDs reads an id-list query param that clients spell two ways: comma-separated in a single diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 804123003..27264b280 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -29,8 +29,9 @@ type fakePlaylists struct { createdIds []string createErr error - getPls *model.Playlist - getErr error + getPls *model.Playlist + getErr error + tracksRepo *tests.MockPlaylistTrackRepo getByIDPls *model.Playlist getByIDErr error @@ -92,6 +93,20 @@ func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playl return f.getPls, nil } +// Tracks serves the same getPls fixture as GetWithTracks. tracksRepo is kept so tests can assert +// what was pushed down to the query. +func (f *fakePlaylists) Tracks(_ context.Context, _ string) (model.PlaylistTrackRepository, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.getPls == nil { + return nil, model.ErrNotFound + } + f.tracksRepo = &tests.MockPlaylistTrackRepo{} + f.tracksRepo.SetData(f.getPls.Tracks) + return f.tracksRepo, nil +} + func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) { f.addPlaylistID = playlistID f.addIds = ids @@ -184,6 +199,30 @@ var _ = Describe("Playlists", func() { Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2"))) }) + It("pages with StartIndex/Limit, pushing them down to the query", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil). + WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.getPlaylistItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(3)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) + Expect(fp.tracksRepo.Options.Max).To(Equal(1)) + }) + It("returns 404 for a non-owned or absent playlist", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() @@ -247,7 +286,7 @@ var _ = Describe("Playlists", func() { Describe("getPlaylist", func() { It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { - fp.getPls = &model.Playlist{ + pls := &model.Playlist{ ID: "pl1", Public: true, Tracks: model.PlaylistTracks{ @@ -255,6 +294,7 @@ var _ = Describe("Playlists", func() { {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, }, } + fp.getPls, fp.getByIDPls = pls, pls w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background()) r = withChiURLParam(r, "playlistId", "pl1") diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index db3671fe4..33cdc4c88 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -20,7 +20,10 @@ import ( // tests can shorten it. var similarWait = 10 * time.Second -const maxSimilarLimit = 100 +const ( + defaultSimilarLimit = 20 + maxSimilarLimit = 100 +) // similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine // indefinitely. @@ -51,7 +54,7 @@ func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch // returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 20)) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { return api.similarArtists(ctx, id, limit) })) @@ -63,7 +66,7 @@ func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 20)) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { @@ -88,7 +91,7 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 20)) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { @@ -136,15 +139,6 @@ func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto return result(items, len(items), 0) } -// clampLimit bounds a client-supplied limit so it can't drive an oversized allocation or provider -// fetch (flagged by CodeQL as a user-controlled allocation size). -func clampLimit(limit int) int { - if limit <= 0 { - return 20 - } - return min(limit, maxSimilarLimit) -} - func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult { songs, err := api.provider.SimilarSongs(ctx, id, limit) if err != nil { diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 6635881b7..03dfed879 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -20,6 +20,7 @@ type MockAlbumRepo struct { All model.Albums Err bool Options model.QueryOptions + SearchQuery string // last query passed to Search ReassignAnnotationCalls map[string]string // prevID -> newID CopyAttributesCalls map[string]string // fromID -> toID } @@ -134,6 +135,7 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { } func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { + m.SearchQuery = q if len(options) > 0 { m.Options = options[0] } diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index c11b077d2..2835baadd 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -1,9 +1,14 @@ package tests -import "github.com/navidrome/navidrome/model" +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) type MockPlaylistTrackRepo struct { model.PlaylistTrackRepository + Data model.PlaylistTracks + Options model.QueryOptions AddedIds []string DeletedIds []string Reordered bool @@ -11,6 +16,63 @@ type MockPlaylistTrackRepo struct { Err error } +func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { + m.Data = tracks +} + +// page applies Max/Offset as the real repository's SQL would. +func (m *MockPlaylistTrackRepo) page(options ...model.QueryOptions) model.PlaylistTracks { + var opts model.QueryOptions + if len(options) > 0 { + opts = options[0] + m.Options = opts + } + tracks := m.Data + if opts.Offset >= len(tracks) { + return nil + } + tracks = tracks[opts.Offset:] + if opts.Max > 0 && opts.Max < len(tracks) { + tracks = tracks[:opts.Max] + } + return tracks +} + +func (m *MockPlaylistTrackRepo) CountAll(_ ...model.QueryOptions) (int64, error) { + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.Data)), nil +} + +func (m *MockPlaylistTrackRepo) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) { + if m.Err != nil { + return nil, m.Err + } + return m.page(options...), nil +} + +func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { + if m.Err != nil { + return nil, m.Err + } + tracks := m.page(options...) + return func(yield func(model.PlaylistTrack, error) bool) { + for _, t := range tracks { + if !yield(t, nil) { + return + } + } + }, nil +} + +func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { + if m.Err != nil { + return nil, m.Err + } + return slice.Map(m.page(options...), func(t model.PlaylistTrack) string { return t.MediaFileID }), nil +} + func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { m.AddedIds = append(m.AddedIds, ids...) if m.Err != nil { From adeaa93e7e03b6b98dc4098812a0c8a9c58adaf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 15 Jul 2026 19:02:12 -0400 Subject: [PATCH 26/56] fix(jellyfin): honor Recursive=false for library parents (#5788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): honor Recursive=false for library parents /Items ignored the Recursive parameter entirely — it appeared only in tests, never in production code. Finamp's download sync sends exactly one Recursive=false request per library (ParentId=&IncludeItemTypes=Audio) to pick up tracks outside any album, supplementing its recursive per-album fetch. We answered with every song in the library: 208MB and 21s per sync measured on a real library, against 20KB for the album queries. Finamp then required every track twice, once via its album and once under the library node. In Jellyfin 10.10 Recursive never reaches SQL. It selects between an in-memory walk of a folder's direct Children (false) and a DB ancestor query (true), with IncludeItemTypes applied as a post-filter over that direct-child list (ItemsController.cs:308, Folder.cs:949-994). The default is false, and neither IncludeItemTypes nor SearchTerm forces recursion, so Recursive=false with IncludeItemTypes=Audio on a music library returns an empty list — which is what Finamp expects and codes for. Filter the requested types to those nested directly under a library when the parent is a library and Recursive is not true; the hierarchy this API exposes is library -> album -> track, so no track is ever a library's direct child. Album and playlist parents are untouched, as their tracks are real direct children in Jellyfin (MusicAlbum.cs:86-89, Playlist.cs:140-167) and Jellify opens playlists with Recursive=false. A library parent is the only case handled: /Items with no ParentId still returns every song where Jellyfin returns the root's children, but no observed client sends that, and honoring it would surprise any client that simply omits Recursive. * refactor(jellyfin): narrow the Recursive=false filter to Audio Replace the libraryChildTypes allowlist with a direct Audio check. The two are behaviorally identical — parseTypes only ever yields Audio, MusicArtist, MusicAlbum, MusicGenre or Playlist, and the allowlist held the latter four, so it excluded exactly Audio and nothing else. The allowlist claimed those four are a library's direct children. That isn't true of MusicGenre or Playlist: listGenres is deliberately unscoped because genres are global tags, and listPlaylists ignores scopeIDs entirely, so neither is nested under a library at all. They were on the list only to leave their behavior untouched. The one verified invariant is that no track is a library's direct child, so state just that — it is also more conservative, as a type added later keeps its current behavior instead of being filtered by a stale list. Add a test pinning the omitted-Recursive default: ItemsController binds `bool? recursive` and reads it as `recursive ?? false`, so omitting the param is a non-recursive request and filters Audio for a library parent. --- server/jellyfin/e2e/browsing_test.go | 22 ++++++++ server/jellyfin/items.go | 6 +++ server/jellyfin/items_test.go | 75 ++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 646de4497..0b2769856 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -118,6 +118,28 @@ var _ = Describe("Browsing", func() { }) }) + // Finamp's download sync asks a library for the tracks outside any album this way; answering + // with every track would stream the whole library. + Describe("Recursive=false", func() { + lib1 := enc("1") + + It("returns no songs for a library parent", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false")) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(BeZero()) + }) + + It("still lists the library's albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + lib1 + "&Recursive=false")) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("still lists an album's tracks", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&Recursive=false")) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + }) + }) + // Finamp's artist screen sends ParentId= (scoping) plus AlbumArtistIds/ArtistIds // for the actual artist filter, not ParentId=. Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index ff38b6491..43c8de12c 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -254,6 +254,12 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu q.types = parseTypes(q.rawTypes) q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) + // Recursive=false asks for direct children only, and no track is a library's direct child. + // Finamp's sync probes a library this way, and every track is a wrong, unbounded answer. + if q.isLibraryParent && !p.BoolOr("recursive", false) { + q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" }) + } + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse // its albums). diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 049151651..401145461 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -133,6 +133,81 @@ var _ = Describe("Items", func() { Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) + // Recursive=false asks for direct children only. Finamp's sync probes a library this way + // looking for tracks outside any album; answering with every track streams the whole library. + Describe("Recursive=false", func() { + BeforeEach(func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + }) + + It("returns no songs for a library parent, as tracks are never its direct children", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(BeZero()) + }) + + It("drops only Audio from a multi-type library query", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicAlbum")) + }) + + It("still lists albums for a library parent, as they are its direct children", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("still lists an album's tracks, as they are its direct children", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + + It("keeps returning every song when no parent scopes the query", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=false", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + // Jellyfin's own default: ItemsController binds `bool? recursive` and reads it as + // `recursive ?? false`, so an omitted Recursive is a non-recursive request. + It("treats an omitted Recursive as false, like Jellyfin", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + }) + }) + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) w := httptest.NewRecorder() From 09022b4bd29a722ca624e45214a78a0b97f87143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 15 Jul 2026 20:44:56 -0400 Subject: [PATCH 27/56] feat(jellyfin): AudioMuse-AI compatible sonic endpoints (#5782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(jellyfin): inject core/sonic into the Jellyfin Router * feat(jellyfin): add AudioMuse /info endpoint * feat(jellyfin): add AudioMuse /similar_tracks endpoint * feat(jellyfin): gate AudioMuse endpoints on sonic provider * feat(jellyfin): add AudioMuse /find_path endpoint * fix(jellyfin): fix case-insensitive route collision across positions canonicalRouteSegments keyed canonical case by lower-cased segment name alone, globally. Two unrelated routes sharing a segment name with different casing at different tree depths (e.g. "Info" in /System/Info/Public vs "info" in /AudioMuseAI/info) silently overwrote each other, 404-ing the loser even for exact-case requests. Replace the flat map with a position-aware trie mirroring the routing tree. * test(jellyfin): e2e tests for AudioMuse endpoints * docs(jellyfin): document AudioMuse compatibility endpoints * test(jellyfin): harden AudioMuse tests and doc note (final-review follow-ups) - Comment-lock the []string{} (not nil) contract for /AudioMuseAI/info's AvailableEndpoints so it keeps serializing as [] rather than null, and add a raw-body assertion to the existing empty-list test to catch a regression a struct-only unmarshal can't detect. - Cover the previously-untested engine-error branch in similar_tracks and find_path, both of which degrade to an empty result. - Document that find_path's path/total_distance only reflect hops through libraries the caller can access in multi-library setups. * refactor(jellyfin): dedup AudioMuse test request helper, presize dedup map * refactor(sonic): expose sonic.Engine interface; drop typed-nil guard in jellyfin.New The Jellyfin Router's sonic field was an interface but New() took the concrete *sonic.Sonic, so a nil arg became a non-nil typed-nil and needed a guard — the only injected dependency that did. Move the interface (sonic.Engine) beside its implementation, take it in New() like every other service, and bind it in wire. * refactor(jellyfin): case-insensitive routing via lowercased paths Replace the position-aware route trie with a trivial middleware that lowercases the request path, and register every route in lowercase. Simpler, and no segment name can collide across positions. caseInsensitivePaths moves into middlewares.go alongside normalizeQueryKeys. Relies on the invariant that no Jellyfin path segment carries case-sensitive data (all ids are lowercase hex via dto.EncodeID). * feat(jellyfin): add AudioMuse /health endpoint A liveness probe matching the reference plugin: 200 with an empty body when a SonicSimilarity provider is loaded, 404 otherwise. /AudioMuseAI/info now advertises it (list alphabetized like the plugin's OrderBy). Also trims the AudioMuse and case-insensitive-routing comments to their essential rationale. * fix(jellyfin): hex-encode user IDs so lowercased paths stay valid Address PR review: user IDs were the one id the Jellyfin API emitted raw (base62, uppercase-capable), so lowercasing request paths could alter a userId segment. Encode them via dto.EncodeID like every other id, making the 'all boundary ids are lowercase hex' invariant true — no routing special-casing needed. Also caps user-controlled n / max_steps, fixes the songAgent test comment, and adds leading slashes to the README endpoint list. --- cmd/wire_gen.go | 5 +- cmd/wire_injectors.go | 1 + core/sonic/sonic.go | 9 + server/jellyfin/README.md | 23 ++ server/jellyfin/api.go | 127 +++++---- server/jellyfin/api_test.go | 10 +- server/jellyfin/audiomuse.go | 161 +++++++++++ server/jellyfin/audiomuse_test.go | 250 ++++++++++++++++++ server/jellyfin/auth.go | 2 +- server/jellyfin/case_insensitive_routes.go | 68 ----- .../jellyfin/case_insensitive_routes_test.go | 90 ------- server/jellyfin/e2e/audiomuse_test.go | 113 ++++++++ server/jellyfin/e2e/auth_test.go | 6 +- server/jellyfin/e2e/e2e_suite_test.go | 67 ++++- server/jellyfin/e2e/playlists_test.go | 2 +- server/jellyfin/middlewares.go | 17 ++ server/jellyfin/middlewares_test.go | 59 +++++ server/jellyfin/playlists.go | 2 +- server/jellyfin/playlists_test.go | 2 +- server/jellyfin/routing_test.go | 2 +- server/jellyfin/socket_test.go | 2 +- server/jellyfin/users.go | 2 +- server/jellyfin/users_test.go | 2 +- 23 files changed, 780 insertions(+), 242 deletions(-) create mode 100644 server/jellyfin/audiomuse.go create mode 100644 server/jellyfin/audiomuse_test.go delete mode 100644 server/jellyfin/case_insensitive_routes.go delete mode 100644 server/jellyfin/case_insensitive_routes_test.go create mode 100644 server/jellyfin/e2e/audiomuse_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4a2b46289..bd211cbfc 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -136,7 +136,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider) + sonicSonic := sonic.New(dataStore, manager, matcherMatcher) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic) return router } @@ -245,7 +246,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.New, 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(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))) +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.New, 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))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 0f6b73891..94faa5af3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -51,6 +51,7 @@ var allProviders = wire.NewSet( 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)), diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go index 19eb69c65..67f5cc7da 100644 --- a/core/sonic/sonic.go +++ b/core/sonic/sonic.go @@ -46,6 +46,15 @@ func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher } } +// Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it. +type Engine interface { + HasProvider() bool + GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) + FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) +} + +var _ Engine = (*Sonic)(nil) + func (s *Sonic) HasProvider() bool { return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 } diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index fb5c4a637..2e4c19950 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -215,6 +215,29 @@ The stream endpoints reuse the same transcode-decision pipeline as the Subsonic Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are advertised and served but packed-audio players won't decode them. +## AudioMuse-AI compatible endpoints + +Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin). +Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no +external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity` +plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. + +- `GET /AudioMuseAI/info` — returns `{"Version": , "AvailableEndpoints": [...]}` (200). + `AvailableEndpoints` lists the endpoints below only when a provider is loaded; otherwise it is empty. +- `GET /AudioMuseAI/health` — liveness probe: 200 with an empty body when a provider is loaded, else 404. +- `GET /AudioMuseAI/similar_tracks?item_id=&n=10&eliminate_duplicates=true` — 404 when no provider is + loaded; otherwise a JSON array of `{author, distance, item_id, title}` (200; `[]` when there is no match + or no `item_id`). `eliminate_duplicates` (default true) limits results to one track per artist. +- `GET /AudioMuseAI/find_path?start_song_id=&end_song_id=&max_steps=25` — 404 when no provider is + loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": }` (200), or 400 + with `start_song_id and end_song_id are required.` when either id is missing. + +`item_id`/`start_song_id`/`end_song_id` are the hex-encoded ids Navidrome hands Jellyfin clients. +`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features +(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library +setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries +the caller can access, since hops through inaccessible libraries are filtered out of the result. + ## curl walkthrough This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index e94d64e85..0740901aa 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -32,6 +33,7 @@ type Router struct { scrobbler scrobbler.PlayTracker playlists playlists.Playlists provider external.Provider + sonic sonic.Engine similarFlight singleflight.Group serverIDMu sync.Mutex serverIDVal string @@ -39,10 +41,12 @@ type Router struct { 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) *Router { + scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, + sonicSvc sonic.Engine) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, + sonic: sonicSvc, } r.Handler = r.routes() return r @@ -55,20 +59,22 @@ func (api *Router) routes() http.Handler { // handler and the api_key check see folded keys. inner.Use(normalizeQueryKeys) + // Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase. + // Public (no auth): handshake + login. - inner.Get("/System/Info/Public", api.getPublicSystemInfo) - inner.Get("/System/Ping", api.ping) - inner.Post("/System/Ping", api.ping) - inner.Get("/QuickConnect/Enabled", api.quickConnectEnabled) + inner.Get("/system/info/public", api.getPublicSystemInfo) + inner.Get("/system/ping", api.ping) + inner.Post("/system/ping", api.ping) + inner.Get("/quickconnect/enabled", api.quickConnectEnabled) // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated // brute-force surface, so it must share the same per-IP throttle when one is configured. if conf.Server.AuthRequestLimit > 0 { limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) - inner.With(limiter).Post("/Users/AuthenticateByName", api.authenticateByName) + inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName) } else { - inner.Post("/Users/AuthenticateByName", api.authenticateByName) + inner.Post("/users/authenticatebyname", api.authenticateByName) } - inner.Get("/Users/Public", api.getPublicUsers) + inner.Get("/users/public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. // Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy, @@ -76,8 +82,8 @@ func (api *Router) routes() http.Handler { inner.Group(func(r chi.Router) { r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, conf.Server.DevArtworkThrottleBacklogTimeout)) - r.Get("/Items/{itemId}/Images/{type}", api.getItemImage) - r.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + r.Get("/items/{itemId}/images/{type}", api.getItemImage) + r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage) }) inner.Group(func(r chi.Router) { @@ -86,10 +92,10 @@ func (api *Router) routes() http.Handler { // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a // player) even before the first playback report. r.Use(api.withPlayer) - r.Get("/UserViews", api.getUserViews) - r.Get("/Users/{userId}/Views", api.getUserViews) - r.Get("/Users/Me", api.getCurrentUser) - r.Get("/Users/{userId}", api.getCurrentUser) + r.Get("/userviews", api.getUserViews) + r.Get("/users/{userId}/views", api.getUserViews) + r.Get("/users/me", api.getCurrentUser) + r.Get("/users/{userId}", api.getCurrentUser) // 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 @@ -97,70 +103,75 @@ func (api *Router) routes() http.Handler { // requests queue rather than fail. r.Group(func(r chi.Router) { r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) - r.Get("/Items", api.getItems) - r.Get("/Users/{userId}/Items", api.getItems) - r.Get("/Users/{userId}/Items/Latest", api.getLatest) - r.Get("/Artists", api.getArtists) - r.Get("/Artists/AlbumArtists", api.getAlbumArtists) - r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) + r.Get("/items", api.getItems) + r.Get("/users/{userId}/items", api.getItems) + r.Get("/users/{userId}/items/latest", api.getLatest) + r.Get("/artists", api.getArtists) + r.Get("/artists/albumartists", api.getAlbumArtists) + r.Get("/playlists/{playlistId}/items", api.getPlaylistItems) }) - r.Get("/Items/{itemId}", api.getItem) - r.Get("/Users/{userId}/Items/{itemId}", api.getItem) - r.Delete("/Items/{itemId}", api.deleteItem) + r.Get("/items/{itemId}", api.getItem) + r.Get("/users/{userId}/items/{itemId}", api.getItem) + r.Delete("/items/{itemId}", api.deleteItem) // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. - r.Post("/UserFavoriteItems/{itemId}", api.markFavorite) - r.Delete("/UserFavoriteItems/{itemId}", api.unmarkFavorite) - r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite) - r.Delete("/Users/{userId}/FavoriteItems/{itemId}", api.unmarkFavorite) - r.Post("/Users/{userId}/Items/{itemId}/Rating", api.setRating) - r.Delete("/Users/{userId}/Items/{itemId}/Rating", api.removeRating) + r.Post("/userfavoriteitems/{itemId}", api.markFavorite) + r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite) + r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite) + r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite) + r.Post("/users/{userId}/items/{itemId}/rating", api.setRating) + r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating) // Per-item play/favorite/rating state. Jellify uses the /UserItems form; // /Users/{userId}/Items is the legacy spelling. - r.Get("/UserItems/{itemId}/UserData", api.getUserItemData) - r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData) + r.Get("/useritems/{itemId}/userdata", api.getUserItemData) + r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData) - r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists) - r.Get("/Items/{itemId}/Similar", api.getSimilarItems) - r.Get("/Items/{itemId}/InstantMix", api.getInstantMix) - r.Get("/Genres", api.getGenres) - r.Get("/MusicGenres", api.getGenres) + r.Get("/artists/{itemId}/similar", api.getSimilarArtists) + r.Get("/items/{itemId}/similar", api.getSimilarItems) + r.Get("/items/{itemId}/instantmix", api.getInstantMix) + r.Get("/genres", api.getGenres) + r.Get("/musicgenres", api.getGenres) - r.Post("/Playlists", api.createPlaylist) - r.Get("/Playlists/{playlistId}", api.getPlaylist) - r.Post("/Playlists/{playlistId}", api.updatePlaylist) - r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist) - r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist) - r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers) - r.Get("/Playlists/{playlistId}/Users/{userId}", api.getPlaylistUser) + r.Post("/playlists", api.createPlaylist) + r.Get("/playlists/{playlistId}", api.getPlaylist) + r.Post("/playlists/{playlistId}", api.updatePlaylist) + r.Post("/playlists/{playlistId}/items", api.addToPlaylist) + r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist) + r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers) + r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser) // Cover upload/delete: only playlists are writable (see postItemImage); the GET routes // above stay public. - r.Post("/Items/{itemId}/Images/{type}", api.postItemImage) - r.Delete("/Items/{itemId}/Images/{type}", api.deleteItemImage) + r.Post("/items/{itemId}/images/{type}", api.postItemImage) + r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage) - r.Get("/Audio/{itemId}/stream", api.streamAudio) - r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio) - r.Get("/Audio/{itemId}/universal", api.streamAudio) - r.Get("/Audio/{itemId}/main.m3u8", api.streamHls) - r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) - r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + r.Get("/audio/{itemId}/stream", api.streamAudio) + r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) + r.Get("/audio/{itemId}/universal", api.streamAudio) + r.Get("/audio/{itemId}/main.m3u8", api.streamHls) + r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) + r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of // /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.Get("/items/{itemId}/file", api.streamFile) + r.Get("/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", 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) // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. r.Get("/socket", api.handleSocket) + + r.Get("/audiomuseai/info", api.audioMuseInfo) + r.Get("/audiomuseai/health", api.audioMuseHealth) + r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks) + r.Get("/audiomuseai/find_path", api.audioMuseFindPath) }) // Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 23504c73e..1b390e75a 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -18,7 +18,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) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -26,7 +26,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -36,7 +36,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) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -53,7 +53,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -70,7 +70,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) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go new file mode 100644 index 000000000..b01a4bf92 --- /dev/null +++ b/server/jellyfin/audiomuse.go @@ -0,0 +1,161 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// audioMuseEndpoints is what /AudioMuseAI/info advertises; it omits info itself, like the plugin, +// and is sorted the same way (the plugin builds it with OrderBy). +var audioMuseEndpoints = []string{ + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", +} + +type audioMuseInfoResponse struct { + Version string `json:"Version"` + AvailableEndpoints []string `json:"AvailableEndpoints"` +} + +func (api *Router) audioMuseInfo(w http.ResponseWriter, r *http.Request) { + endpoints := []string{} // non-nil so an empty list serializes as [], not null + if api.sonic != nil && api.sonic.HasProvider() { + endpoints = audioMuseEndpoints + } + api.ok(w, r, audioMuseInfoResponse{ + Version: consts.Version, + AvailableEndpoints: endpoints, + }) +} + +// audioMuseHealth is a liveness probe: 200 with an empty body when a sonic provider is loaded, else +// 404 — mirroring the reference plugin, which returns 200 when its backend is reachable. +func (api *Router) audioMuseHealth(w http.ResponseWriter, r *http.Request) { + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + w.WriteHeader(http.StatusOK) +} + +type audioMuseSimilarTrack struct { + Author string `json:"author"` + Distance float64 `json:"distance"` + ItemID string `json:"item_id"` + Title string `json:"title"` +} + +func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // 404 without a provider, like the Subsonic sonicSimilarity handlers. + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + p := req.Params(r) + tracks := []audioMuseSimilarTrack{} + + itemID := p.StringOr("item_id", "") + if itemID == "" { + api.ok(w, r, tracks) + return + } + + id := api.resolveItemID(ctx, dto.DecodeID(itemID)) + n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit + eliminateDuplicates := p.BoolOr("eliminate_duplicates", true) + + matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, n) + if err != nil { + api.ok(w, r, tracks) + return + } + + u, _ := request.UserFrom(ctx) + seenArtists := make(map[string]bool, len(matches)) + for _, m := range matches { + mf := m.MediaFile + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + if eliminateDuplicates { + key := strings.ToLower(mf.Artist) + if seenArtists[key] { + continue + } + seenArtists[key] = true + } + tracks = append(tracks, audioMuseSimilarTrack{ + Author: mf.Artist, + Distance: m.Similarity, + ItemID: dto.EncodeID(mf.ID), + Title: mf.Title, + }) + } + api.ok(w, r, tracks) +} + +type audioMusePathTrack struct { + Author string `json:"author"` + ItemID string `json:"item_id"` + Title string `json:"title"` + Tempo *float64 `json:"tempo,omitempty"` +} + +type audioMusePathResponse struct { + Path []audioMusePathTrack `json:"path"` + TotalDistance float64 `json:"total_distance"` +} + +func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + p := req.Params(r) + + startID := p.StringOr("start_song_id", "") + endID := p.StringOr("end_song_id", "") + if startID == "" || endID == "" { + http.Error(w, "start_song_id and end_song_id are required.", http.StatusBadRequest) + return + } + + resp := audioMusePathResponse{Path: []audioMusePathTrack{}} + maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count + matches, err := api.sonic.FindSonicPath(ctx, + api.resolveItemID(ctx, dto.DecodeID(startID)), + api.resolveItemID(ctx, dto.DecodeID(endID)), + maxSteps) + if err != nil { + api.ok(w, r, resp) + return + } + + u, _ := request.UserFrom(ctx) + for _, m := range matches { + mf := m.MediaFile + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + track := audioMusePathTrack{ + Author: mf.Artist, + ItemID: dto.EncodeID(mf.ID), + Title: mf.Title, + } + if mf.BPM != nil { + tempo := float64(*mf.BPM) + track.Tempo = &tempo + } + resp.Path = append(resp.Path, track) + resp.TotalDistance += m.Similarity + } + api.ok(w, r, resp) +} diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go new file mode 100644 index 000000000..e9d6d4e85 --- /dev/null +++ b/server/jellyfin/audiomuse_test.go @@ -0,0 +1,250 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AudioMuse info", func() { + It("lists the sonic endpoints (excluding info) when a provider is present", func() { + api := &Router{sonic: &fakeSonicEngine{provider: true}} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) + + api.audioMuseInfo(w, r) + + Expect(w.Code).To(Equal(200)) + var body audioMuseInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Version).To(Equal(consts.Version)) + Expect(body.AvailableEndpoints).To(ConsistOf( + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", + )) + }) + + It("returns an empty endpoint list when no provider is loaded", func() { + api := &Router{} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) + + api.audioMuseInfo(w, r) + + Expect(w.Code).To(Equal(200)) + var body audioMuseInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.AvailableEndpoints).To(BeEmpty()) + Expect(w.Body.String()).To(ContainSubstring(`"AvailableEndpoints":[]`)) + }) +}) + +type fakeSonicEngine struct { + provider bool + similar []sonic.SimilarMatch + similarErr error + path []sonic.SimilarMatch + pathErr error + gotID string + gotStart string + gotEnd string + gotCount int +} + +func (f *fakeSonicEngine) HasProvider() bool { return f.provider } + +func (f *fakeSonicEngine) GetSonicSimilarTracks(_ context.Context, id string, count int) ([]sonic.SimilarMatch, error) { + f.gotID, f.gotCount = id, count + return f.similar, f.similarErr +} + +func (f *fakeSonicEngine) FindSonicPath(_ context.Context, startID, endID string, count int) ([]sonic.SimilarMatch, error) { + f.gotStart, f.gotEnd, f.gotCount = startID, endID, count + return f.path, f.pathErr +} + +func mf(id, artist, title string, lib int) model.MediaFile { + return model.MediaFile{ID: id, Artist: artist, Title: title, LibraryID: lib} +} + +var _ = Describe("AudioMuse health", func() { + It("returns 200 with an empty body when a provider is loaded", func() { + api := &Router{sonic: &fakeSonicEngine{provider: true}} + w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("returns 404 when no provider is loaded", func() { + api := &Router{} + w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) +}) + +// audioMuseGet drives a GET through normalizeQueryKeys as the given user, mirroring a real request. +func audioMuseGet(handler http.HandlerFunc, path, query string, user model.User) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", path+"?"+query, nil) + r = r.WithContext(request.WithUser(r.Context(), user)) + invoke(handler, w, r) + return w +} + +var _ = Describe("AudioMuse similar_tracks", func() { + var fake *fakeSonicEngine + var api *Router + + call := func(query string, user model.User) *httptest.ResponseRecorder { + return audioMuseGet(api.audioMuseSimilarTracks, "/AudioMuseAI/similar_tracks", query, user) + } + + BeforeEach(func() { + fake = &fakeSonicEngine{provider: true} + api = &Router{sonic: fake} + }) + + It("maps matches, decodes the seed id, encodes item ids, copies distance", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed")+"&n=5", model.User{IsAdmin: true}) + + Expect(w.Code).To(Equal(200)) + Expect(fake.gotID).To(Equal("seed")) + Expect(fake.gotCount).To(Equal(5)) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(2)) + Expect(body[0]).To(Equal(audioMuseSimilarTrack{ + Author: "A", Distance: 0.3, ItemID: dto.EncodeID("mf1"), Title: "T1", + })) + }) + + It("collapses to one track per artist when eliminate_duplicates defaults on", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(1)) + }) + + It("keeps same-artist tracks when eliminate_duplicates=false", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed")+"&eliminate_duplicates=false", model.User{IsAdmin: true}) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(2)) + }) + + It("filters out tracks in libraries the user cannot access", func() { + fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 2), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID("seed"), model.User{Libraries: model.Libraries{{ID: 1}}}) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) + + It("returns an empty array without calling the engine when item_id is missing", func() { + w := call("n=5", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + Expect(fake.gotID).To(Equal("")) + }) + + It("returns 404 when no sonic provider is loaded", func() { + fake.provider = false + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) + + It("returns an empty array when the engine errors", func() { + fake.similarErr = errors.New("boom") + fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) +}) + +var _ = Describe("AudioMuse find_path", func() { + var fake *fakeSonicEngine + var api *Router + + call := func(query string, user model.User) *httptest.ResponseRecorder { + return audioMuseGet(api.audioMuseFindPath, "/AudioMuseAI/find_path", query, user) + } + + BeforeEach(func() { + fake = &fakeSonicEngine{provider: true} + api = &Router{sonic: fake} + }) + + It("returns 400 with the exact message when start_song_id is missing", func() { + w := call("end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(400)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) + }) + + It("returns 400 when end_song_id is missing", func() { + w := call("start_song_id="+dto.EncodeID("s"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(400)) + }) + + It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() { + bpm := 120 + withBPM := mf("mf1", "A", "T1", 1) + withBPM.BPM = &bpm + fake.path = []sonic.SimilarMatch{ + {MediaFile: withBPM, Similarity: 1.5}, + {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 2.0}, + } + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e")+"&max_steps=10", model.User{IsAdmin: true}) + + Expect(w.Code).To(Equal(200)) + Expect(fake.gotStart).To(Equal("s")) + Expect(fake.gotEnd).To(Equal("e")) + Expect(fake.gotCount).To(Equal(10)) + var body audioMusePathResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Path).To(HaveLen(2)) + Expect(body.TotalDistance).To(Equal(3.5)) + Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID("mf1"))) + Expect(*body.Path[0].Tempo).To(Equal(120.0)) + Expect(body.Path[1].Tempo).To(BeNil()) + }) + + It("returns 404 when no sonic provider is loaded", func() { + fake.provider = false + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) + + It("returns an empty path object when the engine errors", func() { + fake.pathErr = errors.New("boom") + fake.path = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 1.0}} + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + var body audioMusePathResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Path).To(BeEmpty()) + Expect(body.TotalDistance).To(Equal(0.0)) + }) +}) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index ecd129222..062ac6458 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -56,7 +56,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { func userToDto(u *model.User, serverName, serverID string) *dto.UserDto { return &dto.UserDto{ Name: u.UserName, - Id: u.ID, + Id: dto.EncodeID(u.ID), // hex like every other id, so lowercased paths stay valid ServerId: serverID, ServerName: serverName, HasPassword: true, diff --git a/server/jellyfin/case_insensitive_routes.go b/server/jellyfin/case_insensitive_routes.go deleted file mode 100644 index cc95b526f..000000000 --- a/server/jellyfin/case_insensitive_routes.go +++ /dev/null @@ -1,68 +0,0 @@ -package jellyfin - -import ( - "net/http" - "strings" - - "github.com/go-chi/chi/v5" -) - -// caseInsensitivePaths normalizes each request path's literal segments to the case they were -// registered with before delegating to r, since Jellyfin clients route case-insensitively but -// chi matches case-sensitively. Param placeholders (e.g. "{itemId}") aren't literals, so id -// segments pass through untouched. -func caseInsensitivePaths(r chi.Router) http.Handler { - canon := canonicalRouteSegments(r) - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - normalizeRequestPath(req, canon) - r.ServeHTTP(w, req) - }) -} - -// canonicalRouteSegments walks every registered route and records, for each literal (non-param) -// "/"-separated segment, the case it was registered with, keyed by its lower-cased form (e.g. -// "audio" -> "Audio"). -func canonicalRouteSegments(router chi.Router) map[string]string { - canon := map[string]string{} - _ = chi.Walk(router, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { - for seg := range strings.SplitSeq(route, "/") { - if seg == "" || strings.Contains(seg, "{") { - continue - } - canon[strings.ToLower(seg)] = seg - } - return nil - }) - return canon -} - -// normalizeRequestPath rewrites literal path segments to the case routes were registered with. -// It must run before chi's matching. When the router is mounted under a parent, chi has already -// stripped the mount prefix and matches against RouteContext.RoutePath rather than r.URL.Path, so -// that's what must be normalized here. -func normalizeRequestPath(r *http.Request, canon map[string]string) { - if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { - rctx.RoutePath = normalizeCase(rctx.RoutePath, canon) - return - } - r.URL.Path = normalizeCase(r.URL.Path, canon) -} - -// normalizeCase rewrites each "/"-separated literal segment of path to the case it was -// registered with in canon. Segments with no match (e.g. case-sensitive ids) are left untouched. -// A segment like "STREAM.mp3" comes from a mixed literal+param route (e.g. "stream.{container}"), -// whose literal prefix ("stream") is registered separately: normalize that prefix and lower-case -// the extension so chi's case-sensitive match still hits. -func normalizeCase(path string, canon map[string]string) string { - segs := strings.Split(path, "/") - for i, seg := range segs { - if canonical, ok := canon[strings.ToLower(seg)]; ok { - segs[i] = canonical - } else if prefix, suffix, found := strings.Cut(seg, "."); found { - if canonical, ok := canon[strings.ToLower(prefix)]; ok { - segs[i] = canonical + "." + strings.ToLower(suffix) - } - } - } - return strings.Join(segs, "/") -} diff --git a/server/jellyfin/case_insensitive_routes_test.go b/server/jellyfin/case_insensitive_routes_test.go deleted file mode 100644 index 4c9140f83..000000000 --- a/server/jellyfin/case_insensitive_routes_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package jellyfin - -import ( - "net/http" - "net/http/httptest" - - "github.com/go-chi/chi/v5" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("caseInsensitivePaths", func() { - var handler http.Handler - var gotID string - - var gotContainer string - - BeforeEach(func() { - gotID = "" - gotContainer = "" - r := chi.NewRouter() - r.Get("/Foo/{id}/Bar", func(w http.ResponseWriter, req *http.Request) { - gotID = chi.URLParam(req, "id") - w.WriteHeader(http.StatusOK) - }) - // A mixed literal+param segment (like Jellyfin's /Audio/{id}/stream.{container}): the "stream" - // literal prefix is registered separately via the bare /Foo/{id}/stream route below. - r.Get("/Foo/{id}/stream", func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(http.StatusOK) - }) - r.Get("/Foo/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { - gotContainer = chi.URLParam(req, "container") - w.WriteHeader(http.StatusOK) - }) - handler = caseInsensitivePaths(r) - }) - - It("normalizes the literal prefix of a mixed literal.param segment", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/ID/STREAM.mp3", nil) - handler.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotContainer).To(Equal("mp3")) - }) - - It("matches a lower-cased request path against mixed-case registered literals", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/ID/bar", nil) - handler.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("preserves the id segment's original casing", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/ID/bar", nil) - handler.ServeHTTP(w, r) - Expect(gotID).To(Equal("ID")) - }) - - It("leaves a real mixed-case id untouched while still matching literals", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/cjsFeXbNOaaSjASu3DM93g/bar", nil) - handler.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotID).To(Equal("cjsFeXbNOaaSjASu3DM93g")) - }) -}) - -var _ = Describe("normalizeCase", func() { - It("rewrites known literal segments to their canonical case", func() { - canon := map[string]string{ - "audio": "Audio", - "stream": "stream", - } - got := normalizeCase("/audio/XyZ123NotARoute/STREAM", canon) - Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream")) - }) - - It("normalizes the literal prefix of a mixed literal.extension segment", func() { - canon := map[string]string{"audio": "Audio", "stream": "stream"} - got := normalizeCase("/audio/XyZ123NotARoute/STREAM.MP3", canon) - Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream.mp3")) - }) - - It("leaves a dotted segment untouched when its prefix isn't a known literal", func() { - canon := map[string]string{"audio": "Audio"} - got := normalizeCase("/audio/some.file.id", canon) - Expect(got).To(Equal("/Audio/some.file.id")) - }) -}) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go new file mode 100644 index 000000000..383dbab83 --- /dev/null +++ b/server/jellyfin/e2e/audiomuse_test.go @@ -0,0 +1,113 @@ +package e2e + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AudioMuse endpoints", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /AudioMuseAI/info", func() { + It("returns version and available endpoints", func() { + var body struct { + Version string `json:"Version"` + AvailableEndpoints []string `json:"AvailableEndpoints"` + } + parseInto(get("/AudioMuseAI/info"), &body) + Expect(body.Version).To(Equal(consts.Version)) + Expect(body.AvailableEndpoints).To(ConsistOf( + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", + )) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/info", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/health", func() { + It("returns 200 with an empty body when a provider is loaded", func() { + w := get("/AudioMuseAI/health") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/health", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/similar_tracks", func() { + It("maps provider results to seeded tracks, encoding item ids", func() { + sonicProviderFake.similar = []sonic.SimilarResult{ + {Song: songAgent("Something"), Similarity: 0.3}, + {Song: songAgent("So What"), Similarity: 0.5}, + } + var body []struct { + Author string `json:"author"` + Distance float64 `json:"distance"` + ItemID string `json:"item_id"` + Title string `json:"title"` + } + parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body) + Expect(body).To(HaveLen(2)) + Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What")) + Expect(dto.DecodeID(body[0].ItemID)).To(Equal(songID(body[0].Title))) + }) + + It("collapses to one track per artist by default", func() { + sonicProviderFake.similar = []sonic.SimilarResult{ + {Song: songAgent("Something"), Similarity: 0.3}, + {Song: songAgent("Come Together"), Similarity: 0.5}, + } + var body []map[string]any + parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Help!"))), &body) + Expect(body).To(HaveLen(1)) // both similar tracks are by The Beatles + }) + + It("returns an empty array (not null) when there are no results", func() { + sonicProviderFake.similar = nil + w := get("/AudioMuseAI/similar_tracks?item_id=" + enc(songID("Come Together"))) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/similar_tracks?item_id=x", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/find_path", func() { + It("returns 400 with the exact message when a required id is missing", func() { + w := get("/AudioMuseAI/find_path?start_song_id=" + enc(songID("Something"))) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) + }) + + It("returns the path and summed total_distance", func() { + sonicProviderFake.path = []sonic.SimilarResult{ + {Song: songAgent("Come Together"), Similarity: 1.5}, + {Song: songAgent("So What"), Similarity: 2.0}, + } + var body struct { + Path []struct { + ItemID string `json:"item_id"` + Title string `json:"title"` + } `json:"path"` + TotalDistance float64 `json:"total_distance"` + } + parseInto(get("/AudioMuseAI/find_path?start_song_id="+enc(songID("Something"))+"&end_song_id="+enc(songID("So What"))+"&max_steps=10"), &body) + Expect(body.Path).To(HaveLen(2)) + Expect(body.TotalDistance).To(Equal(3.5)) + }) + }) +}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index f66833af2..7128972ba 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -27,7 +27,7 @@ var _ = Describe("Authentication", func() { Expect(res.AccessToken).ToNot(BeEmpty()) Expect(res.User).ToNot(BeNil()) Expect(res.User.Name).To(Equal("admin")) - Expect(res.User.Id).To(Equal("admin-1")) + Expect(res.User.Id).To(Equal(enc("admin-1"))) Expect(res.User.Policy.IsAdministrator).To(BeTrue()) Expect(res.ServerId).ToNot(BeEmpty()) @@ -84,7 +84,7 @@ var _ = Describe("Authentication", func() { users := publicUsers() Expect(users).To(HaveLen(1)) Expect(users[0].Name).To(Equal("regular")) - Expect(users[0].Id).To(Equal("regular-1")) + Expect(users[0].Id).To(Equal(enc("regular-1"))) Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login }) }) @@ -94,7 +94,7 @@ var _ = Describe("Authentication", func() { var u dto.UserDto parseInto(getAs(regularUser, "/Users/Me"), &u) Expect(u.Name).To(Equal("regular")) - Expect(u.Id).To(Equal("regular-1")) + Expect(u.Id).To(Equal(enc("regular-1"))) }) It("returns the caller from GET /Users/{userId}", func() { diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 02c5183f8..ea47dcdaa 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -38,11 +38,14 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" @@ -77,14 +80,15 @@ var ( // Shared test state var ( - ctx context.Context - ds *tests.MockDataStore - router http.Handler - streamerSpy *harness.SpyStreamer - artworkSpy *spyArtwork - providerFake *fakeExternalProvider - goldenDB *harness.DB - dataFolder string + ctx context.Context + ds *tests.MockDataStore + router http.Handler + streamerSpy *harness.SpyStreamer + artworkSpy *spyArtwork + providerFake *fakeExternalProvider + sonicProviderFake *fakeSonicProvider + goldenDB *harness.DB + dataFolder string adminUser = model.User{ ID: "admin-1", @@ -308,6 +312,8 @@ func setupTestDB() { streamerSpy = &harness.SpyStreamer{} artworkSpy = &spyArtwork{} providerFake = &fakeExternalProvider{} + sonicProviderFake = &fakeSonicProvider{} + sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds)) decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) router = jellyfin.New( ds, @@ -318,6 +324,7 @@ func setupTestDB() { scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), playlists.NewPlaylists(ds, core.NewImageUploadService()), providerFake, + sonicSvc, ) } @@ -338,6 +345,50 @@ func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model return f.similarSongs, nil } +// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are +// active in e2e; the provider it hands back returns test-configured results. +type fakeSonicLoader struct{ provider sonic.Provider } + +func (f *fakeSonicLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return []string{"fake"} + } + return nil +} + +func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) { + return f.provider, true +} + +// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the +// agents.Song results; the real matcher resolves them back to seeded library tracks. +type fakeSonicProvider struct { + similar []sonic.SimilarResult + path []sonic.SimilarResult +} + +func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) { + return f.similar, nil +} + +func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) { + return f.path, nil +} + +// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an +// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile. +func songAgent(title string) agents.Song { + mfs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range mfs { + if mf.Title == title { + return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}} + } + } + Fail("song not found: " + title) + return agents.Song{} +} + // --- Spy/noop dependencies (shared ones live in tests/harness) --- // spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 73b76e3d8..d2ff49db9 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -103,7 +103,7 @@ var _ = Describe("Playlists", func() { var perms []dto.PlaylistUserPermissions parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) Expect(perms).To(HaveLen(1)) - Expect(perms[0].UserId).To(Equal("admin-1")) + Expect(perms[0].UserId).To(Equal(enc("admin-1"))) Expect(perms[0].CanEdit).To(BeTrue()) }) }) diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index d9e41d252..840143ac8 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -29,6 +30,22 @@ func throttleStreams(limit int) func(http.Handler) http.Handler { return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout) } +// caseInsensitivePaths lowercases the request path so chi (case-sensitive) matches the +// lowercase-registered routes; Jellyfin clients route case-insensitively. It lowercases id/param +// segments too, which is safe because every id the API emits — user ids included — is lowercase hex +// (dto.EncodeID). +func caseInsensitivePaths(r chi.Router) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Mounted under a parent, chi matches RouteContext.RoutePath, not r.URL.Path. + if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { + rctx.RoutePath = strings.ToLower(rctx.RoutePath) + } else { + req.URL.Path = strings.ToLower(req.URL.Path) + } + r.ServeHTTP(w, req) + }) +} + // normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params // case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, // Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index c766bff70..f3aa65d6f 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -321,3 +322,61 @@ var _ = Describe("throttleStreams", func() { Expect(serve(0, 4)).To(BeNumerically(">", int32(1))) }) }) + +var _ = Describe("caseInsensitivePaths", func() { + var handler http.Handler + var gotID, gotContainer string + + BeforeEach(func() { + gotID, gotContainer = "", "" + r := chi.NewRouter() + // Routes are registered lowercase, mirroring the real router. + r.Get("/foo/{id}/bar", func(w http.ResponseWriter, req *http.Request) { + gotID = chi.URLParam(req, "id") + w.WriteHeader(http.StatusOK) + }) + r.Get("/audio/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { + gotContainer = chi.URLParam(req, "container") + w.WriteHeader(http.StatusOK) + }) + // A second route reusing the "bar" segment name at a different position. + r.Get("/bar/{id}", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler = caseInsensitivePaths(r) + }) + + serve := func(path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + return w + } + + It("routes a mixed-case request to its lowercase-registered route", func() { + Expect(serve("/FOO/abc/BAR").Code).To(Equal(http.StatusOK)) + }) + + It("routes both routes that share a segment name, regardless of casing", func() { + Expect(serve("/Foo/abc/Bar").Code).To(Equal(http.StatusOK)) + Expect(serve("/BAR/abc").Code).To(Equal(http.StatusOK)) + }) + + It("lowercases the mixed literal.extension segment so the route and container match", func() { + w := serve("/Audio/abc/STREAM.MP3") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotContainer).To(Equal("mp3")) + }) + + It("lowercases id/param segments (safe: Jellyfin ids are lowercase hex)", func() { + serve("/foo/DEADBEEF/bar") + Expect(gotID).To(Equal("deadbeef")) + }) + + It("normalizes the RoutePath branch when mounted under a parent", func() { + parent := chi.NewRouter() + parent.Mount("/jellyfin", handler) + w := httptest.NewRecorder() + parent.ServeHTTP(w, httptest.NewRequest("GET", "/jellyfin/FOO/abc/BAR", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + }) +}) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index a157aae2e..084ff2975 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -279,7 +279,7 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { // enforced by AddTracks/RemoveTracks. func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { u, _ := request.UserFrom(r.Context()) - api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: u.ID, CanEdit: true}}) + api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: true}}) } func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 27264b280..7770cb6db 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -442,7 +442,7 @@ var _ = Describe("Playlists", func() { Expect(w.Code).To(Equal(http.StatusOK)) var res []dto.PlaylistUserPermissions Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: "u1", CanEdit: true}})) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID("u1"), CanEdit: true}})) }) }) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index e3c9903a8..e007dedf4 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) + api = New(&tests.MockDataStore{}, 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 91542c111..d9d74bcb9 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) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index 5f231e0b5..bbc60c892 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -52,7 +52,7 @@ func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { } users = append(users, dto.UserDto{ Name: usr.UserName, - Id: usr.ID, + Id: dto.EncodeID(usr.ID), ServerId: serverID, HasPassword: true, }) diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index 2b7177044..6a1597b70 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -106,7 +106,7 @@ var _ = Describe("Users", func() { users := publicUsers() Expect(users).To(HaveLen(2)) Expect(users[0].Name).To(Equal("bob")) - Expect(users[0].Id).To(Equal("u2")) + Expect(users[0].Id).To(Equal(dto.EncodeID("u2"))) Expect(users[1].Name).To(Equal("alice")) // The public list must not expose Policy/Configuration to unauthenticated callers. Expect(users[0].Policy).To(BeNil()) From 1d5efdd5a0408cae8a31f80c11d78afd5d18a388 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 15 Jul 2026 21:21:26 -0400 Subject: [PATCH 28/56] fix(jellyfin): don't truncate InstantMix to the Similar ceiling Finamp's Radio Mix requests /Items/{id}/InstantMix?limit=250, but getInstantMix clamped the limit to maxSimilarLimit (100), so the queue was cut to 100 tracks. A mix is a playback queue, not a "related items" list, so it gets its own ceiling instead of sharing the Similar one. The Similar handlers keep 100. Verified against a live library: the sonic provider supplies all 250 tracks for a seed that previously returned 100. --- server/jellyfin/similar.go | 5 ++++- server/jellyfin/similar_test.go | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 33cdc4c88..085080850 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -23,6 +23,9 @@ var similarWait = 10 * time.Second const ( defaultSimilarLimit = 20 maxSimilarLimit = 100 + // A mix is a playback queue, not a "related items" list: Finamp's Radio Mix asks for 250, so the + // Similar ceiling would truncate it. Real Jellyfin builds mixes from a 200-track genre query. + maxInstantMixLimit = 500 ) // similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine @@ -91,7 +94,7 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index cbb68e5cf..302566195 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -3,7 +3,9 @@ package jellyfin import ( "context" "encoding/json" + "fmt" "net/http/httptest" + "strconv" "sync/atomic" "time" @@ -103,6 +105,16 @@ func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.Med return nil, nil } +// fakeSimilarProvider returns up to count of its canned songs, like a real agent honoring the limit. +type fakeSimilarProvider struct { + external.Provider + songs model.MediaFiles +} + +func (p *fakeSimilarProvider) SimilarSongs(_ context.Context, _ string, count int) (model.MediaFiles, error) { + return p.songs[:min(count, len(p.songs))], nil +} + var _ = Describe("getInstantMix", func() { It("returns the seed track even when the provider fetch exceeds the wait", func() { old := similarWait @@ -128,4 +140,28 @@ var _ = Describe("getInstantMix", func() { Expect(res.Items).To(HaveLen(1)) Expect(res.Items[0].Name).To(Equal("Seed Song")) }) + + // Finamp's Radio Mix asks for limit=250. Clamping that to the Similar ceiling (100) truncated the + // queue, so InstantMix gets its own, higher ceiling. + It("honors a mix-sized limit above the Similar ceiling", func() { + const want = 250 + songs := model.MediaFiles{{ID: "s1", Title: "Seed Song", LibraryID: 1}} + for i := range want + 50 { // more than requested, so only the limit bounds the result + songs = append(songs, model.MediaFile{ID: fmt.Sprintf("t%d", i), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) + } + ds := &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix?limit="+strconv.Itoa(want), nil). + WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getInstantMix(w, r) + + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(want), "a Radio Mix-sized request must not be truncated to the Similar ceiling") + Expect(res.Items[0].Name).To(Equal("Seed Song"), "the seed must still lead the mix") + }) }) From caa0043030a02079e463b0827e3510c87a5771f5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 15 Jul 2026 23:25:55 -0400 Subject: [PATCH 29/56] fix(jellyfin): honor AlbumIds filter on /Items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishin fetches an album's tracks with AlbumIds=&IncludeItemTypes=Audio&Recursive=true, but the /Items handler never read the AlbumIds parameter, so the request degenerated into the entire library sorted by album: every track (with MediaSources, when requested) was counted and streamed for what should be one album's worth of songs — very slow on large libraries, and wrong results. Parse AlbumIds like GenreIds (comma-separated and repeated spellings, hex-decoded) and filter songs through a new filter.ByAlbumID helper, keeping the album_id column knowledge in the filter package. --- server/filter/filters.go | 5 +++++ server/jellyfin/e2e/browsing_test.go | 21 +++++++++++++++++++++ server/jellyfin/items.go | 6 ++++++ 3 files changed, 32 insertions(+) diff --git a/server/filter/filters.go b/server/filter/filters.go index e149dbced..5fa53daba 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -189,6 +189,11 @@ func ByGenreID(genreIds []string) Sqlizer { return genreTagFilter(Eq{"value": genreIds}) } +// ByAlbumID matches media files belonging to any of the given albums. +func ByAlbumID(albumIds []string) Sqlizer { + return Eq{"album_id": albumIds} +} + // ArtistsByGenreID matches artists credited as album artist on an album with any of the given // genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. func ArtistsByGenreID(genreIds []string) Sqlizer { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 0b2769856..18565c635 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -179,6 +179,27 @@ var _ = Describe("Browsing", func() { }) }) + // Feishin fetches an album's tracks with AlbumIds=&IncludeItemTypes=Audio&Recursive=true. + Describe("album filtering (AlbumIds)", func() { + It("filters songs by AlbumIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + Expect(q.TotalRecordCount).To(Equal(2)) + }) + + It("matches any of multiple comma-separated AlbumIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")) + "," + enc(albumID("IV")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) + Expect(q.TotalRecordCount).To(Equal(3)) + }) + + It("returns nothing for an unknown album id", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc("no-such-album"))) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. Describe("genre filtering (GenreIds)", func() { lib1 := enc("1") diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 43c8de12c..b9372c397 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -221,6 +221,7 @@ type itemsQuery struct { artistId string contributingOnly bool genreIds []string + albumIds []string } // parseItemsQuery also resolves the entity types (inferring them from the parent when @@ -243,6 +244,8 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu parentId: dto.DecodeID(p.StringOr("parentid", "")), // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. genreIds: decodedQueryIDs(r, "genreids"), + // Feishin fetches an album's tracks with AlbumIds instead of ParentId. + albumIds: decodedQueryIDs(r, "albumids"), } // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. @@ -528,6 +531,9 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite default: filters = append(filters, notMissing) } + if len(q.albumIds) > 0 { + filters = append(filters, filter.ByAlbumID(q.albumIds)) + } if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } From c582ed31fa45beecf7064bab30c1c9fc7480a0d1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 08:49:00 -0400 Subject: [PATCH 30/56] feat(jellyfin): add authenticated System/Info endpoint Wire up GET /System/Info returning the previously unused dto.SystemInfo, available to any authenticated user, matching real Jellyfin's authorization (FirstTimeSetupOrIgnoreParentalControl, not admin-only). Feishin calls this endpoint on connect and reads Version to feature-gate; it previously got the unhandled-route 404. The advertised version stays 10.8.13: Feishin unlocks structured lyrics and public-playlist share permissions at >=10.9.0, and this API serves neither (no lyrics endpoint; playlist user permissions are stubs), so a higher version would falsely advertise capabilities. --- server/jellyfin/README.md | 2 +- server/jellyfin/api.go | 1 + server/jellyfin/e2e/system_test.go | 21 +++++++++++++++++++++ server/jellyfin/system.go | 7 +++++++ server/jellyfin/system_test.go | 21 +++++++++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 2e4c19950..8e0e08ce3 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -115,7 +115,7 @@ favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `Sort | Area | Endpoints | |---|---| -| Handshake / system | `GET System/Info/Public`, `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | +| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | | 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 0740901aa..3aa870704 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -92,6 +92,7 @@ func (api *Router) routes() http.Handler { // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a // player) even before the first playback report. r.Use(api.withPlayer) + r.Get("/system/info", api.getSystemInfo) r.Get("/userviews", api.getUserViews) r.Get("/users/{userId}/views", api.getUserViews) r.Get("/users/me", api.getCurrentUser) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go index d4d2f2777..c6f9145d5 100644 --- a/server/jellyfin/e2e/system_test.go +++ b/server/jellyfin/e2e/system_test.go @@ -30,6 +30,27 @@ var _ = Describe("System", func() { }) }) + Describe("GET /System/Info", func() { + It("returns system info to any authenticated user, matching the public Version", func() { + w := getAs(regularUser, "/System/Info") + var info map[string]any + parseInto(w, &info) + Expect(info["Version"]).ToNot(BeEmpty()) + Expect(info["SupportsLibraryMonitor"]).To(BeTrue()) + + pub := rawReq("GET", "/System/Info/Public", "") + var pubInfo map[string]any + parseInto(pub, &pubInfo) + Expect(info["Version"]).To(Equal(pubInfo["Version"])) + Expect(info["Id"]).To(Equal(pubInfo["Id"])) + }) + + It("rejects unauthenticated requests", func() { + w := rawReq("GET", "/System/Info", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + Describe("GET/POST /System/Ping", func() { It("answers GET with a plain-text server name", func() { w := rawReq("GET", "/System/Ping", "") diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index baa31fdeb..7e539be98 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -83,6 +83,13 @@ func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) { api.ok(w, r, api.publicInfo(r)) } +func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, dto.SystemInfo{ + PublicSystemInfo: api.publicInfo(r), + SupportsLibraryMonitor: true, + }) +} + // ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's // server does this and clients parse the raw body. func (api *Router) ping(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 5ea525100..7846e4a20 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -38,6 +38,27 @@ var _ = Describe("System", func() { Expect(info.ServerName).To(HavePrefix("Navidrome")) }) + It("returns authenticated system info with the public fields plus library monitor support", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info", nil) + api.getSystemInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + var info dto.SystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.Id).ToNot(BeEmpty()) + Expect(info.Version).To(Equal(jellyfinVersion)) + Expect(info.ProductName).To(Equal("Jellyfin Server")) + Expect(info.ServerName).To(HavePrefix("Navidrome")) + Expect(info.SupportsLibraryMonitor).To(BeTrue()) + Expect(info.HasPendingRestart).To(BeFalse()) + Expect(info.IsShuttingDown).To(BeFalse()) + }) + It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() { DeferCleanup(configtest.SetupConfig()) From 29f481cd7b24336615ffe80410ce90e47a9348b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 16 Jul 2026 14:39:11 -0400 Subject: [PATCH 31/56] feat(jellyfin): lyrics endpoint and Lyric stream advertising (#5791) * feat(jellyfin): add LyricDto and lyrics mapper * feat(jellyfin): advertise Lyric media stream for embedded lyrics * feat(jellyfin): implement GET /Audio/{itemId}/Lyrics * feat(jellyfin): advertise pipeline-resolved lyrics in PlaybackInfo * feat(jellyfin): advertise server version 10.9.11 for client lyrics gates * test(jellyfin): e2e coverage for lyrics endpoint and advertising Seeds "Stairway To Heaven" with an embedded LRC lyric tag (lyrics:eng) and covers PlaybackInfo's Lyric MediaStream, GET /Audio/{id}/Lyrics, and the HasLyrics badge end to end. Fixes a bug the new seed exposed: HasLyrics and the Lyric MediaStream gate compared mf.Lyrics against "", but the persistence layer never stores an empty string post-scan (it normalizes to the JSON sentinel "[]"), so every track was reporting HasLyrics=true. Both call sites now parse the column via StructuredLyrics()/LyricList.Main() instead. * fix(jellyfin): cheap sentinel check for embedded lyrics advertising * chore(jellyfin): trim over-budget comments in lyrics code * test(jellyfin): cover lyrics pipeline error and nil-start cue skip * docs(jellyfin): document lyrics support and follow-ups in README * refactor(jellyfin): promote embedded-lyrics sentinel check to MediaFile The "[]" no-lyrics sentinel is persistence-layer knowledge; expose it as model.MediaFile.HasEmbeddedLyrics() instead of a dto-local helper. Also dedupe the test lyrics-cache construction and pre-size the media stream slice. * refactor(jellyfin): consolidate tick conversions around one constant ticksPerMillis is now the single source of the 100ns-tick unit; the scrobble handlers' three inline /10_000 divisions become dto.MillisFromTicks. * fix(jellyfin): align lyric advertising with the serving predicate PlaybackInfo advertised on any non-empty LyricList while the endpoint 404s when the main lyric has no lines; both now share servableLyric. Handler tests also send hex-encoded ids to match real traffic. * chore(jellyfin): drop unneeded lyrics package alias in e2e suite --- cmd/wire_gen.go | 3 +- model/mediafile.go | 6 ++ model/mediafile_test.go | 9 ++ server/jellyfin/README.md | 20 +++- server/jellyfin/api.go | 14 ++- server/jellyfin/api_test.go | 10 +- server/jellyfin/dto/dto.go | 29 ++++++ server/jellyfin/dto/mappers.go | 87 ++++++++++++++--- server/jellyfin/dto/mappers_test.go | 114 +++++++++++++++++++++- server/jellyfin/e2e/e2e_suite_test.go | 10 +- server/jellyfin/e2e/lyrics_test.go | 71 ++++++++++++++ server/jellyfin/lyrics.go | 47 +++++++++ server/jellyfin/lyrics_test.go | 132 ++++++++++++++++++++++++++ server/jellyfin/routing_test.go | 2 +- server/jellyfin/sessions.go | 6 +- server/jellyfin/socket_test.go | 2 +- server/jellyfin/stream.go | 10 ++ server/jellyfin/stream_test.go | 88 ++++++++++++++++- server/jellyfin/system.go | 5 +- 19 files changed, 627 insertions(+), 38 deletions(-) create mode 100644 server/jellyfin/e2e/lyrics_test.go create mode 100644 server/jellyfin/lyrics.go create mode 100644 server/jellyfin/lyrics_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index bd211cbfc..463ac7a44 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -137,7 +137,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic) + lyricsLyrics := lyrics.NewLyrics(dataStore, manager) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics) return router } diff --git a/model/mediafile.go b/model/mediafile.go index 0fd172cee..90db80f71 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -147,6 +147,12 @@ func (mf MediaFile) StructuredLyrics() (LyricList, error) { return lyrics, nil } +// HasEmbeddedLyrics reports whether the lyrics column holds any lyrics. It is never "" post-scan; +// no-lyrics is normalized to the "[]" sentinel, so string emptiness alone is meaningless. +func (mf MediaFile) HasEmbeddedLyrics() bool { + return mf.Lyrics != "" && mf.Lyrics != "[]" +} + // String is mainly used for debugging func (mf MediaFile) String() string { return mf.Path diff --git a/model/mediafile_test.go b/model/mediafile_test.go index f070f4649..c94653530 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -604,6 +604,15 @@ var _ = Describe("MediaFile", func() { }) +var _ = DescribeTable("MediaFile.HasEmbeddedLyrics", + func(lyrics string, expected bool) { + Expect(MediaFile{Lyrics: lyrics}.HasEmbeddedLyrics()).To(Equal(expected)) + }, + Entry("empty string (never-scanned zero value)", "", false), + Entry(`the post-scan "[]" no-lyrics sentinel`, "[]", false), + Entry("a stored lyric list", `[{"lang":"eng","line":[{"value":"la"}]}]`, true), +) + var _ = Describe("MediaFile.Works", func() { It("returns nil when there are no work tags", func() { mf := MediaFile{} diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 8e0e08ce3..7afc2b7a9 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -338,11 +338,21 @@ make test PKG=./server/jellyfin/... working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up would broadcast real session/playstate and library-change events over it (via `server/events`), mirroring Jellyfin's session messages. -- **No lyrics endpoint (follow-up).** `GET Audio/{id}/Lyrics` is unimplemented (404), but Finamp - and Jellify both request it. Navidrome already has line-synced lyrics, so a follow-up would serve - Jellyfin's `LyricsResponse` (`Lyrics: [{Text, Start}]`, `Start` in 100ns ticks) — enough for both - clients' synced view. (Finamp also renders word-level `Cues`, but Navidrome has only line-level - timing, so word-sync is out of scope.) +- **Lyrics.** `GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in + 100ns ticks, word-level `Cues` when present), resolved through the full `core/lyrics` pipeline + (embedded, `.lrc` sidecars, plugins per `LyricsPriority`) behind a 5-minute TTL cache that also + caches misses — Jellify fetches for every played track, Feishin per song change, so lyric-less + tracks are the hot path. No lyrics → 404 (never an empty 200), which all three clients degrade + gracefully. Finamp gates its lyrics view on a `Lyric` `MediaStream` (not `HasLyrics`, which is + 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. + Follow-ups: the lyrics cache loader is not singleflighted, so concurrent misses on the same + track can double-invoke the plugin pipeline (fix belongs in `utils/cache.SimpleCache` via + ttlcache's `SuppressedLoader`, affecting all callers — separate change); tracks whose only + lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in lists (request-time sources + can't be known at list time without per-row I/O). - **No sonic similarity (follow-up).** `Items/{id}/InstantMix` and the `/Similar` endpoints are backed only by external metadata agents (Last.fm), not sonic analysis: an instant mix is the seed track followed by the provider's similar songs (with agents disabled it degrades to a seed-only diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 3aa870704..5e7cad0b8 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "sync" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/httprate" @@ -13,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" @@ -21,6 +23,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/cache" ) type Router struct { @@ -34,6 +37,8 @@ type Router struct { playlists playlists.Playlists provider external.Provider sonic sonic.Engine + lyrics lyrics.Lyrics + lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group serverIDMu sync.Mutex serverIDVal string @@ -42,11 +47,15 @@ type Router struct { 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) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, + sonic: sonicSvc, lyrics: lyricsSvc, + lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ + SizeLimit: 1000, + DefaultTTL: 5 * time.Minute, + }), } r.Handler = r.routes() return r @@ -155,6 +164,7 @@ func (api *Router) routes() http.Handler { r.Get("/audio/{itemId}/main.m3u8", api.streamHls) r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) + r.Get("/audio/{itemId}/lyrics", api.getLyrics) // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. r.Get("/items/{itemId}/file", api.streamFile) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 1b390e75a..62af74794 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -18,7 +18,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) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -26,7 +26,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) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -36,7 +36,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) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -53,7 +53,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -70,7 +70,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) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 7720640c5..9847a239c 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -256,3 +256,32 @@ type PlaybackInfoResponse struct { MediaSources []MediaSourceInfo `json:"MediaSources"` PlaySessionId string `json:"PlaySessionId"` } + +// LyricDto mirrors Jellyfin's GET /Audio/{itemId}/Lyrics response. Feishin and Jellify read only +// Lyrics[].Text and Start (ticks); Metadata is for completeness/Finamp. +type LyricDto struct { + Metadata LyricMetadata `json:"Metadata"` + Lyrics []LyricLine `json:"Lyrics"` +} + +type LyricMetadata struct { + Artist string `json:"Artist,omitempty"` + Album string `json:"Album,omitempty"` + Title string `json:"Title,omitempty"` + Length int64 `json:"Length,omitempty"` + Offset *int64 `json:"Offset,omitempty"` + IsSynced bool `json:"IsSynced"` +} + +type LyricLine struct { + Text string `json:"Text"` + Start *int64 `json:"Start,omitempty"` + Cues []LyricLineCue `json:"Cues,omitempty"` +} + +type LyricLineCue struct { + Position int `json:"Position"` + EndPosition int `json:"EndPosition"` + Start int64 `json:"Start"` + End *int64 `json:"End,omitempty"` +} diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bd817e432..c65b8933d 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -8,7 +8,16 @@ import ( "github.com/navidrome/navidrome/model" ) -func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) } +// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. +const ticksPerMillis = 10_000 + +func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1000 * ticksPerMillis) } + +// TicksFromMillis converts milliseconds (Navidrome lyric timestamps) to ticks. +func TicksFromMillis(ms int64) int64 { return ms * ticksPerMillis } + +// MillisFromTicks converts ticks (client-reported playback positions) to milliseconds. +func MillisFromTicks(ticks int64) int64 { return ticks / ticksPerMillis } // premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601 // PremiereDate clients parse, falling back to year; nil when neither exists. @@ -59,6 +68,20 @@ func channelLayout(n int) string { // Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo // responses (Finamp's download dialog reads MediaSources[0].Size from the browse response). func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { + streams := make([]MediaStream, 1, 2) + streams[0] = MediaStream{ + Type: "Audio", + Index: 0, + Codec: mf.Codec, + BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. + Channels: mf.Channels, + SampleRate: mf.SampleRate, + ChannelLayout: channelLayout(mf.Channels), + } + // Finamp gates its lyrics view on a Lyric stream in PlaybackInfo, not on HasLyrics. + if mf.HasEmbeddedLyrics() { + streams = append(streams, MediaStream{Type: "Lyric", Index: 1, IsExternal: true}) + } return MediaSourceInfo{ Id: EncodeID(mf.ID), Protocol: "Http", @@ -73,17 +96,9 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { SupportsTranscoding: true, IsRemote: false, SupportsProbing: true, - MediaStreams: []MediaStream{{ - Type: "Audio", - Index: 0, - Codec: mf.Codec, - BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. - Channels: mf.Channels, - SampleRate: mf.SampleRate, - ChannelLayout: channelLayout(mf.Channels), - }}, - MediaAttachments: []any{}, - Formats: []string{}, + MediaStreams: streams, + MediaAttachments: []any{}, + Formats: []string{}, } } @@ -119,7 +134,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { MediaType: "Audio", IsFolder: false, LocationType: "FileSystem", - HasLyrics: mf.Lyrics != "", + HasLyrics: mf.HasEmbeddedLyrics(), ParentId: EncodeID(mf.AlbumID), Album: mf.Album, AlbumId: EncodeID(mf.AlbumID), @@ -254,3 +269,49 @@ func PlaylistToBaseItem(p model.Playlist) BaseItemDto { UserData: UserData(p.Annotations, p.ID), } } + +// LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain +// from per-line Start presence, so synced drops start-less lines and unsynced never emits Start. +func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto { + d := LyricDto{ + Metadata: LyricMetadata{ + Artist: cmp.Or(lyrics.DisplayArtist, mf.Artist), + Album: mf.Album, + Title: cmp.Or(lyrics.DisplayTitle, mf.Title), + Length: TicksFromSeconds(mf.Duration), + IsSynced: lyrics.Synced, + }, + Lyrics: make([]LyricLine, 0, len(lyrics.Line)), + } + if lyrics.Offset != nil { + offset := TicksFromMillis(*lyrics.Offset) + d.Metadata.Offset = &offset + } + for _, line := range lyrics.Line { + out := LyricLine{Text: line.Value} + if lyrics.Synced { + if line.Start == nil { + continue + } + start := TicksFromMillis(*line.Start) + out.Start = &start + for _, cue := range line.Cue { + if cue.Start == nil { + continue + } + c := LyricLineCue{ + Position: cue.ByteStart, + EndPosition: cue.ByteEnd, + Start: TicksFromMillis(*cue.Start), + } + if cue.End != nil { + end := TicksFromMillis(*cue.End) + c.End = &end + } + out.Cues = append(out.Cues, c) + } + } + d.Lyrics = append(d.Lyrics, out) + } + return d +} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index bd351bee9..e72efd617 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -39,7 +39,7 @@ var _ = Describe("mappers", func() { Describe("Fields gating (matches real Jellyfin)", func() { mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, - SortTitle: "sort song", Lyrics: `[{"line":"la"}]`} + SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`} It("omits MediaSources and SortName when Fields does not ask for them", func() { item := SongToBaseItem(mf, nil) @@ -60,6 +60,8 @@ var _ = Describe("mappers", func() { It("sets HasLyrics from the media file's lyrics", func() { Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) + // "[]" is the no-lyrics sentinel, not a truthy value. + Expect(SongToBaseItem(model.MediaFile{ID: "s3", Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) }) }) @@ -149,6 +151,29 @@ var _ = Describe("mappers", func() { Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`)) }) + Describe("Lyric media stream advertising", func() { + It("adds a Lyric media stream when the file has embedded lyrics", func() { + mf := model.MediaFile{ID: "s1", Lyrics: `[{"line":[{"value":"la"}]}]`} + src := MediaSourceFromMediaFile(mf) + Expect(src.MediaStreams).To(HaveLen(2)) + Expect(src.MediaStreams[0].Type).To(Equal("Audio")) + Expect(src.MediaStreams[1].Type).To(Equal("Lyric")) + Expect(src.MediaStreams[1].Index).To(Equal(1)) + Expect(src.MediaStreams[1].IsExternal).To(BeTrue()) + }) + + It("emits only the Audio stream without lyrics", func() { + src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1"}) + Expect(src.MediaStreams).To(HaveLen(1)) + Expect(src.MediaStreams[0].Type).To(Equal("Audio")) + }) + + It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() { + src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1", Lyrics: "[]"}) + Expect(src.MediaStreams).To(HaveLen(1)) + }) + }) + It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { mf := model.MediaFile{ ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1", @@ -278,3 +303,90 @@ var _ = Describe("mappers", func() { Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags)) }) }) + +var _ = Describe("LyricDtoFromLyrics", func() { + ms := func(v int64) *int64 { return &v } + + mf := model.MediaFile{ID: "s1", Title: "Song", Artist: "Artist", Album: "Album", Duration: 100} + + It("maps synced lyrics with tick conversion", func() { + l := model.Lyrics{ + DisplayArtist: "Display Artist", + DisplayTitle: "Display Title", + Synced: true, + Offset: ms(-150), + Line: []model.Line{ + {Start: ms(1000), Value: "line one"}, + {Start: ms(2500), Value: "line two"}, + }, + } + d := LyricDtoFromLyrics(mf, l) + Expect(d.Metadata.Artist).To(Equal("Display Artist")) + Expect(d.Metadata.Title).To(Equal("Display Title")) + Expect(d.Metadata.Album).To(Equal("Album")) + Expect(d.Metadata.IsSynced).To(BeTrue()) + Expect(*d.Metadata.Offset).To(Equal(int64(-1_500_000))) + Expect(d.Metadata.Length).To(Equal(TicksFromSeconds(100))) + Expect(d.Lyrics).To(HaveLen(2)) + Expect(d.Lyrics[0].Text).To(Equal("line one")) + Expect(*d.Lyrics[0].Start).To(Equal(int64(10_000_000))) + Expect(*d.Lyrics[1].Start).To(Equal(int64(25_000_000))) + }) + + It("falls back to the media file's artist and title", func() { + d := LyricDtoFromLyrics(mf, model.Lyrics{Line: []model.Line{{Value: "x"}}}) + Expect(d.Metadata.Artist).To(Equal("Artist")) + Expect(d.Metadata.Title).To(Equal("Song")) + }) + + It("drops start-less lines from synced lyrics", func() { + l := model.Lyrics{Synced: true, Line: []model.Line{ + {Start: ms(0), Value: "kept"}, + {Value: "dropped"}, + }} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics).To(HaveLen(1)) + Expect(d.Lyrics[0].Text).To(Equal("kept")) + }) + + It("emits no Start on unsynced lyrics even when lines have one", func() { + l := model.Lyrics{Synced: false, Line: []model.Line{{Start: ms(1000), Value: "plain"}}} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics).To(HaveLen(1)) + Expect(d.Lyrics[0].Start).To(BeNil()) + Expect(d.Metadata.IsSynced).To(BeFalse()) + }) + + It("maps word cues", func() { + end := int64(1500) + l := model.Lyrics{Synced: true, Line: []model.Line{{ + Start: ms(1000), + Value: "word cue", + Cue: []model.Cue{{Start: ms(1000), End: &end, Value: "word", ByteStart: 0, ByteEnd: 4}}, + }}} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics[0].Cues).To(HaveLen(1)) + c := d.Lyrics[0].Cues[0] + Expect(c.Position).To(Equal(0)) + Expect(c.EndPosition).To(Equal(4)) + Expect(c.Start).To(Equal(int64(10_000_000))) + Expect(*c.End).To(Equal(int64(15_000_000))) + }) + + It("skips a start-less cue while keeping its sibling", func() { + l := model.Lyrics{Synced: true, Line: []model.Line{{ + Start: ms(1000), + Value: "word cue", + Cue: []model.Cue{ + {Start: nil, Value: "dropped", ByteStart: 0, ByteEnd: 7}, + {Start: ms(1000), Value: "kept", ByteStart: 8, ByteEnd: 12}, + }, + }}} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics[0].Cues).To(HaveLen(1)) + c := d.Lyrics[0].Cues[0] + Expect(c.Position).To(Equal(8)) + Expect(c.EndPosition).To(Equal(12)) + Expect(c.Start).To(Equal(int64(10_000_000))) + }) +}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index ea47dcdaa..e0f7b5bb5 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -42,6 +42,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" @@ -119,9 +120,11 @@ func buildTestFS() storagetest.FakeFS { "Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")), "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), - "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), - "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), - "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{ + "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + })), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), + "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), // "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a // performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists. "Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})), @@ -325,6 +328,7 @@ func setupTestDB() { playlists.NewPlaylists(ds, core.NewImageUploadService()), providerFake, sonicSvc, + lyrics.NewLyrics(ds, nil), ) } diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go new file mode 100644 index 000000000..279f288b8 --- /dev/null +++ b/server/jellyfin/e2e/lyrics_test.go @@ -0,0 +1,71 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Lyrics", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("PlaybackInfo", func() { + It("advertises a Lyric stream for a track with embedded lyrics", func() { + id := songID("Stairway To Heaven") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + var found bool + for _, s := range info.MediaSources[0].MediaStreams { + if s.Type == "Lyric" { + found = true + } + } + Expect(found).To(BeTrue()) + }) + + It("does not advertise a Lyric stream for a track without lyrics", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + for _, s := range info.MediaSources[0].MediaStreams { + Expect(s.Type).ToNot(Equal("Lyric")) + } + }) + }) + + Describe("GET /Audio/{id}/Lyrics", func() { + It("returns the LyricDto for a track with embedded synced lyrics", func() { + id := songID("Stairway To Heaven") + var lyrics dto.LyricDto + parseInto(get("/Audio/"+enc(id)+"/Lyrics"), &lyrics) + Expect(lyrics.Lyrics).To(HaveLen(2)) + Expect(lyrics.Lyrics[0].Text).To(Equal("There's a lady who's sure")) + Expect(lyrics.Lyrics[0].Start).ToNot(BeNil()) + Expect(*lyrics.Lyrics[0].Start).To(Equal(int64(10000000))) + Expect(lyrics.Metadata.IsSynced).To(BeTrue()) + }) + + It("returns 404 for a track without lyrics", func() { + id := songID("So What") + Expect(get("/Audio/" + enc(id) + "/Lyrics").Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for a fabricated id", func() { + Expect(get("/Audio/" + enc("nope") + "/Lyrics").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("HasLyrics badge", func() { + It("is true for a track with embedded lyrics and omitted/false otherwise", func() { + var stairway dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &stairway) + Expect(stairway.HasLyrics).To(BeTrue()) + + var soWhat dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &soWhat) + Expect(soWhat.HasLyrics).To(BeFalse()) + }) + }) +}) diff --git a/server/jellyfin/lyrics.go b/server/jellyfin/lyrics.go new file mode 100644 index 000000000..f6468ceeb --- /dev/null +++ b/server/jellyfin/lyrics.go @@ -0,0 +1,47 @@ +package jellyfin + +import ( + "context" + "net/http" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// cachedLyrics resolves lyrics through the full source pipeline (embedded, sidecar, plugins), +// caching results — including empty: clients poll per played track, so misses are the hot path. +func (api *Router) cachedLyrics(ctx context.Context, mf *model.MediaFile) model.LyricList { + list, err := api.lyricsCache.GetWithLoader(mf.ID, func(string) (model.LyricList, time.Duration, error) { + l, err := api.lyrics.GetLyrics(ctx, mf) + return l, 0, err // 0 → cache DefaultTTL + }) + if err != nil { + log.Error(ctx, "Error getting lyrics", "id", mf.ID, "title", mf.Title, err) + return nil + } + return list +} + +// getLyrics serves GET /Audio/{itemId}/Lyrics. Jellyfin returns 404 when a track has no lyrics +// (never an empty 200); all surveyed clients treat that gracefully. +func (api *Router) getLyrics(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + main, found := servableLyric(api.cachedLyrics(r.Context(), mf)) + if !found { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + api.ok(w, r, dto.LyricDtoFromLyrics(*mf, main)) +} + +// servableLyric is the single predicate for both serving and advertising, so PlaybackInfo never +// advertises a Lyric stream that this endpoint would 404. +func servableLyric(list model.LyricList) (model.Lyrics, bool) { + main, found := list.Main() + return main, found && !main.IsEmpty() +} diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go new file mode 100644 index 000000000..f2c5993f6 --- /dev/null +++ b/server/jellyfin/lyrics_test.go @@ -0,0 +1,132 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "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/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeLyricsService returns canned lyrics per media-file ID and counts calls. +type fakeLyricsService struct { + lyrics map[string]model.LyricList + err error + calls int +} + +func (f *fakeLyricsService) GetLyrics(_ context.Context, mf *model.MediaFile) (model.LyricList, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.lyrics[mf.ID], nil +} + +func (f *fakeLyricsService) GetLyricsByArtistTitle(context.Context, string, string) (model.LyricList, error) { + return nil, nil +} + +func p(ms int64) *int64 { return &ms } + +func newTestLyricsCache() cache.SimpleCache[string, model.LyricList] { + return cache.NewSimpleCache[string, model.LyricList](cache.Options{SizeLimit: 1000}) +} + +var _ = Describe("getLyrics", func() { + var api *Router + var ds *tests.MockDataStore + var fake *fakeLyricsService + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", LibraryID: 1}, + {ID: "s2", Title: "Silent Song", LibraryID: 1}, + }) + fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}} + api = &Router{ + ds: ds, + lyrics: fake, + lyricsCache: newTestLyricsCache(), + } + }) + + doRequest := func(id string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}}) + // Clients send hex-encoded ids (matching real traffic and the other handler tests). + enc := dto.EncodeID(id) + r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", enc) + invoke(api.getLyrics, w, r) + return w + } + + It("returns 200 with a LyricDto for a track with synced lyrics", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + w := doRequest("s1") + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.LyricDto + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Lyrics).To(HaveLen(1)) + Expect(res.Lyrics[0].Text).To(Equal("hello")) + Expect(res.Lyrics[0].Start).ToNot(BeNil()) + Expect(*res.Lyrics[0].Start).To(Equal(int64(10000000))) + }) + + It("serves the main-kind lyric when a translation is also present", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}}, + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + w := doRequest("s1") + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.LyricDto + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Lyrics).To(HaveLen(1)) + Expect(res.Lyrics[0].Text).To(Equal("hello")) + }) + + It("returns 404 when the service returns no lyrics", func() { + w := doRequest("s2") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the main lyric has no lines", func() { + fake.lyrics["s1"] = model.LyricList{{Kind: "main", Lang: "eng"}} + w := doRequest("s1") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for an unknown item id", func() { + w := doRequest("unknown") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("caches results so a second request doesn't re-invoke the service", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(fake.calls).To(Equal(1)) + }) + + It("caches empty results too", func() { + Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(fake.calls).To(Equal(1)) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index e007dedf4..b0bc92caf 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) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index f462f283a..f83e5182d 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -49,7 +49,7 @@ func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { clientId, clientName := clientIdentity(ctx) err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ MediaId: body.ItemId, - PositionMs: body.PositionTicks / 10_000, + PositionMs: dto.MillisFromTicks(body.PositionTicks), State: scrobbler.StatePlaying, PlaybackRate: 1.0, ClientId: clientId, @@ -73,7 +73,7 @@ func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request clientId, clientName := clientIdentity(ctx) err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ MediaId: body.ItemId, - PositionMs: body.PositionTicks / 10_000, + PositionMs: dto.MillisFromTicks(body.PositionTicks), State: state, PlaybackRate: 1.0, ClientId: clientId, @@ -97,7 +97,7 @@ func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ MediaId: body.ItemId, - PositionMs: body.PositionTicks / 10_000, + PositionMs: dto.MillisFromTicks(body.PositionTicks), State: scrobbler.StateStopped, ClientId: clientId, ClientName: clientName, diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index d9d74bcb9..7d8874476 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) + api = New(ds, 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/stream.go b/server/jellyfin/stream.go index 28411f9d1..022c00c96 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -5,6 +5,7 @@ import ( "math" "net/http" "net/url" + "slices" "strconv" "strings" @@ -44,6 +45,15 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { return } src := dto.MediaSourceFromMediaFile(*mf) + // The mapper only sees embedded lyrics; per-track we can afford the full pipeline + // (sidecars, plugins) so Finamp's Lyric-stream gate reflects every source. + if !slices.ContainsFunc(src.MediaStreams, func(s dto.MediaStream) bool { return s.Type == "Lyric" }) { + if _, found := servableLyric(api.cachedLyrics(r.Context(), mf)); found { + src.MediaStreams = append(src.MediaStreams, dto.MediaStream{ + Type: "Lyric", Index: len(src.MediaStreams), IsExternal: true, + }) + } + } // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index 7063d321a..f77c10ec1 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -33,7 +33,11 @@ var _ = Describe("Stream", func() { ds = &tests.MockDataStore{} streamer = &fakeMediaStreamer{} decider = &fakeTranscodeDecider{} - api = &Router{ds: ds, streamer: streamer, transcodeDecider: decider} + api = &Router{ + ds: ds, streamer: streamer, transcodeDecider: decider, + lyrics: &fakeLyricsService{lyrics: map[string]model.LyricList{}}, + lyricsCache: newTestLyricsCache(), + } }) Describe("getPlaybackInfo", func() { @@ -76,6 +80,88 @@ var _ = Describe("Stream", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + + playbackInfo := func() dto.PlaybackInfoResponse { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getPlaybackInfo(w, r) + var res dto.PlaybackInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + return res + } + + lyricStreams := func(res dto.PlaybackInfoResponse) []dto.MediaStream { + var out []dto.MediaStream + for _, s := range res.MediaSources[0].MediaStreams { + if s.Type == "Lyric" { + out = append(out, s) + } + } + return out + } + + It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ + "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + }} + + Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) + }) + + It("advertises no Lyric stream when the pipeline finds nothing", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + + Expect(lyricStreams(playbackInfo())).To(BeEmpty()) + }) + + It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ + "s1": {{Kind: "main", Lang: "eng"}}, + }} + + Expect(lyricStreams(playbackInfo())).To(BeEmpty()) + }) + + It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, + }) + api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ + "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + }} + + Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) + }) + + It("still returns 200 with a valid MediaSource and no Lyric stream when the lyrics pipeline errors", func() { + // Own ID: an erroring loader isn't cached, but a shared ID could still pick up + // another test's cached (non-error) result and mask this assertion. + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s-err", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + }) + api.lyrics = &fakeLyricsService{err: errors.New("boom")} + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s-err")+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("s-err")) + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaybackInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.MediaSources).To(HaveLen(1)) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s-err"))) + Expect(lyricStreams(res)).To(BeEmpty()) + }) }) Describe("streamAudio", func() { diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 7e539be98..6f0c359d4 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -17,8 +17,9 @@ import ( ) // 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. -const jellyfinVersion = "10.8.13" +// 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" func (api *Router) serverName() string { if conf.Server.Jellyfin.ServerName != "" { From 756df9decf0cfe55a1eb528f892f31367e466c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 16 Jul 2026 20:10:35 -0400 Subject: [PATCH 32/56] fix: dedupe and cap concurrent lyrics plugin fetches (#5792) * fix: dedupe and cap concurrent lyrics plugin fetches Clients like Finamp prefetch lyrics for several queue tracks at once. The resulting burst of concurrent plugin calls can rate-limit the primary lyrics provider into a timeout, making the plugin fall back to a lower quality source and cache the bad result. SimpleCache.GetWithLoader now deduplicates concurrent loads of the same key via singleflight, with every waiter receiving the winner's result or error. The Jellyfin lyrics loader is detached from the request context so one cancelled request cannot fail the load for all waiters, and the lyrics adapter caps in-flight plugin calls at 2 per plugin, queueing the rest. As a side effect, the cached HTTP client used by the Last.fm, Deezer and ListenBrainz agents also collapses identical concurrent requests into a single upstream call. * fix: harden lyrics concurrency fixes per review Replace the stringified singleflight keys with a per-cache flight map keyed by the cache key type itself, eliminating potential key collisions for non-string keys, the nil-interface assertion panic, and the stringification overhead. Release the lyrics semaphore slot via defer so a panicking plugin call cannot leak it, and bound the detached lyrics load with a one-minute timeout so a hung plugin cannot pin its singleflight and semaphore slot indefinitely. --- plugins/lyrics_adapter.go | 10 +++ plugins/lyrics_adapter_test.go | 41 +++++++++++ plugins/manager_loader.go | 1 + plugins/manager_plugin.go | 1 + server/jellyfin/lyrics.go | 9 ++- server/jellyfin/lyrics_test.go | 31 +++++++-- utils/cache/simple_cache.go | 79 ++++++++++++++++------ utils/cache/simple_cache_test.go | 112 +++++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 27 deletions(-) diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 281f022fb..9e02115e7 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -14,6 +14,10 @@ const ( FuncLyricsGetLyrics = "nd_lyrics_get_lyrics" ) +// maxConcurrentLyricsCalls caps in-flight lyrics calls per plugin: clients prefetch +// lyrics for whole queues, and the resulting burst can rate-limit upstream providers. +const maxConcurrentLyricsCalls = 2 + func init() { registerCapability( CapabilityLyrics, @@ -34,6 +38,12 @@ type LyricsPlugin struct { // GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response // via model.ParseLyrics (TTML/SRT/YAML/LRC/plain). func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { + select { + case l.plugin.lyricsSem <- struct{}{}: + defer func() { <-l.plugin.lyricsSem }() + case <-ctx.Done(): + return nil, ctx.Err() + } req := capabilities.GetLyricsRequest{ Track: mediaFileToTrackInfo(l.plugin, mf), } diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index 6e82dbfab..d110665f5 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -3,6 +3,8 @@ package plugins import ( + "context" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -71,6 +73,45 @@ var _ = Describe("LyricsPlugin", Ordered, func() { Expect(result[0].Lang).To(Equal("xxx")) }) + It("blocks new calls while the per-plugin concurrency cap is saturated", func() { + sem := provider.plugin.lyricsSem + for range cap(sem) { + sem <- struct{}{} + } + + ctx := GinkgoT().Context() + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + done := make(chan error, 1) + go func() { + _, err := provider.GetLyrics(ctx, track) + done <- err + }() + + Consistently(done, "500ms").ShouldNot(Receive()) + <-sem // free one slot; the pending call should now proceed + Eventually(done).Should(Receive(BeNil())) + for range cap(sem) - 1 { + <-sem + } + }) + + It("gives up waiting for a slot when the context is cancelled", func() { + sem := provider.plugin.lyricsSem + for range cap(sem) { + sem <- struct{}{} + } + defer func() { + for range cap(sem) { + <-sem + } + }() + + ctx, cancel := context.WithCancel(GinkgoT().Context()) + cancel() + _, err := provider.GetLyrics(ctx, &model.MediaFile{ID: "track-1"}) + Expect(err).To(MatchError(context.Canceled)) + }) + It("returns error when plugin returns error", func() { manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ "test-lyrics": {"error": "service unavailable"}, diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 757ededb5..675c85e26 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -421,6 +421,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { allowedUserIDs: allowedUsers, allUsers: p.AllUsers, libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), + lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), } m.mu.Unlock() loaded = true diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index f0c7c56d5..155663781 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -24,6 +24,7 @@ type plugin struct { allowedUserIDs []string // User IDs this plugin can access (from DB configuration) allUsers bool // If true, plugin can access all users libraries libraryAccess + lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics) } // instance creates a new plugin instance for the given context. diff --git a/server/jellyfin/lyrics.go b/server/jellyfin/lyrics.go index f6468ceeb..a9d77ba19 100644 --- a/server/jellyfin/lyrics.go +++ b/server/jellyfin/lyrics.go @@ -10,11 +10,18 @@ import ( "github.com/navidrome/navidrome/server/jellyfin/dto" ) +const lyricsLoadTimeout = time.Minute + // cachedLyrics resolves lyrics through the full source pipeline (embedded, sidecar, plugins), // caching results — including empty: clients poll per played track, so misses are the hot path. func (api *Router) cachedLyrics(ctx context.Context, mf *model.MediaFile) model.LyricList { + // The load is shared across requests (singleflight) and cached, so don't let one + // cancelled request abort it for everybody — detach it from the request's lifetime, + // keeping a bound so a hung plugin can't pin the fetch (and its plugin slot) forever. + loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), lyricsLoadTimeout) + defer cancel() list, err := api.lyricsCache.GetWithLoader(mf.ID, func(string) (model.LyricList, time.Duration, error) { - l, err := api.lyrics.GetLyrics(ctx, mf) + l, err := api.lyrics.GetLyrics(loadCtx, mf) return l, 0, err // 0 → cache DefaultTTL }) if err != nil { diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go index f2c5993f6..402d14ed0 100644 --- a/server/jellyfin/lyrics_test.go +++ b/server/jellyfin/lyrics_test.go @@ -17,13 +17,18 @@ import ( // fakeLyricsService returns canned lyrics per media-file ID and counts calls. type fakeLyricsService struct { - lyrics map[string]model.LyricList - err error - calls int + lyrics map[string]model.LyricList + err error + calls int + hadDeadline bool } -func (f *fakeLyricsService) GetLyrics(_ context.Context, mf *model.MediaFile) (model.LyricList, error) { +func (f *fakeLyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { f.calls++ + _, f.hadDeadline = ctx.Deadline() + if err := ctx.Err(); err != nil { + return nil, err + } if f.err != nil { return nil, f.err } @@ -129,4 +134,22 @@ var _ = Describe("getLyrics", func() { Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) Expect(fake.calls).To(Equal(1)) }) + + It("completes and caches the fetch even when the request context is cancelled", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + list := api.cachedLyrics(ctx, &model.MediaFile{ID: "s1"}) + Expect(list).ToNot(BeEmpty()) + Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(fake.calls).To(Equal(1)) + }) + + It("bounds the detached fetch with a timeout", func() { + Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(fake.hadDeadline).To(BeTrue()) + }) }) diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index eb3c99995..494451c9e 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "runtime" + "sync" "sync/atomic" "time" @@ -44,7 +45,8 @@ func NewSimpleCache[K comparable, V any](options ...Options) SimpleCache[K, V] { c := ttlcache.New[K, V](opts...) cache := &simpleCache[K, V]{ - data: c, + data: c, + loads: make(map[K]*flight[V]), } go cache.data.Start() @@ -61,6 +63,23 @@ const evictionTimeout = 1 * time.Hour type simpleCache[K comparable, V any] struct { data *ttlcache.Cache[K, V] evictionDeadline atomic.Pointer[time.Time] + loadsMu sync.Mutex + loads map[K]*flight[V] +} + +// flight tracks an in-progress load so concurrent misses of the same key share it. +type flight[V any] struct { + done chan struct{} + val V + err error +} + +func (f *flight[V]) result() (V, error) { + if f.err != nil { + var zero V + return zero, fmt.Errorf("cache error: loader returned %w", f.err) + } + return f.val, nil } func (c *simpleCache[K, V]) Add(key K, value V) error { @@ -90,31 +109,47 @@ func (c *simpleCache[K, V]) Get(key K) (V, error) { return item.Value(), nil } +// GetWithLoader loads misses via the loader, deduplicating concurrent loads of +// the same key: one loader call runs, and every waiter shares its result (or error). func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) { - var err error - loaderWrapper := ttlcache.LoaderFunc[K, V]( - func(t *ttlcache.Cache[K, V], key K) *ttlcache.Item[K, V] { - c.evictExpired() - var value V - var ttl time.Duration - value, ttl, err = loader(key) - if err != nil { - return nil - } - return t.Set(key, value, ttl) - }, - ) - item := c.data.Get(key, ttlcache.WithLoader[K, V](loaderWrapper)) - if item == nil { - var zero V - if err != nil { - return zero, fmt.Errorf("cache error: loader returned %w", err) - } - return zero, errors.New("item not found") + if item := c.data.Get(key); item != nil { + return item.Value(), nil } - return item.Value(), nil + + c.loadsMu.Lock() + if f, ok := c.loads[key]; ok { + c.loadsMu.Unlock() + <-f.done + return f.result() + } + f := &flight[V]{done: make(chan struct{}), err: errLoaderPanicked} + c.loads[key] = f + c.loadsMu.Unlock() + + // Deregister even if the loader panics, so waiters get an error instead of + // blocking forever on a flight that will never complete. + defer func() { + close(f.done) + c.loadsMu.Lock() + delete(c.loads, key) + c.loadsMu.Unlock() + }() + + if item := c.data.Get(key); item != nil { // a flight may have completed since the miss + f.val, f.err = item.Value(), nil + } else { + c.evictExpired() + var ttl time.Duration + f.val, ttl, f.err = loader(key) + if f.err == nil { + c.data.Set(key, f.val, ttl) + } + } + return f.result() } +var errLoaderPanicked = errors.New("loader panicked") + func (c *simpleCache[K, V]) evictExpired() { if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { c.data.DeleteExpired() diff --git a/utils/cache/simple_cache_test.go b/utils/cache/simple_cache_test.go index 45ba2c966..1c4f5c9bb 100644 --- a/utils/cache/simple_cache_test.go +++ b/utils/cache/simple_cache_test.go @@ -3,6 +3,8 @@ package cache import ( "errors" "fmt" + "sync" + "sync/atomic" "time" . "github.com/onsi/ginkgo/v2" @@ -69,6 +71,116 @@ var _ = Describe("SimpleCache", func() { _, err := cache.GetWithLoader("key", loader) Expect(err).To(HaveOccurred()) }) + + It("suppresses concurrent loads for the same key", func() { + var calls atomic.Int32 + release := make(chan struct{}) + started := make(chan struct{}, 10) + loader := func(key string) (string, time.Duration, error) { + calls.Add(1) + started <- struct{}{} + <-release + return "shared", time.Minute, nil + } + + const n = 5 + var wg sync.WaitGroup + results := make([]string, n) + errs := make([]error, n) + for i := range n { + wg.Go(func() { + results[i], errs[i] = cache.GetWithLoader("key", loader) + }) + } + + Eventually(started).Should(Receive()) + Consistently(started).ShouldNot(Receive()) + close(release) + wg.Wait() + + Expect(calls.Load()).To(Equal(int32(1))) + for i := range n { + Expect(errs[i]).ToNot(HaveOccurred()) + Expect(results[i]).To(Equal("shared")) + } + }) + + It("returns the loader error to all concurrent callers", func() { + release := make(chan struct{}) + started := make(chan struct{}, 10) + loader := func(key string) (string, time.Duration, error) { + started <- struct{}{} + <-release + return "", 0, errors.New("load failed") + } + + const n = 3 + var wg sync.WaitGroup + errs := make([]error, n) + for i := range n { + wg.Go(func() { + _, errs[i] = cache.GetWithLoader("key", loader) + }) + } + + Eventually(started).Should(Receive()) + Consistently(started).ShouldNot(Receive()) + close(release) + wg.Wait() + + for i := range n { + Expect(errs[i]).To(MatchError(ContainSubstring("load failed"))) + } + }) + + It("supports interface value types with nil results", func() { + c := NewSimpleCache[string, any]() + v, err := c.GetWithLoader("key", func(string) (any, time.Duration, error) { + return nil, time.Minute, nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(v).To(BeNil()) + }) + + It("cleans up the in-flight registration when the loader panics", func() { + Expect(func() { + _, _ = cache.GetWithLoader("key", func(string) (string, time.Duration, error) { + panic("boom") + }) + }).To(PanicWith("boom")) + + // Without cleanup this would deadlock on the never-completed flight + v, err := cache.GetWithLoader("key", func(string) (string, time.Duration, error) { + return "ok", 0, nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(v).To(Equal("ok")) + }) + + It("loads different keys independently", func() { + release := make(chan struct{}) + started := make(chan struct{}, 10) + loader := func(key string) (string, time.Duration, error) { + started <- struct{}{} + <-release + return key + "=value", time.Minute, nil + } + + var wg sync.WaitGroup + for _, key := range []string{"key1", "key2"} { + wg.Go(func() { + value, err := cache.GetWithLoader(key, loader) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal(key + "=value")) + }) + } + + // Both loaders must be in flight at once: distinct keys are not suppressed + Eventually(started).Should(Receive()) + Eventually(started).Should(Receive()) + close(release) + wg.Wait() + }) }) Describe("Keys and Values", func() { From 27f02103923f0927a3cc20ba98ecbca4d73700bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 16 Jul 2026 20:47:47 -0400 Subject: [PATCH 33/56] fix(scrobbler): tolerate out-of-order playback reports (#5793) * fix(scrobbler): tolerate out-of-order playback reports Clients may fire reportPlayback requests concurrently (Feishin sends 'starting' and 'playing' in parallel, and the previous track's 'stopped' races the next track's start), so reports can be processed out of order. Two cases corrupted the now-playing session: a late 'starting' for the track already playing downgraded the session state, freezing position estimation at 0:00 until the next report; and a late 'stopped' for the previous track removed the new track's session and dispatched a playback report mislabeled with the new track's metadata, causing presence-style plugins (e.g. Discord Rich Presence) to clear or show stale state. ReportPlayback now ignores a 'starting' report when the session already has the same track in playing state, and ignores a 'stopped' report for a track other than the current session's - skipping both the session removal and the plugin dispatch, while still counting the play and dispatching external scrobbles for the stopped track. Reported in https://github.com/jeffvli/feishin/issues/2131 * fix(scrobbler): serialize session writes to close starting/playing race The out-of-order 'starting' guard checked the session cache before the media-file load, leaving a window where a concurrent 'playing' report on a fresh session could write between the check and the write, and still be overwritten back to 'starting'. Session check-then-write sections are now serialized by a mutex, with the guard re-checked after the load. Also tightens the guard comment to say 'playing session', matching the condition. Found by Codex review on #5793. * fix(scrobbler): fully exit ReportPlayback when ignoring out-of-order reports The out-of-order guards used 'break', which only exits the switch, so the post-switch NowPlaying block still ran for an ignored 'starting' report and enqueued a NowPlaying dispatch with the stale report's position - potentially overwriting a pending correct-position entry, since the queue is keyed by client. Return nil instead, so ignored reports have no side effects. Found by Gemini review on #5793. --- core/scrobbler/play_tracker.go | 37 +++++++- core/scrobbler/play_tracker_test.go | 134 +++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index a2d7e8639..e21db42d2 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -89,6 +89,7 @@ type playTracker struct { ds model.DataStore broker events.Broker playMap cache.SimpleCache[string, PlaybackSession] + sessionsMu sync.Mutex // serializes playMap check-then-write across concurrent reports builtinScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler pluginLoader PluginLoader @@ -249,6 +250,12 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { return combined } +// hasPlayingSession reports whether clientId's current session is already playing mediaId. +func (p *playTracker) hasPlayingSession(clientId, mediaId string) bool { + cur, err := p.playMap.Get(clientId) + return err == nil && cur.MediaFile.ID == mediaId && cur.State == StatePlaying +} + func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration { if rate <= 0 { rate = 1.0 @@ -268,6 +275,12 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP switch params.State { case StateStarting: + // Clients may send starting/playing unordered; a late "starting" must not downgrade + // a playing session, or position estimation freezes until the next report. + if p.hasPlayingSession(clientId, params.MediaId) { + log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) + return nil + } mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err @@ -284,7 +297,15 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP PlaybackRate: params.PlaybackRate, LastReport: now, } + p.sessionsMu.Lock() + // re-check: a concurrent "playing" report may have created the session during the load above + if p.hasPlayingSession(clientId, params.MediaId) { + p.sessionsMu.Unlock() + log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) + return nil + } err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) + p.sessionsMu.Unlock() if err != nil { log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } @@ -315,7 +336,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) } log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl) + p.sessionsMu.Lock() err := p.playMap.AddWithTTL(clientId, info, ttl) + p.sessionsMu.Unlock() if err != nil { log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } @@ -339,6 +362,17 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.dispatchScrobble(ctx, mf, now) } } + p.sessionsMu.Lock() + info, getErr := p.playMap.Get(clientId) + // A late stop for a previous track must not end the current session nor reach + // playback reporters, or presence-style plugins would clear the active track. + if getErr == nil && info.MediaFile.ID != params.MediaId { + p.sessionsMu.Unlock() + log.Trace(ctx, "Ignoring out-of-order stopped report for different track", "clientId", clientId, "stoppedMediaId", params.MediaId, "currentMediaId", info.MediaFile.ID) + return nil + } + p.playMap.Remove(clientId) + p.sessionsMu.Unlock() stoppedInfo := PlaybackSession{ UserId: user.ID, Username: user.UserName, @@ -349,7 +383,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP PlaybackRate: params.PlaybackRate, LastReport: now, } - if info, getErr := p.playMap.Get(clientId); getErr == nil { + if getErr == nil { stoppedInfo.MediaFile = info.MediaFile stoppedInfo.Start = info.Start } else { @@ -364,7 +398,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP stoppedInfo.MediaFile = *mf } p.enqueuePlaybackReport(ctx, stoppedInfo) - p.playMap.Remove(clientId) } if conf.Server.EnableNowPlaying { diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 831b0ce0d..f49d9a0bf 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -3,6 +3,7 @@ package scrobbler import ( "context" "errors" + "fmt" "net/http" "sync" "sync/atomic" @@ -45,6 +46,17 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { return s, ok } +// slowMediaFileRepo widens the window between a report's session check and its +// write, making check-then-write races reproducible. +type slowMediaFileRepo struct { + model.MediaFileRepository +} + +func (s *slowMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { + time.Sleep(5 * time.Millisecond) + return s.MediaFileRepository.GetWithParticipants(id) +} + var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore @@ -376,18 +388,23 @@ var _ = Describe("PlayTracker", func() { Expect(playing).To(BeEmpty()) }) - It("starting replaces existing entry for same player", func() { + It("starting replaces existing entry when switching tracks on same player", func() { + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + MediaId: "456", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) + Expect(playing[0].MediaFile.ID).To(Equal("456")) Expect(playing[0].State).To(Equal("starting")) Expect(playing[0].PositionMs).To(Equal(int64(0))) }) @@ -696,6 +713,119 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("resilience (out-of-order reports)", func() { + BeforeEach(func() { + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + }) + + It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 1000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(1000))) + }) + + It("keeps the current session when a stopped report arrives for a different track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].MediaFile.ID).To(Equal("456")) + Expect(playing[0].State).To(Equal("playing")) + }) + + It("still auto-scrobbles the stopped track when the current session is for a different track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + }) + + It("does not dispatch NowPlaying from an ignored out-of-order starting report", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 60000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + fake.nowPlayingCalled.Store(false) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("never lets a concurrent starting report downgrade the playing session", func() { + ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile(ctx)} + for i := range 20 { + raceClientId := fmt.Sprintf("race-client-%d", i) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + defer GinkgoRecover() + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: raceClientId, + }) + }() + go func() { + defer wg.Done() + defer GinkgoRecover() + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: raceClientId, + }) + }() + wg.Wait() + info, err := tracker.playMap.Get(raceClientId) + Expect(err).ToNot(HaveOccurred()) + Expect(info.State).To(Equal("playing"), "iteration %d", i) + } + }) + + It("does NOT forward a stopped report for a different track to playback reporters", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) + }) + }) + Describe("external scrobbler dispatch", func() { It("dispatches NowPlaying on starting", func() { fake.nowPlayingCalled.Store(false) From ae7e81e33fcffee25c089cc93630dcccb2d74e64 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 20:24:15 -0400 Subject: [PATCH 34/56] docs(jellyfin): sync README with current implementation Update the Jellyfin API README to cover changes that landed after it was written: add the Lyrics and AudioMuse-AI endpoints to the implemented-endpoints table, document the AlbumIds filter (Feishin), Recursive=false handling, and the Jellyfin.MaxConcurrentStreams config option. Mention Symfonium as a client of the AudioMuse-AI endpoints. Update the lyrics limitation for the singleflighted cache loader shipped in #5792. Drop two stale known-limitation entries: sonic similarity (plugin metadata agents already feed InstantMix/Similar) and MD5-hash ids (the codec round-trip is symmetric, and with the API unreleased no client can hold a raw un-encoded id). --- server/jellyfin/README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 7afc2b7a9..17d2797ab 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -22,6 +22,10 @@ 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: 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. +MaxConcurrentStreams = 4 ``` or via environment variables: @@ -104,12 +108,14 @@ authenticated user has access to; a library (or item within it) the user cannot `GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or -tracks — Finamp's artist screen sends these *alongside* `ParentId=`); `GenreIds` (a +tracks — Finamp's artist screen sends these *alongside* `ParentId=`); `AlbumIds` (an +album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreIds` (a genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); `SearchTerm`; favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`; -`StartIndex`/`Limit`; and `Ids` (batch fetch by id). +`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` +returns direct children only (no tracks — no track is a library's direct child). ## Implemented endpoints @@ -124,9 +130,11 @@ favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `Sort | 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` | +| 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}]` | | 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` | Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint @@ -217,7 +225,9 @@ The stream endpoints reuse the same transcode-decision pipeline as the Subsonic ## AudioMuse-AI compatible endpoints -Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin). +Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin) +— e.g. [Symfonium](https://symfonium.app/) can use these endpoints for sonic mixes when +connected as a Jellyfin client. Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity` plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. @@ -320,9 +330,6 @@ make test PKG=./server/jellyfin/... Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client that already has an artist id from elsewhere is not re-checked against library membership. -- **MD5-hash ids from old migrated libraries.** The hex id codec assumes ids are opaque; a raw - 32-char MD5 id is itself valid hex and so must be encoded/decoded symmetrically like any other. - This is handled, but is the most fragile id case — see the note in `dto/ids.go`. - **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a @@ -348,15 +355,8 @@ make test PKG=./server/jellyfin/... 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. - Follow-ups: the lyrics cache loader is not singleflighted, so concurrent misses on the same - track can double-invoke the plugin pipeline (fix belongs in `utils/cache.SimpleCache` via - ttlcache's `SuppressedLoader`, affecting all callers — separate change); tracks whose only - lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in lists (request-time sources - can't be known at list time without per-row I/O). -- **No sonic similarity (follow-up).** `Items/{id}/InstantMix` and the `/Similar` endpoints are - backed only by external metadata agents (Last.fm), not sonic analysis: an instant mix is the seed - track followed by the provider's similar songs (with agents disabled it degrades to a seed-only - mix). A follow-up would back them with Navidrome's `core/sonic` provider — the same one behind - the OpenSubsonic `sonicSimilarity` extension (`getSonicSimilarTracks`) that AudioMuse-AI feeds - via its Navidrome plugin, and the exact endpoint AudioMuse's own Jellyfin plugin overrides. - Needs the `core/sonic.Sonic` service injected into the `Router` (wire change). + 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. + Follow-up: tracks whose only lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in + lists (request-time sources can't be known at list time without per-row I/O). From 85132240e0909dc460f604144cfd8578fa409e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 16 Jul 2026 22:20:35 -0400 Subject: [PATCH 35/56] feat(smartplaylist): per-playlist refreshDelay for stable daily/weekly playlists (#5790) * feat(utils): add ParseDuration/FormatDuration with day and week units * feat(criteria): add per-playlist refreshDelay to smart playlist rules * feat(smartplaylist): honor per-playlist refreshDelay in refresh gate * feat(subsonic): compute smart playlist validUntil from effective refresh delay * fix(playlists): reset smart playlist evaluation window when rules change via API * refactor(utils): flatten FormatDuration recursion, single-pass duration regex * fix(playlists): address PR review feedback - Reset EvaluatedAt to nil instead of zero-time on rules change and NSP re-import, so getPlaylist(s) never reports year-1 Changed/validUntil in the window between an edit and the next owner read - Parse negative d/w durations so they are rejected with the consistent "negative duration" error instead of "unknown unit" - Quote input in the negative-duration error, matching the parse error - Gate per-playlist RefreshDelay behind IsSmartPlaylist, matching its doc --- core/playlists/import.go | 3 +- core/playlists/rest_adapter.go | 1 + core/playlists/rest_adapter_test.go | 32 +++++++++ model/criteria/criteria.go | 17 +++++ model/criteria/criteria_test.go | 66 +++++++++++++++++ model/playlist.go | 10 +++ model/playlist_test.go | 30 ++++++++ persistence/smart_playlist_repository.go | 3 +- persistence/smart_playlist_repository_test.go | 44 ++++++++++++ server/subsonic/playlists.go | 2 +- server/subsonic/playlists_test.go | 14 ++++ utils/time.go | 64 ++++++++++++++++- utils/time_test.go | 71 +++++++++++++++++++ 13 files changed, 351 insertions(+), 6 deletions(-) diff --git a/core/playlists/import.go b/core/playlists/import.go index 9d3ecabc5..bafb870cd 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strings" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -187,7 +186,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, newPls.OwnerID = pls.OwnerID newPls.Public = pls.Public newPls.UploadedImage = pls.UploadedImage // Preserve manual upload - newPls.EvaluatedAt = &time.Time{} + newPls.EvaluatedAt = nil // force re-evaluation on next read } else { log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) newPls.OwnerID = owner.ID diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3f886aadd..f34524e27 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -135,6 +135,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod } if rulesChanged { current.Rules = entity.Rules + current.EvaluatedAt = nil // force re-evaluation on next read } if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 79d72d147..58a327bde 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -314,6 +314,38 @@ var _ = Describe("REST Adapter", func() { Expect(mockPlsRepo.Last.Public).To(BeTrue()) }) + It("resets EvaluatedAt when rules change", func() { + evaluatedAt := time.Now().Add(-1 * time.Hour) + mockPlsRepo.Data["smart-reset"] = &model.Playlist{ + ID: "smart-reset", + Name: "Smart", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + EvaluatedAt: &evaluatedAt, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}} + err := repo.Update("smart-reset", &model.Playlist{Rules: newRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil()) + }) + + It("keeps EvaluatedAt when rules are not changed", func() { + evaluatedAt := time.Now().Add(-1 * time.Hour) + mockPlsRepo.Data["smart-keep"] = &model.Playlist{ + ID: "smart-keep", + Name: "Smart", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + EvaluatedAt: &evaluatedAt, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("smart-keep", &model.Playlist{Name: "Renamed Smart"}, "name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil()) + Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) + }) + It("updates name and rules together (smart-playlist Edit form)", func() { mockPlsRepo.Data["smart-edit"] = &model.Playlist{ ID: "smart-edit", diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 8c3d183a9..5d7dc3826 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -4,9 +4,12 @@ package criteria import ( "encoding/json" "errors" + "fmt" "slices" + "time" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils" ) type Expression interface { @@ -20,6 +23,7 @@ type Criteria struct { Limit int LimitPercent int Offset int + RefreshDelay time.Duration // 0 = use conf.Server.SmartPlaylistRefreshDelay } // EffectiveLimit resolves the effective limit for a query. If a fixed Limit is @@ -83,6 +87,7 @@ func (c Criteria) MarshalJSON() ([]byte, error) { Limit int `json:"limit,omitempty"` LimitPercent int `json:"limitPercent,omitempty"` Offset int `json:"offset,omitempty"` + RefreshDelay string `json:"refreshDelay,omitempty"` }{ Sort: c.Sort, Order: c.Order, @@ -90,6 +95,9 @@ func (c Criteria) MarshalJSON() ([]byte, error) { LimitPercent: c.LimitPercent, Offset: c.Offset, } + if c.RefreshDelay > 0 { + aux.RefreshDelay = utils.FormatDuration(c.RefreshDelay) + } switch rules := c.Expression.(type) { case Any: aux.Any = rules @@ -110,6 +118,7 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { Limit int `json:"limit"` LimitPercent int `json:"limitPercent"` Offset int `json:"offset"` + RefreshDelay string `json:"refreshDelay"` } if err := json.Unmarshal(data, &aux); err != nil { return err @@ -131,6 +140,14 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { c.Limit = aux.Limit c.Offset = aux.Offset + if aux.RefreshDelay != "" { + d, err := utils.ParseDuration(aux.RefreshDelay) + if err != nil { + return fmt.Errorf("invalid refreshDelay: %w", err) + } + c.RefreshDelay = d + } + // Clamp LimitPercent to [0, 100] if aux.LimitPercent < 0 { log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent) diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 7f214e703..5e653150a 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -3,6 +3,7 @@ package criteria import ( "bytes" "encoding/json" + "time" "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" @@ -255,6 +256,71 @@ var _ = Describe("Criteria", func() { }) }) + Describe("refreshDelay", func() { + newCriteria := func(extra string) []byte { + return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`) + } + + It("unmarshals a valid refreshDelay", func() { + var c Criteria + gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed()) + gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour)) + }) + + It("supports week units", func() { + var c Criteria + gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed()) + gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour)) + }) + + It("leaves RefreshDelay zero when absent", func() { + var c Criteria + gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed()) + gomega.Expect(c.RefreshDelay).To(gomega.BeZero()) + }) + + It("rejects an invalid refreshDelay", func() { + var c Criteria + err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c) + gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) + }) + + It("rejects a negative refreshDelay", func() { + var c Criteria + err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c) + gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) + }) + + It("marshals RefreshDelay back as a duration string", func() { + c := Criteria{ + Expression: All{Is{"loved": true}}, + RefreshDelay: 24 * time.Hour, + } + j, err := json.Marshal(c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`)) + }) + + It("omits refreshDelay from JSON when zero", func() { + c := Criteria{Expression: All{Is{"loved": true}}} + j, err := json.Marshal(c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay")) + }) + + It("round-trips through marshal and unmarshal", func() { + c := Criteria{ + Expression: All{Is{"loved": true}}, + RefreshDelay: 36 * time.Hour, + } + j, err := json.Marshal(c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + var c2 Criteria + gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed()) + gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour)) + }) + }) + Context("with child playlists", func() { var ( topLevelInPlaylistID string diff --git a/model/playlist.go b/model/playlist.go index 40adb8d0a..185f6f942 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model/criteria" ) @@ -39,6 +40,15 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +// RefreshDelay returns the playlist's own refresh window when set, falling +// back to the global SmartPlaylistRefreshDelay. +func (pls Playlist) RefreshDelay() time.Duration { + if pls.IsSmartPlaylist() && pls.Rules.RefreshDelay > 0 { + return pls.Rules.RefreshDelay + } + return conf.Server.SmartPlaylistRefreshDelay +} + func (pls Playlist) MediaFiles() MediaFiles { if len(pls.Tracks) == 0 { return nil diff --git a/model/playlist_test.go b/model/playlist_test.go index 9ed24f00f..d936129ce 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -1,7 +1,12 @@ package model_test import ( + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -43,4 +48,29 @@ var _ = Describe("Playlist", func() { Expect(pls.ToM3U8()).To(Equal(expected)) }) }) + + Describe("RefreshDelay", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SmartPlaylistRefreshDelay = 5 * time.Second + }) + + It("returns the global config value when rules have no refreshDelay", func() { + pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}} + Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) + }) + + It("returns the per-playlist value when set", func() { + pls := model.Playlist{Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Is{"loved": true}}, + RefreshDelay: 24 * time.Hour, + }} + Expect(pls.RefreshDelay()).To(Equal(24 * time.Hour)) + }) + + It("returns the global value for non-smart playlists", func() { + pls := model.Playlist{} + Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) + }) + }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 54f316152..f7c41597d 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -4,7 +4,6 @@ import ( "time" . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -77,7 +76,7 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr if !pls.IsSmartPlaylist() { return false } - if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay { + if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < pls.RefreshDelay() { return false } if pls.OwnerID != usr.ID { diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 7bc705385..8e3ae488f 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -147,6 +147,50 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) }) }) + + Context("per-playlist refreshDelay", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("does NOT refresh when the per-playlist delay has not elapsed, even if global has", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + evaluatedAt := time.Now().Add(-1 * time.Hour) + + rules := &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + RefreshDelay: 24 * time.Hour, + } + pls := model.Playlist{Name: "Frozen Daily", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + + got, err := repo.GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + // Not re-evaluated: EvaluatedAt unchanged, no tracks materialized + Expect(*got.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) + Expect(got.Tracks).To(BeEmpty()) + }) + + It("refreshes when the per-playlist delay has elapsed, even if global has not", func() { + conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour + evaluatedAt := time.Now().Add(-10 * time.Minute) + + rules := &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + RefreshDelay: 5 * time.Minute, + } + pls := model.Playlist{Name: "Fast Refresh", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + + got, err := repo.GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + Expect(got.Tracks).To(HaveLen(1)) + Expect(got.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + }) + }) }) }) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 7101f9f15..c58fb9ab9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -168,7 +168,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso pls.Readonly = true if p.EvaluatedAt != nil { - pls.ValidUntil = new(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + pls.ValidUntil = new(p.EvaluatedAt.Add(p.RefreshDelay())) } } else { user, ok := request.UserFrom(ctx) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 697dd5852..f0a2f8ac5 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -248,6 +248,20 @@ var _ = Describe("buildPlaylist", func() { Expect(result.OpenSubsonicPlaylist).To(BeNil()) }) }) + + Context("with a per-playlist refreshDelay", func() { + BeforeEach(func() { + playlist.Rules.RefreshDelay = 24 * time.Hour + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("computes validUntil from the playlist's own delay", func() { + result := router.buildPlaylist(ctx, playlist) + expected := evaluatedAt.Add(24 * time.Hour) + Expect(result.ValidUntil).To(Equal(&expected)) + }) + }) }) Describe("annotation leakage", func() { diff --git a/utils/time.go b/utils/time.go index c1e949589..b3f1a98fb 100644 --- a/utils/time.go +++ b/utils/time.go @@ -1,6 +1,12 @@ package utils -import "time" +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) func TimeNewest(times ...time.Time) time.Time { newest := time.Time{} @@ -11,3 +17,59 @@ func TimeNewest(times ...time.Time) time.Time { } return newest } + +var durationDayWeekRe = regexp.MustCompile(`-?\d+(?:\.\d+)?[dw]`) + +// ParseDuration is time.ParseDuration extended with d (24h) and w (168h) units. +// Negative durations are rejected. +func ParseDuration(s string) (time.Duration, error) { + expanded := durationDayWeekRe.ReplaceAllStringFunc(s, func(match string) string { + value, err := strconv.ParseFloat(match[:len(match)-1], 64) + if err != nil { + return match + } + hours := value * 24 + if match[len(match)-1] == 'w' { + hours = value * 24 * 7 + } + return strconv.FormatFloat(hours, 'f', -1, 64) + "h" + }) + d, err := time.ParseDuration(expanded) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + if d < 0 { + return 0, fmt.Errorf("negative duration not allowed: %q", s) + } + return d, nil +} + +// FormatDuration renders whole w/d multiples with those units, falling back to +// time.Duration.String for the sub-day remainder, so ParseDuration round-trips. +func FormatDuration(d time.Duration) string { + if d < 24*time.Hour { + return formatSubDay(d) + } + var b strings.Builder + weekDuration := 7 * 24 * time.Hour + if weeks := d / weekDuration; weeks > 0 { + b.WriteString(strconv.Itoa(int(weeks)) + "w") + d %= weekDuration + } + dayDuration := 24 * time.Hour + if days := d / dayDuration; days > 0 { + b.WriteString(strconv.Itoa(int(days)) + "d") + d %= dayDuration + } + if d > 0 { + b.WriteString(formatSubDay(d)) + } + return b.String() +} + +func formatSubDay(d time.Duration) string { + if d >= time.Hour && d%time.Hour == 0 { + return strconv.Itoa(int(d/time.Hour)) + "h" + } + return d.String() +} diff --git a/utils/time_test.go b/utils/time_test.go index f89f0d2be..8460b98f9 100644 --- a/utils/time_test.go +++ b/utils/time_test.go @@ -26,3 +26,74 @@ var _ = Describe("TimeNewest", func() { Expect(utils.TimeNewest(t1, t2, t3)).To(Equal(t2)) }) }) + +var _ = Describe("ParseDuration", func() { + DescribeTable("parses valid durations", + func(input string, expected time.Duration) { + d, err := utils.ParseDuration(input) + Expect(err).ToNot(HaveOccurred()) + Expect(d).To(Equal(expected)) + }, + Entry("standard Go units", "90m", 90*time.Minute), + Entry("hours", "12h", 12*time.Hour), + Entry("days", "1d", 24*time.Hour), + Entry("weeks", "1w", 7*24*time.Hour), + Entry("multiple days", "3d", 72*time.Hour), + Entry("mixed day and hours", "1d12h", 36*time.Hour), + Entry("mixed week, day and hours", "1w2d3h", (7*24+2*24+3)*time.Hour), + Entry("fractional days", "0.5d", 12*time.Hour), + ) + + DescribeTable("rejects invalid durations", + func(input string) { + _, err := utils.ParseDuration(input) + Expect(err).To(HaveOccurred()) + }, + Entry("empty string", ""), + Entry("not a duration", "tomorrow"), + Entry("bare number", "42"), + Entry("unit only", "d"), + Entry("unknown unit", "5y"), + ) + + DescribeTable("rejects negative durations", + func(input string) { + _, err := utils.ParseDuration(input) + Expect(err).To(MatchError(ContainSubstring("negative duration"))) + }, + Entry("negative days", "-1d"), + Entry("negative weeks", "-0.5w"), + Entry("negative Go units", "-30m"), + ) +}) + +var _ = Describe("FormatDuration", func() { + DescribeTable("formats durations using the largest whole units", + func(input time.Duration, expected string) { + Expect(utils.FormatDuration(input)).To(Equal(expected)) + }, + Entry("whole weeks", 7*24*time.Hour, "1w"), + Entry("whole days", 24*time.Hour, "1d"), + Entry("multiple days", 72*time.Hour, "3d"), + Entry("day and hours", 36*time.Hour, "1d12h"), + Entry("week, day and hours", (7*24+2*24+3)*time.Hour, "1w2d3h"), + Entry("hours only", 12*time.Hour, "12h"), + Entry("sub-hour", 90*time.Minute, "1h30m0s"), + Entry("zero", time.Duration(0), "0s"), + ) + + DescribeTable("round-trips through ParseDuration", + func(input string) { + d, err := utils.ParseDuration(input) + Expect(err).ToNot(HaveOccurred()) + formatted := utils.FormatDuration(d) + d2, err := utils.ParseDuration(formatted) + Expect(err).ToNot(HaveOccurred()) + Expect(d2).To(Equal(d)) + }, + Entry("1d", "1d"), + Entry("1w", "1w"), + Entry("1d12h", "1d12h"), + Entry("90m", "90m"), + ) +}) From 64430af9ce442c7266101204bcf53060f7e1443a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 17 Jul 2026 21:53:17 -0400 Subject: [PATCH 36/56] feat(ui): add Radio to Default View options (#5801) --- ui/src/personal/defaultViews.js | 2 +- ui/src/personal/defaultViews.test.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/personal/defaultViews.js b/ui/src/personal/defaultViews.js index 09e615419..a5a2c074d 100644 --- a/ui/src/personal/defaultViews.js +++ b/ui/src/personal/defaultViews.js @@ -1,6 +1,6 @@ import albumLists, { defaultAlbumList } from '../album/albumLists' -export const resourceDefaultViews = ['artist', 'song', 'playlist'] +export const resourceDefaultViews = ['artist', 'song', 'playlist', 'radio'] export const isResourceDefaultView = (defaultView) => resourceDefaultViews.includes(defaultView) diff --git a/ui/src/personal/defaultViews.test.js b/ui/src/personal/defaultViews.test.js index 44057a736..4e80b2340 100644 --- a/ui/src/personal/defaultViews.test.js +++ b/ui/src/personal/defaultViews.test.js @@ -25,6 +25,7 @@ describe('defaultViews', () => { { id: 'artist', name: 'resources.artist.name:2' }, { id: 'song', name: 'resources.song.name:2' }, { id: 'playlist', name: 'resources.playlist.name:2' }, + { id: 'radio', name: 'resources.radio.name:2' }, ]), ) }) @@ -33,6 +34,7 @@ describe('defaultViews', () => { expect(isResourceDefaultView('artist')).toBe(true) expect(isResourceDefaultView('song')).toBe(true) expect(isResourceDefaultView('playlist')).toBe(true) + expect(isResourceDefaultView('radio')).toBe(true) expect(isResourceDefaultView('recentlyAdded')).toBe(false) }) From deaa5e6c028abd70acb2427c50c478e79eab61dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 14:45:47 -0400 Subject: [PATCH 37/56] feat(ui): playlist favourites (heart, list filter, sidebar favourites-only toggle) (#5805) * feat(playlists): register starred REST filter on playlist repository * feat(ui): add persisted sidebarPlaylistsOnlyFavourites setting * feat(ui): playlist favourites heart column and list filter * feat(ui): favourite heart on playlist details header * feat(ui): sidebar favourites-only playlist toggle with live refresh * fix(playlists): qualify id in REST filter to avoid ambiguous column The annotation join in selectPlaylist made a bare id filter ambiguous, so GET /api/playlist?id=X (react-admin getMany, used by the sidebar refetch and useResourceRefresh) failed with 'ambiguous column name: id'. Register idFilter("playlist") like the album/artist/mediafile repos. * fix(ui): refresh favourites sidebar on local star toggle The SSE broker skips the client that originated a star, so the acting client never got the refreshResource echo and its favourites-only sidebar went stale. Key the sidebar query on a fingerprint of locally-known starred playlists so a star/unstar on this client refetches; SSE still covers other clients. * style(ui): prettier-format PlaylistsSubMenu test * feat(ui): refine playlist favourites layout - Move the favourite heart column to just before the edit button - Space the Playlists sidebar header text from its action icons - Use a list icon instead of a cog for the playlist-management action * feat(ui): make the playlist Favourite column toggleable Move the heart into the toggleable fields map so users can show/hide it from the column selector like the other optional columns, keeping it last so it stays just before the edit button. * refactor(ui): memoize sidebar star fingerprint and gate it on favourites-only - Derive starFingerprint via useMemo on the playlist data reference instead of recomputing sort/join inside useSelector on every app-wide dispatch. - Only include the fingerprint in the query payload when favourites-only is on, so a star toggle no longer refetches the sidebar when it shows all playlists. * fix(ui): don't refetch favourites sidebar on SSE events when showing all When favourites-only is off the sidebar shows every playlist, so a star event from another client changes nothing visible. Gate the SSE-driven refresh counter (and its payload key) on onlyFavourites so the sidebar no longer redraws on unrelated playlist star events. * fix(ui): address automated review feedback on playlist favourites - Ignore a persisted favourites-only preference when EnableFavourites is off, so disabling the feature later can't strand a filtered sidebar (Codex P2). - Make PlaylistLove's datagrid header props explicit (source/sortable via defaultProps, className forwarded) instead of relying on prop pass-through. - Add aria-label to the SubMenu secondary action button. - Cover the PlaylistLove list column with tests (Codex P1). --- persistence/playlist_repository.go | 6 +- persistence/playlist_repository_test.go | 29 ++++ ui/src/actions/settings.js | 7 + ui/src/i18n/en.json | 4 +- ui/src/layout/PlaylistsSubMenu.jsx | 65 ++++++++- ui/src/layout/PlaylistsSubMenu.test.jsx | 180 ++++++++++++++++++++++++ ui/src/layout/SubMenu.jsx | 31 +++- ui/src/playlist/PlaylistDetails.jsx | 36 +++-- ui/src/playlist/PlaylistList.jsx | 18 +++ ui/src/playlist/PlaylistList.test.jsx | 34 +++++ ui/src/reducers/settingsReducer.js | 7 + ui/src/reducers/settingsReducer.test.js | 36 +++++ 12 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 ui/src/layout/PlaylistsSubMenu.test.jsx create mode 100644 ui/src/playlist/PlaylistList.test.jsx create mode 100644 ui/src/reducers/settingsReducer.test.js diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index e39f0bbd3..c78e0df1e 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -51,8 +51,10 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe r.ctx = ctx r.db = db r.registerModel(&model.Playlist{}, map[string]filterFunc{ - "q": playlistFilter, - "smart": smartPlaylistFilter, + "id": idFilter("playlist"), + "q": playlistFilter, + "smart": smartPlaylistFilter, + "starred": annotationBoolFilter("starred"), }) r.setSortMappings(map[string]string{ "owner_name": "owner_name", diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index c51ff6222..00d4ff9f2 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -4,6 +4,7 @@ import ( "slices" "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -155,6 +156,34 @@ var _ = Describe("PlaylistRepository", func() { Expect(count).To(Equal(int64(len(starred)))) }) + It("filters starred playlists through the registered REST filter", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"starred": "true"}, + }) + Expect(err).ToNot(HaveOccurred()) + starred := res.(model.Playlists) + Expect(starred).To(ContainElement(HaveField("ID", plsID))) + for _, p := range starred { + Expect(p.Starred).To(BeTrue()) + } + + res, err = repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"starred": "false"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.Playlists)).ToNot(ContainElement(HaveField("ID", plsID))) + }) + + It("reads a playlist by id through the REST id filter without ambiguity", func() { + res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"id": plsID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.Playlists)).To(ContainElement(HaveField("ID", plsID))) + }) + It("does not leak an annotation row of another item_type sharing the playlist id", func() { // Older builds (and the star fallthrough) can leave a media_file-typed row // under a playlist id; the item_type-scoped join must not surface or dupe it. diff --git a/ui/src/actions/settings.js b/ui/src/actions/settings.js index e62ecde8f..89c8f248f 100644 --- a/ui/src/actions/settings.js +++ b/ui/src/actions/settings.js @@ -1,6 +1,8 @@ export const SET_NOTIFICATIONS_STATE = 'SET_NOTIFICATIONS_STATE' export const SET_TOGGLEABLE_FIELDS = 'SET_TOGGLEABLE_FIELDS' export const SET_OMITTED_FIELDS = 'SET_OMITTED_FIELDS' +export const SET_SIDEBAR_PLAYLISTS_FAVOURITES = + 'SET_SIDEBAR_PLAYLISTS_FAVOURITES' export const setNotificationsState = (enabled) => ({ type: SET_NOTIFICATIONS_STATE, @@ -16,3 +18,8 @@ export const setOmittedFields = (obj) => ({ type: SET_OMITTED_FIELDS, data: obj, }) + +export const setSidebarPlaylistsOnlyFavourites = (enabled) => ({ + type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, + data: enabled, +}) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 74fb23ab9..c0e226453 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -210,7 +210,8 @@ "songCount": "Songs", "comment": "Comment", "sync": "Auto-import", - "path": "Import from" + "path": "Import from", + "starred": "Favourite" }, "actions": { "selectPlaylist": "Select a playlist:", @@ -635,6 +636,7 @@ }, "albumList": "Albums", "playlists": "Playlists", + "onlyFavourites": "Only show favourites", "sharedPlaylists": "Shared Playlists", "about": "About" }, diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.jsx index b94bebf86..f332f6810 100644 --- a/ui/src/layout/PlaylistsSubMenu.jsx +++ b/ui/src/layout/PlaylistsSubMenu.jsx @@ -1,19 +1,24 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' import { MenuItemLink, useDataProvider, useNotify, useQueryWithStore, + useTranslate, } from 'react-admin' import { useHistory } from 'react-router-dom' import QueueMusicIcon from '@material-ui/icons/QueueMusic' import { Typography } from '@material-ui/core' import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined' -import { BiCog } from 'react-icons/bi' +import FavoriteIcon from '@material-ui/icons/Favorite' +import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' +import { BiListUl } from 'react-icons/bi' import { useDrop } from 'react-dnd' import SubMenu from './SubMenu' -import { canChangeTracks, OverflowTooltip } from '../common' +import { canChangeTracks, OverflowTooltip, useRefreshOnEvents } from '../common' import { DraggableTypes } from '../consts' +import { setSidebarPlaylistsOnlyFavourites } from '../actions' import config from '../config' const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { @@ -53,6 +58,37 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { const history = useHistory() + const dispatch = useDispatch() + const translate = useTranslate() + const onlyFavourites = useSelector( + (state) => state.settings.sidebarPlaylistsOnlyFavourites, + ) + // Ignore a persisted preference when the feature is off, so disabling it later + // (with the toggle now hidden) doesn't strand the user on a filtered sidebar + const showFavouritesOnly = config.enableFavourites && onlyFavourites + const playlistData = useSelector( + (state) => state.admin.resources.playlist?.data, + ) + // Fingerprint of local star state; changes only when a playlist is (un)starred, + // so a local toggle refetches the sidebar without the SSE echo the actor never gets + const starFingerprint = useMemo(() => { + const data = playlistData || {} + return Object.keys(data) + .filter((id) => data[id]?.starred) + .sort() + .join(',') + }, [playlistData]) + const [refreshCount, setRefreshCount] = useState(0) + + // Only the favourites-only view depends on star state changing elsewhere; + // when showing all playlists a star event from another client changes nothing + // async because useRefreshOnEvents calls .catch() on the returned value + const onRefresh = useCallback(async () => { + if (showFavouritesOnly) setRefreshCount((count) => count + 1) + }, [showFavouritesOnly]) + useRefreshOnEvents({ events: ['playlist'], onRefresh }) + + // A changed payload signature makes useQueryWithStore refetch const { data, loaded } = useQueryWithStore({ type: 'getList', resource: 'playlist', @@ -62,6 +98,11 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { perPage: config.maxSidebarPlaylists, }, sort: { field: 'name' }, + ...(showFavouritesOnly && { + filter: { starred: true }, + starFingerprint, + refresh: refreshCount, + }), }, }) @@ -98,6 +139,10 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { [history], ) + const handleToggleFavourites = useCallback(() => { + dispatch(setSidebarPlaylistsOnlyFavourites(!onlyFavourites)) + }, [dispatch, onlyFavourites]) + return ( <> { name={'menu.playlists'} icon={} dense={dense} - actionIcon={} + actionIcon={} onAction={onPlaylistConfig} + onSecondaryAction={ + config.enableFavourites ? handleToggleFavourites : undefined + } + secondaryActionIcon={ + onlyFavourites ? ( + + ) : ( + + ) + } + secondaryActionTitle={translate('menu.onlyFavourites')} + secondaryActionActive={onlyFavourites} > {myPlaylists.map(renderPlaylistMenuItemLink)} diff --git a/ui/src/layout/PlaylistsSubMenu.test.jsx b/ui/src/layout/PlaylistsSubMenu.test.jsx new file mode 100644 index 000000000..617f60a4e --- /dev/null +++ b/ui/src/layout/PlaylistsSubMenu.test.jsx @@ -0,0 +1,180 @@ +import React from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { settingsReducer, activityReducer } from '../reducers' +import { processEvent, EVENT_REFRESH_RESOURCE } from '../actions' +import PlaylistsSubMenu from './PlaylistsSubMenu' + +const mockUseQueryWithStore = vi.fn() + +vi.mock('../config', () => ({ + // losslessFormats is read at module-load time by common/QualityInfo.jsx, + // pulled in transitively via the '../common' barrel file + default: { + enableFavourites: true, + maxSidebarPlaylists: 100, + losslessFormats: '', + }, +})) + +vi.mock('react-dnd', () => ({ + useDrop: () => [{}, () => {}], +})) + +vi.mock('react-router-dom', () => ({ + useHistory: () => ({ push: vi.fn() }), +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslate: () => (x) => x, + useDataProvider: () => ({ addToPlaylist: vi.fn() }), + useNotify: () => vi.fn(), + useQueryWithStore: (query) => mockUseQueryWithStore(query), + MenuItemLink: ({ primaryText }) =>
{primaryText}
, + } +}) + +const playlists = { + 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' }, + 'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' }, +} + +const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA' +const adminReducer = (state = { resources: {} }, action) => + action.type === SET_PLAYLIST_DATA + ? { resources: { playlist: { data: action.data } } } + : state + +const renderMenu = (preloadedSettings = {}, preloadedPlaylistData) => { + const store = createStore( + combineReducers({ + settings: settingsReducer, + activity: activityReducer, + admin: adminReducer, + }), + { + settings: preloadedSettings, + activity: {}, + admin: { + resources: preloadedPlaylistData + ? { playlist: { data: preloadedPlaylistData } } + : {}, + }, + }, + ) + const theme = createTheme() + render( + + + + + , + ) + return store +} + +const lastQuery = () => + mockUseQueryWithStore.mock.calls[ + mockUseQueryWithStore.mock.calls.length - 1 + ][0] + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.setItem('userId', 'user-1') + mockUseQueryWithStore.mockReturnValue({ data: playlists, loaded: true }) + // SubMenu uses MUI's useMediaQuery, which needs window.matchMedia in jsdom + window.matchMedia = (query) => ({ + matches: false, + media: query, + addListener: () => {}, + removeListener: () => {}, + }) + // OverflowTooltip (via MenuItemLink) needs ResizeObserver, unavailable in jsdom + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } + }) + + it('queries without a starred filter by default', () => { + renderMenu() + expect(lastQuery().payload.filter).toBeUndefined() + expect(screen.getByText('Mine')).not.toBeNull() + expect(screen.getByText('Theirs')).not.toBeNull() + }) + + it('adds the starred filter when favourites-only is enabled', () => { + renderMenu({ sidebarPlaylistsOnlyFavourites: true }) + expect(lastQuery().payload.filter).toEqual({ starred: true }) + }) + + it('toggles the setting when the heart action is clicked', () => { + const store = renderMenu() + fireEvent.click(screen.getByTitle('menu.onlyFavourites')) + expect(store.getState().settings.sidebarPlaylistsOnlyFavourites).toBe(true) + expect(lastQuery().payload.filter).toEqual({ starred: true }) + }) + + it('refetches on a playlist SSE event when favourites-only is on', async () => { + const store = renderMenu({ sidebarPlaylistsOnlyFavourites: true }) + const before = lastQuery().payload.refresh + // useRefreshOnEvents compares Date.now() timestamps; make sure it advances + await act(() => new Promise((resolve) => setTimeout(resolve, 5))) + act(() => { + store.dispatch( + processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), + ) + }) + expect(lastQuery().payload.refresh).toBe(before + 1) + }) + + it('does not change the query signature on an SSE event when favourites-only is off', async () => { + const store = renderMenu() + const before = JSON.stringify(lastQuery().payload) + await act(() => new Promise((resolve) => setTimeout(resolve, 5))) + act(() => { + store.dispatch( + processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), + ) + }) + // Signature unchanged → useQueryWithStore dedupes, no wasted refetch + expect(lastQuery().payload.refresh).toBeUndefined() + expect(JSON.stringify(lastQuery().payload)).toBe(before) + }) + + it('refetches when a playlist is starred locally (no SSE echo)', () => { + const store = renderMenu( + { sidebarPlaylistsOnlyFavourites: true }, + { 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' } }, + ) + const before = lastQuery().payload.starFingerprint + act(() => { + store.dispatch({ + type: SET_PLAYLIST_DATA, + data: { + 'pl-1': { + id: 'pl-1', + name: 'Mine', + ownerId: 'user-1', + starred: true, + }, + }, + }) + }) + expect(lastQuery().payload.starFingerprint).not.toBe(before) + expect(lastQuery().payload.starFingerprint).toContain('pl-1') + }) +}) diff --git a/ui/src/layout/SubMenu.jsx b/ui/src/layout/SubMenu.jsx index 418f4c651..ee1bf343e 100644 --- a/ui/src/layout/SubMenu.jsx +++ b/ui/src/layout/SubMenu.jsx @@ -33,6 +33,9 @@ const useStyles = makeStyles( menuHeader: { width: '100%', }, + headerText: { + flexGrow: 1, + }, headerWrapper: { display: 'flex', '&:hover $actionIcon': { @@ -55,6 +58,10 @@ const SubMenu = ({ dense, onAction, actionIcon, + onSecondaryAction, + secondaryActionIcon, + secondaryActionTitle, + secondaryActionActive, }) => { const translate = useTranslate() const classes = useStyles() @@ -70,6 +77,11 @@ const SubMenu = ({ } } + const handleSecondaryClick = (e) => { + e.stopPropagation() + onSecondaryAction(e) + } + const header = (
{isOpen ? : icon} - + {translate(name)} + {onSecondaryAction && sidebarIsOpen && ( + + {secondaryActionIcon} + + )} {onAction && sidebarIsOpen && ( {
- - - {record.name || translate('ra.page.loading')} - - +
+ + + {record.name || translate('ra.page.loading')} + + + +
{record.songCount ? ( diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 8732725bc..642d90dd5 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -4,6 +4,7 @@ import { DateField, EditButton, Filter, + NullableBooleanInput, NumberField, ReferenceInput, SearchInput, @@ -22,11 +23,14 @@ import { CoverArtAvatar, DurationField, List, + LoveButton, Writable, isWritable, useSelectedFields, useResourceRefresh, } from '../common' +import FavoriteIcon from '@material-ui/icons/Favorite' +import config from '../config' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' @@ -53,6 +57,12 @@ const PlaylistFilter = (props) => { )} + {config.enableFavourites && ( + } + /> + )} ) } @@ -139,6 +149,13 @@ const PlaylistListBulkActions = (props) => { ) } +// Datagrid reads `source`/`sortable`/`label` off this element for the column +// header; only record/resource are forwarded so they never leak onto the button. +export const PlaylistLove = ({ record, className }) => ( + +) +PlaylistLove.defaultProps = { source: 'starred', sortable: false } + const PlaylistList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) @@ -159,6 +176,7 @@ const PlaylistList = (props) => { sync: !isXsmall && ( ), + starred: config.enableFavourites && , }), [isDesktop, isXsmall], ) diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx new file mode 100644 index 000000000..4fbc6d516 --- /dev/null +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { PlaylistLove } from './PlaylistList' + +vi.mock('../config', () => ({ + default: { enableFavourites: true }, +})) + +vi.mock('../common', () => ({ + LoveButton: ({ record, resource }) => ( + + ), +})) + +describe('', () => { + it('renders a LoveButton bound to the playlist resource', () => { + render() + const btn = screen.getByTestId('love') + expect(btn.getAttribute('data-resource')).toBe('playlist') + expect(btn.textContent).toBe('starred') + }) + + it('exposes datagrid header props so the column renders unsorted', () => { + // The Datagrid reads these off the element; the wrapper body must not + // forward them to the button (which would leak onto the DOM). + expect(PlaylistLove.defaultProps).toEqual({ + source: 'starred', + sortable: false, + }) + }) +}) diff --git a/ui/src/reducers/settingsReducer.js b/ui/src/reducers/settingsReducer.js index 0e598c22d..3f8278ea4 100644 --- a/ui/src/reducers/settingsReducer.js +++ b/ui/src/reducers/settingsReducer.js @@ -1,6 +1,7 @@ import { SET_NOTIFICATIONS_STATE, SET_OMITTED_FIELDS, + SET_SIDEBAR_PLAYLISTS_FAVOURITES, SET_TOGGLEABLE_FIELDS, } from '../actions' @@ -8,6 +9,7 @@ const initialState = { notifications: false, toggleableFields: {}, omittedFields: {}, + sidebarPlaylistsOnlyFavourites: false, } export const settingsReducer = (previousState = initialState, payload) => { @@ -34,6 +36,11 @@ export const settingsReducer = (previousState = initialState, payload) => { ...data, }, } + case SET_SIDEBAR_PLAYLISTS_FAVOURITES: + return { + ...previousState, + sidebarPlaylistsOnlyFavourites: data, + } default: return previousState } diff --git a/ui/src/reducers/settingsReducer.test.js b/ui/src/reducers/settingsReducer.test.js new file mode 100644 index 000000000..7ad68b291 --- /dev/null +++ b/ui/src/reducers/settingsReducer.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { settingsReducer } from './settingsReducer' +import { + SET_SIDEBAR_PLAYLISTS_FAVOURITES, + setSidebarPlaylistsOnlyFavourites, +} from '../actions' + +describe('settingsReducer', () => { + it('defaults sidebarPlaylistsOnlyFavourites to false', () => { + const state = settingsReducer(undefined, { type: 'UNKNOWN' }) + expect(state.sidebarPlaylistsOnlyFavourites).toBe(false) + }) + + it('enables the flag via the action creator', () => { + const state = settingsReducer( + undefined, + setSidebarPlaylistsOnlyFavourites(true), + ) + expect(state.sidebarPlaylistsOnlyFavourites).toBe(true) + }) + + it('disables the flag and preserves other settings', () => { + const initial = settingsReducer(undefined, { type: 'UNKNOWN' }) + const on = settingsReducer(initial, { + type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, + data: true, + }) + const off = settingsReducer(on, { + type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, + data: false, + }) + expect(off.sidebarPlaylistsOnlyFavourites).toBe(false) + expect(off.notifications).toEqual(initial.notifications) + expect(off.toggleableFields).toEqual(initial.toggleableFields) + }) +}) From f61b4eee210d692f50d37d3bad5b3714e28a094d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 16:40:10 -0400 Subject: [PATCH 38/56] fix(deezer): pick most-popular artist among same-name matches (#5808) * fix(deezer): pick most-popular artist among same-name matches The Deezer agent searched with order=RANKING and always took the top-ranked result (artists[0]) as long as its name matched. Deezer's RANKING order isn't reliable for homonyms, so for names shared by several artists (e.g. "Queen") it locked onto a low-popularity artist whose Top Tracks are empty, leaving getTopSongs empty. Among the exact-name matches, select the one with the highest fan count instead. This resolves "Queen" to the real band (Deezer ID 412) and preserves the existing ErrNotFound behavior when nothing matches the name exactly. Fixes #5802 * fix(deezer): improve artist disambiguation by ranking exact-case names Signed-off-by: Deluan --------- Signed-off-by: Deluan --- adapters/deezer/deezer.go | 31 +++++++++++++++--- adapters/deezer/deezer_test.go | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index ed3071766..d8e832cf1 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -1,10 +1,12 @@ package deezer import ( + "cmp" "context" "errors" "fmt" "net/http" + "slices" "strings" "github.com/navidrome/navidrome/conf" @@ -95,13 +97,32 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e } } - // If the first one has the same name, that's the one - if !strings.EqualFold(artists[0].Name, name) { - log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name) + // Deezer's RANKING order isn't reliable for homonyms: rank name matches + // ahead of non-matches, prefer an exact-case match, then the most fans. + rank := func(a Artist) int { + switch { + case a.Name == name: + return 2 + case strings.EqualFold(a.Name, name): + return 1 + default: + return 0 + } + } + slices.SortFunc(artists, func(a, b Artist) int { + return cmp.Or( + cmp.Compare(rank(b), rank(a)), + cmp.Compare(b.NbFan, a.NbFan), + cmp.Compare(a.ID, b.ID), + ) + }) + best := artists[0] + if !strings.EqualFold(best.Name, name) { + log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name) return nil, agents.ErrNotFound } - log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link) - return &artists[0], err + log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan) + return new(best), nil } func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index 4dd251585..f478af115 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -34,6 +34,66 @@ var _ = Describe("deezerAgent", func() { }) }) + Describe("searchArtist", func() { + var agent *deezerAgent + var httpClient *fakeHttpClient + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + agent = &deezerAgent{ + dataStore: &tests.MockDataStore{}, + client: newClient(httpClient), + } + }) + + It("picks the exact-name match with the most fans when several share the name", func() { + // Deezer RANKING order returns a low-popularity homonym first (see issue #5802) + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":61045802,"name":"Queen","nb_fan":75}, + {"id":141954732,"name":"Queen","nb_fan":397}, + {"id":135041032,"name":"Queen(Ares)","nb_fan":133}, + {"id":183179807,"name":"Queen","nb_fan":53}, + {"id":412,"name":"Queen","nb_fan":12744378} + ],"total":5}`)), + }) + + artist, err := agent.searchArtist(ctx, "Queen") + + Expect(err).ToNot(HaveOccurred()) + Expect(artist.ID).To(Equal(412)) + }) + + It("matches the name case-insensitively", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":1,"name":"QUEEN","nb_fan":10}, + {"id":2,"name":"queen","nb_fan":20} + ],"total":2}`)), + }) + + artist, err := agent.searchArtist(ctx, "Queen") + + Expect(err).ToNot(HaveOccurred()) + Expect(artist.ID).To(Equal(2)) + }) + + It("returns ErrNotFound when no result matches the name exactly", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":1,"name":"Queens of the Stone Age","nb_fan":100} + ],"total":1}`)), + }) + + _, err := agent.searchArtist(ctx, "Queen") + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + }) + Describe("GetArtistBiography - Language Fallback", func() { var agent *deezerAgent var httpClient *langAwareHttpClient From e4a423db11bae287a2d41980e36536d8f1c00a9f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 16:49:31 -0400 Subject: [PATCH 39/56] feat(jellyfin): emit refreshResource events on favorite/rating changes Like Subsonic's setStar/setRating, the Jellyfin favorite and rating endpoints now broadcast a refreshResource event, so the web UI updates immediately when a Jellyfin client changes an annotation. Also fixes model.GetEntityByID to propagate unexpected repository errors instead of reporting them as not-found, preserving the 500-vs-404 distinction for all its callers. --- cmd/wire_gen.go | 2 +- model/get_entity.go | 35 ++++++------- model/get_entity_test.go | 40 +++++++++++++++ server/jellyfin/annotations.go | 72 ++++++++++++--------------- server/jellyfin/annotations_test.go | 64 +++++++++++++++++++++++- server/jellyfin/api.go | 6 ++- server/jellyfin/api_test.go | 10 ++-- server/jellyfin/e2e/e2e_suite_test.go | 1 + server/jellyfin/routing_test.go | 2 +- server/jellyfin/socket_test.go | 2 +- 10 files changed, 163 insertions(+), 71 deletions(-) create mode 100644 model/get_entity_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 463ac7a44..c4a797ef4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -138,7 +138,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) return router } diff --git a/model/get_entity.go b/model/get_entity.go index 60972b2e9..3e1a78d1d 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -2,29 +2,26 @@ package model import ( "context" + "errors" ) // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { - ar, err := ds.Artist(ctx).Get(id) - if err == nil { - return ar, nil + getters := []func() (any, error){ + func() (any, error) { return ds.Artist(ctx).Get(id) }, + func() (any, error) { return ds.Album(ctx).Get(id) }, + func() (any, error) { return ds.Playlist(ctx).Get(id) }, + func() (any, error) { return ds.MediaFile(ctx).Get(id) }, + func() (any, error) { return ds.Radio(ctx).Get(id) }, } - al, err := ds.Album(ctx).Get(id) - if err == nil { - return al, nil + for _, get := range getters { + entity, err := get() + if err == nil { + return entity, nil + } + if !errors.Is(err, ErrNotFound) { + return nil, err + } } - pls, err := ds.Playlist(ctx).Get(id) - if err == nil { - return pls, nil - } - mf, err := ds.MediaFile(ctx).Get(id) - if err == nil { - return mf, nil - } - r, err := ds.Radio(ctx).Get(id) - if err == nil { - return r, nil - } - return nil, err + return nil, ErrNotFound } diff --git a/model/get_entity_test.go b/model/get_entity_test.go new file mode 100644 index 000000000..f8a4c9e8e --- /dev/null +++ b/model/get_entity_test.go @@ -0,0 +1,40 @@ +package model_test + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetEntityByID", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ctx = GinkgoT().Context() + }) + + It("returns the entity matching the id", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + entity, err := model.GetEntityByID(ctx, ds, "a1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Album{})) + Expect(entity.(*model.Album).ID).To(Equal("a1")) + }) + + It("returns ErrNotFound when no entity matches", func() { + _, err := model.GetEntityByID(ctx, ds, "missing") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("propagates unexpected repository errors instead of reporting not-found", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true) + _, err := model.GetEntityByID(ctx, ds, "a1") + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(model.ErrNotFound)) + }) +}) diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index 8f900ee87..f2c2f546e 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -8,53 +8,41 @@ import ( "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" ) -// resolveAnnotated finds which annotated repo owns id. Albums and songs 404 when the user can't -// access their library; artists span libraries (library_artist), so have no single LibraryID to -// gate on and rely on list-time scoping. PlaylistRepository.Get enforces playlist visibility. -// When ok is false the response has already been written, so callers must return without writing -// the annotation. -func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, ok bool) { +// resolveAnnotated finds which annotated repo owns id, returning the resource name used in +// refreshResource events. Albums and songs 404 when the user can't access their library; artists +// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time +// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has +// already been written, so callers must return without writing the annotation. +func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) { ctx := r.Context() + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil && !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, "" + } u, _ := request.UserFrom(ctx) - if al, err := api.ds.Album(ctx).Get(id); err == nil { - if !u.HasLibraryAccess(al.LibraryID) { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + switch e := entity.(type) { + case *model.Album: + if u.HasLibraryAccess(e.LibraryID) { + return api.ds.Album(ctx), "album" } - return api.ds.Album(ctx), true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false - } - if _, err := api.ds.Artist(ctx).Get(id); err == nil { - return api.ds.Artist(ctx), true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false - } - if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { - if !u.HasLibraryAccess(mf.LibraryID) { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + case *model.Artist: + return api.ds.Artist(ctx), "artist" + case *model.MediaFile: + if u.HasLibraryAccess(e.LibraryID) { + return api.ds.MediaFile(ctx), "song" } - return api.ds.MediaFile(ctx), true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false - } - playlistRepo := api.ds.Playlist(ctx) - if _, err := playlistRepo.Get(id); err == nil { - return playlistRepo, true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false + case *model.Playlist: + return api.ds.Playlist(ctx), "playlist" } + // Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent. http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + return nil, "" } // getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify @@ -77,14 +65,15 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - repo, ok := api.resolveAnnotated(w, r, id) - if !ok { + repo, resource := api.resolveAnnotated(w, r, id) + if repo == nil { return } if err := repo.SetStar(starred, id); err != nil { api.internalError(w, r, err) return } + api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) encodedID := dto.EncodeID(id) api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) } @@ -96,14 +85,15 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - repo, ok := api.resolveAnnotated(w, r, id) - if !ok { + repo, resource := api.resolveAnnotated(w, r, id) + if repo == nil { return } if err := repo.SetRating(rating, id); err != nil { api.internalError(w, r, err) return } + api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) encodedID := dto.EncodeID(id) d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} if rating > 0 { diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index 4a7efabd7..9e811a379 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -17,6 +18,7 @@ import ( var _ = Describe("Annotations", func() { var api *Router var ds *tests.MockDataStore + var broker *fakeEventBroker // alice has access to library 1 only. ctxUser := func() context.Context { return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) @@ -24,7 +26,8 @@ var _ = Describe("Annotations", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - api = &Router{ds: ds} + broker = &fakeEventBroker{} + api = &Router{ds: ds, broker: broker} }) Describe("markFavorite / unmarkFavorite", func() { @@ -134,6 +137,39 @@ var _ = Describe("Annotations", func() { invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) + + It("emits a refreshResource event when starring a song", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + }) + + It("emits a refreshResource event when starring an album", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`)) + }) + + It("does not emit an event when the item is not accessible", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(broker.Events).To(BeEmpty()) + }) }) Describe("setRating / removeRating", func() { @@ -253,5 +289,31 @@ var _ = Describe("Annotations", func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) }) + + It("emits a refreshResource event when rating a song", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + }) }) }) + +type fakeEventBroker struct { + http.Handler + Events []events.Event +} + +func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + +var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 5e7cad0b8..0f79011c7 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -22,6 +22,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/cache" ) @@ -38,6 +39,7 @@ type Router struct { provider external.Provider sonic sonic.Engine lyrics lyrics.Lyrics + broker events.Broker lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group serverIDMu sync.Mutex @@ -47,11 +49,11 @@ type Router struct { 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) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, lyrics: lyricsSvc, + sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ SizeLimit: 1000, DefaultTTL: 5 * time.Minute, diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 62af74794..e8e9cbd4b 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -18,7 +18,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) + api := New(ds, 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) @@ -26,7 +26,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) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -36,7 +36,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) + api := New(&tests.MockDataStore{}, 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) @@ -53,7 +53,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -70,7 +70,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) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index e0f7b5bb5..445566387 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -329,6 +329,7 @@ func setupTestDB() { providerFake, sonicSvc, lyrics.NewLyrics(ds, nil), + events.NoopBroker(), ) } diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index b0bc92caf..62b52cfff 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) + api = New(&tests.MockDataStore{}, 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 7d8874476..8c509fe91 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) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { From 9c7cf7d734e0a34fd47bdbd66c3e87983f5b383e Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:46:11 +0000 Subject: [PATCH 40/56] feat(jellyfin): expose genreitems for song/album (#5809) * feat(jellyfin): expose genreitems for song/album * use encode id instead --- server/jellyfin/dto/dto.go | 1 + server/jellyfin/dto/mappers.go | 2 ++ server/jellyfin/dto/mappers_test.go | 7 ++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 9847a239c..2a817739b 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -70,6 +70,7 @@ type BaseItemDto struct { Artists []string `json:"Artists,omitempty"` ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` Genres []string `json:"Genres,omitempty"` + GenreItems []NameGuidPair `json:"GenreItems,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` SongCount *int `json:"SongCount,omitempty"` AlbumCount *int `json:"AlbumCount,omitempty"` diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index c65b8933d..2894edd6e 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -175,6 +175,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if len(mf.Genres) > 0 { for _, g := range mf.Genres { item.Genres = append(item.Genres, g.Name) + item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } else if mf.Genre != "" { item.Genres = []string{mf.Genre} @@ -216,6 +217,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { if len(al.Genres) > 0 { for _, g := range al.Genres { item.Genres = append(item.Genres, g.Name) + item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } return item diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index e72efd617..db0c56aa3 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -15,6 +15,7 @@ var _ = Describe("mappers", func() { ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1", Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, Year: 1999, Duration: 60, Size: 2_500_000, + Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}, } mf.PlayCount = 2 mf.Starred = true @@ -35,6 +36,8 @@ var _ = Describe("mappers", func() { Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) Describe("Fields gating (matches real Jellyfin)", func() { @@ -198,7 +201,7 @@ var _ = Describe("mappers", func() { }) It("maps an album to a MusicAlbum folder item", func() { - al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10} + al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} item := AlbumToBaseItem(al) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) @@ -211,6 +214,8 @@ var _ = Describe("mappers", func() { Expect(*item.ChildCount).To(Equal(10)) Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) + Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) It("maps an artist to a MusicArtist folder item", func() { From 09ac342f5ae58ca69d60ca2d0cdf2302cf5bf76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 18:16:52 -0400 Subject: [PATCH 41/56] fix(jellyfin): split Artists/ArtistItems per track artist (#5810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): split Artists/ArtistItems per track artist The Jellyfin API returned a single ArtistItems entry built from the flattened display artist, so a multi-artist track (e.g. "De La Soul feat. Redman") lost its individual artists and dropped every artist id but the first — while the same track's OpenSubsonic response correctly split them. Real Jellyfin splits Artists/ArtistItems one entry per track artist. Build both from Participants[RoleArtist], which is already loaded on the search and browse paths, falling back to the flattened display fields when absent. AlbumArtists stays a single credit, matching real Jellyfin. * fix(jellyfin): omit empty Artists in the fallback path When a track has no participants and no display artist, the fallback set Artists to a single-element slice holding an empty string. Only populate it when the display artist is non-empty, so untagged tracks omit the field instead of sending [""]. --- server/jellyfin/dto/mappers.go | 21 +++++++++++++++------ server/jellyfin/dto/mappers_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 2894edd6e..dc9bc5fbd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -6,6 +6,7 @@ import ( "time" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" ) // Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. @@ -139,7 +140,6 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { Album: mf.Album, AlbumId: EncodeID(mf.AlbumID), AlbumArtist: mf.AlbumArtist, - Artists: []string{mf.Artist}, RunTimeTicks: TicksFromSeconds(mf.Duration), DateCreated: jellyfinDate(&mf.CreatedAt), Container: mf.Suffix, @@ -153,11 +153,20 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if fields.Has("SortName") { item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title) } - // Finamp's Now Playing screen reads ArtistItems for the displayed artist (falling back to "Unknown - // Artist" if absent), even though Artists carries the same name. ArtistItems is the track artist; - // AlbumArtists the album artist. - if mf.ArtistID != "" { - item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + // Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit). + // Participants holds the per-artist list; fall back to the flattened display fields when absent. + if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 { + item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name }) + item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair { + return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)} + }) + } else { + if mf.Artist != "" { + item.Artists = []string{mf.Artist} + } + if mf.ArtistID != "" { + item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + } } if mf.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index db0c56aa3..6bfc9d95a 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -99,6 +99,32 @@ var _ = Describe("mappers", func() { Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) }) + It("omits Artists when the track has no artist name or participants", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil()) + }) + + It("splits Artists and ArtistItems per track artist from Participants", func() { + mf := model.MediaFile{ + ID: "s1", Title: "Oooh", + Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul", + AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul", + } + mf.Participants = model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}}, + {Artist: model.Artist{ID: "ar-redman", Name: "Redman"}}, + }, + } + item := SongToBaseItem(mf, nil) + Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"})) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{ + {Name: "De La Soul", Id: EncodeID("ar-delasoul")}, + {Name: "Redman", Id: EncodeID("ar-redman")}, + })) + // AlbumArtists stays single, matching real Jellyfin. + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) + }) + It("builds a MediaSourceInfo from a media file", func() { mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) From 1e82f515c462bc08c1f0ffe058e286a20560626c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 18:59:08 -0400 Subject: [PATCH 42/56] fix(jellyfin): honor repeated Fields query params (#5811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fields param was read with StringOr, which keeps only one value, so a request sending it as repeated params (Fields=Genres&Fields=MediaSources) — as Finamp and Feishin do — lost all but the first. Field-gated data like MediaSources was then omitted for Audio items even though the client asked for it; the comma-separated form happened to work because ParseFields splits on commas. Real Jellyfin accepts both forms. ParseFields is now variadic and a parseFields helper reads every repeated value via req.Values.Strings, applied to the item list, single-item, and playlist endpoints. --- server/jellyfin/dto/fields.go | 13 ++++++++----- server/jellyfin/dto/fields_test.go | 26 ++++++++++++++++++++++++++ server/jellyfin/e2e/browsing_test.go | 10 ++++++++++ server/jellyfin/items.go | 11 +++++++++-- server/jellyfin/playlists.go | 2 +- 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 server/jellyfin/dto/fields_test.go diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go index faa31ec0d..fb49e43c7 100644 --- a/server/jellyfin/dto/fields.go +++ b/server/jellyfin/dto/fields.go @@ -7,12 +7,15 @@ import "strings" // omits those unless the client asks for them. type Fields map[string]struct{} -// ParseFields splits the comma-separated Fields param into a lowercased set. -func ParseFields(csv string) Fields { +// ParseFields builds a lowercased set from the Fields param. It accepts each value comma-separated +// (Fields=a,b) and across repeated params (Fields=a&Fields=b), both of which real Jellyfin honors. +func ParseFields(values ...string) Fields { f := Fields{} - for name := range strings.SplitSeq(csv, ",") { - if name = strings.TrimSpace(strings.ToLower(name)); name != "" { - f[name] = struct{}{} + for _, csv := range values { + for name := range strings.SplitSeq(csv, ",") { + if name = strings.TrimSpace(strings.ToLower(name)); name != "" { + f[name] = struct{}{} + } } } return f diff --git a/server/jellyfin/dto/fields_test.go b/server/jellyfin/dto/fields_test.go new file mode 100644 index 000000000..59d06734d --- /dev/null +++ b/server/jellyfin/dto/fields_test.go @@ -0,0 +1,26 @@ +package dto + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseFields", func() { + It("parses a single comma-separated value", func() { + f := ParseFields("Genres,MediaSources") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + }) + + It("parses fields spread across repeated params", func() { + f := ParseFields("Genres", "MediaSources", "SortName") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + Expect(f.Has("SortName")).To(BeTrue()) + }) + + It("returns an empty set for no values", func() { + Expect(ParseFields()).To(BeEmpty()) + Expect(ParseFields("")).To(BeEmpty()) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 18565c635..382ec9406 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -62,6 +62,16 @@ var _ = Describe("Browsing", func() { } }) + // Clients (Finamp, Feishin) send Fields as repeated params rather than one comma-separated + // value; real Jellyfin accepts both, so a later Fields=MediaSources must still take effect. + It("honors MediaSources when Fields is sent as repeated params", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=Genres&Fields=MediaSources")) + Expect(q.Items).ToNot(BeEmpty()) + for _, it := range q.Items { + Expect(it.MediaSources).To(HaveLen(1)) + } + }) + It("lists all album artists", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) Expect(q.TotalRecordCount).To(Equal(4)) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index b9372c397..6ed24b056 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -224,13 +224,20 @@ type itemsQuery struct { albumIds []string } +// parseFields reads the Fields param, accepting both repeated params (Fields=a&Fields=b) and a +// comma-separated value (Fields=a,b), matching real Jellyfin. StringOr would keep only one value. +func parseFields(p *req.Values) dto.Fields { + values, _ := p.Strings("fields") + return dto.ParseFields(values...) +} + // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because // normalizeQueryKeys folded them (Jellyfin binds case-insensitively). func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { p := req.Params(r) q := itemsQuery{ - fields: dto.ParseFields(p.StringOr("fields", "")), + fields: parseFields(p), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), @@ -730,7 +737,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + fields := parseFields(req.Params(r)) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) return diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 084ff2975..4d3959a79 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { return } p := req.Params(r) - fields := dto.ParseFields(p.StringOr("fields", "")) + fields := parseFields(p) res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err) From 3158451b8d56cabf63e6b43b818bab99534d0396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 19:30:04 -0400 Subject: [PATCH 43/56] refactor(server): drop redundant error return from req.Strings parsing (#5812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(req): drop redundant error return from Strings The error from Strings carried no information beyond emptiness — it fired exactly when the param was absent — and nearly every caller discarded it with a blank identifier. Strings now just returns the values (empty when absent), making the common optional-list reads one clean expression. The few required-param callers (scrobble, createShare) check for emptiness and return the same Subsonic error code 10 as before; their e2e tests now pin that code. Ints and Times keep their contracts by synthesizing ErrMissingParam themselves, so selectedMusicFolderIds is untouched. The jellyfin parseFields helper is inlined away, since ParseFields(p.Strings("fields")...) now compiles directly. * docs(req): clarify Strings returns nil when param is absent --- server/jellyfin/items.go | 11 ++-------- server/jellyfin/playlists.go | 2 +- server/nativeapi/missing.go | 2 +- server/nativeapi/playlists.go | 2 +- server/subsonic/bookmarks.go | 4 ++-- .../e2e/subsonic_media_annotation_test.go | 1 + server/subsonic/e2e/subsonic_sharing_test.go | 1 + server/subsonic/jukebox.go | 4 ++-- server/subsonic/library_scanning.go | 3 ++- server/subsonic/media_annotation.go | 18 +++++++-------- server/subsonic/playlists.go | 4 ++-- server/subsonic/sharing.go | 6 ++--- utils/req/req.go | 22 +++++++++---------- utils/req/req_test.go | 4 +--- 14 files changed, 38 insertions(+), 46 deletions(-) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 6ed24b056..c3c217cfc 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -224,20 +224,13 @@ type itemsQuery struct { albumIds []string } -// parseFields reads the Fields param, accepting both repeated params (Fields=a&Fields=b) and a -// comma-separated value (Fields=a,b), matching real Jellyfin. StringOr would keep only one value. -func parseFields(p *req.Values) dto.Fields { - values, _ := p.Strings("fields") - return dto.ParseFields(values...) -} - // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because // normalizeQueryKeys folded them (Jellyfin binds case-insensitively). func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { p := req.Params(r) q := itemsQuery{ - fields: parseFields(p), + fields: dto.ParseFields(p.Strings("fields")...), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), @@ -737,7 +730,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - fields := parseFields(req.Params(r)) + fields := dto.ParseFields(req.Params(r).Strings("fields")...) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) return diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 4d3959a79..9afee5ac3 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { return } p := req.Params(r) - fields := parseFields(p) + fields := dto.ParseFields(p.Strings("fields")...) res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err) diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 2b455e622..0ad9bb0cc 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -68,7 +68,7 @@ func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { ctx := r.Context() p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") var err error if len(ids) == 0 { diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index ea1cf579b..90b2f9e94 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -102,7 +102,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := req.Params(r) playlistId, _ := p.String(":playlistId") - ids, _ := p.Strings("id") + ids := p.Strings("id") err := pls.RemoveTracks(r.Context(), playlistId, ids) if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0]) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 337712750..4a7ebaa6c 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -103,7 +103,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") currentID, _ := p.String("current") position := p.Int64Or("position", 0) @@ -176,7 +176,7 @@ func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, er func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") position := p.Int64Or("position", 0) diff --git a/server/subsonic/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go index ec9b070de..74b5238f2 100644 --- a/server/subsonic/e2e/subsonic_media_annotation_test.go +++ b/server/subsonic/e2e/subsonic_media_annotation_test.go @@ -155,6 +155,7 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) }) }) diff --git a/server/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go index 03bf1f80f..0421d96ea 100644 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ b/server/subsonic/e2e/subsonic_sharing_test.go @@ -109,6 +109,7 @@ var _ = Describe("Sharing Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) }) It("updateShare returns error when id parameter is missing", func() { diff --git a/server/subsonic/jukebox.go b/server/subsonic/jukebox.go index c4bc643ab..d8bf53360 100644 --- a/server/subsonic/jukebox.go +++ b/server/subsonic/jukebox.go @@ -68,7 +68,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) case ActionStatus: return createResponse(pb.Status(ctx)) case ActionSet: - ids, _ := p.Strings("id") + ids := p.Strings("id") return createResponse(pb.Set(ctx, ids)) case ActionStart: return createResponse(pb.Start(ctx)) @@ -82,7 +82,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) offset := p.IntOr("offset", 0) return createResponse(pb.Skip(ctx, index, offset)) case ActionAdd: - ids, _ := p.Strings("id") + ids := p.Strings("id") return createResponse(pb.Add(ctx, ids)) case ActionClear: return createResponse(pb.Clear(ctx)) diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index e6f64456d..9630425d2 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -45,7 +45,8 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { // Parse optional target parameters for selective scanning var targets []model.ScanTarget - if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 { + if targetParams := p.Strings("target"); len(targetParams) > 0 { + var err error targets, err = model.ParseTargets(targetParams) if err != nil { return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err)) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 27170c11b..cfbff3ecb 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -71,9 +71,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") - albumIds, _ := p.Strings("albumId") - artistIds, _ := p.Strings("artistId") + ids := p.Strings("id") + albumIds := p.Strings("albumId") + artistIds := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -90,9 +90,9 @@ func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") - albumIds, _ := p.Strings("albumId") - artistIds, _ := p.Strings("artistId") + ids := p.Strings("id") + albumIds := p.Strings("albumId") + artistIds := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -163,9 +163,9 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, err := p.Strings("id") - if err != nil { - return nil, err + ids := p.Strings("id") + if len(ids) == 0 { + return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") } times, _ := p.Times("time") if len(times) > 0 && len(times) != len(ids) { diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index c58fb9ab9..17ba1b2c9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -62,7 +62,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) - songIds, _ := p.Strings("songId") + songIds := p.Strings("songId") playlistId, _ := p.String("playlistId") name, _ := p.String("name") if playlistId == "" && name == "" { @@ -99,7 +99,7 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - songsToAdd, _ := p.Strings("songIdToAdd") + songsToAdd := p.Strings("songIdToAdd") songIndexesToRemove, _ := p.Ints("songIndexToRemove") var plsName *string if s, err := p.String("name"); err == nil { diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index a9ccfdca4..540ae79d7 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -52,9 +52,9 @@ func (api *Router) buildShare(r *http.Request, share model.Share) responses.Shar func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, err := p.Strings("id") - if err != nil { - return nil, err + ids := p.Strings("id") + if len(ids) == 0 { + return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") } description, _ := p.String("description") diff --git a/utils/req/req.go b/utils/req/req.go index 2757fc3f5..861cca9f7 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -60,12 +60,10 @@ func (r *Values) StringOr(param, def string) string { return v } -func (r *Values) Strings(param string) ([]string, error) { - values := r.URL.Query()[param] - if len(values) == 0 { - return nil, newError(ErrMissingParam, param) - } - return values, nil +// Strings returns all occurrences of the param, or a nil (empty) slice when absent. Callers that +// require the param should check for emptiness themselves. +func (r *Values) Strings(param string) []string { + return r.URL.Query()[param] } func (r *Values) TimeOr(param string, def time.Time) time.Time { @@ -85,9 +83,9 @@ func (r *Values) TimeOr(param string, def time.Time) time.Time { } func (r *Values) Times(param string) ([]time.Time, error) { - pStr, err := r.Strings(param) - if err != nil { - return nil, err + pStr := r.Strings(param) + if len(pStr) == 0 { + return nil, newError(ErrMissingParam, param) } times := make([]time.Time, len(pStr)) for i, t := range pStr { @@ -139,9 +137,9 @@ func (r *Values) Int64Or(param string, def int64) int64 { } func (r *Values) Ints(param string) ([]int, error) { - pStr, err := r.Strings(param) - if err != nil { - return nil, err + pStr := r.Strings(param) + if len(pStr) == 0 { + return nil, newError(ErrMissingParam, param) } ints := make([]int, 0, len(pStr)) for _, s := range pStr { diff --git a/utils/req/req_test.go b/utils/req/req_test.go index d76b3b934..5f9de8483 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -60,9 +60,7 @@ var _ = Describe("Request Helpers", func() { }) It("returns empty array if param does not exist", func() { - v, err := r.Strings("xx") - Expect(err).To(MatchError(req.ErrMissingParam)) - Expect(v).To(BeEmpty()) + Expect(r.Strings("xx")).To(BeEmpty()) }) }) From 59f1b4206c9fd40b10b1bb32938b546c646c8034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 19:36:12 -0400 Subject: [PATCH 44/56] fix(jellyfin): serve playlist covers regardless of visibility (#5813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image endpoint only served a private playlist's cover when the request carried a token identifying its owner or an admin. But clients fetch cover URLs without credentials — real Jellyfin's image routes are anonymous — so every private playlist rendered the generic placeholder in Jellyfin clients (observed in production), while the same covers displayed fine through the always-authenticated Subsonic/native APIs. Drop the gate and serve playlist covers like album/artist/track artwork: playlist ids are unguessable without credentials, so anonymous access does not meaningfully expose private playlist contents. --- server/jellyfin/e2e/images_test.go | 34 ++++++++---------------------- server/jellyfin/images.go | 16 +++++--------- server/jellyfin/images_test.go | 17 ++------------- server/jellyfin/middlewares.go | 2 +- 4 files changed, 17 insertions(+), 52 deletions(-) diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go index 0c53d75f3..7fb85cba8 100644 --- a/server/jellyfin/e2e/images_test.go +++ b/server/jellyfin/e2e/images_test.go @@ -28,9 +28,9 @@ var _ = Describe("Item images", func() { Expect(artworkSpy.lastID).To(ContainSubstring(id)) }) - It("resolves a private playlist's cover for its owner under an elevated context", func() { - // The route carries no user in ctx (public); the owner is identified by the request token, - // and resolution then runs elevated so the visibility filter doesn't eat the cover. + It("resolves a private playlist's cover under an elevated context", func() { + // The route carries no user in ctx (public); resolution runs elevated so the visibility + // filter doesn't eat the cover. plID := createPlaylist("Private Mix", nil) w := get("/Items/" + enc(plID) + "/Images/Primary") Expect(w.Code).To(Equal(http.StatusOK)) @@ -48,27 +48,11 @@ var _ = Describe("Item images", func() { Expect(w.Body.String()).To(Equal("IMG")) }) - Describe("private playlist covers", func() { - It("does not resolve a private playlist's cover for an unauthenticated caller", func() { - plID := createPlaylist("Secret Mix", nil) // owned by admin, private - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) // placeholder, not an auth error - Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) - }) - - It("does not resolve a private playlist's cover for another user", func() { - plID := createPlaylist("Secret Mix", nil) - w := getAs(regularUser, "/Items/"+enc(plID)+"/Images/Primary") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) - }) - - It("resolves a public playlist's cover for anyone", func() { - plID := createPlaylist("Shared Mix", nil) - Expect(post("/Playlists/"+enc(plID), `{"IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(plID)) - }) + // Covers are served regardless of playlist visibility — see getItemImage for the rationale. + It("resolves a private playlist's cover for an unauthenticated caller", func() { + plID := createPlaylist("Secret Mix", nil) // owned by admin, private + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) }) }) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 722e828c5..0ec34f491 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -25,14 +25,13 @@ import ( ) func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { - // Public endpoint (no user in ctx): library artwork isn't user-sensitive, so resolution runs - // under an elevated context to bypass the persistence visibility filter; playlist access is - // gated inside resolveArtworkID. + // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials + // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) - artID := api.resolveArtworkID(ctx, r, itemId) + artID := api.resolveArtworkID(ctx, itemId) reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) switch { case errors.Is(err, context.Canceled): @@ -49,7 +48,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { // resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing // album -> artist -> media file -> playlist. -func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId string) string { +func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { if al, err := api.ds.Album(ctx).Get(itemId); err == nil { return al.CoverArtID().String() } @@ -60,12 +59,7 @@ func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId return mf.CoverArtID().String() } if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil { - // Playlist covers are user-scoped: serve a private one only for a public playlist or a - // token identifying its owner/an admin, so this public route can't probe others' covers. - u, ok := api.userFromToken(r) - if pl.Public || (ok && (u.IsAdmin || pl.OwnerID == u.ID)) { - return pl.CoverArtID().String() - } + return pl.CoverArtID().String() } return (model.ArtworkID{}).String() } diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 8099fcf94..e435b99fd 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -85,20 +85,7 @@ var _ = Describe("Images", func() { Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) }) - It("resolves a public playlist id to its cover artwork", func() { - ds := &tests.MockDataStore{} - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", Public: true}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID("pl1")) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(ContainSubstring("pl1")) - }) - - It("serves the placeholder, not the cover, for a private playlist and an anonymous caller", func() { + It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) fa := &fakeArtwork{} @@ -108,7 +95,7 @@ var _ = Describe("Images", func() { api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).ToNot(ContainSubstring("pl1")) + Expect(fa.recvId).To(ContainSubstring("pl1")) }) // This endpoint is public (no user in the request), so artwork must be resolved under an diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 840143ac8..c90f9c088 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -152,7 +152,7 @@ func tokenFromRequest(r *http.Request) string { } // userFromToken resolves the user for the request's token; ok is false for a missing/invalid token -// or unknown subject. Used by authenticate and by public routes that optionally identify the caller. +// or unknown subject. func (api *Router) userFromToken(r *http.Request) (model.User, bool) { token := tokenFromRequest(r) if token == "" { From 7234ea23b76c228385922cc674f7c56a1d55b3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 20:26:14 -0400 Subject: [PATCH 45/56] feat(jellyfin): expose NormalizationGain from ReplayGain tags (#5815) * feat(jellyfin): expose NormalizationGain from ReplayGain tags Adds NormalizationGain and AlbumNormalizationGain to Audio BaseItemDtos, sourced from the scanner's ReplayGain values (REPLAYGAIN_* tags, with R128_* already converted to the same -18 LUFS reference). Same wire contract as real Jellyfin: PascalCase keys, omitted when absent, no Fields gating. Album items intentionally omit the field: model.Album has no gain column and Feishin reads gain from song DTOs. * test(jellyfin): e2e coverage for NormalizationGain fields * test(jellyfin): drop redundant NormalizationGain passthrough spec The JSON-casing spec already proves the mapped values (the substring "NormalizationGain":-3.5 can only appear if the passthrough worked), so the direct-struct spec added no coverage. * style(jellyfin): use ASCII punctuation in gain comment --- server/jellyfin/dto/dto.go | 30 ++++++++++++++------------- server/jellyfin/dto/mappers.go | 3 +++ server/jellyfin/dto/mappers_test.go | 16 ++++++++++++++ server/jellyfin/e2e/browsing_test.go | 16 ++++++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 4 +++- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 2a817739b..c8f7129cb 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -61,20 +61,22 @@ type BaseItemDto struct { PremiereDate *string `json:"PremiereDate,omitempty"` // DateCreated is the ISO 8601 date the item was added to the library; clients show it as // "Date Added" and sort "Recently Added" by it. - DateCreated string `json:"DateCreated,omitempty"` - Album string `json:"Album,omitempty"` - AlbumId string `json:"AlbumId,omitempty"` - AlbumArtist string `json:"AlbumArtist,omitempty"` - AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` - AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` - Artists []string `json:"Artists,omitempty"` - ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` - Genres []string `json:"Genres,omitempty"` - GenreItems []NameGuidPair `json:"GenreItems,omitempty"` - ChildCount *int `json:"ChildCount,omitempty"` - SongCount *int `json:"SongCount,omitempty"` - AlbumCount *int `json:"AlbumCount,omitempty"` - ImageTags map[string]string `json:"ImageTags,omitempty"` + DateCreated string `json:"DateCreated,omitempty"` + Album string `json:"Album,omitempty"` + AlbumId string `json:"AlbumId,omitempty"` + AlbumArtist string `json:"AlbumArtist,omitempty"` + AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` + AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` + Artists []string `json:"Artists,omitempty"` + ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` + Genres []string `json:"Genres,omitempty"` + GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + NormalizationGain *float64 `json:"NormalizationGain,omitempty"` + AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` + ChildCount *int `json:"ChildCount,omitempty"` + SongCount *int `json:"SongCount,omitempty"` + AlbumCount *int `json:"AlbumCount,omitempty"` + ImageTags map[string]string `json:"ImageTags,omitempty"` // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't // calculating blurhashes. diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index dc9bc5fbd..bdb0a1e37 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -171,6 +171,9 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if mf.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} } + // dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion. + item.NormalizationGain = mf.RGTrackGain + item.AlbumNormalizationGain = mf.RGAlbumGain if mf.Year > 0 { item.ProductionYear = new(mf.Year) } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 6bfc9d95a..8867a5e73 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -125,6 +125,22 @@ var _ = Describe("mappers", func() { Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) }) + It("serializes normalization gains with Jellyfin's exact key casing", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", + RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)} + b, err := json.Marshal(SongToBaseItem(mf, nil)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-3.5`)) + Expect(string(b)).To(ContainSubstring(`"AlbumNormalizationGain":-4.25`)) + }) + + It("omits normalization gains when the file has no ReplayGain tags", func() { + b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil)) + Expect(err).ToNot(HaveOccurred()) + // Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain). + Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) + }) + It("builds a MediaSourceInfo from a media file", func() { mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 382ec9406..a0632994e 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -415,6 +415,22 @@ var _ = Describe("Browsing", func() { Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) }) + It("exposes NormalizationGain and AlbumNormalizationGain from ReplayGain tags", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &item) + Expect(item.NormalizationGain).ToNot(BeNil()) + Expect(*item.NormalizationGain).To(BeNumerically("~", -3.5, 0.001)) + Expect(item.AlbumNormalizationGain).ToNot(BeNil()) + Expect(*item.AlbumNormalizationGain).To(BeNumerically("~", -4.25, 0.001)) + }) + + It("omits normalization gains for files without ReplayGain tags", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.NormalizationGain).To(BeNil()) + Expect(item.AlbumNormalizationGain).To(BeNil()) + }) + It("resolves an artist", func() { var item dto.BaseItemDto parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 445566387..5a4c8c031 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -121,7 +121,9 @@ func buildTestFS() storagetest.FakeFS { "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{ - "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + "replaygain_track_gain": "-3.50 dB", + "replaygain_album_gain": "-4.25 dB", })), "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), From 4efd92cf835bd7a8e3c971b5b87e834b0f32ee6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 22:19:29 -0400 Subject: [PATCH 46/56] feat(server): expose album-level ReplayGain in albums (#5816) * feat(model): aggregate album ReplayGain in ToAlbum * feat(db): add nullable album ReplayGain columns with backfill * feat(persistence): persist album ReplayGain fields * feat(jellyfin): expose album NormalizationGain from ReplayGain * refactor(model): lazy-init mostFrequentPtr map; clean up RG test rows * fix(db): backfill album ReplayGain with most-frequent value, not max A plain max() picked a minority outlier that diverged from MediaFiles.ToAlbum (which uses the most-frequent value), and GetTouchedAlbums never re-derives an unchanged album, so the wrong value would persist. Reproduce the modal aggregation via grouped CTEs, which also groups media_file once instead of a per-album correlated scan. * refactor(model): return an owned pointer from mostFrequentPtr Avoid aliasing a MediaFile field so the resulting Album is independent of the source slice. --- .../20260719005427_add_album_replaygain.go | 44 +++++++++++++++++++ model/album.go | 2 + model/mediafile.go | 32 ++++++++++++++ model/mediafile_test.go | 29 ++++++++++++ persistence/album_repository.go | 6 +++ persistence/album_repository_test.go | 27 ++++++++++++ server/jellyfin/dto/mappers.go | 3 ++ server/jellyfin/dto/mappers_test.go | 15 +++++++ 8 files changed, 158 insertions(+) create mode 100644 db/migrations/20260719005427_add_album_replaygain.go diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go new file mode 100644 index 000000000..79c8b8b3e --- /dev/null +++ b/db/migrations/20260719005427_add_album_replaygain.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain) +} + +func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // Backfill the most-frequent non-null value per album, matching MediaFiles.ToAlbum. A plain + // max() would pick a minority outlier, and GetTouchedAlbums never re-derives an unchanged album, + // so a wrong value would persist. The CTEs group media_file once instead of scanning it per album. + _, err := tx.ExecContext(ctx, ` +ALTER TABLE album ADD COLUMN rg_album_gain real; +ALTER TABLE album ADD COLUMN rg_album_peak real; + +WITH gain_mode AS ( + SELECT album_id, rg_album_gain AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_gain) AS rn + FROM media_file WHERE rg_album_gain IS NOT NULL + GROUP BY album_id, rg_album_gain +), +peak_mode AS ( + SELECT album_id, rg_album_peak AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_peak) AS rn + FROM media_file WHERE rg_album_peak IS NOT NULL + GROUP BY album_id, rg_album_peak +) +UPDATE album SET + rg_album_gain = (SELECT val FROM gain_mode WHERE gain_mode.album_id = album.id AND gain_mode.rn = 1), + rg_album_peak = (SELECT val FROM peak_mode WHERE peak_mode.album_id = album.id AND peak_mode.rn = 1); + `) + return err +} + +func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/album.go b/model/album.go index ade7f6ee0..16727ea39 100644 --- a/model/album.go +++ b/model/album.go @@ -49,6 +49,8 @@ type Album struct { MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` + RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` + RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` // External metadata fields Description string `structs:"description" json:"description,omitempty" hash:"ignore"` diff --git a/model/mediafile.go b/model/mediafile.go index 90db80f71..910cd998d 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -314,6 +314,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears := make([]int, 0, len(mfs)) originalDates := make([]string, 0, len(mfs)) releaseDates := make([]string, 0, len(mfs)) + rgAlbumGains := make([]*float64, 0, len(mfs)) + rgAlbumPeaks := make([]*float64, 0, len(mfs)) tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs)) a.Missing = true @@ -344,6 +346,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears = append(originalYears, m.OriginalYear) originalDates = append(originalDates, m.OriginalDate) releaseDates = append(releaseDates, m.ReleaseDate) + rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain) + rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak) comments = append(comments, m.Comment) mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID) @@ -378,6 +382,8 @@ func (mfs MediaFiles) ToAlbum() Album { a.Comment, _ = allOrNothing(comments) a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds) + a.RGAlbumGain = mostFrequentPtr(rgAlbumGains) + a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks) fixAlbumArtist(&a) return a @@ -407,6 +413,32 @@ func minMax(items []int) (int, int) { return mn, mx } +// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if +// none. It counts by dereferenced value so a genuine 0.0 is a real candidate +// (slice.MostFrequent skips the zero value and compares pointers by identity). +func mostFrequentPtr(items []*float64) *float64 { + var counts map[float64]int + var best float64 + var bestCount int + for _, it := range items { + if it == nil { + continue + } + if counts == nil { + counts = map[float64]int{} + } + counts[*it]++ + if counts[*it] > bestCount { + bestCount = counts[*it] + best = *it + } + } + if bestCount == 0 { + return nil + } + return &best +} + func newer(t1, t2 time.Time) time.Time { if t1.After(t2) { return t1 diff --git a/model/mediafile_test.go b/model/mediafile_test.go index c94653530..6085ccf25 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -268,6 +268,35 @@ var _ = Describe("MediaFiles", func() { }) }) }) + Context("ReplayGain", func() { + It("picks the most frequent non-nil album gain and peak", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(-8.0)) + Expect(album.RGAlbumPeak).ToNot(BeNil()) + Expect(*album.RGAlbumPeak).To(Equal(0.9)) + }) + It("keeps a genuine 0.0 gain instead of dropping it", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(0.0)}, + {Path: "b", RGAlbumGain: new(0.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(0.0)) + }) + It("leaves gain and peak nil when no track has a value", func() { + mfs := MediaFiles{{Path: "a"}, {Path: "b"}} + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).To(BeNil()) + Expect(album.RGAlbumPeak).To(BeNil()) + }) + }) Context("Participants", func() { var album Album BeforeEach(func() { diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6ebbd9202..53e8efe32 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -31,6 +31,10 @@ type dbAlbum struct { Participants string `structs:"-" json:"-"` Tags string `structs:"-" json:"-"` FolderIDs string `structs:"-" json:"-"` + // dbx maps columns to fields by name; RGAlbumGain doesn't convert to + // rg_album_gain, so shim fields carry the read and PostScan copies them over. + RgAlbumGain *float64 `structs:"-" json:"-"` + RgAlbumPeak *float64 `structs:"-" json:"-"` } func (a *dbAlbum) PostScan() error { @@ -58,6 +62,8 @@ func (a *dbAlbum) PostScan() error { } a.Album.FolderIDs = ids } + a.Album.RGAlbumGain = a.RgAlbumGain + a.Album.RGAlbumPeak = a.RgAlbumPeak return nil } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 64ff0095e..761255b7d 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -890,6 +890,33 @@ var _ = Describe("AlbumRepository", func() { Expect(albums[0].ID).To(Equal("a1")) }) }) + + Describe("ReplayGain", func() { + BeforeEach(func() { + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}})) + }) + }) + It("round-trips album ReplayGain gain and peak", func() { + Expect(albumRepo.Put(&model.Album{ + ID: "rg-1", Name: "rg", LibraryID: 1, + RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98), + })).To(Succeed()) + got, err := albumRepo.Get("rg-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).ToNot(BeNil()) + Expect(*got.RGAlbumGain).To(Equal(-7.5)) + Expect(got.RGAlbumPeak).ToNot(BeNil()) + Expect(*got.RGAlbumPeak).To(Equal(0.98)) + }) + It("reads nil when ReplayGain is unset", func() { + Expect(albumRepo.Put(&model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed()) + got, err := albumRepo.Get("rg-2") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).To(BeNil()) + Expect(got.RGAlbumPeak).To(BeNil()) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bdb0a1e37..036dfbd91 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -232,6 +232,9 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same + // convention as tracks; clients read it off the album item as NormalizationGain. + item.NormalizationGain = al.RGAlbumGain return item } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 8867a5e73..c986e7f81 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -260,6 +260,21 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("sets NormalizationGain on the album from its ReplayGain", func() { + al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} + b, err := json.Marshal(AlbumToBaseItem(al)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) + // Real Jellyfin never sets AlbumNormalizationGain on an album item. + Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain")) + }) + + It("omits NormalizationGain when the album has no ReplayGain", func() { + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) + }) + It("maps an artist to a MusicArtist folder item", func() { ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} item := ArtistToBaseItem(ar) From b27d6f61ae7813d47eb4479637187bf26ab35adb Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 22:59:53 -0400 Subject: [PATCH 47/56] fix(db): make album ReplayGain backfill scale to large libraries The windowed-CTE backfill was compiled as a correlated scalar subquery re-evaluated once per album (a full media_file group-by + window sort each time), which never finished on a large library. Stage the ReplayGain-bearing rows into an indexed temp table once and update only the affected albums: ~14s on a 727MB / 6945-album library, vs. effectively never. --- .../20260719005427_add_album_replaygain.go | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go index 79c8b8b3e..7f5665947 100644 --- a/db/migrations/20260719005427_add_album_replaygain.go +++ b/db/migrations/20260719005427_add_album_replaygain.go @@ -12,28 +12,25 @@ func init() { } func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { - // Backfill the most-frequent non-null value per album, matching MediaFiles.ToAlbum. A plain - // max() would pick a minority outlier, and GetTouchedAlbums never re-derives an unchanged album, - // so a wrong value would persist. The CTEs group media_file once instead of scanning it per album. + // Backfill the most-frequent value per album (matching MediaFiles.ToAlbum), staging RG-bearing rows + // into an indexed temp table — a correlated subquery over a windowed CTE re-scans media_file per album. _, err := tx.ExecContext(ctx, ` ALTER TABLE album ADD COLUMN rg_album_gain real; ALTER TABLE album ADD COLUMN rg_album_peak real; -WITH gain_mode AS ( - SELECT album_id, rg_album_gain AS val, - row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_gain) AS rn - FROM media_file WHERE rg_album_gain IS NOT NULL - GROUP BY album_id, rg_album_gain -), -peak_mode AS ( - SELECT album_id, rg_album_peak AS val, - row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_peak) AS rn - FROM media_file WHERE rg_album_peak IS NOT NULL - GROUP BY album_id, rg_album_peak -) +CREATE TEMP TABLE _rg_backfill AS + SELECT album_id, rg_album_gain, rg_album_peak FROM media_file + WHERE rg_album_gain IS NOT NULL OR rg_album_peak IS NOT NULL; +CREATE INDEX _rg_backfill_album ON _rg_backfill(album_id); + UPDATE album SET - rg_album_gain = (SELECT val FROM gain_mode WHERE gain_mode.album_id = album.id AND gain_mode.rn = 1), - rg_album_peak = (SELECT val FROM peak_mode WHERE peak_mode.album_id = album.id AND peak_mode.rn = 1); + rg_album_gain = (SELECT rg_album_gain FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_gain IS NOT NULL + GROUP BY rg_album_gain ORDER BY count(*) DESC, rg_album_gain LIMIT 1), + rg_album_peak = (SELECT rg_album_peak FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_peak IS NOT NULL + GROUP BY rg_album_peak ORDER BY count(*) DESC, rg_album_peak LIMIT 1) +WHERE album.id IN (SELECT album_id FROM _rg_backfill); + +DROP TABLE _rg_backfill; `) return err } From 5927e693d128cb3c830f6d87b59bfd5128d35dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 12:39:31 -0400 Subject: [PATCH 48/56] feat(jellyfin): filter items by year and record label (#5817) * feat(persistence): add AlbumRepository.GetYears for distinct album years * test(persistence): verify GetYears de-duplicates repeated years Regression test that adds two albums with the same non-zero max_year (2005) and verifies that GetYears() returns that year exactly once, ensuring the SQL DISTINCT clause is applied correctly. Catches any future removal of DISTINCT from the GetYears query. * feat(jellyfin): add legacy /Items/Filters endpoint (genres + years) * feat(jellyfin): add /Studios endpoint from record label tags * refactor(persistence): drop duplicate columns in tagRepository.GetAll * fix(jellyfin): exclude missing albums from filter years GetYears only filtered max_year > 0, so albums whose files were all removed (missing=true, kept when Scanner.PurgeMissing=never) contributed stale years to /Items/Filters. Filter them out like the normal album listings do. Also return an empty slice from the MockAlbumRepo to match the real repository. * feat(jellyfin): filter /Items by Years= * feat(jellyfin): filter /Items by StudioIds= (record labels) * feat(jellyfin): scope filter and studio lists to ParentId library * refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call sites and the duplicated empty-scope guard into two small helpers, so the empty-scope=unrestricted contract lives in one place. Reuse the existing names() helper in the Years= e2e test. * feat(jellyfin): expose record labels as album Studios Add a Studios field to the album BaseItemDto, populated from the record-label tags and gated behind Fields=Studios (matching Jellyfin's ItemFields convention). Studio ids reuse the record-label tag identity, so they round-trip with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios empty for music; Feishin reads it as the album's record label. --- model/album.go | 1 + model/tag.go | 1 + persistence/album_repository.go | 15 +++++++ persistence/album_repository_test.go | 60 +++++++++++++++++++++++++ persistence/tag_repository.go | 11 ++++- server/filter/filters.go | 25 +++++++++-- server/jellyfin/api.go | 2 + server/jellyfin/browsing.go | 44 +++++++++++++++++- server/jellyfin/browsing_test.go | 48 ++++++++++++++++++++ server/jellyfin/dto/dto.go | 10 +++++ server/jellyfin/dto/filters_test.go | 22 +++++++++ server/jellyfin/dto/mappers.go | 19 +++++++- server/jellyfin/dto/mappers_test.go | 32 ++++++++++--- server/jellyfin/e2e/browsing_test.go | 65 +++++++++++++++++++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 2 +- server/jellyfin/items.go | 42 ++++++++++++++--- server/jellyfin/library.go | 17 +++++++ server/jellyfin/similar.go | 2 +- tests/mock_album_repo.go | 7 +++ tests/mock_data_store.go | 2 +- tests/mock_genre_repo.go | 10 +++-- tests/mock_tag_repo.go | 24 ++++++++++ 22 files changed, 435 insertions(+), 26 deletions(-) create mode 100644 server/jellyfin/dto/filters_test.go create mode 100644 tests/mock_tag_repo.go diff --git a/model/album.go b/model/album.go index 16727ea39..0afeb2760 100644 --- a/model/album.go +++ b/model/album.go @@ -144,6 +144,7 @@ type AlbumRepository interface { Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) GetCursor(...QueryOptions) (AlbumCursor, error) + GetYears(libraryIDs ...int) ([]int, error) // The following methods are used exclusively by the scanner: Touch(ids ...string) error diff --git a/model/tag.go b/model/tag.go index 02ccac05d..1bc011495 100644 --- a/model/tag.go +++ b/model/tag.go @@ -153,6 +153,7 @@ func (t Tags) Add(name TagName, v string) { type TagRepository interface { Add(libraryID int, tags ...Tag) error UpdateCounts() error + GetAll(name TagName, options ...QueryOptions) (TagList, error) } type TagName string diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 53e8efe32..2bb541003 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "encoding/json" + "errors" "fmt" "iter" "maps" @@ -262,6 +263,20 @@ func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumC return wrapAlbumCursor(cursor), nil } +func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { + cond := And{Gt{"max_year": 0}, Eq{"missing": false}} + if len(libraryIDs) > 0 { + cond = append(cond, Eq{"library_id": libraryIDs}) + } + sq := r.applyLibraryFilter(Select("distinct max_year").From("album").Where(cond).OrderBy("max_year")) + years := []int{} + err := r.queryAllSlice(sq, &years) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + return years, nil +} + func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { var from dbx.NullStringMap err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 761255b7d..465617b10 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "errors" "fmt" + "sort" "time" "github.com/Masterminds/squirrel" @@ -851,6 +852,65 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetYears", func() { + It("returns distinct album years ascending, excluding zero", func() { + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // Sorted ascending, no duplicates, no zero-year entries. + Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue()) + Expect(years).ToNot(ContainElement(0)) + for i := 1; i < len(years); i++ { + Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct + } + }) + + It("deduplicates repeated years", func() { + // Regression test: verify that DISTINCT is applied in the SQL. + // Insert two albums with the same non-zero max_year (2005). + album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005} + album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005} + Expect(albumRepo.Put(album1)).To(Succeed()) + Expect(albumRepo.Put(album2)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + + // Count occurrences of 2005 in the result + count := 0 + for _, y := range years { + if y == 2005 { + count++ + } + } + Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it") + }) + + It("scopes years to the given libraries", func() { + all, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // A library with no albums yields no years. + scoped, err := albumRepo.GetYears(99999) + Expect(err).ToNot(HaveOccurred()) + Expect(scoped).To(BeEmpty()) + Expect(all).ToNot(BeEmpty()) + }) + + It("excludes years that belong only to missing albums", func() { + gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true} + Expect(albumRepo.Put(gone)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + Expect(years).ToNot(ContainElement(1911)) + }) + }) + Describe("wrapAlbumCursor", func() { It("does not panic when the cursor yields a dbAlbum with nil Album", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index 5bb8b3832..f2093c9d8 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -74,13 +74,20 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; return nil } +func (r *tagRepository) GetAll(name model.TagName, options ...model.QueryOptions) (model.TagList, error) { + sq := r.newSelect(options...).Where(Eq{"tag.tag_name": name}) + res := model.TagList{} + err := r.queryAll(sq, &res) + return res, err +} + func (r *tagRepository) purgeUnused() error { - del := Delete(r.tableName).Where(` + del := Delete(r.tableName).Where(` id not in (select jt.value from album left join json_tree(album.tags, '$') as jt where atom is not null and key = 'id' - UNION + UNION select jt.value from media_file left join json_tree(media_file.tags, '$') as jt where atom is not null diff --git a/server/filter/filters.go b/server/filter/filters.go index 5fa53daba..067f7b1f9 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -194,6 +194,16 @@ func ByAlbumID(albumIds []string) Sqlizer { return Eq{"album_id": albumIds} } +// AlbumsByYears matches albums whose production year (max_year) is in years. +func AlbumsByYears(years []int) Sqlizer { + return Eq{"max_year": years} +} + +// SongsByYears matches media files whose year is in years. +func SongsByYears(years []int) Sqlizer { + return Eq{"year": years} +} + // ArtistsByGenreID matches artists credited as album artist on an album with any of the given // genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. func ArtistsByGenreID(genreIds []string) Sqlizer { @@ -204,10 +214,17 @@ func ArtistsByGenreID(genreIds []string) Sqlizer { ) } -// genreTagFilter builds an EXISTS over the genre entries in the tags JSON, matching each entry -// against cond (its name via Like, or its tag id via Eq/IN). Shared by the name- and id-based lookups. -func genreTagFilter(cond Sqlizer) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.genre")`, And{NotEq{"atom": nil}, cond}) +// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each +// entry against cond (its name via Like, or its tag id via Eq/IN). +func tagIDFilter(tagName string, cond Sqlizer) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond}) +} + +func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) } + +// ByStudioID matches items (albums or songs) whose record-label tag id is in ids. +func ByStudioID(ids []string) Sqlizer { + return tagIDFilter("recordlabel", Eq{"value": ids}) } func filterByGenre(genre string) Sqlizer { diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 0f79011c7..1f46c08b4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -146,6 +146,8 @@ func (api *Router) routes() http.Handler { r.Get("/items/{itemId}/instantmix", api.getInstantMix) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) + r.Get("/studios", api.getStudios) + r.Get("/items/filters", api.getQueryFiltersLegacy) r.Post("/playlists", api.createPlaylist) r.Get("/playlists/{playlistId}", api.getPlaylist) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index fae21fc0b..29fe48c2b 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" ) // getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists @@ -27,7 +28,7 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) - scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + scopeIDs, _ := parentIDScope(ctx, r) // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. // Finamp's artist tab sends GenreIds when a genre filter is active. q := itemsQuery{ @@ -59,3 +60,44 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { } api.ok(w, r, res) } + +// getStudios handles GET /Studios, exposing record labels (Jellyfin's audio "studio" source) as +// Studio items, scoped to ParentId's library when accessible. +func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + scope, _ := parentIDScope(ctx, r) + opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} + labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts) + if err != nil { + api.internalError(w, r, err) + return + } + items := slice.Map(labels, dto.StudioToBaseItem) + offset, max := p.IntOr("startindex", 0), p.IntOr("limit", 0) + api.ok(w, r, result(paginate(items, offset, max), len(items), offset)) +} + +// getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's +// library when accessible. Tags/OfficialRatings have no music source, so they are always empty. +func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + scope, _ := parentIDScope(ctx, r) + genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} + genres, err := api.ds.Genre(ctx).GetAll(genreOpts) + if err != nil { + api.internalError(w, r, err) + return + } + years, err := api.ds.Album(ctx).GetYears(scope...) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, dto.QueryFiltersLegacy{ + Genres: slice.Map(genres, func(g model.Genre) string { return g.Name }), + Tags: []string{}, + OfficialRatings: []string{}, + Years: years, + }) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 660e5d293..e346b56cb 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -175,4 +175,52 @@ var _ = Describe("Browsing", func() { Expect(w.Code).To(Equal(http.StatusOK)) }) }) + + Describe("getStudios", func() { + It("scopes results to the user's accessible libraries", func() { + tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := tagRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_tag.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + // An empty scope (admin, or a non-admin with no explicit library grants) must be treated + // as unrestricted, matching accessibleLibraryIDs' documented contract, not as "match nothing". + It("does not restrict results for an admin user", func() { + tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxAdmin()) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(tagRepo.Options.Filters).To(BeNil()) + }) + }) + + Describe("getQueryFiltersLegacy", func() { + It("scopes genres to the user's accessible libraries", func() { + genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := genreRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_tag.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("does not restrict genres for an admin user", func() { + genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxAdmin()) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(genreRepo.Options.Filters).To(BeNil()) + }) + }) }) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index c8f7129cb..9b2c35c69 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -71,6 +71,7 @@ type BaseItemDto struct { ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` Genres []string `json:"Genres,omitempty"` GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + Studios []NameGuidPair `json:"Studios,omitempty"` NormalizationGain *float64 `json:"NormalizationGain,omitempty"` AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` @@ -288,3 +289,12 @@ type LyricLineCue struct { Start int64 `json:"Start"` End *int64 `json:"End,omitempty"` } + +// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present; +// clients (jellyfin-web) render each unconditionally. +type QueryFiltersLegacy struct { + Genres []string `json:"Genres"` + Tags []string `json:"Tags"` + OfficialRatings []string `json:"OfficialRatings"` + Years []int `json:"Years"` +} diff --git a/server/jellyfin/dto/filters_test.go b/server/jellyfin/dto/filters_test.go new file mode 100644 index 000000000..ad3367264 --- /dev/null +++ b/server/jellyfin/dto/filters_test.go @@ -0,0 +1,22 @@ +package dto + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("QueryFiltersLegacy", func() { + It("marshals all four keys, empty ones as [] not null", func() { + b, err := json.Marshal(QueryFiltersLegacy{ + Genres: []string{"Rock"}, Tags: []string{}, OfficialRatings: []string{}, Years: []int{1999}, + }) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"Genres":["Rock"]`)) + Expect(j).To(ContainSubstring(`"Tags":[]`)) + Expect(j).To(ContainSubstring(`"OfficialRatings":[]`)) + Expect(j).To(ContainSubstring(`"Years":[1999]`)) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 036dfbd91..444a25cac 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -200,7 +200,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { return item } -func AlbumToBaseItem(al model.Album) BaseItemDto { +func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { item := BaseItemDto{ Name: al.Name, Id: EncodeID(al.ID), @@ -232,6 +232,14 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios + // list and StudioIds= filter, so a client can display and click through to filter by label. + if fields.Has("Studios") { + for _, label := range al.Tags.Values(model.TagRecordLabel) { + id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID) + item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id}) + } + } // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same // convention as tracks; clients read it off the album item as NormalizationGain. item.NormalizationGain = al.RGAlbumGain @@ -264,6 +272,15 @@ func GenreToBaseItem(g model.Genre) BaseItemDto { } } +func StudioToBaseItem(t model.Tag) BaseItemDto { + return BaseItemDto{ + Name: t.TagValue, + Id: EncodeID(t.ID), + Type: "Studio", + BackdropImageTags: []string{}, + } +} + // PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. func PlaylistToBaseItem(p model.Playlist) BaseItemDto { // Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover. diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index c986e7f81..c11f38eaf 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -244,7 +244,7 @@ var _ = Describe("mappers", func() { It("maps an album to a MusicAlbum folder item", func() { al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} - item := AlbumToBaseItem(al) + item := AlbumToBaseItem(al, nil) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("alb-1"))) @@ -260,9 +260,22 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("populates album Studios from record-label tags only when Fields=Studios", func() { + al := model.Album{ID: "alb-2", Name: "Alb2"} + al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}} + + Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty()) + + item := AlbumToBaseItem(al, ParseFields("Studios")) + Expect(item.Studios).To(Equal([]NameGuidPair{ + {Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)}, + {Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)}, + })) + }) + It("sets NormalizationGain on the album from its ReplayGain", func() { al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} - b, err := json.Marshal(AlbumToBaseItem(al)) + b, err := json.Marshal(AlbumToBaseItem(al, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) // Real Jellyfin never sets AlbumNormalizationGain on an album item. @@ -270,7 +283,7 @@ var _ = Describe("mappers", func() { }) It("omits NormalizationGain when the album has no ReplayGain", func() { - b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) @@ -293,6 +306,13 @@ var _ = Describe("mappers", func() { Expect(item.Name).To(Equal("Rock")) }) + It("maps a tag to a Studio BaseItemDto", func() { + item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"}) + Expect(item.Type).To(Equal("Studio")) + Expect(item.Name).To(Equal("Blue Note")) + Expect(item.Id).To(Equal(EncodeID("t1"))) + }) + Describe("premiereDate", func() { // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. It("serializes a full date", func() { @@ -321,9 +341,9 @@ var _ = Describe("mappers", func() { }) It("is set on albums from their date, falling back to MaxYear", func() { - Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) - Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) - Expect(AlbumToBaseItem(model.Album{ID: "a3"}).PremiereDate).To(BeNil()) + Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil()) }) }) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index a0632994e..be6d6bb5d 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -2,6 +2,7 @@ package e2e import ( "net/http" + "sort" "time" "github.com/navidrome/navidrome/server/jellyfin/dto" @@ -210,6 +211,44 @@ var _ = Describe("Browsing", func() { }) }) + Describe("year filtering (Years=)", func() { + It("filters items by Years=", func() { + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Years=1959")) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + + songs := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Years=1959")) + for _, it := range songs.Items { + Expect(it.ProductionYear).ToNot(BeNil()) + Expect(*it.ProductionYear).To(Equal(1959)) + } + Expect(songs.Items).ToNot(BeEmpty()) + }) + }) + + Describe("studio filtering (StudioIds=)", func() { + It("filters items by StudioIds=", func() { + studios := queryResult(get("/Studios")) + var columbiaID string + for _, it := range studios.Items { + if it.Name == "Columbia" { + columbiaID = it.Id + } + } + Expect(columbiaID).ToNot(BeEmpty()) + + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + columbiaID)) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + }) + + It("returns filter lists scoped to a ParentId library", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Years).To(ContainElements(1959, 1965)) + studios := queryResult(get("/Studios?ParentId=" + enc("1"))) + Expect(names(studios.Items)).To(ContainElement("Columbia")) + }) + }) + // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. Describe("genre filtering (GenreIds)", func() { lib1 := enc("1") @@ -482,5 +521,31 @@ var _ = Describe("Browsing", func() { Expect(q.Items).To(HaveLen(1)) Expect(q.TotalRecordCount).To(Equal(3)) }) + + It("returns record labels as Studio items", func() { + q := queryResult(get("/Studios")) + names := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Studio")) + names = append(names, it.Name) + } + Expect(names).To(ContainElement("Columbia")) + }) + }) + + Describe("GET /Items/Filters", func() { + It("returns legacy query filters with genres, years, and empty tags/ratings", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Genres).To(ContainElements("Rock", "Jazz")) + Expect(filters.Years).To(ContainElements(1959, 1965, 1969, 1971)) + // Verify ascending sort by checking it equals itself sorted. + sorted := make([]int, len(filters.Years)) + copy(sorted, filters.Years) + sort.Ints(sorted) + Expect(filters.Years).To(Equal(sorted)) + Expect(filters.Tags).To(BeEmpty()) + Expect(filters.OfficialRatings).To(BeEmpty()) + }) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 5a4c8c031..31d98d1d9 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -111,7 +111,7 @@ func buildTestFS() storagetest.FakeFS { abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) - kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz", "label": "Columbia"}) singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) return harness.CreateFS(fstest.MapFS{ diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index c3c217cfc..7cf2a17ba 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -222,6 +222,8 @@ type itemsQuery struct { contributingOnly bool genreIds []string albumIds []string + years []int + studioIds []string } // parseItemsQuery also resolves the entity types (inferring them from the parent when @@ -245,7 +247,9 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. genreIds: decodedQueryIDs(r, "genreids"), // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds: decodedQueryIDs(r, "albumids"), + albumIds: decodedQueryIDs(r, "albumids"), + years: parseYears(r), + studioIds: decodedQueryIDs(r, "studioids"), } // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. @@ -414,6 +418,17 @@ func decodedQueryIDs(r *http.Request, key string) []string { return slice.Map(queryIDs(r, key), dto.DecodeID) } +// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. +func parseYears(r *http.Request) []int { + var years []int + for _, v := range queryIDs(r, "years") { + if y, err := strconv.Atoi(v); err == nil && y > 0 { + years = append(years, y) + } + } + return years +} + // parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to // {"MusicAlbum"} when none are recognized (so ParentId= browses that artist's albums). func parseTypes(types string) []string { @@ -481,6 +496,7 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO } func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) } repo := api.ds.Album(ctx) filters := squirrel.And{} // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's @@ -496,6 +512,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.AlbumsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -509,12 +531,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if err != nil { return itemsResult{}, err } - return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil + return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil } total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -537,6 +559,12 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.SongsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -665,7 +693,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if !u.HasLibraryAccess(al.LibraryID) { return dto.BaseItemDto{}, false } - return dto.AlbumToBaseItem(*al), true + return dto.AlbumToBaseItem(*al, fields), true } if ar, err := api.ds.Artist(ctx).Get(id); err == nil { // TODO: an artist spans multiple libraries (library_artist), so there's no single @@ -754,13 +782,15 @@ func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { // /Items/Latest, and why it writes directly instead of going through api.ok. func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + p := req.Params(r) + fields := dto.ParseFields(p.Strings("fields")...) opts := filter.AlbumsByNewest() - opts.Max = req.Params(r).IntOr("limit", 20) + opts.Max = p.IntOr("limit", 20) opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) repo := api.ds.Album(ctx) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) }) api.writeItemsArray(w, r, streamed(open, 0, 0)) } diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index 2e486c36f..2830f1105 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -2,11 +2,14 @@ package jellyfin import ( "context" + "net/http" "strconv" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" ) // accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty @@ -30,6 +33,20 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, return accessibleLibraryIDs(ctx), false } +// parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). +func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) { + return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", ""))) +} + +// libraryScopeFilter restricts a tag query to the given library scope. Empty scope means +// unrestricted (see accessibleLibraryIDs), so it returns nil rather than an impossible IN (). +func libraryScopeFilter(scope []int) squirrel.Sqlizer { + if len(scope) == 0 { + return nil + } + return squirrel.Eq{"library_tag.library_id": scope} +} + // libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. // Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. func libraryView(lib model.Library) dto.BaseItemDto { diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 085080850..661fb95cc 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -177,7 +177,7 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto. } seen[s.AlbumID] = true if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { - items = append(items, dto.AlbumToBaseItem(*al)) + items = append(items, dto.AlbumToBaseItem(*al, nil)) if len(items) >= limit { break } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 03dfed879..cc7d66b4d 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -209,4 +209,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { return nil } +func (m *MockAlbumRepo) GetYears(libraryIDs ...int) ([]int, error) { + if m.Err { + return nil, errors.New("error") + } + return []int{}, nil +} + var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 754f0c084..e016a28de 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -65,7 +65,7 @@ func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { if db.RealDS != nil { return db.RealDS.Tag(ctx) } - db.MockedTag = struct{ model.TagRepository }{} + db.MockedTag = &MockTagRepo{} return db.MockedTag } diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 122ccc278..50796efc0 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -5,8 +5,9 @@ import ( ) type MockedGenreRepo struct { - Error error - Data map[string]model.Genre + Error error + Data map[string]model.Genre + Options model.QueryOptions } func (r *MockedGenreRepo) init() { @@ -15,7 +16,10 @@ func (r *MockedGenreRepo) init() { } } -func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) { +func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, error) { + if len(options) > 0 { + r.Options = options[0] + } if r.Error != nil { return nil, r.Error } diff --git a/tests/mock_tag_repo.go b/tests/mock_tag_repo.go new file mode 100644 index 000000000..a59035ea6 --- /dev/null +++ b/tests/mock_tag_repo.go @@ -0,0 +1,24 @@ +package tests + +import ( + "github.com/navidrome/navidrome/model" +) + +// MockTagRepo records the QueryOptions passed to GetAll, mirroring MockArtistRepo, so tests can +// assert on which filters a caller attached (e.g. a library scope). +type MockTagRepo struct { + model.TagRepository + Data model.TagList + Options model.QueryOptions + Err error +} + +func (r *MockTagRepo) GetAll(_ model.TagName, options ...model.QueryOptions) (model.TagList, error) { + if len(options) > 0 { + r.Options = options[0] + } + if r.Err != nil { + return nil, r.Err + } + return r.Data, nil +} From e6597398c2f9834f3376ee041b135d26bfab27e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 13:36:05 -0400 Subject: [PATCH 49/56] feat(scrobbler): exponential backoff for scrobble retries during outages (#5818) * feat(scrobbler): add exponential backoff delay helper * feat(scrobbler): back off retries up to 4m during outages * test(scrobbler): verify backoff schedule with synctest; clarify backoffDelay doc Adds a testing/synctest-based test that drives the real run loop against a failing service and asserts the exact 5s/10s/20s/40s retry schedule and the drain-on-recovery reset, addressing the review note that the run loop's behavior was untested. Also clarifies the backoffDelay doc comment: the argument is a zero-based retry index. --- core/scrobbler/buffered_scrobbler.go | 42 +++++++++-- core/scrobbler/buffered_scrobbler_test.go | 91 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 408ab410d..38ea83228 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -10,6 +10,30 @@ import ( "github.com/navidrome/navidrome/model/request" ) +const ( + minRetryDelay = 5 * time.Second + maxRetryDelay = 4 * time.Minute + // maxRetryShift caps the exponent so the shift never overflows int64. + // minRetryDelay<<6 = 320s already exceeds maxRetryDelay, so 6 reaches the ceiling. + maxRetryShift = 6 +) + +// backoffDelay returns the delay for a zero-based retry index (0 = first retry): +// minRetryDelay doubled per prior failure, clamped to maxRetryDelay. +func backoffDelay(failures int) time.Duration { + if failures < 0 { + failures = 0 + } + if failures >= maxRetryShift { + return maxRetryDelay + } + d := minRetryDelay << failures + if d > maxRetryDelay { + return maxRetryDelay + } + return d +} + // Loader is a function that loads a scrobbler by name. // It returns the scrobbler and true if found, or nil and false if not available. // This allows the buffered scrobbler to always get the current plugin instance. @@ -98,15 +122,23 @@ func (b *bufferedScrobbler) sendWakeSignal() { } func (b *bufferedScrobbler) run(ctx context.Context) { + timer := time.NewTimer(time.Hour) + timer.Stop() + defer timer.Stop() + failures := 0 for { - if !b.processQueue(ctx) { - time.AfterFunc(5*time.Second, func() { - b.sendWakeSignal() - }) + if b.processQueue(ctx) { + failures = 0 + timer.Stop() + } else { + timer.Reset(backoffDelay(failures)) + if failures < maxRetryShift { + failures++ + } } select { case <-b.wakeSignal: - continue + case <-timer.C: case <-ctx.Done(): return } diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index d11f0b003..c250085ef 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -2,6 +2,9 @@ package scrobbler import ( "context" + "sync/atomic" + "testing" + "testing/synctest" "time" "github.com/navidrome/navidrome/model" @@ -100,3 +103,91 @@ var _ = Describe("BufferedScrobbler", func() { } }) }) + +var _ = Describe("backoffDelay", func() { + DescribeTable("computes the exponential backoff curve clamped to the ceiling", + func(failures int, expected time.Duration) { + Expect(backoffDelay(failures)).To(Equal(expected)) + }, + Entry("first failure", 0, 5*time.Second), + Entry("second failure", 1, 10*time.Second), + Entry("third failure", 2, 20*time.Second), + Entry("fourth failure", 3, 40*time.Second), + Entry("fifth failure", 4, 80*time.Second), + Entry("sixth failure", 5, 160*time.Second), + Entry("reaches the ceiling", 6, 4*time.Minute), + Entry("stays clamped past the ceiling", 7, 4*time.Minute), + Entry("stays clamped for large values", 1000, 4*time.Minute), + Entry("negative is treated as zero", -1, 5*time.Second), + ) +}) + +// Drives the real run loop and asserts the exact retry schedule + recovery. Plain +// test: testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. +func TestBufferedScrobblerBackoffSchedule(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + g.Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + + flaky := &recoveringScrobbler{} + flaky.fail(ErrRetryLater) + bs := newBufferedScrobbler(ds, flaky, "flaky") + defer func() { bs.Stop(); synctest.Wait() }() + + // Let the loop settle on the empty buffer, then enqueue a scrobble. + synctest.Wait() + track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} + g.Expect(bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: track, TimeStamp: time.Now()})).To(Succeed()) + + // First attempt fires immediately on the enqueue wake and is left buffered. + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(int32(1))) + g.Expect(buffer.Length()).To(Equal(int64(1))) + + // Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s. + for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} { + want := int32(i + 2) + time.Sleep(gap - time.Nanosecond) + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(want-1), "retry fired before the %s backoff", gap) + time.Sleep(time.Nanosecond) + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap) + } + + // Once the service recovers, waking the loop drains the buffered entry. + flaky.succeed() + bs.sendWakeSignal() + synctest.Wait() + g.Expect(buffer.Length()).To(Equal(int64(0))) + }) +} + +// recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while +// the buffered scrobbler's goroutine is draining, to exercise retry then recovery. +type recoveringScrobbler struct { + err atomic.Pointer[error] + count atomic.Int32 +} + +func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) } +func (f *recoveringScrobbler) succeed() { f.err.Store(nil) } + +func (f *recoveringScrobbler) IsAuthorized(context.Context, string) bool { return true } + +func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFile, int) error { + return nil +} + +func (f *recoveringScrobbler) Scrobble(_ context.Context, _ string, _ Scrobble) error { + f.count.Add(1) + if e := f.err.Load(); e != nil { + return *e + } + return nil +} + +func (f *recoveringScrobbler) PlaybackReport(context.Context, PlaybackSession) error { return nil } From fed966506012113ba7e4272ba422116c43d28b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 18:51:32 -0400 Subject: [PATCH 50/56] fix(streaming): surface why a transcode decision failed (#5820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(subsonic): surface the reason a transcode decision failed getTranscodeDecision returned a bare "failed to make transcode decision" with no clue why, and the probe command ran ffprobe with -v quiet, so even the server log bottomed out at "exit status 1". A user whose files had been moved by an external tool only saw the opaque error. ProbeAudioStream now returns a typed ProbeError that separates the file path from the reason: ffprobe runs with -v error so its stderr diagnostic is captured, and a missing or unreadable file is reported as "file not found" rather than ffprobe's misleading "Invalid data found". The handler logs the full detail (including the path) and returns the reason to the client with the server path stripped out. Reported-by: Tolriq (Symfonium) * refactor(ffmpeg): use errors.AsType for ExitError match probeErrorReason used the older var+errors.As form while the rest of the codebase (and its sibling transcodeFailureReason) uses the generic errors.AsType. Switch to it for consistency; behavior is unchanged. * fix(subsonic): return error 70 when the source file is missing A getTranscodeDecision probe failure was always reported as generic error 0. When the source file is gone (moved or deleted out from under the DB), that is a not-found condition, so return the standard Subsonic error 70 ("data not found") instead — matching what the endpoint already returns for an unknown mediaId. Files that exist but are corrupt or unreadable stay error 0. ProbeError now wraps the underlying cause and implements Unwrap, so the handler detects the case with errors.Is(err, fs.ErrNotExist). * fix(ffmpeg): keep probe error paths out of client-facing reasons Addresses review feedback on the ProbeError type: the Reason field doubled as both the log detail and the client message, so an ffprobe launch failure (a *os.PathError from fork/exec) could leak the ffprobe binary path to clients, and an unexpected stat error was reduced to "file not accessible" in the log. Split the two concerns: Reason now holds only a path-free, client-safe string (built at construction), while Error() logs the full underlying cause. Launch failures return a generic "could not read file" instead of the raw exec error. SafeReason no longer does substring path-stripping (removing the empty-Path edge case); the stripping happens once, against ffprobe's stderr. * fix(subsonic): don't report a broken ffprobe as a missing media file Two issues from review of the previous commit: Code 70 was selected with errors.Is(err, fs.ErrNotExist), but a launch failure of a deleted ffprobe binary is an *os.PathError that also wraps fs.ErrNotExist. A server-side ffprobe problem was therefore reported to clients as a missing media file. ProbeError now carries an explicit NotFound flag, set only on the file-access branch, and the handler keys the code off that instead of the chain. ffprobe can also exit 0 while yielding no audio stream (an audio-suffixed container holding only video). That parse failure was returned unwrapped, so clients got "internal error"; it is now wrapped in a ProbeError too. --- core/ffmpeg/ffmpeg.go | 73 +++++++++++++++++++++++++++++-- core/ffmpeg/ffmpeg_test.go | 70 +++++++++++++++++++++++++++++ server/subsonic/transcode.go | 17 ++++++- server/subsonic/transcode_test.go | 49 +++++++++++++++++++++ 4 files changed, 204 insertions(+), 5 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 3d4cd0e72..af2dab647 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -67,7 +68,7 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" probeCmd = "ffmpeg %s -f ffmetadata" - probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s" + probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s" ) type ffmpeg struct{} @@ -159,16 +160,80 @@ func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioP return nil, err } if err := fileExists(filePath); err != nil { - return nil, err + return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err), + NotFound: errors.Is(err, fs.ErrNotExist), err: err} } args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0) log.Trace(ctx, "Executing ffprobe command", "args", args) cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err) + return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err} } - return parseProbeOutput(output) + result, err := parseProbeOutput(output) + if err != nil { + return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err} + } + return result, nil +} + +// ProbeError reports an ffprobe failure. Reason is a path-free message safe to +// expose to clients; the wrapped cause carries the full detail for logging. +// NotFound marks the media file itself as missing — a launch failure of a +// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer +// it from the error chain. +type ProbeError struct { + Path string + Reason string + NotFound bool + err error +} + +func (e *ProbeError) Error() string { + if e.err == nil { + return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason) + } + return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err)) +} + +// Unwrap exposes the underlying cause so callers can test it with errors.Is +// (e.g. fs.ErrNotExist to detect a missing file). +func (e *ProbeError) Unwrap() error { return e.err } + +// SafeReason returns the path-free reason, safe to send to clients. +func (e *ProbeError) SafeReason() string { return e.Reason } + +// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved +// or unreadable file reads as "file not found" rather than a raw ffprobe message. +func fileAccessReason(err error) string { + switch { + case errors.Is(err, fs.ErrNotExist): + return "file not found" + case errors.Is(err, fs.ErrPermission): + return "permission denied" + default: + return "file not accessible" + } +} + +// probeDetail returns the full diagnostic for logging (may contain paths): +// ffprobe's stderr when present, otherwise the raw error text. +func probeDetail(err error) string { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 { + return strings.TrimSpace(string(exitErr.Stderr)) + } + return err.Error() +} + +// probeClientReason returns a path-free reason for an ffprobe execution failure: +// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe +// couldn't run at all (its launch error may embed the binary path). +func probeClientReason(err error, path string) string { + exitErr, ok := errors.AsType[*exec.ExitError](err) + if !ok || len(exitErr.Stderr) == 0 { + return "could not read file" + } + return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file")) } type probeOutput struct { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 9c20e6c05..0fa3de111 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -2,6 +2,7 @@ package ffmpeg import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -553,6 +554,65 @@ var _ = Describe("ffmpeg", func() { }) }) + Describe("ProbeError", func() { + It("uses the underlying cause in Error() so logs keep the full detail", func() { + e := &ProbeError{Path: "/music/foo.flac", + err: errors.New("/music/foo.flac: Invalid data found when processing input")} + Expect(e.Error()).To(ContainSubstring("/music/foo.flac")) + Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input")) + }) + + It("returns the path-free reason from SafeReason()", func() { + e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"} + Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input")) + Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac")) + }) + + It("unwraps to the underlying cause so errors.Is detects a missing file", func() { + e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist} + Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue()) + }) + }) + + Describe("probeClientReason", func() { + It("strips the file path from ffprobe stderr", func() { + if runtime.GOOS == "windows" { + Skip("uses /bin/sh") + } + _, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output() + Expect(err).To(HaveOccurred()) + Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found")) + }) + + It("returns a generic reason for launch failures, without leaking the binary path", func() { + err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory") + Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file")) + }) + }) + + Describe("probeDetail", func() { + It("surfaces ffprobe stderr for logging", func() { + if runtime.GOOS == "windows" { + Skip("uses /bin/sh") + } + _, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output() + Expect(err).To(HaveOccurred()) + Expect(probeDetail(err)).To(Equal("boom detail")) + }) + }) + + Describe("fileAccessReason", func() { + It("reports a missing file as 'file not found', not a raw stat message", func() { + _, err := os.Stat("/no/such/dir/really-missing.flac") + Expect(err).To(HaveOccurred()) + Expect(fileAccessReason(err)).To(Equal("file not found")) + }) + + It("falls back to a generic reason for other access errors", func() { + Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible")) + }) + }) + Describe("FFmpeg", func() { Context("when FFmpeg is available", func() { var ff FFmpeg @@ -566,6 +626,16 @@ var _ = Describe("ffmpeg", func() { } }) + It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() { + _, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue()) + var pe *ProbeError + Expect(errors.As(err, &pe)).To(BeTrue()) + Expect(pe.SafeReason()).To(Equal("file not found")) + Expect(pe.NotFound).To(BeTrue()) + }) + It("should interrupt transcoding when context is cancelled", func() { ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second) defer cancel() diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 511db2b85..9eb2af160 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -8,6 +8,7 @@ import ( "slices" "strconv" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -315,7 +316,8 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, stream.TranscodeOptions{}) if err != nil { log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) - return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") + code, reason := transcodeFailure(err) + return nil, newError(code, "failed to make transcode decision: %s", reason) } // Only create a token when there is a valid playback path @@ -346,6 +348,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return response, nil } +// transcodeFailure maps a decision error to a Subsonic error code and a reason +// safe to send to clients, omitting server file paths. +func transcodeFailure(err error) (int32, string) { + pe, ok := errors.AsType[*ffmpeg.ProbeError](err) + if !ok { + return responses.ErrorGeneric, "internal error" + } + if pe.NotFound { + return responses.ErrorDataNotFound, pe.SafeReason() + } + return responses.ErrorGeneric, pe.SafeReason() +} + // GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint. // It streams media using the decision encoded in the transcodeParams JWT token. // All errors are returned as proper HTTP status codes (not Subsonic error responses). diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 7e36ab243..8d5cbb974 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -4,12 +4,16 @@ import ( "bytes" "context" "errors" + "fmt" + "io/fs" "net/http" "net/http/httptest" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/stream" "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" @@ -77,6 +81,47 @@ var _ = Describe("Transcode endpoints", func() { Expect(err.Error()).To(ContainSubstring("error retrieving media file")) }) + It("enriches the decision error with the reason, without leaking the file path", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", + &ffmpeg.ProbeError{Path: "/music/secret/foo.flac", Reason: "the file: Invalid data found when processing input"}) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to make transcode decision")) + Expect(err.Error()).To(ContainSubstring("Invalid data found when processing input")) + Expect(err.Error()).ToNot(ContainSubstring("/music/secret")) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + + It("returns ErrorDataNotFound when the source file is missing on disk", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", + &ffmpeg.ProbeError{Path: "/music/gone.flac", Reason: "file not found", NotFound: true}) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("file not found")) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorDataNotFound)) + }) + + It("keeps ErrorGeneric when ffprobe is missing, even though the cause wraps fs.ErrNotExist", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + pe := &ffmpeg.ProbeError{Path: "/music/song.flac", Reason: "could not read file"} + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w (%w)", pe, fs.ErrNotExist) + Expect(errors.Is(mockTD.decisionErr, fs.ErrNotExist)).To(BeTrue()) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + It("returns error when body is empty", func() { r := newJSONPostRequest("mediaId=song-1&mediaType=song", "") _, err := router.GetTranscodeDecision(w, r) @@ -516,6 +561,7 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { // mockTranscodeDecision is a test double for stream.TranscodeDecider type mockTranscodeDecision struct { decision *stream.TranscodeDecision + decisionErr error token string tokenErr error resolvedReq stream.Request @@ -525,6 +571,9 @@ type mockTranscodeDecision struct { func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { m.capturedClient = ci + if m.decisionErr != nil { + return nil, m.decisionErr + } if m.decision != nil { return m.decision, nil } From bf79d2f3a2a8c569d4d533a596f4575d39d5e1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 18:52:30 -0400 Subject: [PATCH 51/56] refactor(plugins): remove non-functional experimental manifest option (#5821) The `experimental.threads` manifest option never actually worked, so drop it from the schema, the generated types, the loader and the docs. --- plugins/README.md | 20 +---------- plugins/manager_loader.go | 8 ----- plugins/manifest-schema.json | 24 ------------- plugins/manifest.go | 5 --- plugins/manifest_gen.go | 15 -------- plugins/manifest_test.go | 70 ------------------------------------ 6 files changed, 1 insertion(+), 141 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index b9118d36f..08ab967f3 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -136,7 +136,7 @@ Every plugin must include a `manifest.json` file. Example: **Required fields:** `name`, `author`, `version` -**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental` +**Optional fields:** `description`, `website`, `config`, `permissions` #### Config Definition @@ -160,24 +160,6 @@ The `config` field defines the plugin's configuration schema using [JSON Schema } ``` -#### Experimental Features - -Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported: - -- **`threads`** – Enables WebAssembly threads support (for plugins compiled with multi-threading) - -```json -{ - "experimental": { - "threads": { - "reason": "Required for concurrent audio processing" - } - } -} -``` - -> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary. - --- ## Capabilities diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 675c85e26..0ba5fcf76 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -13,8 +13,6 @@ import ( "github.com/navidrome/navidrome/plugins/host" "github.com/navidrome/navidrome/scheduler" "github.com/tetratelabs/wazero" - "github.com/tetratelabs/wazero/api" - "github.com/tetratelabs/wazero/experimental" "golang.org/x/sync/errgroup" ) @@ -377,12 +375,6 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { WithCompilationCache(m.cache). WithCloseOnContextDone(true) - // Enable experimental threads if requested in manifest - if pkg.Manifest.HasExperimentalThreads() { - runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads) - log.Debug(ctx, "Enabling experimental threads support") - } - extismConfig := extism.PluginConfig{ EnableWasi: true, RuntimeConfig: runtimeConfig, diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 29e5d1fc7..28adeed79 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -34,9 +34,6 @@ "permissions": { "$ref": "#/$defs/Permissions" }, - "experimental": { - "$ref": "#/$defs/Experimental" - }, "config": { "$ref": "#/$defs/ConfigDefinition" } @@ -58,27 +55,6 @@ } } }, - "Experimental": { - "type": "object", - "description": "Experimental features that may change or be removed in future versions", - "additionalProperties": false, - "properties": { - "threads": { - "$ref": "#/$defs/ThreadsFeature" - } - } - }, - "ThreadsFeature": { - "type": "object", - "description": "Enable experimental WebAssembly threads support", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why threads support is needed" - } - } - }, "Permissions": { "type": "object", "description": "Permissions required by the plugin", diff --git a/plugins/manifest.go b/plugins/manifest.go index 6bd0e8049..5e144b5c8 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -117,11 +117,6 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { return nil } -// HasExperimentalThreads returns true if the manifest requests experimental threads support. -func (m *Manifest) HasExperimentalThreads() bool { - return m.Experimental != nil && m.Experimental.Threads != nil -} - // HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries. func (m *Manifest) HasLibraryFilesystemPermission() bool { return m.Permissions != nil && diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 3599eafc4..c2aa5e298 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -45,12 +45,6 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error { return nil } -// Experimental features that may change or be removed in future versions -type Experimental struct { - // Threads corresponds to the JSON schema field "threads". - Threads *ThreadsFeature `json:"threads,omitempty" yaml:"threads,omitempty" mapstructure:"threads,omitempty"` -} - // HTTP access permissions for a plugin type HTTPPermission struct { // Explanation for why HTTP access is needed @@ -109,9 +103,6 @@ type Manifest struct { // A brief description of what the plugin does Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` - // Experimental corresponds to the JSON schema field "experimental". - Experimental *Experimental `json:"experimental,omitempty" yaml:"experimental,omitempty" mapstructure:"experimental,omitempty"` - // The display name of the plugin Name string `json:"name" yaml:"name" mapstructure:"name"` @@ -242,12 +233,6 @@ func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error { return nil } -// Enable experimental WebAssembly threads support -type ThreadsFeature struct { - // Explanation for why threads support is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - // Users service permissions for accessing user information type UsersPermission struct { // Explanation for why users access is needed diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 2a8b0dcfa..32bcbba08 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -117,76 +117,6 @@ var _ = Describe("Manifest", func() { }) }) - Describe("HasExperimentalThreads", func() { - It("returns false when no experimental section", func() { - m := &Manifest{} - Expect(m.HasExperimentalThreads()).To(BeFalse()) - }) - - It("returns false when experimental section has no threads", func() { - m := &Manifest{ - Experimental: &Experimental{}, - } - Expect(m.HasExperimentalThreads()).To(BeFalse()) - }) - - It("returns true when threads feature is present", func() { - m := &Manifest{ - Experimental: &Experimental{ - Threads: &ThreadsFeature{}, - }, - } - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - - It("returns true when threads feature has a reason", func() { - m := &Manifest{ - Experimental: &Experimental{ - Threads: &ThreadsFeature{ - Reason: new("Required for concurrent processing"), - }, - }, - } - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - - It("parses experimental.threads from JSON", func() { - data := []byte(`{ - "name": "Threaded Plugin", - "author": "Test Author", - "version": "1.0.0", - "experimental": { - "threads": { - "reason": "To use multi-threaded WASM module" - } - } - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.HasExperimentalThreads()).To(BeTrue()) - Expect(m.Experimental.Threads.Reason).ToNot(BeNil()) - Expect(*m.Experimental.Threads.Reason).To(Equal("To use multi-threaded WASM module")) - }) - - It("parses experimental.threads without reason from JSON", func() { - data := []byte(`{ - "name": "Threaded Plugin", - "author": "Test Author", - "version": "1.0.0", - "experimental": { - "threads": {} - } - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - }) - Describe("ParseManifest", func() { It("parses a valid manifest with users permission", func() { data := []byte(`{ From 6c2644d20860fa052fed1e150b2390727e37fa34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 18:59:37 -0400 Subject: [PATCH 52/56] feat(ui): remember 'items per page' selection across sessions (#5819) * feat(ui): add perPageStore helper for items-per-page persistence * feat(ui): persist items-per-page selection in localStorage * feat(ui): enable per-page persistence on album grid, missing files and playlist tracks * feat(ui): restore saved album grid page size on fresh load * feat(ui): restore saved items-per-page on list load * fix(ui): move defaultRowsPerPageOptions to perPageStore to satisfy react-refresh lint * fix(ui): correct defaultRowsPerPageOptions import in List and add render test * refactor(ui): default getStoredPerPage fallback to the first option The fallback equalled options[0] at three of four call sites; default it so those sites stop restating it. SongList and useAlbumsPerPage still pass an explicit fallback where it legitimately differs from the first option. * fix(ui): persist only user-selected page sizes, validate album size against width Persisting on every pagination context value let a URL-injected perPage (e.g. the perPage=15 album link in NowPlayingPanel) or a forced single-option mobile grid overwrite the saved preference. Persist only a value that is an actual option in a multi-option selector. Also validate the album grid's redux session value against the current width so a size chosen at a wider breakpoint can't leave an out-of-range selector. * fix(ui): restore saved items-per-page on the radio list RadioList passed a hard-coded perPage that overrode List's stored seed via the props spread, so a radio-list page size was persisted but never restored. Seed it from storage like the other list views. * fix(ui): persist page size only on an actual selector change Watching the pagination context value meant any valid value persisted itself: loading a list at a breakpoint where the saved size is invalid stored the responsive fallback, and opening a URL with a valid ?perPage= stored that too, either way discarding the user's real preference. Inject a wrapped setPerPage instead, so only the rows-per-page selector writes. This also drops the option-validation heuristics, which the new trigger makes unnecessary. --- ui/src/album/AlbumList.jsx | 2 +- ui/src/artist/ArtistShow.jsx | 1 + ui/src/common/List.jsx | 3 +- ui/src/common/List.test.jsx | 27 +++++++++++ ui/src/common/Pagination.jsx | 33 +++++++++++-- ui/src/common/Pagination.test.jsx | 62 +++++++++++++++++++++++++ ui/src/common/index.js | 1 + ui/src/common/perPageStore.js | 11 +++++ ui/src/common/perPageStore.test.js | 40 ++++++++++++++++ ui/src/common/useAlbumsPerPage.jsx | 16 +++++-- ui/src/common/useAlbumsPerPage.test.jsx | 61 ++++++++++++++++++++++++ ui/src/missing/MissingFilesList.jsx | 15 ++++-- ui/src/playlist/PlaylistShow.jsx | 20 ++++++-- ui/src/radio/RadioList.jsx | 8 +++- ui/src/song/SongList.jsx | 8 +++- 15 files changed, 286 insertions(+), 22 deletions(-) create mode 100644 ui/src/common/List.test.jsx create mode 100644 ui/src/common/Pagination.test.jsx create mode 100644 ui/src/common/perPageStore.js create mode 100644 ui/src/common/perPageStore.test.js create mode 100644 ui/src/common/useAlbumsPerPage.test.jsx diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index a860c85bb..5108bfaa1 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -6,7 +6,6 @@ import { Filter, NullableBooleanInput, NumberInput, - Pagination, ReferenceArrayInput, ReferenceInput, SearchInput, @@ -20,6 +19,7 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, + Pagination, Title, useAlbumsPerPage, useResourceRefresh, diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index 935b0bab7..955a565d6 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -100,6 +100,7 @@ const ArtistShowLayout = (props) => { const rowsPerPageOptions = [1, 2, 3].map((option) => Math.trunc(option * (perPage / 3)), ) + // react-admin's Pagination on purpose: the common one would persist 30/60/90 under the album grid's key pagination = } diff --git a/ui/src/common/List.jsx b/ui/src/common/List.jsx index 72c2d9482..0ae089460 100644 --- a/ui/src/common/List.jsx +++ b/ui/src/common/List.jsx @@ -2,6 +2,7 @@ import React from 'react' import { List as RAList } from 'react-admin' import config from '../config' import { Pagination } from './Pagination' +import { defaultRowsPerPageOptions, getStoredPerPage } from './perPageStore' import { Title } from './index' export const List = (props) => { @@ -15,7 +16,7 @@ export const List = (props) => { /> } debounce={config.uiSearchDebounceMs} - perPage={15} + perPage={getStoredPerPage(resource, defaultRowsPerPageOptions)} pagination={} {...props} /> diff --git a/ui/src/common/List.test.jsx b/ui/src/common/List.test.jsx new file mode 100644 index 000000000..5bc4b910f --- /dev/null +++ b/ui/src/common/List.test.jsx @@ -0,0 +1,27 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { List } from './List' + +// Only stub the heavy react-admin List controller (data fetching, router sync); +// everything else, including our own Pagination/perPageStore wiring, stays real +// so a bad import (the bug this test guards against) throws on render. +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + List: ({ children }) =>
{children}
, + } +}) + +describe('List', () => { + it('renders without throwing and shows its children', () => { + render( + +
list content
+
, + ) + expect(screen.getByTestId('ra-list')).toBeInTheDocument() + expect(screen.getByText('list content')).toBeInTheDocument() + }) +}) diff --git a/ui/src/common/Pagination.jsx b/ui/src/common/Pagination.jsx index e17d9e63e..dd18961ce 100644 --- a/ui/src/common/Pagination.jsx +++ b/ui/src/common/Pagination.jsx @@ -1,6 +1,29 @@ -import React from 'react' -import { Pagination as RAPagination } from 'react-admin' +import React, { useCallback } from 'react' +import { + Pagination as RAPagination, + useListPaginationContext, +} from 'react-admin' +import { setStoredPerPage, defaultRowsPerPageOptions } from './perPageStore' -export const Pagination = (props) => ( - -) +export const Pagination = ({ + rowsPerPageOptions = defaultRowsPerPageOptions, + ...props +}) => { + const { resource, setPerPage } = useListPaginationContext() + // Persist only a selector-driven change: mount, URL params and responsive + // fallbacks never call setPerPage, so they can't overwrite the preference. + const handleSetPerPage = useCallback( + (value) => { + if (resource) setStoredPerPage(resource, value) + setPerPage(value) + }, + [resource, setPerPage], + ) + return ( + + ) +} diff --git a/ui/src/common/Pagination.test.jsx b/ui/src/common/Pagination.test.jsx new file mode 100644 index 000000000..a488aba91 --- /dev/null +++ b/ui/src/common/Pagination.test.jsx @@ -0,0 +1,62 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Pagination } from './Pagination' + +// stub RA's Pagination so a test can invoke the injected setPerPage, i.e. +// simulate an actual rows-per-page selection +vi.mock('react-admin', async () => { + const React = await vi.importActual('react') + return { + Pagination: ({ setPerPage }) => + React.createElement( + 'button', + { onClick: () => setPerPage(50) }, + 'select 50', + ), + useListPaginationContext: vi.fn(), + } +}) + +describe('Pagination', () => { + let mockContext + let setPerPage + + beforeEach(async () => { + vi.clearAllMocks() + localStorage.clear() + setPerPage = vi.fn() + const { useListPaginationContext } = await import('react-admin') + mockContext = vi.mocked(useListPaginationContext) + }) + + const selectPerPage = () => fireEvent.click(screen.getByText('select 50')) + + it('persists the page size chosen in the selector', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render() + selectPerPage() + expect(localStorage.getItem('perPage.song')).toEqual('50') + }) + + it('still applies the change to the list', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render() + selectPerPage() + expect(setPerPage).toHaveBeenCalledWith(50) + }) + + it('does not persist a page size the user did not select', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render() + expect(localStorage.getItem('perPage.song')).toBeNull() + }) + + it('does not persist without a resource in context', () => { + mockContext.mockReturnValue({ perPage: 15, setPerPage }) + render() + selectPerPage() + expect(localStorage.getItem('perPage.undefined')).toBeNull() + expect(setPerPage).toHaveBeenCalledWith(50) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 362a0ced3..ac8d7f62c 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -10,6 +10,7 @@ export * from './DurationField' export * from './List' export * from './MultiLineTextField' export * from './Pagination' +export * from './perPageStore' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' diff --git a/ui/src/common/perPageStore.js b/ui/src/common/perPageStore.js new file mode 100644 index 000000000..52a8a8f29 --- /dev/null +++ b/ui/src/common/perPageStore.js @@ -0,0 +1,11 @@ +export const defaultRowsPerPageOptions = [15, 25, 50] + +const key = (resource) => `perPage.${resource}` + +export const getStoredPerPage = (resource, options, fallback = options[0]) => { + const stored = parseInt(localStorage.getItem(key(resource)), 10) + return options.includes(stored) ? stored : fallback +} + +export const setStoredPerPage = (resource, perPage) => + localStorage.setItem(key(resource), String(perPage)) diff --git a/ui/src/common/perPageStore.test.js b/ui/src/common/perPageStore.test.js new file mode 100644 index 000000000..3aeba3ad8 --- /dev/null +++ b/ui/src/common/perPageStore.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { getStoredPerPage, setStoredPerPage } from './perPageStore' + +const options = [15, 25, 50] + +describe('perPageStore', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('round-trips a stored value', () => { + setStoredPerPage('song', 25) + expect(getStoredPerPage('song', options, 15)).toEqual(25) + }) + + it('keys values per resource', () => { + setStoredPerPage('song', 25) + setStoredPerPage('playlist', 50) + expect(getStoredPerPage('song', options, 15)).toEqual(25) + expect(getStoredPerPage('playlist', options, 15)).toEqual(50) + }) + + it('returns the fallback when nothing is stored', () => { + expect(getStoredPerPage('song', options, 15)).toEqual(15) + }) + + it('returns the fallback for garbage values', () => { + localStorage.setItem('perPage.song', 'bogus') + expect(getStoredPerPage('song', options, 15)).toEqual(15) + }) + + it('returns the fallback when the stored value is not a valid option', () => { + setStoredPerPage('album', 90) + expect(getStoredPerPage('album', [18, 36, 72], 18)).toEqual(18) + }) + + it('defaults the fallback to the first option', () => { + expect(getStoredPerPage('song', options)).toEqual(15) + }) +}) diff --git a/ui/src/common/useAlbumsPerPage.jsx b/ui/src/common/useAlbumsPerPage.jsx index 6a02bdeb7..0fb5616c3 100644 --- a/ui/src/common/useAlbumsPerPage.jsx +++ b/ui/src/common/useAlbumsPerPage.jsx @@ -1,4 +1,5 @@ import { useSelector } from 'react-redux' +import { getStoredPerPage } from './perPageStore' const getPerPage = (width) => { if (width === 'xs') return 12 @@ -17,10 +18,15 @@ const getPerPageOptions = (width) => { } export const useAlbumsPerPage = (width) => { - const perPage = - useSelector( - (state) => state?.admin.resources?.album?.list?.params?.perPage, - ) || getPerPage(width) + const options = getPerPageOptions(width) + const sessionPerPage = useSelector( + (state) => state?.admin.resources?.album?.list?.params?.perPage, + ) + // Use the session value only when it's valid for the current width, so a + // size picked at a wider breakpoint can't leave an out-of-range selector. + const perPage = options.includes(sessionPerPage) + ? sessionPerPage + : getStoredPerPage('album', options, getPerPage(width)) - return [perPage, getPerPageOptions(width)] + return [perPage, options] } diff --git a/ui/src/common/useAlbumsPerPage.test.jsx b/ui/src/common/useAlbumsPerPage.test.jsx new file mode 100644 index 000000000..b194a6ef2 --- /dev/null +++ b/ui/src/common/useAlbumsPerPage.test.jsx @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useAlbumsPerPage } from './useAlbumsPerPage' +import { setStoredPerPage } from './perPageStore' + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})) + +describe('useAlbumsPerPage', () => { + let mockUseSelector + + beforeEach(async () => { + vi.clearAllMocks() + localStorage.clear() + const { useSelector } = await import('react-redux') + mockUseSelector = vi.mocked(useSelector) + }) + + const setReduxPerPage = (value) => + mockUseSelector.mockImplementation((selector) => + selector({ + admin: { + resources: { album: { list: { params: { perPage: value } } } }, + }, + }), + ) + + it('prefers the redux session value over the stored one', () => { + setReduxPerPage(36) + setStoredPerPage('album', 72) + const { result } = renderHook(() => useAlbumsPerPage('lg')) + expect(result.current[0]).toEqual(36) + }) + + it('falls back to the stored value on fresh load', () => { + setReduxPerPage(undefined) + setStoredPerPage('album', 72) + const { result } = renderHook(() => useAlbumsPerPage('lg')) + expect(result.current[0]).toEqual(72) + }) + + it('ignores stored values invalid for the current width', () => { + setReduxPerPage(undefined) + setStoredPerPage('album', 72) // valid for lg, not for md + const { result } = renderHook(() => useAlbumsPerPage('md')) + expect(result.current[0]).toEqual(12) + }) + + it('returns the responsive default when nothing is stored', () => { + setReduxPerPage(undefined) + const { result } = renderHook(() => useAlbumsPerPage('xl')) + expect(result.current).toEqual([36, [18, 36, 72]]) + }) + + it('ignores a redux value invalid for the current width', () => { + setReduxPerPage(72) // valid for lg, not for md + const { result } = renderHook(() => useAlbumsPerPage('md')) + expect(result.current[0]).toEqual(12) + }) +}) diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx index 87d9f629f..a09f552d9 100644 --- a/ui/src/missing/MissingFilesList.jsx +++ b/ui/src/missing/MissingFilesList.jsx @@ -1,10 +1,15 @@ -import { List, SizeField, useResourceRefresh } from '../common/index' +import { + List, + Pagination, + SizeField, + getStoredPerPage, + useResourceRefresh, +} from '../common/index' import { Datagrid, DateField, TextField, downloadCSV, - Pagination, Filter, ReferenceInput, useTranslate, @@ -49,8 +54,10 @@ const BulkActionButtons = (props) => ( ) +const missingPerPageOptions = [50, 100, 200] + const MissingPagination = (props) => ( - + ) const MissingFilesList = (props) => { @@ -63,7 +70,7 @@ const MissingFilesList = (props) => { actions={} filters={} bulkActionButtons={} - perPage={50} + perPage={getStoredPerPage('missing', missingPerPageOptions)} pagination={} > diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx index f0cb472b1..4e269be18 100644 --- a/ui/src/playlist/PlaylistShow.jsx +++ b/ui/src/playlist/PlaylistShow.jsx @@ -4,14 +4,21 @@ import { ShowContextProvider, useShowContext, useShowController, - Pagination, Title as RaTitle, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import PlaylistDetails from './PlaylistDetails' import PlaylistSongs from './PlaylistSongs' import PlaylistActions from './PlaylistActions' -import { Title, canChangeTracks, useResourceRefresh } from '../common' +import { + Pagination, + Title, + canChangeTracks, + getStoredPerPage, + useResourceRefresh, +} from '../common' + +const playlistTrackPerPageOptions = [100, 250, 500] const useStyles = makeStyles( (theme) => ({ @@ -41,7 +48,10 @@ const PlaylistShowLayout = (props) => { reference="playlistTrack" target="playlist_id" sort={{ field: 'id', order: 'ASC' }} - perPage={100} + perPage={getStoredPerPage( + 'playlistTrack', + playlistTrackPerPageOptions, + )} filter={{ playlist_id: props.id }} > { } resource={'playlistTrack'} exporter={false} - pagination={} + pagination={ + + } /> )} diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 945bac519..ccdb9f1ef 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -16,6 +16,8 @@ import { } from 'react-admin' import { List, + defaultRowsPerPageOptions, + getStoredPerPage, useImageUrl, ToggleFieldsMenu, useSelectedFields, @@ -135,7 +137,11 @@ const RadioList = ({ permissions, ...props }) => { hasCreate={isAdmin} actions={} filters={} - perPage={isXsmall ? 25 : 10} + perPage={getStoredPerPage( + 'radio', + defaultRowsPerPageOptions, + isXsmall ? 25 : 10, + )} > {isXsmall ? ( { bulkActionButtons={} actions={} filters={} - perPage={isXsmall ? 50 : 15} + perPage={getStoredPerPage( + 'song', + defaultRowsPerPageOptions, + isXsmall ? 50 : 15, + )} > {isXsmall ? ( From c35b14dd74d35660a4781d3bd72f7f9c8efa8bd9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 20 Jul 2026 09:49:48 -0400 Subject: [PATCH 53/56] chore(deps): update go-taglib to v2.3.1 Signed-off-by: Deluan --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 014a43a56..5488b41e4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index 064974edb..29983a27d 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs= -github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= +github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= +github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= From 62257527d7f24fdf90b717f12827e708f6223033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 20 Jul 2026 21:49:14 -0400 Subject: [PATCH 54/56] fix(subsonic): avoid double brackets when appending subtitle or version (#5832) When AppendSubtitle or AppendAlbumVersion is enabled, the subtitle/version tag was always wrapped in parentheses and appended to the title/album name. If the tag value already came wrapped in brackets (e.g. "(non-explicit version)"), the result was doubled: "Title ((non-explicit version))". Append the tag as-is when it is already wrapped in a matching bracket pair - (), [], {} or <> - and trim surrounding whitespace first. The shared appendSuffix helper is used by MediaFile.FullTitle, MediaFile.FullAlbumName and Album.FullName so all consumers behave consistently. --- model/album.go | 3 +-- model/album_test.go | 2 ++ model/mediafile.go | 14 ++++++++++++-- model/mediafile_test.go | 9 +++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/model/album.go b/model/album.go index 0afeb2760..55df8c63c 100644 --- a/model/album.go +++ b/model/album.go @@ -1,7 +1,6 @@ package model import ( - "fmt" "iter" "math" "sync" @@ -77,7 +76,7 @@ func (a Album) CoverArtID() ArtworkID { func (a Album) FullName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0]) + return appendSuffix(a.Name, a.Tags[TagAlbumVersion][0]) } return a.Name } diff --git a/model/album_test.go b/model/album_test.go index 0f4c912cd..ad2ca1cb6 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -24,6 +24,8 @@ var _ = Describe("Album", func() { Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"), Entry("returns just name when tag is absent", true, Tags{}, "Album"), Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Remastered)"}}, "Album (Remastered)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Remastered]"}}, "Album [Remastered]"), ) }) diff --git a/model/mediafile.go b/model/mediafile.go index 910cd998d..22ab7fbbe 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -100,18 +100,28 @@ type MediaFile struct { func (mf MediaFile) FullTitle() string { if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0]) + return appendSuffix(mf.Title, mf.Tags[TagSubtitle][0]) } return mf.Title } func (mf MediaFile) FullAlbumName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0]) + return appendSuffix(mf.Album, mf.Tags[TagAlbumVersion][0]) } return mf.Album } +var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'} + +func appendSuffix(base, suffix string) string { + suffix = strings.TrimSpace(suffix) + if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] { + return base + " " + suffix + } + return base + " (" + suffix + ")" +} + func (mf MediaFile) ContentType() string { return mime.TypeByExtension("." + mf.Suffix) } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 6085ccf25..3f306f1a7 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -533,6 +533,13 @@ var _ = Describe("MediaFile", func() { Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"), Entry("returns just title when tag is absent", true, Tags{}, "Song"), Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"), + Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"), + Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"), + Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"), + Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{""}}, "Song "), + Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"), + Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"), + Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"), ) DescribeTable("FullAlbumName", func(enabled bool, tags Tags, expected string) { @@ -544,6 +551,8 @@ var _ = Describe("MediaFile", func() { Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"), Entry("returns just album name when tag is absent", true, Tags{}, "Album"), Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"), ) Describe("CoverArtId", func() { It("returns its own id if it HasCoverArt", func() { From 93f6afb684b44b29ebdbd35eb18d3f063d14caa6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 12:19:50 -0400 Subject: [PATCH 55/56] chore: add OpenCollective funding option Signed-off-by: Deluan --- .github/FUNDING.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 534cfbd11..e5ed36e70 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,10 +1,10 @@ # These are supported funding model platforms -github: deluan -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username ko_fi: deluan +github: deluan +open_collective: navidrome liberapay: deluan +patreon: # Replace with a single Patreon username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry issuehunt: # Replace with a single IssueHunt username From ecf606e52320b87d099991f4f3da87913523c303 Mon Sep 17 00:00:00 2001 From: Marco Ciotola Date: Sat, 25 Jul 2026 19:23:32 +0200 Subject: [PATCH 56/56] fix(ui): update Italian translation (#5848) --- resources/i18n/it.json | 108 +++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/resources/i18n/it.json b/resources/i18n/it.json index b91c04064..656043589 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -17,7 +17,7 @@ "genre": "Genere", "compilation": "Compilation", "year": "Anno", - "size": "Dimensioni", + "size": "Dimensione file", "updatedAt": "Ultimo aggiornamento", "bitRate": "Bitrate", "bitDepth": "Profondità di bit", @@ -98,9 +98,9 @@ "lists": { "all": "Tutti", "random": "Casuali", - "recentlyAdded": "Aggiunti di Recente", - "recentlyPlayed": "Riprodotti di Recente", - "mostPlayed": "I Più Riprodotti", + "recentlyAdded": "Aggiunti di recente", + "recentlyPlayed": "Riprodotti di recente", + "mostPlayed": "I più riprodotti", "starred": "Preferiti", "topRated": "Più votati" } @@ -121,17 +121,17 @@ "roles": { "albumartist": "Artista Album |||| Artisti Album", "artist": "Artista |||| Artisti", - "composer": "Compositore |||| Compositori", - "conductor": "Direttore d'orchestra |||| Direttori d'orchestra", - "lyricist": "Paroliere |||| Parolieri", - "arranger": "Arrangiatore |||| Arrangiatori", - "producer": "Produttore |||| Produttori", - "director": "Direttore |||| Direttori", - "engineer": "Ingegnere del suono |||| Ingegneri del suono", + "composer": "Composizione |||| Composizione", + "conductor": "Direzione d'orchestra |||| Direzione d'orchestra", + "lyricist": "Testi |||| Testi", + "arranger": "Arrangiamento |||| Arrangiamento", + "producer": "Produzione |||| Produzione", + "director": "Direzione |||| Direzione", + "engineer": "Ingegneria del suono |||| Ingegneria del suono", "mixer": "Mixer |||| Mixer", "remixer": "Remixer |||| Remixer", "djmixer": "DJ Mixer |||| DJ Mixer", - "performer": "Esecutore |||| Esecutori", + "performer": "Esecuzione |||| Esecuzione", "maincredit": "Artista Album o Artista |||| Artisti Album o Artisti" }, "actions": { @@ -144,7 +144,7 @@ "name": "Utente |||| Utenti", "fields": { "userName": "Nome utente", - "isAdmin": "Amministratore", + "isAdmin": "Admin", "lastLoginAt": "Ultimo login", "lastAccessAt": "Ultimo accesso", "updatedAt": "Ultimo aggiornamento", @@ -152,13 +152,13 @@ "password": "Password", "createdAt": "Creato il", "changePassword": "Cambiare la password?", - "currentPassword": "Password Attuale", - "newPassword": "Nuova Password", + "currentPassword": "Password attuale", + "newPassword": "Nuova password", "token": "Token", "libraries": "Librerie" }, "helperTexts": { - "name": "Le modifiche effettuate al tuo nome verranno mostrate al prossimo accesso", + "name": "Le modifiche al tuo nome verranno mostrate solo al prossimo accesso", "libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite" }, "notifications": { @@ -167,13 +167,13 @@ "deleted": "Utente eliminato" }, "validation": { - "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non amministratori" + "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non admin" }, "message": { - "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz", + "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", "clickHereForToken": "Clicca qui per ottenere il tuo token", "selectAllLibraries": "Seleziona tutte le librerie", - "adminAutoLibraries": "Gli utenti amministratori hanno automaticamente accesso a tutte le librerie" + "adminAutoLibraries": "Gli utenti admin hanno automaticamente accesso a tutte le librerie" } }, "player": { @@ -203,28 +203,29 @@ "fields": { "name": "Nome", "duration": "Durata", - "ownerName": "Creatore", + "ownerName": "Di", "public": "Pubblica", "updatedAt": "Ultimo aggiornamento", "createdAt": "Data creazione", "songCount": "Tracce", "comment": "Commento", "sync": "Importazione automatica", - "path": "Importa da" + "path": "Importa da", + "starred": "Preferita" }, "actions": { "selectPlaylist": "Seleziona una playlist:", "addNewPlaylist": "Crea \"%{name}\"", "export": "Esporta", "saveQueue": "Salva la coda nella playlist", - "makePublic": "Rendi Pubblica", - "makePrivate": "Rendi Privata", + "makePublic": "Rendi pubblica", + "makePrivate": "Rendi privata", "searchOrCreate": "Cerca playlist o digita per crearne una nuova...", "pressEnterToCreate": "Premi Invio per creare una nuova playlist", "removeFromSelection": "Rimuovi dalla selezione" }, "message": { - "duplicate_song": "Aggiungere i duplicati", + "duplicate_song": "Aggiungi tracce duplicate", "song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?", "noPlaylistsFound": "Nessuna playlist trovata", "noPlaylists": "Nessuna playlist disponibile" @@ -331,7 +332,7 @@ "pathInvalid": "Percorso della libreria non valido" }, "messages": { - "deleteConfirm": "Sei sicuro di voler eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", + "deleteConfirm": "Vuoi eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", "scanInProgress": "Scansione in corso...", "noLibrariesAssigned": "Nessuna libreria assegnata a questo utente" } @@ -367,7 +368,7 @@ "configuration": "Configurazione", "manifest": "Manifest", "usersPermission": "Permessi utenti", - "libraryPermission": "Permesso libreria" + "libraryPermission": "Permessi librerie" }, "status": { "enabled": "Abilitato", @@ -400,10 +401,10 @@ "allUsersHelp": "Se abilitato, il plugin avrà accesso a tutti gli utenti, inclusi quelli creati in futuro.", "noUsers": "Nessun utente selezionato", "permissionReason": "Motivo", - "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", + "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona a quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", "allLibrariesHelp": "Se abilitato, il plugin avrà accesso a tutte le librerie, incluse quelle create in futuro.", "noLibraries": "Nessuna libreria selezionata", - "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", + "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona a quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", "allowWriteAccessHelp": "Se abilitato, il plugin può modificare i file nelle directory della libreria. Per impostazione predefinita, i plugin hanno accesso in sola lettura.", "requiredHosts": "Host richiesti" }, @@ -416,9 +417,9 @@ "ra": { "auth": { "welcome1": "Grazie per aver installato Navidrome!", - "welcome2": "Per iniziare, crea un amministratore", + "welcome2": "Per iniziare, crea un account amministratore", "confirmPassword": "Conferma la password", - "buttonCreateAdmin": "Crea amministratore", + "buttonCreateAdmin": "Crea admin", "auth_check_error": "Per favore accedi per continuare", "user_menu": "Profilo", "username": "Nome utente", @@ -488,8 +489,8 @@ "loading": "Caricamento in corso", "not_found": "Non trovato", "show": "%{name} #%{id}", - "empty": "Nessun %{name} per adesso.", - "invite": "Vuoi aggiungerne uno?" + "empty": "Ancora niente %{name}.", + "invite": "Vuoi aggiungerne?" }, "input": { "file": { @@ -512,10 +513,10 @@ }, "message": { "about": "Informazioni", - "are_you_sure": "Sei sicuro?", - "bulk_delete_content": "Sei sicuro di voler rimuovere questo %{name}? |||| Sei sicuro di voler rimuovere questi %{smart_count} elementi?", + "are_you_sure": "Vuoi procedere?", + "bulk_delete_content": "Vuoi rimuovere questo %{name}? |||| Vuoi rimuovere questi %{smart_count} elementi?", "bulk_delete_title": "Rimuovi %{name} |||| Rimuovi %{smart_count} %{name}", - "delete_content": "Sei sicuro di voler eliminare questo elemento?", + "delete_content": "Vuoi eliminare questo elemento?", "delete_title": "Rimuovi %{name} #%{id}", "details": "Dettagli", "error": "Un errore dal lato client ha impedito il completamento della tua richiesta.", @@ -524,7 +525,7 @@ "no": "No", "not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.", "yes": "Sì", - "unsaved_changes": "Alcune modifiche non sono state salvate. Sei sicuro di volerle ignorare?" + "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ignorarle?" }, "navigation": { "no_results": "Nessun risultato trovato", @@ -574,11 +575,11 @@ "noTopSongsFound": "Nessun brano più ascoltato trovato", "noPlaylistsAvailable": "Nessuna disponibile", "delete_user_title": "Rimuovi utente '%{name}'", - "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", + "delete_user_content": "Vuoi rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", "remove_missing_title": "Rimuovi i file mancanti", - "remove_missing_content": "Sei sicuro di voler rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_missing_content": "Vuoi rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "remove_all_missing_title": "Rimuovi tutti i file mancanti", - "remove_all_missing_content": "Sei sicuro di voler rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_all_missing_content": "Vuoi rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser", "notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS", "lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato", @@ -619,7 +620,7 @@ "options": { "theme": "Tema", "language": "Lingua", - "defaultView": "Vista Predefinita", + "defaultView": "Vista predefinita", "desktop_notifications": "Notifiche desktop", "lastfmNotConfigured": "La chiave API di Last.fm non è configurata", "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", @@ -635,21 +636,22 @@ }, "albumList": "Album", "playlists": "Playlist", - "sharedPlaylists": "Playlist Condivise", - "about": "Info" + "sharedPlaylists": "Playlist condivise", + "about": "Info", + "onlyFavourites": "Mostra solo i preferiti" }, "player": { "playListsText": "Coda", "openText": "Apri", "closeText": "Chiudi", - "notContentText": "Nessuna traccia", + "notContentText": "Niente musica", "clickToPlayText": "Clicca per riprodurre", "clickToPauseText": "Clicca per mettere in pausa", "nextTrackText": "Traccia successiva", "previousTrackText": "Traccia precedente", "reloadText": "Ricarica", "volumeText": "Volume", - "toggleLyricText": "Mostra testo", + "toggleLyricText": "Mostra/nascondi testo", "toggleMiniModeText": "Minimizza", "destroyText": "Distruggi", "downloadText": "Scarica", @@ -659,7 +661,7 @@ "playModeText": { "order": "In ordine", "orderLoop": "Ripeti", - "singleLoop": "Ripeti una volta", + "singleLoop": "Ripeti traccia", "shufflePlay": "Casuale" } }, @@ -667,7 +669,7 @@ "links": { "homepage": "Sito web", "source": "Codice sorgente", - "featureRequests": "Richieste", + "featureRequests": "Proponi idee", "lastInsightsCollection": "Ultima raccolta dati", "insights": { "disabled": "Disabilitato", @@ -693,7 +695,7 @@ }, "activity": { "title": "Attività", - "totalScanned": "Cartelle scansionate totali", + "totalScanned": "Totale cartelle scansionate", "quickScan": "Rapida", "fullScan": "Completa", "selectiveScan": "Selettiva", @@ -709,17 +711,17 @@ "minutesAgo": "%{smart_count} minuto fa |||| %{smart_count} minuti fa" }, "help": { - "title": "Scorciatoie da Tastiera di Navidrome", + "title": "Scorciatoie da tastiera di Navidrome", "hotkeys": { "show_help": "Mostra questa schermata", "toggle_menu": "Mostra/Nascondi la barra laterale", "toggle_play": "Riproduzione/Pausa", - "prev_song": "Traccia Precedente", - "next_song": "Traccia Successiva", + "prev_song": "Traccia precedente", + "next_song": "Traccia successiva", "current_song": "Vai alla traccia corrente", - "vol_up": "Alza il Volume", - "vol_down": "Abbassa il Volume", + "vol_up": "Alza il volume", + "vol_down": "Abbassa il volume", "toggle_love": "Aggiungi questa traccia ai preferiti" } } -} \ No newline at end of file +}