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