Deluan Quintão 5927e693d1
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.
2026-07-19 12:39:31 -04:00

239 lines
10 KiB
Go

package jellyfin
import (
"encoding/json"
"net/http"
"sync"
"time"
"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/lyrics"
"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"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/cache"
)
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
sonic sonic.Engine
lyrics lyrics.Lyrics
broker events.Broker
lyricsCache cache.SimpleCache[string, model.LyricList]
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,
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, broker: broker,
lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{
SizeLimit: 1000,
DefaultTTL: 5 * time.Minute,
}),
}
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)
// 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)
// 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.
// 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)
// 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("/system/info", api.getSystemInfo)
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
// 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("/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)
// /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/{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("/studios", api.getStudios)
r.Get("/items/filters", api.getQueryFiltersLegacy)
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.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}/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)
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)
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
// 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 — 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.writeItems(w, r, materialized(p))
return
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)
}
}
// 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)
}