mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(jellyfin): experimental Jellyfin Music API support (#5730)
* feat(sharing): enable sharing by default
Flip the EnableSharing default from false to true so new installations have the sharing feature available out of the box. Users can still disable it via the EnableSharing config option.
The native API only registers the /share route when sharing is enabled, so the nativeapi tests that build the router without wiring a share service now explicitly disable sharing in their setup to avoid registering a route backed by a nil service.
* feat(jellyfin): add config flag and URL path constant
Adds the disabled-by-default Server.Jellyfin config option (Enabled,
ServerName) and consts.URLPathJellyfinAPI, following the existing
LastFM/ListenBrainz patterns. Later tasks will use these to mount the
Jellyfin-compatible API router.
* fix(jellyfin): default Jellyfin ServerName to "Navidrome"
* feat(jellyfin): package skeleton with System handshake endpoints
Adds server/jellyfin: the Router (mirrors server/subsonic and
server/public), its Wire-friendly New(...) constructor, a chi routes()
table, and the ok() JSON response helper. Implements the unauthenticated
handshake surface Jellyfin clients probe first: GET /System/Info/Public,
GET+POST /System/Ping, and GET /QuickConnect/Enabled (quick connect is
unsupported, so it always reports disabled).
The public info payload's Id must be stable across restarts (Jellyfin
clients cache ServerId), so it reuses the get-or-create Property pattern
already used for InsightsID in core/metrics/insights.go: fetch
consts.JellyfinServerIDKey from the Property repository, generating and
persisting a new UUID on first read. A dedicated key (rather than the
existing InsightsID) keeps the anonymous telemetry identifier from being
exposed on this unauthenticated endpoint.
* test(jellyfin): cover ping and quickConnectEnabled handlers
Both were added alongside the System/Info/Public handshake endpoint but
had no direct test coverage.
* fix(jellyfin): memoize stable server Id and cover get-or-create path
* feat(jellyfin): wire and mount the router behind Jellyfin.Enabled
Add jellyfin.New to the shared Wire provider set and a
CreateJellyfinAPIRouter injector, then mount the router at /jellyfin
in startServer(), gated by conf.Server.Jellyfin.Enabled, mirroring the
existing LastFM/ListenBrainz router mounts.
* feat(jellyfin): add Jellyfin DTOs and model mappers
Adds BaseItemDto, QueryResult, UserItemDataDto, UserDto,
AuthenticationResult, MediaSourceInfo, PlaybackInfoResponse,
NameGuidPair and SessionInfo DTOs to server/jellyfin/dto, plus
model-to-DTO mappers for songs, albums, artists and genres that
later browse/stream/write endpoints will consume.
* test(jellyfin): cover GenreToBaseItem mapper
* feat(jellyfin): advertise Jellyfin-compatible version, brand ServerName with Navidrome version
* fix(jellyfin): map LastPlayedDate and omit zero track/disc numbers
* feat(jellyfin): authentication middleware and AuthenticateByName login
* fix(jellyfin): reject empty login password, wire ServerName, add negative auth tests
* refactor(jellyfin): use new(x) builtin instead of intPtr helper
* feat(jellyfin): user views and current-user endpoints
* feat(jellyfin): Items query engine, item detail, and latest
Adds the /Items universal query endpoint, dispatching by IncludeItemTypes
over albums/artists/songs/genres with ParentId, SearchTerm, Filters=IsFavorite,
SortBy/SortOrder and StartIndex/Limit support, plus GET /Items/{itemId} and
/Users/{userId}/Items/Latest. Reuses server/subsonic/filter builders (by
artist/album/starred) instead of hand-rolled squirrel filters, and resolves
sort keys per item type since each repository maps sort names to different
real/aliased columns.
Also adds the missing CountAll to tests.MockArtistRepo, exposed by this task
(the mock embedded a nil ArtistRepository for it and would panic on use).
* refactor(server): extract shared query filter builders to server/filter
Moves server/subsonic/filter to server/filter so server/jellyfin can use
the shared query-option builders without importing server/subsonic,
enforcing the rule that no API package imports another API's package.
* feat(jellyfin): full multi-library support with per-user access scoping
Replace the single hardcoded "music" UserView with one CollectionFolder
view per library the user can access, and scope every /Items browse
query (albums, songs, artists, /Latest) to those libraries via
server/filter's ApplyLibraryFilter/ApplyArtistLibraryFilter.
ParentId is now disambiguated: a numeric value the user has access to is
treated as a library scope (browsing a UserView), otherwise it falls
through as an entity id (artist/album), which safely matches nothing
rather than leaking another library's content. getItem now 404s when
fetching an album or song outside the user's accessible libraries;
artists are skipped (they can span multiple libraries) with a TODO.
Genres remain unscoped since they're global tags, not per-library
entities.
* test(jellyfin): cover admin library-access path and drop nil test contexts
* feat(jellyfin): Artists and Genres endpoints
Add /Artists, /Artists/AlbumArtists, /Genres and /MusicGenres. Artists
listing is library-scoped (defaulting to the user's accessible
libraries, narrowed by an accessible ParentId), delegating access
control to listArtists/ApplyArtistLibraryFilter. Genres are global and
unscoped, matching the ML decision already made for listGenres.
* refactor(jellyfin): share resolveLibraryScope between items and artists
* feat(jellyfin): item image endpoint via artwork service
* fix(jellyfin): let net/http sniff image Content-Type instead of forcing jpeg
* feat(jellyfin): audio streaming and PlaybackInfo with library access control
* feat(jellyfin): favorites and rating write-back with library access control
Adds POST/DELETE handlers for /Users/{userId}/FavoriteItems/{itemId} and
/Users/{userId}/Items/{itemId}/Rating. A shared resolveAnnotated helper
probes album/artist/media file (mirroring getItem's order) and 404s before
writing if the user lacks access to the album/song's library; artists are
exempt since they span multiple libraries. Ratings are halved coming in
(Jellyfin 0-10 -> Navidrome 0-5) to match the doubling in dto.UserData.
Also teaches MockMediaFileRepo and MockArtistRepo's SetStar/SetRating (and
MockAlbumRepo's, previously a no-op) to actually mutate the backing data so
write-back can be asserted in tests.
* feat(jellyfin): playback reporting and scrobbling
Add /Sessions/Playing[/Progress|/Stopped] and /Sessions/Capabilities[/Full]
handlers, backed by core/scrobbler.PlayTracker. A new withPlayer middleware
resolves/registers a model.Player from the Emby DeviceId header (used
directly as the stable player id, unlike Subsonic's cookie fallback) and
injects it into the request context for scrobbling.
The Stopped report calls ReportPlayback with IgnoreScrobble to end the
now-playing session without double-counting, then calls Submit as the
single source of the play-count increment and external scrobble.
* feat(jellyfin): playlist read and write-back
Adds POST /Playlists, GET/POST/DELETE /Playlists/{id}/Items. Tags each
playlist item with PlaylistItemId (the entry's position within the
playlist) so DELETE .../Items?EntryIds=... can remove a specific
occurrence by the id core/playlists.RemoveTracks actually expects,
rather than the song id used everywhere else.
* test(jellyfin): cover empty Ids/EntryIds in playlist add/remove
* feat(jellyfin): unknown-route logging, generic 500s, rating clamp, docs
Hardening pass ahead of real client testing: unmatched routes and
unsupported methods now return a logged, JSON 404 instead of chi's
default plain-text response, so a missing endpoint a client needs is
easy to spot in the logs. Internal errors (ffmpeg output, file paths,
etc.) no longer leak into 500 response bodies -- a shared
internalError helper logs the real error server-side and always
returns a generic message. Inbound Jellyfin ratings are clamped to
0-10 before being halved into Navidrome's 0-5 scale, and /System/Ping
now replies with a bare plain-text body as real Jellyfin servers do.
Adds a README with an enable/curl walkthrough and known limitations.
* docs(jellyfin): clarify public image endpoint and accessibleLibraryIDs comments
* fix(jellyfin): case-insensitive path routing for Jellyfin client compatibility
* refactor(server): extract case-insensitive path routing to a shared helper
* fix(jellyfin): return User Policy and Configuration so Finamp completes login
* fix(jellyfin): support Playlist type, multi-type Items queries, and PlayCount/DatePlayed sort
Real Finamp requests break against three /Items query engine bugs: Playlist
requests fell through to albums, multi-type IncludeItemTypes (e.g. Finamp's
favorites screen) only returned the first requested type, and comma-separated
SortBy lists (e.g. "DateCreated,SortName") were matched as one opaque string
so they always fell through to the repo default.
Also extends tests/mock_playlist_repo.go with SetData/GetAll so playlist
listing is testable like the other mock repos.
* feat(jellyfin): implement /socket WebSocket for real-time client sessions
* fix(jellyfin): resolve library-view ids in getItem so clients can load the library
* fix(jellyfin): serve direct file at /Items/{id}/File and accept ApiKey query param
* fix(jellyfin): resolve playlist ids in getItem
* fix(jellyfin): read lowercase ids/entryIds playlist params and add playlist-users endpoints
* fix(jellyfin): include MediaSources with Size/Bitrate on tracks so clients show download size
* fix(jellyfin): populate all required MediaSourceInfo bool/array fields to match Jellyfin
* fix(jellyfin): hex-encode item ids at the API boundary for Jellyfin client compatibility
Finamp (and presumably other clients) parses ids as radix-16, but Navidrome's
base62 nanoids aren't valid hex and crash its queue packing. Hex-encode every
id emitted by the Jellyfin API and decode every id received, keeping the
transform reversible and stateless at the boundary rather than touching any
model id.
* fix(jellyfin): populate MediaStreams with the audio stream so clients can size/transcode
* fix(jellyfin): support Ids batch-fetch in /Items so clients can fetch items by id
* fix(jellyfin): do not disable Jellyfin server when disabling external services
* fix(jellyfin): emit per-image ImageBlurHashes so clients de-dupe images and stop warning
* fix(jellyfin): support playlist cover upload/delete and display via item image endpoints
* feat(jellyfin): implement GET /Playlists/{id} for playlist visibility
Finamp's playlist edit screen calls GET /Playlists/{id} to read the
OpenAccess (public visibility) flag; we only had the sub-routes, so it
404'd and the edit screen failed to load. Return the Jellyfin PlaylistDto
shape (OpenAccess from Public, empty Shares, media item ids).
* fix(jellyfin): display playlist cover art
Uploaded playlist covers never showed in clients for two reasons: the
playlist BaseItemDto advertised no Primary ImageTag (so clients didn't
know to fetch a cover), and the public image endpoint resolved artwork
under the request's anonymous context, so a private playlist failed its
visibility filter and fell back to the placeholder. Advertise the Primary
image tag/blurhash on playlists, and resolve artwork under an elevated
context (as core/artwork's cache warmer does).
* fix(jellyfin): expand album/artist/playlist ids when building playlists
Jellyfin clients (Finamp) send container ids — an album, artist or
playlist — in a playlist's Ids list and expect the server to expand each
into its child tracks. core/playlists only understands media file ids, so
creating or adding with an album id silently produced an empty playlist.
Expand container ids to their tracks (in order) before create/add; bare
song ids still pass through. Adds filter.SongsByArtistID.
* fix(jellyfin): support playlist deletion via DELETE /Items/{id}
Finamp deletes a playlist with DELETE /Items/{id}, which we didn't route,
so deletion silently failed. Implement it via core/playlists.Delete (which
enforces ownership and removes the cover file). Only playlists are
deletable through this API; non-playlist ids return 404, non-owners 403.
* test(jellyfin): add e2e suite harness + smoke tests
* test(jellyfin): e2e for system, auth, routing, browsing, annotations
* test(jellyfin): e2e for playlists (CRUD, expansion, cover) and item images
* test(jellyfin): e2e for streaming, sessions, and multi-user access control
* fix(jellyfin): artist search 500 (library filter leaked into FTS query)
getArtists/listArtists reused the browse-path filters (notMissing +
ApplyArtistLibraryFilter) for the search path, but artist Search expects a
sole Eq{library_id} filter it can consume as a scope — artists have no
library_id column, so the compound/join filter leaked into the FTS query
and 500'd. Every Finamp artist search failed (the filter is applied even
unscoped, since admins resolve to all library ids). Build search filters
separately. Adds e2e search coverage.
* feat(jellyfin): implement POST /Playlists/{id} to update name, visibility, tracks
Finamp edits a playlist (make public, rename, reorder) via POST
/Playlists/{id}, which we didn't route, so every edit 404'd. Implement it:
Ids present -> replace track list (Create with existing id, preserving
name); otherwise update Name/IsPublic via core/playlists.Update. Adds a
shared playlistError helper (403/404/500) reused by deleteItem, plus e2e
coverage.
* docs(jellyfin): update README for playlists, images, id encoding, and e2e
* fix(jellyfin): default album track listing to track order
Browsing an album's tracks (Items?ParentId=<albumId>&IncludeItemTypes=Audio)
took only SongsByAlbum's filters and dropped its Sort, so tracks came back
in arbitrary order. Default opts.Sort to the album (disc+track) order when
browsing an album without an explicit SortBy — matching Subsonic's GetAlbum
and real Jellyfin. An explicit SortBy still wins.
* fix(jellyfin): filter items by AlbumArtistIds/ArtistIds
An artist's page in Finamp sends ParentId=<libraryId> (scoping) plus
AlbumArtistIds/ArtistIds/contributingArtistIds for the artist itself, but
queryItems only honored ParentId, so an artist's albums and tracks came
back unfiltered (every artist's content). Parse the artist-id params and
apply AlbumsByArtistID (albums) / SongsByArtistID (tracks). Adds e2e
coverage.
* fix(jellyfin): only count a play past the scrobble threshold
reportPlaybackStopped set IgnoreScrobble and force-submitted a play on
every Stopped report, so a briefly-played track (e.g. an immediate skip)
was marked played. Finamp sends Stopped on every track switch, so the
threshold must be applied server-side. Let ReportPlayback's StateStopped
logic decide (play + scrobble only past 50% of the track / 4-minute cap)
instead. Subsonic differs because there the client gates submission.
* fix(jellyfin): sort album tracks by track number when client sends IndexNumber SortBy
Finamp's album view requests SortBy=ParentIndexNumber,IndexNumber,SortName
(disc, track, name), but applySort didn't recognize ParentIndexNumber or
IndexNumber and fell through to SortName, sorting tracks alphabetically by
title. Map both keys to the album (disc+track) sort. The reversed-title
fixture lets the e2e tell track order from title order.
* fix(jellyfin): honor the isFavorite query param for favorites filtering
Finamp's artist 'Favourite tracks' widget requests favorites via the
standalone isFavorite=true query param, not Filters=IsFavorite, so the
filter was ignored and non-favorited tracks were returned. Detect both
forms. (The widget's reshuffling is Finamp's explicit SortBy=Random.)
* feat(jellyfin): emit DateCreated (Date Added) on items
BaseItemDto had no DateCreated, so clients showed 'No Date Added' and had
nothing to sort 'Recently Added' by. Emit it as ISO 8601 from each entity's
CreatedAt (the same field the recently_added sort uses) for songs, albums
and artists.
* fix(jellyfin): set ArtistItems/AlbumArtists on songs (Now Playing artist)
SongToBaseItem only set Artists (names) and AlbumArtist, not the structured
ArtistItems/AlbumArtists. Finamp's Now Playing screen reads ArtistItems and
shows 'Unknown Artist' when it's absent. Populate both (track artist and
album artist) as name+id pairs, mirroring AlbumToBaseItem.
* docs(jellyfin): note synthetic blurhash as a follow-up
Document that ImageBlurHashes are derived from the item id (a solid-color
placeholder), not computed from the cover art like real Jellyfin, and
outline what a proper implementation would take.
* docs(jellyfin): note WebSocket events and favourited playlists as follow-ups
* fix(jellyfin): filter the Artists page by role (album artist vs performer)
/Artists and /Artists/AlbumArtists both called the same role-agnostic
handler, so Navidrome's per-role artist entries (composers, arrangers,
performers) all showed as Album Artists, and the two tabs were identical.
Filter /Artists/AlbumArtists to RoleAlbumArtist and /Artists to RoleArtist
(and the MusicArtist browse to album artists), via filter.ArtistsByRole.
Verified live: Beatles Singles' album-artists dropped 138->5, composers
excluded, performers (Billy Preston) show only under /Artists.
* fix(jellyfin): sort playlists by name (missing Playlist sort mapping)
applySort had no sortColumnsByType entry for the Playlist type, so
SortBy=SortName was ignored and the Playlists screen showed them in the
repo's default order. Map SortName/Name -> name (as Subsonic does) and
DateCreated -> created_at. Verified live: case-insensitive alphabetical.
* fix(jellyfin): read query params case-insensitively (Jellify support)
Jellify (and the official Jellyfin TypeScript SDK) send query params in
camelCase (parentId, albumArtistIds, artistIds, includeItemTypes), where
Finamp sends PascalCase. Our handlers read fixed-case keys, so every
Jellify filter/sort/paging param was silently dropped:
- an artist's page listed albums and tracks from all artists
- opening an album listed every album instead of its tracks
Real Jellyfin binds query params case-insensitively (ASP.NET model
binding), so add a normalizeQueryKeys middleware that folds every query
key to lowercase once, and read params by their lowercase name. This
also removes the scattered dual-case hacks (Ids/ids, IsFavorite,
ContributingArtistIds, api_key/ApiKey, queryParam) that let this bug
class through.
Also infer the child type from an album parent: Jellify browses an album
with only parentId (no IncludeItemTypes), and Jellyfin infers Audio from
the parent; without it we fell back to listing all albums.
Verified live against Finamp+Jellify and with 4 new e2e specs replaying
Jellify's exact request shapes.
* fix(jellyfin): advertise LocalAddress in the public system info handshake
Jellify (and other @jellyfin/sdk clients) that connect by raw address fall
back to HTTP when TLS isn't available, then adopt the handshake's
LocalAddress as their server base URL. We never populated it, so Jellify's
SDK `api` object was undefined and sign-in crashed with "Cannot read
property 'configuration' of undefined" (getUserApi(api!) with an undefined
api). Over an HTTPS connection Jellify uses connectionType=hostname and
never reads LocalAddress, which is why it worked while an HTTPS proxy was
in front.
Populate LocalAddress from the request — scheme + host (honoring
X-Forwarded-*), plus the /jellyfin mount path — matching real Jellyfin,
which always sends it. Export server.ServerAddress for the resolution.
* fix(jellyfin): separate "Featured On" from an artist's own discography
Jellify's artist page fetches the discography via albumArtistIds and the
"Featured On" section via contributingArtistIds, relying on the server to
return disjoint sets. We collapsed albumArtistIds/artistIds/
contributingArtistIds into one AlbumsByArtistID filter, so an artist's own
albums appeared in both sections.
Add AlbumsByContributingArtistID — albums where the artist is a track
artist but NOT the album artist — matching Jellyfin's ContributingArtistIds
(in Artists, not in AlbumArtists), and route contributingArtistIds to it.
* fix(jellyfin): accept a bare Authorization token for audio streaming
Jellify's native player (react-native-nitro-player) authenticates the audio
stream by setting a bare Authorization header carrying the raw access token
({ AUTHORIZATION: api.accessToken }), not the "MediaBrowser ... Token=" scheme
parseEmbyAuth understands. tokenFromRequest didn't recognize it, so every
/Audio/{id}/stream request from the native player 401'd and no audio played
(the JS client still optimistically posted playback progress, masking it).
Accept a bare (or "Bearer <token>") Authorization header, as real Jellyfin
does. Verified live: the native player's exact request now streams 200.
* fix(jellyfin): embed a self-authenticating stream URL in PlaybackInfo
Jellify's native audio player (react-native-nitro-player / ExoPlayer) fetches
the stream without forwarding any auth — captured request headers were only
Connection/Icy-Metadata/Accept-Encoding/User-Agent, no Authorization and no
api_key — so every /Audio/{id}/stream request 401'd and nothing played (the
JS client still optimistically posted progress, masking it).
Real Jellyfin returns stream URLs with the token embedded; we returned none,
so Jellify fell back to a token-less DirectPlay URL. Populate
MediaSources[0].TranscodingUrl with /Audio/{id}/universal?api_key=<caller
token>, which Jellify's player uses verbatim. Direct-play clients (Finamp
builds its own /Items/{id}/File?ApiKey URL) ignore the field, so they're
unaffected.
* feat(jellyfin): serve per-item UserData (GET /UserItems/{id}/UserData)
Jellify fetches this per item to render played/favourite indicators; we 404'd
it. Resolve the item via the existing getItem resolver (which loads the
caller's annotations and enforces the same library-access gate) and return its
UserData, falling back to an empty-but-valid object for items without
annotations (e.g. playlists). Also registers the legacy
/Users/{userId}/Items/{itemId}/UserData spelling.
* refactor(jellyfin): drop unused bare-Authorization token support
Added mid-troubleshooting on the theory that Jellify's native player sends a
bare Authorization header, but the debug capture showed it sends no auth header
at all — the real fix was embedding api_key in PlaybackInfo's TranscodingUrl.
No client reaches this path (Jellify JS uses the MediaBrowser scheme, Finamp
uses X-Emby-Token, the native player uses the api_key URL), so remove it and
its tests per YAGNI. Effectively reverts 4f8ac1e0.
* feat(jellyfin): implement Similar endpoints via the external provider
Add GET /Artists/{id}/Similar (related artists) and GET /Items/{id}/Similar
(similar songs for a track, similar albums for an album, related artists for an
artist), sourced from the same external.Provider (Last.fm etc.) that powers
Subsonic's getArtistInfo2/getSimilarSongs. Only library-present artists are
returned so each is navigable; provider errors and unknown ids degrade to an
empty 200 result, so clients (Jellify) stop hammering these with 404 retries.
Injects external.Provider into the Router (regenerated via make wire).
* perf(jellyfin): make Similar endpoints non-blocking (bounded quick wait)
Each /Similar request fetched from Last.fm synchronously (500ms-1.4s), and
Jellify requests Similar for many items at once on the home/artist screens, so
the whole screen stalled — a home pull-to-refresh dragged for seconds.
Run the external lookup on a background context and return within a 500ms quick
wait: a cached artist resolves instantly, a cold one returns empty now while the
lookup finishes caching in the background, so a later load is fast and
populated. Verified live: cold calls bounded at ~500ms (was up to 1.4s), warm
calls 3-8ms.
* fix(jellyfin): answer ManualPlaylistsFolder so the home stops stalling
Jellify resolves its "playlists library" via IncludeItemTypes=
ManualPlaylistsFolder, then lists playlists with ParentId set to that folder's
id. We didn't recognize the type, so parseTypes fell back to MusicAlbum and
returned the album list. Jellify's query then found no item with
CollectionType=playlists and resolved undefined — which React Query rejects,
retrying it in a backoff loop that stalled the home pull-to-refresh for ~5s
(every response was fast server-side; the delay was the client's retries).
Return a synthetic "playlists" folder (CollectionType=playlists) for the
ManualPlaylistsFolder query, resolve ParentId=<that folder> to the user's
playlists, and give playlists a Path under "data" (Jellify drops playlists
whose Path lacks it). Adds a Path field to BaseItemDto.
* docs(jellyfin): note lyrics and InstantMix/sonic-similarity as follow-ups
* fix(jellyfin): genre paging params and same-key casing collisions
getGenres read StartIndex/Limit in PascalCase, which normalizeQueryKeys had
already folded to lowercase, so genre paging was silently ignored; totals now
come from the full (small) genre list instead of the page length.
normalizeQueryKeys now merges values when two casings of a key collide,
instead of nondeterministically keeping one.
* fix(jellyfin): round ratings to the nearest star instead of truncating
Rating is a nullable double 0-10 in Jellyfin's contract. Truncating integer
division stored 9 as 4 stars, and both Rating=1 and fractional values (which
failed integer parsing) became 0 — silently deleting the rating. Parse as
float, round, and floor nonzero input at one star.
* fix(jellyfin): apply Name/IsPublic sent together with a track replacement
Jellyfin's UpdatePlaylist applies every provided field, but the Ids branch
returned early, silently discarding a rename or visibility change sent in the
same body (core/playlists.Create with an existing id ignores the name).
* fix(jellyfin): don't rotate the stored server id on transient DB errors
serverID treated any Property.Get error as "no id yet" and persisted a fresh
UUID over JellyfinServerID, with sync.Once pinning it for the process lifetime
— a busy DB or canceled request context on the first request would break every
client's cached ServerId. Only ErrNotFound mints a new id now, and failures
yield an uncached temporary value so the next request retries.
* fix(jellyfin): report real search totals instead of page length or unfiltered counts
Artist search returned TotalRecordCount = len(page), so clients stopped after
the first page; album/song search counted via CountAll, which can't see the
search term, so clients paged through phantom results. The repos' Search API
has no match count, so fetch one row beyond the page: offset+len is exact on
the last page and a strictly growing lower bound before it — paging clients
terminate exactly at the last match.
* fix(jellyfin): browse playlists via the generic /Items path and resolve the playlists folder by id
A typeless /Items?ParentId=<playlistId> (legal in real Jellyfin, used by
generic clients) fell through to the MusicAlbum default and returned an empty
list; it now returns the playlist's tracks, paginated, with visibility
enforced by GetWithTracks. resolveItemByID also answers the synthetic
playlists-folder id the server itself advertises instead of 404ing it.
* perf(jellyfin): cap per-type queries in multi-type /Items requests
The multi-type merge path queried each type with a zero-value QueryOptions —
no LIMIT in SQL — materializing every matching row (each with embedded
MediaSources) just to slice out one page in memory. Each type now fetches at
most StartIndex+Limit rows, the worst case one type can contribute to the
merged window; totals still come from CountAll.
* fix(jellyfin): dedupe and bound the background Similar fetches
awaitSimilar spawned a detached, deadline-free goroutine per request; clients
re-polling after the empty quick-wait response piled up duplicate provider
chains (no singleflight anywhere below) racing writes on the same artist row.
Identical in-flight requests now share one fetch — keyed per user, since the
mapped items embed the user's annotations — and the background context gets a
one-minute deadline so a hung provider can't hold goroutines forever.
* fix(jellyfin): gate private playlist covers on the public image route
The unauthenticated image endpoint elevated every request to an admin context,
so anyone who knew or guessed a playlist id could fetch another user's private
uploaded cover. Library artwork still resolves elevated (Jellyfin clients fetch
images without auth headers), but playlist covers are now served only when the
playlist is public or the request's optional token identifies its owner or an
admin; everyone else gets the placeholder.
* fix(log): redact full api_key values, including JWTs
The api_key pattern matched only word characters, stopping at a JWT's first
'.' — the Jellyfin API embeds the session JWT as api_key in TranscodingUrl, so
request logs kept its payload and signature, enough to reconstruct a replayable
token by prepending the constant header. Match to the next query separator
instead, like the sibling s=/p=/jwt= patterns.
* fix(jellyfin): record LastLoginAt on Jellyfin logins
authenticateByName re-implements credential validation and skipped the
UpdateLastLoginAt call the web UI's validateLogin makes, so users who only log
in via Jellyfin clients showed a never/stale Last Login in the admin UI.
* perf(jellyfin): batch song resolution in /Items?ids= and playlist expansion
Both paths probed up to four repositories per client-supplied id. Songs — the
common case — now resolve via chunked media_file.id IN queries (same pattern
as playqueue's loadTracks); only the residue pays the container probes.
* fix(jellyfin): wait for the real Similar result instead of answering a cacheable empty list
The 500ms quick wait returned an empty 200 for any cold lookup —
indistinguishable from "no similar items exist", so clients cached the wrong
answer until an app restart. With fetches deduplicated, wait up to the agents'
HTTP timeout for the actual result; only a hung provider now yields the empty
fallback, and its fetch still warms the cache in the background. Also makes
the dedup test deterministic (the old one raced its release channel).
* refactor(tests): extract the shared e2e harness into tests/harness
The Subsonic and Jellyfin e2e suites each carried their own copy of the
golden-DB lifecycle (boot, seed users/library, scan, WAL snapshot), the
ATTACH-DATABASE restore, fixture-FS registration, and the SpyStreamer /
NoopFFmpeg doubles. Those now live in one importable package (same pattern as
core/storage/storagetest); fixture libraries and request helpers stay
per-suite since they encode each API's test expectations.
* docs(jellyfin): make comments concise
Compress the narrative comments accumulated during live client testing into
short why-only notes; keep the client quirks, security rationales and gotchas,
drop the exposition. Comments-only change (net -141 lines).
* fix(tests): silence gosec taint false-positive in harness snapshot write
The write moved from a _test.go file (which gosec skips) into the importable
harness package; the path derives from GinkgoT().TempDir().
* fix(jellyfin): route the current /UserFavoriteItems favorite endpoint
@jellyfin/sdk 0.13.0 (used by Jellify) posts favorites to
POST/DELETE /UserFavoriteItems/{itemId}, while we only routed the legacy
/Users/{userId}/FavoriteItems/{itemId} (Finamp). Jellify favorites 404'd
("Failed to add favourite"). Route both spellings to the same handlers.
* feat(jellyfin): honor Fields on /Items and add missing conformance fields
Match real Jellyfin's response shape: gate MediaSources (and MediaStreams)
behind Fields=MediaSources instead of always embedding them — clients that
need Size request it, as Finamp does — which cuts a 46-track artist response
from ~87KB to a fraction. Also emit the always-present fields Jellyfin sets:
ServerId (stamped centrally in ok), LocationType, HasLyrics, and SortName
(when Fields=SortName). PlaybackInfo still carries MediaSources (its purpose).
* fix(jellyfin): address code review security and correctness findings
Applies the reviewed findings from PR #5730:
- Gate similar songs/albums on the caller's library access, so the
external provider can't surface metadata from libraries the user
cannot see. Also clamp the client-supplied limit before it sizes any
allocation or provider fetch (CodeQL user-controlled allocation).
- Prepend the /jellyfin mount prefix to the PlaybackInfo TranscodingUrl,
so a client resolving it as an absolute host path still reaches the
mounted router.
- Recognize WebP and GIF magic numbers on raw cover uploads, matching
the formats Navidrome already supports.
- Bound the playlist cover upload: honor EnableArtworkUpload for
non-admins and cap the body with MaxBytesReader/MaxImageUploadSize,
mirroring the native image endpoint.
- Propagate non-not-found repository errors from resolveAnnotated as 500
instead of silently returning 404.
- Normalize the literal prefix of mixed literal.param path segments
(e.g. STREAM.mp3) so case-insensitive routing reaches stream.{container}.
- Rate-limit POST /Users/AuthenticateByName with the same per-IP limiter
as /auth/login when AuthRequestLimit is set.
- Guard against a nil user in authenticateByName.
* fix(jellyfin): correct playlist track browsing and multi-id edits
Fixes three playlist issues, two found testing against Jellify and one
from the PR review:
- Resolve a playlist ParentId to its tracks even when the client sends
IncludeItemTypes=Audio. Jellify opens a playlist with
ParentId=<playlist>&IncludeItemTypes=Audio; the id was routed through
listSongs as an album id, returning an empty list.
- Read repeated id query params (ids=X&ids=Y), not just the first value.
Jellify's @jellyfin/sdk serializes id arrays as repeated params, so
adding an album (which it expands client-side into many ids) only
added the first track. Applies to both add and remove; the
comma-separated form other clients use still works.
- Let an explicit empty Ids array clear a playlist. Ids is now a pointer
so an omitted field still means 'leave unchanged', while an empty list
clears the tracks (via RemoveTracks, since the repository skips track
writes for an empty list).
* fix(jellyfin): register clients as players on any authenticated request
Jellyfin clients did not appear in the players list. Unlike Subsonic,
whose getPlayer middleware runs on every authenticated endpoint, the
Jellyfin router only registered a player on the /Sessions/Playing
reports, so browsing or streaming never created one.
Apply withPlayer to the whole authenticated group, mirroring Subsonic,
so the calling device registers (and scrobbling has a player) as soon as
it makes any authenticated request. Two follow-ups found while testing:
- Skip registration when the request carries no client/device info (no
X-Emby-Authorization, e.g. the /socket handshake that auths via
?api_key= only), which otherwise created a junk player named ' []'.
- URL-decode the X-Emby-Authorization field values. Jellify's
@jellyfin/sdk percent-encodes them (Device='Pixel%208%20Pro') while
Finamp sends them raw, so the player name showed as
'Jellify [Pixel%208%20Pro]'.
* refactor(jellyfin): move case-insensitive routing into the jellyfin package
The case-insensitive path normalization lived in the server package but
was only ever used by the Jellyfin router (its whole purpose is that
Jellyfin clients route case-insensitively while chi does not). Move it
into server/jellyfin and unexport it, so it sits with its only caller
and no longer needs to be exported across a package boundary.
* docs(jellyfin): document player registration, playlist and image behavior
Updates the package README for the behavior added/fixed this round:
- New 'Players and sessions' section: any authenticated request now
registers the device as a player (like Subsonic), with the
Client [Device] naming, URL-decoding of the Emby auth fields, and the
/socket skip that avoids a nameless player.
- Authentication: note AuthenticateByName is rate-limited per IP.
- Playlists: repeated vs comma-separated id params, and that an explicit
empty Ids clears the playlist while an omitted Ids leaves it untouched.
- Cover art: WebP/GIF magic-number detection plus the MaxImageUploadSize
and EnableArtworkUpload gates.
- Endpoints table: add the /Similar and /UserFavoriteItems /
/UserItems/.../UserData routes that were already served but unlisted.
* feat(jellyfin): expose configured users on the login user-picker
Adds Jellyfin.ExposedPublicUsers, a comma-separated allowlist of
usernames that GET /Users/Public advertises so Jellyfin clients (Finamp,
Jellify) can show a login user-picker instead of a blank username field.
The endpoint is unauthenticated, so it defaults to exposing no users and
never lists the full user table: only the admin-configured names are
returned, resolved live per request (a name that doesn't exist is skipped
and logged). Each entry is a minimal DTO (Name, Id) with no
Policy/Configuration, so admin status isn't leaked pre-login, and no
avatar since Navidrome has no per-user profile images.
* style(jellyfin): trim redundant comments
Remove comments that restated the code or duplicated an explanation
already given nearby, keeping the ones that capture non-obvious rationale
(client-specific quirks, gotchas). Comment-only change; no behavior
difference.
* perf(db): keep query planner statistics trustworthy with full ANALYZE
PRAGMA optimize's internal ANALYZE runs with a limited analysis budget
(~2000 rows) that writes wrong sqlite_stat1 entries for low-cardinality
indexes: on a 96K-track library it claimed (missing, library_id) narrows
to ~2000 rows when it matches the whole table. The planner then prefers
that index over the sort index and falls back to a full-table temp
B-tree sort per request, turning paginated song listings into
multi-second queries (reproduced at 5.5s on real hardware; ~90x slower
than with correct stats). Every index-creating migration re-triggered
the poisoning via the post-migration optimize, and the daily optimizer
could re-trigger it on large library changes. Setting analysis_limit on
the connection does not help: optimize ignores it.
Run a plain full ANALYZE instead: after migrations with schema changes,
and in db.Optimize (daily schedule and scan-end). Stats are stored in
the database file, so one connection suffices and the per-connection
pool loop is gone. The Optimize call at shutdown is removed: stats are
maintained at migration/scan/daily points, and an ANALYZE during
shutdown only delays it and races container stop timeouts.
* perf(db): add covering index for title-sorted song listings
Deep pagination over songs sorted by title (WHERE missing/library_id,
ORDER BY order_title LIMIT/OFFSET - the shape Jellyfin clients use to
enumerate the library, and non-admin native/Subsonic song lists share)
walked media_file_order_title and fetched the table row for every
skipped entry just to evaluate the filter and the annotation/bookmark
join keys: offset+limit random reads, seconds per page on cold spinning
disks.
The new (missing, library_id, order_title, id) index makes the offset
skip fully index-resident: filter columns and the join key come from the
index, and only the emitted page touches table rows. Measured on a
96K-track library: offset 50000 drops from ~6.5s (poisoned stats) /
~100ms (good stats, warm) to 24ms, and cold deep pages on NAS hardware
from ~7s to ~0.5s.
* feat(jellyfin): support transcoding via HLS playlist and server-forced player format
- withPlayer now propagates the player's configured transcoding into the
request context (like Subsonic's getPlayer), so a format forced in
Settings > Players applies to the Jellyfin stream endpoints
- new GET /Audio/{itemId}/main.m3u8, the endpoint Finamp plays through
when its transcoding setting is enabled: a single-segment HLS VOD
playlist pointing at the existing progressive transcode endpoint
- streamAudio: treat audioBitRate as bits/sec (Jellyfin convention) and
fall back to audioCodec as target format when no container is given
* fix(jellyfin): honor GenreIds when browsing genre albums and tracks
Finamp's genre screen sends ParentId=<libraryId> plus GenreIds=<genreId>,
but /Items ignored the param, so every genre returned the whole library.
- filter.ByGenreID delegates to persistence.TagIDFilter (exported, was
tagIDFilter), the same mechanism behind the native API's genre_id filter
- id lists are read via queryIDs, covering both spellings clients use
(comma-separated and repeated params); /Items?ids= gains the repeated
form too
* feat(jellyfin): filter album artists by GenreIds
Finamp's artist tab sends GenreIds to /Artists/AlbumArtists when a genre
filter is active; the param was ignored, returning all artists.
filter.ArtistsByGenreID matches artists credited as album artist on an
album with the genre, via a non-correlated semi-join over album
participants (86ms on a 29K-artist library; the correlated EXISTS form
takes 11 minutes). Applied on the browse path of /Artists* and
/Items?IncludeItemTypes=MusicArtist.
The performers variant (/Artists?GenreIds=) still ignores the filter: it
needs the same semi-join against media_file.participants, unmeasured on
large libraries.
* fix(jellyfin): version playlist image tag so clients refresh uploaded covers
Uploads were stored and served correctly, but Finamp kept showing the old
cover: it caches covers keyed by blurHash (its imageId is the item id,
which never changes), and both our image tag and synthetic blurhash were
derived from the playlist id alone.
The tag is now <id>-<UpdatedAt millis hex> (SetImage/RemoveImage go
through a full Put, which bumps UpdatedAt), and the blurhash derives from
the tag, so every cover change rotates both. UpdatedAt over-invalidates
(any playlist edit busts the cover cache), which only costs a refetch;
hashing the actual image remains the proper long-term tag.
An e2e test guards the whole chain, since a partial Put(pls, cols...)
would silently stop bumping UpdatedAt.
* fix(jellyfin): align cover upload limit and validation with the native endpoint
Cover uploads of large photos failed with a silent 400: the
MaxImageUploadSize cap was applied to the wire body, which Jellyfin
clients base64-encode (4/3 inflation), so the effective raw-image limit
was only ~7.5MB — and Finamp uploads picked photos uncompressed.
Align with the native endpoint:
- the limit caps the decoded image; the read cap allows for base64
inflation
- validate by decoding (image.DecodeConfig) and take the storage
extension from the real format instead of the Content-Type header,
which clients get wrong (Finamp falls back to image/jpeg); a HEIC or
corrupt file is now rejected instead of stored as a broken cover
- rejected uploads log the reason (size/limit/decode error); diagnosing
this from production logs previously required guesswork
* fix(jellyfin): optimize SongsByArtistID filter to improve performance at library scale
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(jellyfin): sort tracks by release year for SortBy=PremiereDate
Finamp's "Latest Releases" artist section sends
SortBy=PremiereDate,Album,... descending; PremiereDate wasn't in the
Audio sort map, so applySort fell through to Album and the view came
back in reverse album-name order (a 2002 remix album first, the 2013
release last).
Map premieredate/productionyear to the year sort key, matching the
ProductionYear the DTO exposes for songs and the existing MusicAlbum
mapping (premieredate -> max_year).
* feat(jellyfin): expose PremiereDate on tracks and albums
Finamp's "Latest Releases" artist/genre sections re-sort the merged
server responses client-side by PremiereDate and keep the top 5. Without
the field every comparison returns equal and Dart's unstable sort leaves
the picks in arbitrary order — a 2007 remix could lead the list even
with the server sorting by year correctly.
Serialize PremiereDate as ISO 8601 from the date tag (padding partial
"2007"/"2007-02" values so DateTime.tryParse accepts them), falling
back to the year; omitted when neither exists.
* feat(jellyfin): resolve Finamp-truncated item ids (saved queue restore)
Finamp persists its play queue by packing every item id into exactly 16
bytes (packIds assumes Jellyfin's 32-hex GUID ids), so our longer ids
come back truncated after an app restart and "Failed to restore queue"
loops forever: the /Items?ids= batch resolves nothing.
Navidrome ids can't be made GUID-shaped (nanoid ids can exceed 128 bits),
so compensate server-side: a 16-char id — a length no Navidrome id family
uses — is resolved by unique-prefix range scan, with ambiguity failing
safe. The ids= batch echoes the id as requested (Finamp matches restored
items by its stored ids), and stream, image, item, user-data, favorite,
rating and playback-report endpoints accept truncated ids transparently.
The proper fix belongs upstream in Finamp's packIds; documented in the
README so this layer can be removed once that ships.
* feat(jellyfin): implement Items/{id}/InstantMix
Finamp requests an instant mix on every track tap when its "start
instant mix for individual tracks" setting is on (plus the long-press
menus); the 404 made those taps fail with an error and play nothing.
A track seed returns itself first — Finamp plays exactly what comes
back — followed by the external provider's similar songs, capped at the
requested limit and filtered to the caller's libraries. Container seeds
(artist/album) return the provider's similar-songs blend. Provider
errors and unknown seeds degrade to seed-only/empty results instead of
404s, reusing the Similar endpoints' bounded-wait singleflight (with a
distinct cache key, since mixes and similar lists answer different
shapes). Sonic-similarity backing stays a follow-up (see README).
* style(jellyfin): trim wordy comments
* refactor(jellyfin): apply cleanup review findings
- batch truncated-id resolution in /Items?ids=: one chunked range query
for all media-file prefixes instead of a query per id (a restored
queue sends hundreds of truncated ids)
- resolve truncated ids on the /Similar endpoints too, matching the
neighboring InstantMix; document which entry points don't resolve
- hoist maxImageUploadSize to core, deleting the byte-identical copies
in nativeapi and jellyfin (tests moved to core)
- drop the unused type parameter on premiereDate
* fix(jellyfin): never drop the instant mix seed on a slow provider
The seed track was built inside the awaited provider fetch, so when the
external agent was slow or unreachable the request hit the 10s wait and
answered a fully empty mix — Finamp then played nothing on tap, even
though the seed needs no provider at all (seen live: Last.fm unreachable
from the server, responseSize=49).
Build the seed outside the await: only the similar-songs tail is fetched
and bounded, and a timeout now degrades to a seed-only mix. Also folds
instantMixForSong into getInstantMix, since the tail is exactly
similarSongs.
* fix(jellyfin): prefer the recommended Authorization scheme when picking a token
Jellyfin's authorization guidance deprecates X-Emby-Token,
X-MediaBrowser-Token, X-Emby-Authorization and api_key; the Authorization
MediaBrowser scheme is the recommended form. All spellings stay accepted,
but when a client sends several, the recommended one now wins. Adds
coverage for the canonical Authorization header, which no test exercised
directly.
* refactor(jellyfin): rename parseEmbyAuth to parseMediaBrowserAuth
The scheme is named MediaBrowser; the old name evoked the deprecated
X-Emby-* spellings even though the function also parses the recommended
Authorization header.
* fix(jellyfin): prefer the Authorization header over X-Emby-Authorization
The recommended header now wins when both carry MediaBrowser data — but
only when it actually parses as MediaBrowser: a reverse proxy may inject
Basic/Digest credentials into Authorization while the client sends the
deprecated header, and those must not swallow the client's auth.
* fix(jellyfin): require the MediaBrowser scheme when parsing auth headers
The parser extracted key="value" pairs from any Authorization value; a
foreign scheme whose parameters happened to use our field names would
have been misread as client auth. Validate the scheme word instead
(case-insensitively, per HTTP), accepting the legacy "Emby" spelling
like real Jellyfin. Replaces the any-recognized-field heuristic for
detecting proxy-injected Basic/Digest credentials.
* Revert "perf(db): keep query planner statistics trustworthy with full ANALYZE"
This reverts commit 118563e1053196f0e2e42dd4bee54e39a04ab145.
The planner-statistics work now lives in its own PR (#5740); this branch
keeps only the covering-index migration.
* fix(db): renumber jellyfin covering-index migration after master's latest
* fix(db): renumber Jellyfin covering-index migration
* docs(jellyfin): document playlist annotations
* refactor(log): clarify comments on external services query params
* refactor(e2e): move Subsonic e2e suite to server/subsonic/e2e
All files in server/e2e were Subsonic tests, so relocate the package
under server/subsonic/e2e to sit alongside the API it exercises. The
package name stays 'e2e'; only doc/comment references are updated.
* refactor(filter): consolidate genre filters and unexport tagIDFilter
Route filterByGenre, ByGenreID, and ArtistsByGenreID through a single
genreTagFilter helper so the EXISTS json_tree(tags,"$.genre") predicate
lives in one place. With server/filter no longer using it, persistence's
TagIDFilter is only referenced within the package, so unexport it.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
3cd4f1eb24
commit
fe6ac2e577
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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", "")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
@ -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]+",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 "<name>_id" key maps to "$.<name>".
|
||||
func tagIDFilter(name string, idValue any) Sqlizer {
|
||||
name = strings.TrimSuffix(name, "_id")
|
||||
return Exists(
|
||||
|
||||
@ -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 {
|
||||
329
server/jellyfin/README.md
Normal file
329
server/jellyfin/README.md
Normal file
@ -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 <version>")
|
||||
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://<host>:<port>/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=<libraryId>`); `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).
|
||||
131
server/jellyfin/annotations.go
Normal file
131
server/jellyfin/annotations.go
Normal file
@ -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)
|
||||
}
|
||||
257
server/jellyfin/annotations_test.go
Normal file
257
server/jellyfin/annotations_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
200
server/jellyfin/api.go
Normal file
200
server/jellyfin/api.go
Normal file
@ -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)
|
||||
}
|
||||
87
server/jellyfin/api_test.go
Normal file
87
server/jellyfin/api_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
134
server/jellyfin/auth.go
Normal file
134
server/jellyfin/auth.go
Normal file
@ -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: "",
|
||||
}
|
||||
}
|
||||
103
server/jellyfin/auth_test.go
Normal file
103
server/jellyfin/auth_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
53
server/jellyfin/browsing.go
Normal file
53
server/jellyfin/browsing.go
Normal file
@ -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)
|
||||
}
|
||||
161
server/jellyfin/browsing_test.go
Normal file
161
server/jellyfin/browsing_test.go
Normal file
@ -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=<inaccessible library id> 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
68
server/jellyfin/case_insensitive_routes.go
Normal file
68
server/jellyfin/case_insensitive_routes.go
Normal file
@ -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, "/")
|
||||
}
|
||||
90
server/jellyfin/case_insensitive_routes_test.go
Normal file
90
server/jellyfin/case_insensitive_routes_test.go
Normal file
@ -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"))
|
||||
})
|
||||
})
|
||||
36
server/jellyfin/dto/blurhash.go
Normal file
36
server/jellyfin/dto/blurhash.go
Normal file
@ -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)
|
||||
}
|
||||
27
server/jellyfin/dto/blurhash_test.go
Normal file
27
server/jellyfin/dto/blurhash_test.go
Normal file
@ -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")))
|
||||
})
|
||||
})
|
||||
258
server/jellyfin/dto/dto.go
Normal file
258
server/jellyfin/dto/dto.go
Normal file
@ -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"`
|
||||
}
|
||||
17
server/jellyfin/dto/dto_suite_test.go
Normal file
17
server/jellyfin/dto/dto_suite_test.go
Normal file
@ -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")
|
||||
}
|
||||
24
server/jellyfin/dto/fields.go
Normal file
24
server/jellyfin/dto/fields.go
Normal file
@ -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
|
||||
}
|
||||
23
server/jellyfin/dto/ids.go
Normal file
23
server/jellyfin/dto/ids.go
Normal file
@ -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
|
||||
}
|
||||
35
server/jellyfin/dto/ids_test.go
Normal file
35
server/jellyfin/dto/ids_test.go
Normal file
@ -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"))
|
||||
})
|
||||
})
|
||||
256
server/jellyfin/dto/mappers.go
Normal file
256
server/jellyfin/dto/mappers.go
Normal file
@ -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),
|
||||
}
|
||||
}
|
||||
280
server/jellyfin/dto/mappers_test.go
Normal file
280
server/jellyfin/dto/mappers_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
142
server/jellyfin/e2e/annotations_test.go
Normal file
142
server/jellyfin/e2e/annotations_test.go
Normal file
@ -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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
120
server/jellyfin/e2e/auth_test.go
Normal file
120
server/jellyfin/e2e/auth_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
389
server/jellyfin/e2e/browsing_test.go
Normal file
389
server/jellyfin/e2e/browsing_test.go
Normal file
@ -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=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds
|
||||
// for the actual artist filter, not ParentId=<artistId>.
|
||||
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=<libraryId> (scoping) plus GenreIds=<genreId>.
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
365
server/jellyfin/e2e/e2e_suite_test.go
Normal file
365
server/jellyfin/e2e/e2e_suite_test.go
Normal file
@ -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{}
|
||||
74
server/jellyfin/e2e/images_test.go
Normal file
74
server/jellyfin/e2e/images_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
64
server/jellyfin/e2e/multiuser_test.go
Normal file
64
server/jellyfin/e2e/multiuser_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
311
server/jellyfin/e2e/playlists_test.go
Normal file
311
server/jellyfin/e2e/playlists_test.go
Normal file
@ -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=<playlistId> 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=<playlist>&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))
|
||||
})
|
||||
})
|
||||
})
|
||||
31
server/jellyfin/e2e/routing_test.go
Normal file
31
server/jellyfin/e2e/routing_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
76
server/jellyfin/e2e/search_test.go
Normal file
76
server/jellyfin/e2e/search_test.go
Normal file
@ -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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
62
server/jellyfin/e2e/sessions_test.go
Normal file
62
server/jellyfin/e2e/sessions_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
134
server/jellyfin/e2e/similar_test.go
Normal file
134
server/jellyfin/e2e/similar_test.go
Normal file
@ -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!"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
49
server/jellyfin/e2e/smoke_test.go
Normal file
49
server/jellyfin/e2e/smoke_test.go
Normal file
@ -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"))
|
||||
})
|
||||
})
|
||||
128
server/jellyfin/e2e/streaming_test.go
Normal file
128
server/jellyfin/e2e/streaming_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
55
server/jellyfin/e2e/system_test.go
Normal file
55
server/jellyfin/e2e/system_test.go
Normal file
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
170
server/jellyfin/images.go
Normal file
170
server/jellyfin/images.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
394
server/jellyfin/images_test.go
Normal file
394
server/jellyfin/images_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
577
server/jellyfin/items.go
Normal file
577
server/jellyfin/items.go
Normal file
@ -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=<libraryId> 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=<libraryId> 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=<playlist>&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=<artistId> 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
|
||||
}
|
||||
608
server/jellyfin/items_test.go
Normal file
608
server/jellyfin/items_test.go
Normal file
@ -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=<id>; 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=<inaccessible library id> 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 <playlist> 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
25
server/jellyfin/jellyfin_suite_test.go
Normal file
25
server/jellyfin/jellyfin_suite_test.go
Normal file
@ -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)
|
||||
}
|
||||
44
server/jellyfin/library.go
Normal file
44
server/jellyfin/library.go
Normal file
@ -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{},
|
||||
}
|
||||
}
|
||||
187
server/jellyfin/middlewares.go
Normal file
187
server/jellyfin/middlewares.go
Normal file
@ -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))
|
||||
})
|
||||
}
|
||||
254
server/jellyfin/middlewares_test.go
Normal file
254
server/jellyfin/middlewares_test.go
Normal file
@ -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"))
|
||||
})
|
||||
})
|
||||
257
server/jellyfin/playlists.go
Normal file
257
server/jellyfin/playlists.go
Normal file
@ -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})
|
||||
}
|
||||
424
server/jellyfin/playlists_test.go
Normal file
424
server/jellyfin/playlists_test.go
Normal file
@ -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}))
|
||||
})
|
||||
})
|
||||
})
|
||||
56
server/jellyfin/routing_test.go
Normal file
56
server/jellyfin/routing_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
115
server/jellyfin/sessions.go
Normal file
115
server/jellyfin/sessions.go
Normal file
@ -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)
|
||||
}
|
||||
216
server/jellyfin/sessions_test.go
Normal file
216
server/jellyfin/sessions_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
190
server/jellyfin/similar.go
Normal file
190
server/jellyfin/similar.go
Normal file
@ -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)
|
||||
}
|
||||
131
server/jellyfin/similar_test.go
Normal file
131
server/jellyfin/similar_test.go
Normal file
@ -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"))
|
||||
})
|
||||
})
|
||||
55
server/jellyfin/socket.go
Normal file
55
server/jellyfin/socket.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
125
server/jellyfin/socket_test.go
Normal file
125
server/jellyfin/socket_test.go
Normal file
@ -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=<jwt>.
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
164
server/jellyfin/stream.go
Normal file
164
server/jellyfin/stream.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
316
server/jellyfin/stream_test.go
Normal file
316
server/jellyfin/stream_test.go
Normal file
@ -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
|
||||
}
|
||||
96
server/jellyfin/system.go
Normal file
96
server/jellyfin/system.go
Normal file
@ -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)
|
||||
}
|
||||
124
server/jellyfin/system_test.go
Normal file
124
server/jellyfin/system_test.go
Normal file
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
113
server/jellyfin/truncated_ids.go
Normal file
113
server/jellyfin/truncated_ids.go
Normal file
@ -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 }
|
||||
61
server/jellyfin/users.go
Normal file
61
server/jellyfin/users.go
Normal file
@ -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)
|
||||
}
|
||||
130
server/jellyfin/users_test.go
Normal file
130
server/jellyfin/users_test.go
Normal file
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)))
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
//
|
||||
@ -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())
|
||||
}
|
||||
@ -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{},
|
||||
178
tests/harness/harness.go
Normal file
178
tests/harness/harness.go
Normal file
@ -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{}
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user