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.
This commit is contained in:
Deluan 2026-07-16 08:49:00 -04:00
parent caa0043030
commit c582ed31fa
5 changed files with 51 additions and 1 deletions

View File

@ -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) |

View File

@ -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)

View File

@ -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", "")

View File

@ -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) {

View File

@ -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())