navidrome/server/middlewares.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

396 lines
13 KiB
Go
Raw Permalink Normal View History

2020-02-01 20:07:15 -05:00
package server
import (
2024-05-12 20:04:11 -04:00
"cmp"
"context"
2024-06-16 22:25:25 -04:00
"encoding/json"
2022-09-30 18:54:25 -04:00
"errors"
2020-02-01 20:07:15 -05:00
"fmt"
"io/fs"
"net"
2020-02-01 20:07:15 -05:00
"net/http"
"net/netip"
2022-12-30 22:34:00 -05:00
"net/url"
"strings"
2020-02-01 20:07:15 -05:00
"time"
2022-12-30 22:34:00 -05:00
"github.com/go-chi/chi/v5"
2024-06-05 22:48:00 -04:00
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
2023-02-15 20:18:53 -05:00
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
2020-02-01 20:07:15 -05:00
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
feat(bfr): Big Refactor: new scanner, lots of new fields and tags, improvements and DB schema changes (#2709) * fix(server): more race conditions when updating artist/album from external sources Signed-off-by: Deluan <deluan@navidrome.org> * feat(scanner): add .gitignore syntax to .ndignore. Resolves #1394 Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): null Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): pass configfile option to child process Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): resume interrupted fullScans Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): remove old scanner code Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): rename old metadata package Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): move old metadata package Signed-off-by: Deluan <deluan@navidrome.org> * fix: tests Signed-off-by: Deluan <deluan@navidrome.org> * chore(deps): update Go to 1.23.4 Signed-off-by: Deluan <deluan@navidrome.org> * fix: logs Signed-off-by: Deluan <deluan@navidrome.org> * fix(test): Signed-off-by: Deluan <deluan@navidrome.org> * fix: log level Signed-off-by: Deluan <deluan@navidrome.org> * fix: remove log message Signed-off-by: Deluan <deluan@navidrome.org> * feat: add config for scanner watcher Signed-off-by: Deluan <deluan@navidrome.org> * refactor: children playlists Signed-off-by: Deluan <deluan@navidrome.org> * refactor: replace `interface{}` with `any` Signed-off-by: Deluan <deluan@navidrome.org> * fix: smart playlists with genres Signed-off-by: Deluan <deluan@navidrome.org> * fix: allow any tags in smart playlists Signed-off-by: Deluan <deluan@navidrome.org> * fix: artist names in playlists Signed-off-by: Deluan <deluan@navidrome.org> * fix: smart playlist's sort by tags Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add moods to child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add moods to AlbumID3 Signed-off-by: Deluan <deluan@navidrome.org> * refactor(subsonic): use generic JSONArray for OS arrays Signed-off-by: Deluan <deluan@navidrome.org> * refactor(subsonic): use https in test Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add releaseTypes to AlbumID3 Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add recordLabels to AlbumID3 Signed-off-by: Deluan <deluan@navidrome.org> * refactor(subsonic): rename JSONArray to Array Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add artists to AlbumID3 Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add artists to Child Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): do not pre-populate smart playlists Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): implement a simplified version of ArtistID3. See https://github.com/opensubsonic/open-subsonic-api/discussions/120 Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add artists to album child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add contributors to mediafile Child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add albumArtists to mediafile Child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add displayArtist and displayAlbumArtist Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add displayComposer to Child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add roles to ArtistID3 Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): use " • " separator for displayComposer Signed-off-by: Deluan <deluan@navidrome.org> * refactor: Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): respect `PreferSortTags` config option Signed-off-by: Deluan <deluan@navidrome.org> * refactor(subsonic): Signed-off-by: Deluan <deluan@navidrome.org> * refactor: optimize purging non-unused tags Signed-off-by: Deluan <deluan@navidrome.org> * refactor: don't run 'refresh artist stats' concurrently with other transactions Signed-off-by: Deluan <deluan@navidrome.org> * refactor: Signed-off-by: Deluan <deluan@navidrome.org> * fix: log message Signed-off-by: Deluan <deluan@navidrome.org> * feat: add Scanner.ScanOnStartup config option, default true Signed-off-by: Deluan <deluan@navidrome.org> * feat: better json parsing error msg when importing NSPs Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't update album's imported_time when updating external_metadata Signed-off-by: Deluan <deluan@navidrome.org> * fix: handle interrupted scans and full scans after migrations Signed-off-by: Deluan <deluan@navidrome.org> * feat: run `analyze` when migration requires a full rescan Signed-off-by: Deluan <deluan@navidrome.org> * feat: run `PRAGMA optimize` at the end of the scan Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't update artist's updated_at when updating external_metadata Signed-off-by: Deluan <deluan@navidrome.org> * feat: handle multiple artists and roles in smart playlists Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): dim missing tracks Signed-off-by: Deluan <deluan@navidrome.org> * fix: album missing logic Signed-off-by: Deluan <deluan@navidrome.org> * fix: error encoding in gob Signed-off-by: Deluan <deluan@navidrome.org> * feat: separate warnings from errors Signed-off-by: Deluan <deluan@navidrome.org> * fix: mark albums as missing if they were contained in a deleted folder Signed-off-by: Deluan <deluan@navidrome.org> * refactor: add participant names to media_file and album tables Signed-off-by: Deluan <deluan@navidrome.org> * refactor: use participations in criteria, instead of m2m relationship Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename participations to participants Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add moods to album child Signed-off-by: Deluan <deluan@navidrome.org> * fix: albumartist role case Signed-off-by: Deluan <deluan@navidrome.org> * feat(scanner): run scanner as an external process by default Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): show albumArtist names Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): dim out missing albums Signed-off-by: Deluan <deluan@navidrome.org> * fix: flaky test Signed-off-by: Deluan <deluan@navidrome.org> * fix(server): scrobble buffer mapping. fix #3583 Signed-off-by: Deluan <deluan@navidrome.org> * refactor: more participations renaming Signed-off-by: Deluan <deluan@navidrome.org> * fix: listenbrainz scrobbling Signed-off-by: Deluan <deluan@navidrome.org> * feat: send release_group_mbid to listenbrainz Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): implement OpenSubsonic explicitStatus field (#3597) * feat: implement OpenSubsonic explicitStatus field * fix(subsonic): fix failing snapshot tests * refactor: create helper for setting explicitStatus * fix: store smaller values for explicit-status on database * test: ToAlbum explicitStatus * refactor: rename explicitStatus helper function --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> * fix: handle album and track tags in the DB based on the mappings.yaml file Signed-off-by: Deluan <deluan@navidrome.org> * save similar artists as JSONB Signed-off-by: Deluan <deluan@navidrome.org> * fix: getAlbumList byGenre Signed-off-by: Deluan <deluan@navidrome.org> * detect changes in PID configuration Signed-off-by: Deluan <deluan@navidrome.org> * set default album PID to legacy_pid Signed-off-by: Deluan <deluan@navidrome.org> * fix tests Signed-off-by: Deluan <deluan@navidrome.org> * fix SIGSEGV Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't lose album stars/ratings when migrating Signed-off-by: Deluan <deluan@navidrome.org> * store full PID conf in properties Signed-off-by: Deluan <deluan@navidrome.org> * fix: keep album annotations when changing PID.Album config Signed-off-by: Deluan <deluan@navidrome.org> * fix: reassign album annotations Signed-off-by: Deluan <deluan@navidrome.org> * feat: use (display) albumArtist and add links to each artist Signed-off-by: Deluan <deluan@navidrome.org> * fix: not showing albums by albumartist Signed-off-by: Deluan <deluan@navidrome.org> * fix: error msgs Signed-off-by: Deluan <deluan@navidrome.org> * fix: hide PID from Native API Signed-off-by: Deluan <deluan@navidrome.org> * fix: album cover art resolution Signed-off-by: Deluan <deluan@navidrome.org> * fix: trim participant names Signed-off-by: Deluan <deluan@navidrome.org> * fix: reduce watcher log spam Signed-off-by: Deluan <deluan@navidrome.org> * fix: panic when initializing the watcher Signed-off-by: Deluan <deluan@navidrome.org> * fix: various artists Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't store empty lyrics in the DB Signed-off-by: Deluan <deluan@navidrome.org> * remove unused methods Signed-off-by: Deluan <deluan@navidrome.org> * drop full_text indexes, as they are not being used by SQLite Signed-off-by: Deluan <deluan@navidrome.org> * keep album created_at when upgrading Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): null pointer Signed-off-by: Deluan <deluan@navidrome.org> * fix: album artwork cache Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't expose missing files in Subsonic API Signed-off-by: Deluan <deluan@navidrome.org> * refactor: searchable interface Signed-off-by: Deluan <deluan@navidrome.org> * fix: filter out missing items from subsonic search * fix: filter out missing items from playlists * fix: filter out missing items from shares Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add filter by artist role Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): only return albumartists in getIndexes and getArtists endpoints Signed-off-by: Deluan <deluan@navidrome.org> * sort roles alphabetically Signed-off-by: Deluan <deluan@navidrome.org> * fix: artist playcounts Signed-off-by: Deluan <deluan@navidrome.org> * change default Album PID conf Signed-off-by: Deluan <deluan@navidrome.org> * fix albumartist link when it does not match any albumartists values Signed-off-by: Deluan <deluan@navidrome.org> * fix `Ignoring filter not whitelisted` (role) message Signed-off-by: Deluan <deluan@navidrome.org> * fix: trim any names/titles being imported Signed-off-by: Deluan <deluan@navidrome.org> * remove unused genre code Signed-off-by: Deluan <deluan@navidrome.org> * serialize calls to Last.fm's getArtist Signed-off-by: Deluan <deluan@navidrome.org> xxx Signed-off-by: Deluan <deluan@navidrome.org> * add counters to genres Signed-off-by: Deluan <deluan@navidrome.org> * nit: fix migration `notice` message Signed-off-by: Deluan <deluan@navidrome.org> * optimize similar artists query Signed-off-by: Deluan <deluan@navidrome.org> * fix: last.fm.getInfo when mbid does not exist Signed-off-by: Deluan <deluan@navidrome.org> * ui only show missing items for admins Signed-off-by: Deluan <deluan@navidrome.org> * don't allow interaction with missing items Signed-off-by: Deluan <deluan@navidrome.org> * Add Missing Files view (WIP) Signed-off-by: Deluan <deluan@navidrome.org> * refactor: merged tag_counts into tag table Signed-off-by: Deluan <deluan@navidrome.org> * add option to completely disable automatic scanner Signed-off-by: Deluan <deluan@navidrome.org> * add delete missing files functionality Signed-off-by: Deluan <deluan@navidrome.org> * fix: playlists not showing for regular users Signed-off-by: Deluan <deluan@navidrome.org> * reduce updateLastAccess frequency to once every minute Signed-off-by: Deluan <deluan@navidrome.org> * reduce update player frequency to once every minute Signed-off-by: Deluan <deluan@navidrome.org> * add timeout when updating player Signed-off-by: Deluan <deluan@navidrome.org> * remove dead code Signed-off-by: Deluan <deluan@navidrome.org> * fix duplicated roles in stats Signed-off-by: Deluan <deluan@navidrome.org> * add `; ` to artist splitters Signed-off-by: Deluan <deluan@navidrome.org> * fix stats query Signed-off-by: Deluan <deluan@navidrome.org> * more logs Signed-off-by: Deluan <deluan@navidrome.org> * fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP Signed-off-by: Deluan <deluan@navidrome.org> * fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP Signed-off-by: Deluan <deluan@navidrome.org> * fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP Signed-off-by: Deluan <deluan@navidrome.org> * fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP Signed-off-by: Deluan <deluan@navidrome.org> * add record label filter Signed-off-by: Deluan <deluan@navidrome.org> * add release type filter Signed-off-by: Deluan <deluan@navidrome.org> * fix purgeUnused tags Signed-off-by: Deluan <deluan@navidrome.org> * add grouping filter to albums Signed-off-by: Deluan <deluan@navidrome.org> * allow any album tags to be used in as filters in the API Signed-off-by: Deluan <deluan@navidrome.org> * remove empty tags from album info Signed-off-by: Deluan <deluan@navidrome.org> * comments in the migration Signed-off-by: Deluan <deluan@navidrome.org> * fix: Cannot read properties of undefined Signed-off-by: Deluan <deluan@navidrome.org> * fix: listenbrainz scrobbling (#3640) Signed-off-by: Deluan <deluan@navidrome.org> * fix: remove duplicated tag values Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't ignore the taglib folder! Signed-off-by: Deluan <deluan@navidrome.org> * feat: show track subtitle tag Signed-off-by: Deluan <deluan@navidrome.org> * fix: show artists stats based on selected role Signed-off-by: Deluan <deluan@navidrome.org> * fix: inspect Signed-off-by: Deluan <deluan@navidrome.org> * add media type to album info/filters Signed-off-by: Deluan <deluan@navidrome.org> * fix: change format of subtitle in the UI Signed-off-by: Deluan <deluan@navidrome.org> * fix: subtitle in Subsonic API and search Signed-off-by: Deluan <deluan@navidrome.org> * fix: subtitle in UI's player Signed-off-by: Deluan <deluan@navidrome.org> * fix: split strings should be case-insensitive Signed-off-by: Deluan <deluan@navidrome.org> * disable ScanSchedule Signed-off-by: Deluan <deluan@navidrome.org> * increase default sessiontimeout Signed-off-by: Deluan <deluan@navidrome.org> * add sqlite command line tool to docker image Signed-off-by: Deluan <deluan@navidrome.org> * fix: resources override Signed-off-by: Deluan <deluan@navidrome.org> * fix: album PID conf Signed-off-by: Deluan <deluan@navidrome.org> * change migration to mark current artists as albumArtists Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): Allow filtering on multiple genres (#3679) * feat(ui): Allow filtering on multiple genres Signed-off-by: Henrik Nordvik <henrikno@gmail.com> Signed-off-by: Deluan <deluan@navidrome.org> * add multi-genre filter in Album list Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Henrik Nordvik <henrikno@gmail.com> Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Henrik Nordvik <henrikno@gmail.com> * add more multi-valued tag filters to Album and Song views Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): unselect missing files after removing Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): song filter Signed-off-by: Deluan <deluan@navidrome.org> * fix sharing tracks. fix #3687 Signed-off-by: Deluan <deluan@navidrome.org> * use rowids when using search for sync (ex: Symfonium) Signed-off-by: Deluan <deluan@navidrome.org> * fix "Report Real Paths" option for subsonic clients Signed-off-by: Deluan <deluan@navidrome.org> * fix "Report Real Paths" option for subsonic clients for search Signed-off-by: Deluan <deluan@navidrome.org> * add libraryPath to Native API /songs endpoint Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add album version Signed-off-by: Deluan <deluan@navidrome.org> * made all tags lowercase as they are case-insensitive anyways. Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): Show full paths, extended properties for album/song (#3691) * feat(ui): Show full paths, extended properties for album/song - uses library path + os separator + path - show participants (album/song) and tags (song) - make album/participant clickable in show info * add source to path * fix pathSeparator in UI Signed-off-by: Deluan <deluan@navidrome.org> * fix local artist artwork (#3695) Signed-off-by: Deluan <deluan@navidrome.org> * fix: parse vorbis performers Signed-off-by: Deluan <deluan@navidrome.org> * refactor: clean function into smaller functions Signed-off-by: Deluan <deluan@navidrome.org> * fix translations for en and pt Signed-off-by: Deluan <deluan@navidrome.org> * add trace log to show annotations reassignment Signed-off-by: Deluan <deluan@navidrome.org> * add trace log to show annotations reassignment Signed-off-by: Deluan <deluan@navidrome.org> * fix: allow performers without instrument/subrole Signed-off-by: Deluan <deluan@navidrome.org> * refactor: metadata clean function again Signed-off-by: Deluan <deluan@navidrome.org> * refactor: optimize split function Signed-off-by: Deluan <deluan@navidrome.org> * refactor: split function is now a method of TagConf Signed-off-by: Deluan <deluan@navidrome.org> * fix: humanize Artist total size Signed-off-by: Deluan <deluan@navidrome.org> * add album version to album details Signed-off-by: Deluan <deluan@navidrome.org> * don't display album-level tags in SongInfo Signed-off-by: Deluan <deluan@navidrome.org> * fix genre clicking in Album Page Signed-off-by: Deluan <deluan@navidrome.org> * don't use mbids in Last.fm api calls. From https://discord.com/channels/671335427726114836/704303730660737113/1337574018143879248: With MBID: ``` GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&mbid=a41ac10f-0a56-4672-9161-b83f9b223559&method=artist.getInfo { artist: { name: "Bee Gees", mbid: "bf0f7e29-dfe1-416c-b5c6-f9ebc19ea810", url: "https://www.last.fm/music/Bee+Gees", } ``` Without MBID: ``` GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&method=artist.getInfo { artist: { name: "Van Morrison", mbid: "a41ac10f-0a56-4672-9161-b83f9b223559", url: "https://www.last.fm/music/Van+Morrison", } ``` Signed-off-by: Deluan <deluan@navidrome.org> * better logging for when the artist folder is not found Signed-off-by: Deluan <deluan@navidrome.org> * fix various issues with artist image resolution Signed-off-by: Deluan <deluan@navidrome.org> * hide "Additional Tags" header if there are none. Signed-off-by: Deluan <deluan@navidrome.org> * simplify tag rendering Signed-off-by: Deluan <deluan@navidrome.org> * enhance logging for artist folder detection Signed-off-by: Deluan <deluan@navidrome.org> * make folderID consistent for relative and absolute folderPaths Signed-off-by: Deluan <deluan@navidrome.org> * handle more folder paths scenarios Signed-off-by: Deluan <deluan@navidrome.org> * filter out other roles when SubsonicArtistParticipations = true Signed-off-by: Deluan <deluan@navidrome.org> * fix "Cannot read properties of undefined" Signed-off-by: Deluan <deluan@navidrome.org> * fix lyrics and comments being truncated (#3701) * fix lyrics and comments being truncated * specifically test for lyrics and comment length * reorder assertions Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan <deluan@navidrome.org> * fix(server): Expose library_path for playlist (#3705) Allows showing absolute path for UI, and makes "report real path" work for playlists (Subsonic) * fix BFR on Windows (#3704) * fix potential reflected cross-site scripting vulnerability Signed-off-by: Deluan <deluan@navidrome.org> * hack to make it work on Windows * ignore windows executables * try fixing the pipeline Signed-off-by: Deluan <deluan@navidrome.org> * allow MusicFolder in other drives * move windows local drive logic to local storage implementation --------- Signed-off-by: Deluan <deluan@navidrome.org> * increase pagination sizes for missing files Signed-off-by: Deluan <deluan@navidrome.org> * reduce level of "already scanning" watcher log message Signed-off-by: Deluan <deluan@navidrome.org> * only count folders with audio files in it See https://github.com/navidrome/navidrome/discussions/3676#discussioncomment-11990930 Signed-off-by: Deluan <deluan@navidrome.org> * add album version and catalog number to search Signed-off-by: Deluan <deluan@navidrome.org> * add `organization` alias for `recordlabel` Signed-off-by: Deluan <deluan@navidrome.org> * remove mbid from Last.fm agent Signed-off-by: Deluan <deluan@navidrome.org> * feat: support inspect in ui (#3726) * inspect in ui * address round 1 * add catalogNum to AlbumInfo Signed-off-by: Deluan <deluan@navidrome.org> * remove dependency on metadata_old (deprecated) package Signed-off-by: Deluan <deluan@navidrome.org> * add `RawTags` to model Signed-off-by: Deluan <deluan@navidrome.org> * support parsing MBIDs for roles (from the https://github.com/kgarner7/picard-all-mbids plugin) (#3698) * parse standard roles, vorbis/m4a work for now * fix djmixer * working roles, use DJ-mix * add performers to file * map mbids * add a few more tests * add test Signed-off-by: Deluan <deluan@navidrome.org> * try to simplify the performers logic Signed-off-by: Deluan <deluan@navidrome.org> * stylistic changes --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan <deluan@navidrome.org> * remove param mutation Signed-off-by: Deluan <deluan@navidrome.org> * run automated SQLite optimizations Signed-off-by: Deluan <deluan@navidrome.org> * fix playlists import/export on Windows * fix import playlists * fix export playlists * better handling of Windows volumes Signed-off-by: Deluan <deluan@navidrome.org> * handle more album ID reassignments Signed-off-by: Deluan <deluan@navidrome.org> * allow adding/overriding tags in the config file Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): Fix playlist track id, handle missing tracks better (#3734) - Use `mediaFileId` instead of `id` for playlist tracks - Only fetch if the file is not missing - If extractor fails to get the file, also error (rather than panic) * optimize DB after each scan. Signed-off-by: Deluan <deluan@navidrome.org> * remove sortable from AlbumSongs columns Signed-off-by: Deluan <deluan@navidrome.org> * simplify query to get missing tracks Signed-off-by: Deluan <deluan@navidrome.org> * mark Scanner.Extractor as deprecated Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Signed-off-by: Henrik Nordvik <henrikno@gmail.com> Co-authored-by: Caio Cotts <caio@cotts.com.br> Co-authored-by: Henrik Nordvik <henrikno@gmail.com> Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2025-02-19 17:35:17 -08:00
"github.com/navidrome/navidrome/utils"
"github.com/unrolled/secure"
2020-02-01 20:07:15 -05:00
)
func requestLogger(next http.Handler) http.Handler {
2020-02-01 20:07:15 -05:00
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
status := ww.Status()
message := fmt.Sprintf("HTTP: %s %s://%s%s", r.Method, scheme, r.Host, r.RequestURI)
2026-02-08 08:57:30 -06:00
logArgs := []any{
2020-02-01 20:07:15 -05:00
r.Context(),
message,
"remoteAddr", r.RemoteAddr,
2020-02-23 21:41:10 -05:00
"elapsedTime", time.Since(start),
2020-02-01 20:07:15 -05:00
"httpStatus", ww.Status(),
"responseSize", ww.BytesWritten(),
}
2024-06-16 22:25:25 -04:00
if log.IsGreaterOrEqualTo(log.LevelTrace) {
headers, _ := json.Marshal(r.Header)
logArgs = append(logArgs, "header", string(headers))
} else if log.IsGreaterOrEqualTo(log.LevelDebug) {
2020-02-01 20:07:15 -05:00
logArgs = append(logArgs, "userAgent", r.UserAgent())
}
switch {
case status >= 500:
log.Error(logArgs...)
case status >= 400:
log.Warn(logArgs...)
default:
log.Debug(logArgs...)
2020-02-01 20:07:15 -05:00
}
})
}
func loggerInjector(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = log.NewContext(r.Context(), "requestId", middleware.GetReqID(ctx))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func robotsTXT(fs fs.FS) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/robots.txt") {
r.URL.Path = "/robots.txt"
2024-05-12 20:04:11 -04:00
http.FileServerFS(fs).ServeHTTP(w, r)
} else {
next.ServeHTTP(w, r)
}
})
}
}
func corsHandler() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
},
AllowedHeaders: []string{"*"},
AllowCredentials: false,
ExposedHeaders: []string{"x-content-duration", "x-total-count", "x-nd-authorization"},
})
}
func secureMiddleware() func(http.Handler) http.Handler {
sec := secure.New(secure.Options{
ContentTypeNosniff: true,
FrameDeny: true,
ReferrerPolicy: "same-origin",
PermissionsPolicy: "autoplay=(), camera=(), microphone=(), usb=()",
CustomFrameOptionsValue: conf.Server.HTTPHeaders.FrameOptions,
2020-10-06 11:24:59 -04:00
//ContentSecurityPolicy: "script-src 'self' 'unsafe-inline'",
})
return sec.Handler
}
func compressMiddleware() func(http.Handler) http.Handler {
return middleware.Compress(
5,
"application/xml",
"application/json",
"application/javascript",
"text/html",
"text/plain",
"text/css",
"text/javascript",
2024-01-20 19:17:21 -05:00
"text/event-stream",
)
}
2023-03-26 20:22:18 -04:00
// clientUniqueIDMiddleware is a middleware that sets a unique client ID as a cookie if it's provided in the request header.
// If the unique client ID is not in the header but present as a cookie, it adds the ID to the request context.
func clientUniqueIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
clientUniqueId := r.Header.Get(consts.UIClientUniqueIDHeader)
2023-03-26 20:22:18 -04:00
// If clientUniqueId is found in the header, set it as a cookie
if clientUniqueId != "" {
c := &http.Cookie{
Name: consts.UIClientUniqueIDHeader,
Value: clientUniqueId,
MaxAge: consts.CookieExpiry,
HttpOnly: true,
2021-06-20 13:27:50 -04:00
Secure: true,
SameSite: http.SameSiteStrictMode,
2024-06-05 22:48:00 -04:00
Path: cmp.Or(conf.Server.BasePath, "/"),
}
http.SetCookie(w, c)
} else {
2023-03-26 20:22:18 -04:00
// If clientUniqueId is not found in the header, check if it's present as a cookie
c, err := r.Cookie(consts.UIClientUniqueIDHeader)
2022-09-30 18:54:25 -04:00
if !errors.Is(err, http.ErrNoCookie) {
clientUniqueId = c.Value
}
}
2023-03-26 20:22:18 -04:00
// If a valid clientUniqueId is found, add it to the request context
if clientUniqueId != "" {
ctx = request.WithClientUniqueId(ctx, clientUniqueId)
r = r.WithContext(ctx)
}
2023-03-26 20:22:18 -04:00
// Call the next middleware or handler in the chain
next.ServeHTTP(w, r)
})
}
// realIPMiddleware resolves the request's client IP into the context, where it can be read with
// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration.
// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that
// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept
// in the context as request.ReverseProxyIp.
func realIPMiddleware(next http.Handler) http.Handler {
trusted := conf.Server.ExtAuth.TrustedSources
fromPeer := middleware.ClientIPFromRemoteAddr(next)
if trusted == "" {
return fromPeer
}
// Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP,
// X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list.
fromProxy := chi.Chain(
middleware.ClientIPFromRemoteAddr,
middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...),
middleware.ClientIPFromHeader("X-Real-IP"),
middleware.ClientIPFromHeader("True-Client-IP"),
).Handler(mirrorClientIP(next))
dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if validateIPAgainstList(r.RemoteAddr, trusted) {
fromProxy.ServeHTTP(w, r)
return
}
log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr)
fromPeer.ServeHTTP(w, r)
})
return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch)
}
// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer.
func mirrorClientIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) {
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
// peerHost returns the host part of RemoteAddr, which may already be a bare IP.
func peerHost(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR
// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust
// exactly one hop.
func trustedProxyPrefixes(list string) []string {
var prefixes []string
for _, entry := range strings.Split(list, ",") {
entry = strings.TrimSpace(entry)
if _, err := netip.ParsePrefix(entry); err == nil {
prefixes = append(prefixes, entry)
}
}
return prefixes
}
fix(subsonic): rate limit failed authentication attempts (#6185) * fix(subsonic): limit failed authentication attempts per client IP and username The Subsonic API checked credentials on every request with no limit on failures, so any account could be brute-forced over /rest/*. Failed u+p, t+s and jwt attempts are now capped per (client IP, lower-cased username) using AuthRequestLimit and AuthWindowLength, the same settings that guard the UI login. Every request carries credentials, so only failures count: a slot is taken before the check and given back on success or on a server error, which also stops concurrent guesses from overshooting the limit. Blocked attempts get the same response as a wrong password (HTTP 200, error code 40, no Retry-After), so an attacker cannot tell a block from a wrong guess. Reverse proxy and internal authentication are not limited. The client IP helper behind ClientIPRateLimiter is now exported as server.ClientIP, so spoofed forwarding headers cannot open a fresh bucket. * refactor(subsonic): simplify failed authentication limiter Release the limiter slot from a single place in authenticate(), after the user lookup and credential check, instead of separately in the canceled branch. Store attempt counters by value instead of by pointer, and drop limiter unit tests that only repeated the middleware specs. * fix(subsonic): wait for an in-flight auth check instead of rejecting Slots were reserved before the credential check and only released afterwards, so once AuthRequestLimit checks for the same client IP and username overlapped, the next request was answered with error code 40 even when its credentials were valid. Clients that fan out parallel requests hit this constantly: a burst of six valid logins lost one, a burst of fifty lost forty five, and the web UI authenticates its own /rest calls the same way. A key now carries a slot channel of AuthRequestLimit capacity, and a request waits on it rather than failing when other checks for that key are in flight. Failures are recorded after the check, and a request is only rejected when the key already reached the limit within the window. A waiting request gives up if its context is canceled. Concurrent guesses still cannot run unchecked: at most AuthRequestLimit checks run at once and the rest are turned away as soon as the failures land. * docs(subsonic): state the real guess ceiling of the auth limiter The comment claimed a burst cannot overshoot, which reads as a hard cap of AuthRequestLimit. Allowing concurrent checks means a window admits up to 2*limit-1 guesses, so say that instead.
2026-09-20 22:02:23 -04:00
// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers
// cannot be rotated for a fresh bucket.
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
fix(subsonic): rate limit failed authentication attempts (#6185) * fix(subsonic): limit failed authentication attempts per client IP and username The Subsonic API checked credentials on every request with no limit on failures, so any account could be brute-forced over /rest/*. Failed u+p, t+s and jwt attempts are now capped per (client IP, lower-cased username) using AuthRequestLimit and AuthWindowLength, the same settings that guard the UI login. Every request carries credentials, so only failures count: a slot is taken before the check and given back on success or on a server error, which also stops concurrent guesses from overshooting the limit. Blocked attempts get the same response as a wrong password (HTTP 200, error code 40, no Retry-After), so an attacker cannot tell a block from a wrong guess. Reverse proxy and internal authentication are not limited. The client IP helper behind ClientIPRateLimiter is now exported as server.ClientIP, so spoofed forwarding headers cannot open a fresh bucket. * refactor(subsonic): simplify failed authentication limiter Release the limiter slot from a single place in authenticate(), after the user lookup and credential check, instead of separately in the canceled branch. Store attempt counters by value instead of by pointer, and drop limiter unit tests that only repeated the middleware specs. * fix(subsonic): wait for an in-flight auth check instead of rejecting Slots were reserved before the credential check and only released afterwards, so once AuthRequestLimit checks for the same client IP and username overlapped, the next request was answered with error code 40 even when its credentials were valid. Clients that fan out parallel requests hit this constantly: a burst of six valid logins lost one, a burst of fifty lost forty five, and the web UI authenticates its own /rest calls the same way. A key now carries a slot channel of AuthRequestLimit capacity, and a request waits on it rather than failing when other checks for that key are in flight. Failures are recorded after the check, and a request is only rejected when the key already reached the limit within the window. A waiting request gives up if its context is canceled. Concurrent guesses still cannot run unchecked: at most AuthRequestLimit checks run at once and the rest are turned away as soon as the failures land. * docs(subsonic): state the real guess ceiling of the auth limiter The comment claimed a burst cannot overshoot, which reads as a hard cap of AuthRequestLimit. Allowing concurrent checks means a window admits up to 2*limit-1 guesses, so say that instead.
2026-09-20 22:02:23 -04:00
return ClientIP(r), nil
})
}
fix(subsonic): rate limit failed authentication attempts (#6185) * fix(subsonic): limit failed authentication attempts per client IP and username The Subsonic API checked credentials on every request with no limit on failures, so any account could be brute-forced over /rest/*. Failed u+p, t+s and jwt attempts are now capped per (client IP, lower-cased username) using AuthRequestLimit and AuthWindowLength, the same settings that guard the UI login. Every request carries credentials, so only failures count: a slot is taken before the check and given back on success or on a server error, which also stops concurrent guesses from overshooting the limit. Blocked attempts get the same response as a wrong password (HTTP 200, error code 40, no Retry-After), so an attacker cannot tell a block from a wrong guess. Reverse proxy and internal authentication are not limited. The client IP helper behind ClientIPRateLimiter is now exported as server.ClientIP, so spoofed forwarding headers cannot open a fresh bucket. * refactor(subsonic): simplify failed authentication limiter Release the limiter slot from a single place in authenticate(), after the user lookup and credential check, instead of separately in the canceled branch. Store attempt counters by value instead of by pointer, and drop limiter unit tests that only repeated the middleware specs. * fix(subsonic): wait for an in-flight auth check instead of rejecting Slots were reserved before the credential check and only released afterwards, so once AuthRequestLimit checks for the same client IP and username overlapped, the next request was answered with error code 40 even when its credentials were valid. Clients that fan out parallel requests hit this constantly: a burst of six valid logins lost one, a burst of fifty lost forty five, and the web UI authenticates its own /rest calls the same way. A key now carries a slot channel of AuthRequestLimit capacity, and a request waits on it rather than failing when other checks for that key are in flight. Failures are recorded after the check, and a request is only rejected when the key already reached the limit within the window. A waiting request gives up if its context is canceled. Concurrent guesses still cannot run unchecked: at most AuthRequestLimit checks run at once and the rest are turned away as soon as the failures land. * docs(subsonic): state the real guess ceiling of the auth limiter The comment claimed a burst cannot overshoot, which reads as a hard cap of AuthRequestLimit. Allowing concurrent checks means a window admits up to 2*limit-1 guesses, so say that instead.
2026-09-20 22:02:23 -04:00
// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The
// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket.
func ClientIP(r *http.Request) string {
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r)))
}
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
// can only be set once.
func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Context().Value(key) == nil {
ctx := context.WithValue(r.Context(), key, fn(r))
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
2024-01-20 18:58:12 -05:00
}
}
2023-03-26 20:22:18 -04:00
// serverAddressMiddleware is a middleware function that modifies the request object
// to reflect the address of the server handling the request, as determined by the
// presence of X-Forwarded-* headers or the scheme and host of the request URL.
func serverAddressMiddleware(h http.Handler) http.Handler {
2023-03-26 20:22:18 -04:00
// Define a new handler function that will be returned by this middleware function.
fn := func(w http.ResponseWriter, r *http.Request) {
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>
2026-07-14 11:46:37 -04:00
// Call the ServerAddress function to get the scheme and host of the server
2023-03-26 20:22:18 -04:00
// handling the request. If a host is found, modify the request object to use
// that host and scheme instead of the original ones.
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>
2026-07-14 11:46:37 -04:00
if rScheme, rHost := ServerAddress(r); rHost != "" {
r.Host = rHost
r.URL.Scheme = rScheme
fix(plugins): build public URLs on the caller's address instead of localhost (#6059) * fix(plugins): build public URLs on the caller's address instead of localhost The artwork host service had no `*http.Request`, so it passed `nil` to `publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that produced `http://localhost/share/img/...`, which is useless to anything outside the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so it fell back to the Navidrome logo instead of the real cover art. `serverAddressMiddleware` already works out the client-facing scheme and host from the `X-Forwarded-*` headers. It now also records them in the request context, and `publicurl` takes a `context.Context` instead of an `*http.Request` so any caller can reach them. Extism passes the caller's context through to host functions, so plugins invoked during a request now get a reachable URL with no configuration. Switching the parameter also removes the need for a second, parallel entry point: the package previously wanted only a scheme, a host, and a context, and took a whole request to get them. `AbsoluteURL` no longer dereferences a possibly-nil request on its parse-error path. Plugin calls that start from `context.Background()` (scheduler and websocket callbacks, the buffered scrobble drain) still fall back to localhost, since they have no request to learn from. A debug log now points at `ShareURL` when that happens. * fix(publicurl): include the configured port in the localhost fallback The last-resort fallback built `http://localhost/...`, which points at port 80 and so is unreachable for a server listening anywhere else — the default 4533 included. Use `conf.Server.Port` so a consumer on the same machine can actually fetch the URL. * fix(publicurl): use https in the localhost fallback when TLS is configured The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL advertised a URL it does not answer on. Mirror the server's own switch, which requires both a certificate and a key. * refactor(publicurl): tidy the localhost fallback and its tests Use gg.If for the fallback scheme so it reads as an expression, like the BaseScheme branch above it, instead of assigning http and overwriting it. Drop two tests the ctx refactor left redundant: one asserted PublicURL "works without a request" but became a byte-identical copy of the ShareURL spec once the *http.Request parameter went away, and the two port specs differed only in the integer, where the non-default port is the stronger assertion. * refactor(conf): add TLSEnabled and use it instead of repeating the predicate Whether the server speaks HTTPS was decided inline in three unconnected places. This PR added the third, in a URL-building package that has no business inferring the transport config. Move the rule to conf, next to the fields it derives from, and call it from publicurl and the insights collector. server.Run keeps its own expression: it takes the certificate and key as parameters, and its test passes values that do not come from the config.
2026-08-31 21:27:43 -04:00
// Recorded so code running without the request (e.g. plugins) can build public URLs.
r = r.WithContext(request.WithServerAddress(r.Context(), rScheme, rHost))
}
2023-03-26 20:22:18 -04:00
// Call the next handler in the chain with the modified request and response.
h.ServeHTTP(w, r)
}
2024-05-12 20:04:11 -04:00
// Return the new handler function as a http.Handler object.
return http.HandlerFunc(fn)
}
2023-03-26 20:22:18 -04:00
// Define constants for the X-Forwarded-* header keys.
var (
xForwardedHost = http.CanonicalHeaderKey("X-Forwarded-Host")
2023-03-26 13:29:57 -04:00
xForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto")
xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme")
)
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>
2026-07-14 11:46:37 -04:00
// ServerAddress is a helper function that returns the scheme and host of the server
2023-03-26 20:22:18 -04:00
// handling the given request, as determined by the presence of X-Forwarded-* headers
// or the scheme and host of the request URL.
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>
2026-07-14 11:46:37 -04:00
func ServerAddress(r *http.Request) (scheme, host string) {
2023-03-26 20:22:18 -04:00
// Save the original request host for later comparison.
origHost := r.Host
2023-03-26 20:22:18 -04:00
// Determine the protocol of the request based on the presence of a TLS connection.
protocol := "http"
if r.TLS != nil {
protocol = "https"
}
2023-03-26 20:22:18 -04:00
// Get the X-Forwarded-Host header and extract the first host name if there are
// multiple hosts listed. If there is no X-Forwarded-Host header, use the original
// request host as the default.
xfh := r.Header.Get(xForwardedHost)
if xfh != "" {
i := strings.Index(xfh, ",")
if i == -1 {
i = len(xfh)
}
xfh = xfh[:i]
}
2024-05-12 20:04:11 -04:00
host = cmp.Or(xfh, r.Host)
2023-03-26 20:22:18 -04:00
// Determine the protocol and scheme of the request based on the presence of
// X-Forwarded-* headers or the scheme of the request URL.
2024-05-12 20:04:11 -04:00
scheme = cmp.Or(
r.Header.Get(xForwardedProto),
r.Header.Get(xForwardedScheme),
r.URL.Scheme,
2024-05-12 20:04:11 -04:00
protocol,
)
2023-03-26 20:22:18 -04:00
// If the request host has changed due to the X-Forwarded-Host header, log a trace
// message with the original and new host values, as well as the scheme and URL.
if host != origHost {
log.Trace(r.Context(), "Request host has changed", "origHost", origHost, "host", host, "scheme", scheme, "url", r.URL)
}
2023-03-26 20:22:18 -04:00
// Return the scheme and host of the server handling the request.
return scheme, host
}
2023-03-26 20:22:18 -04:00
// URLParamsMiddleware is a middleware function that decodes the query string of
// the incoming HTTP request, adds the URL parameters from the routing context,
// and re-encodes the modified query string.
2022-12-30 22:34:00 -05:00
func URLParamsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2023-03-26 20:22:18 -04:00
// Retrieve the routing context from the request context.
2022-12-30 22:34:00 -05:00
ctx := chi.RouteContext(r.Context())
2023-03-26 20:22:18 -04:00
// Parse the existing query string into a URL values map.
params, _ := url.ParseQuery(r.URL.RawQuery)
// Loop through each URL parameter in the routing context.
2022-12-30 22:34:00 -05:00
for i, key := range ctx.URLParams.Keys {
2023-03-26 20:22:18 -04:00
// Skip any wildcard URL parameter keys.
if strings.Contains(key, "*") {
2022-12-30 22:34:00 -05:00
continue
}
2023-03-26 20:22:18 -04:00
// Add the URL parameter key-value pair to the URL values map.
params.Add(":"+key, ctx.URLParams.Values[i])
2022-12-30 22:34:00 -05:00
}
2023-03-26 20:22:18 -04:00
// Re-encode the URL values map as a query string and replace the
// existing query string in the request.
r.URL.RawQuery = params.Encode()
// Call the next handler in the chain with the modified request and response.
2022-12-30 22:34:00 -05:00
next.ServeHTTP(w, r)
})
}
func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http.Handler {
feat(bfr): Big Refactor: new scanner, lots of new fields and tags, improvements and DB schema changes (#2709) * fix(server): more race conditions when updating artist/album from external sources Signed-off-by: Deluan <deluan@navidrome.org> * feat(scanner): add .gitignore syntax to .ndignore. Resolves #1394 Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): null Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): pass configfile option to child process Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): resume interrupted fullScans Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): remove old scanner code Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): rename old metadata package Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): move old metadata package Signed-off-by: Deluan <deluan@navidrome.org> * fix: tests Signed-off-by: Deluan <deluan@navidrome.org> * chore(deps): update Go to 1.23.4 Signed-off-by: Deluan <deluan@navidrome.org> * fix: logs Signed-off-by: Deluan <deluan@navidrome.org> * fix(test): Signed-off-by: Deluan <deluan@navidrome.org> * fix: log level Signed-off-by: Deluan <deluan@navidrome.org> * fix: remove log message Signed-off-by: Deluan <deluan@navidrome.org> * feat: add config for scanner watcher Signed-off-by: Deluan <deluan@navidrome.org> * refactor: children playlists Signed-off-by: Deluan <deluan@navidrome.org> * refactor: replace `interface{}` with `any` Signed-off-by: Deluan <deluan@navidrome.org> * fix: smart playlists with genres Signed-off-by: Deluan <deluan@navidrome.org> * fix: allow any tags in smart playlists Signed-off-by: Deluan <deluan@navidrome.org> * fix: artist names in playlists Signed-off-by: Deluan <deluan@navidrome.org> * fix: smart playlist's sort by tags Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add moods to child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add moods to AlbumID3 Signed-off-by: Deluan <deluan@navidrome.org> * refactor(subsonic): use generic JSONArray for OS arrays Signed-off-by: Deluan <deluan@navidrome.org> * refactor(subsonic): use https in test Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add releaseTypes to AlbumID3 Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add recordLabels to AlbumID3 Signed-off-by: Deluan <deluan@navidrome.org> * refactor(subsonic): rename JSONArray to Array Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add artists to AlbumID3 Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add artists to Child Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): do not pre-populate smart playlists Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): implement a simplified version of ArtistID3. See https://github.com/opensubsonic/open-subsonic-api/discussions/120 Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add artists to album child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add contributors to mediafile Child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add albumArtists to mediafile Child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add displayArtist and displayAlbumArtist Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add displayComposer to Child Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add roles to ArtistID3 Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): use " • " separator for displayComposer Signed-off-by: Deluan <deluan@navidrome.org> * refactor: Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): respect `PreferSortTags` config option Signed-off-by: Deluan <deluan@navidrome.org> * refactor(subsonic): Signed-off-by: Deluan <deluan@navidrome.org> * refactor: optimize purging non-unused tags Signed-off-by: Deluan <deluan@navidrome.org> * refactor: don't run 'refresh artist stats' concurrently with other transactions Signed-off-by: Deluan <deluan@navidrome.org> * refactor: Signed-off-by: Deluan <deluan@navidrome.org> * fix: log message Signed-off-by: Deluan <deluan@navidrome.org> * feat: add Scanner.ScanOnStartup config option, default true Signed-off-by: Deluan <deluan@navidrome.org> * feat: better json parsing error msg when importing NSPs Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't update album's imported_time when updating external_metadata Signed-off-by: Deluan <deluan@navidrome.org> * fix: handle interrupted scans and full scans after migrations Signed-off-by: Deluan <deluan@navidrome.org> * feat: run `analyze` when migration requires a full rescan Signed-off-by: Deluan <deluan@navidrome.org> * feat: run `PRAGMA optimize` at the end of the scan Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't update artist's updated_at when updating external_metadata Signed-off-by: Deluan <deluan@navidrome.org> * feat: handle multiple artists and roles in smart playlists Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): dim missing tracks Signed-off-by: Deluan <deluan@navidrome.org> * fix: album missing logic Signed-off-by: Deluan <deluan@navidrome.org> * fix: error encoding in gob Signed-off-by: Deluan <deluan@navidrome.org> * feat: separate warnings from errors Signed-off-by: Deluan <deluan@navidrome.org> * fix: mark albums as missing if they were contained in a deleted folder Signed-off-by: Deluan <deluan@navidrome.org> * refactor: add participant names to media_file and album tables Signed-off-by: Deluan <deluan@navidrome.org> * refactor: use participations in criteria, instead of m2m relationship Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename participations to participants Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add moods to album child Signed-off-by: Deluan <deluan@navidrome.org> * fix: albumartist role case Signed-off-by: Deluan <deluan@navidrome.org> * feat(scanner): run scanner as an external process by default Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): show albumArtist names Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): dim out missing albums Signed-off-by: Deluan <deluan@navidrome.org> * fix: flaky test Signed-off-by: Deluan <deluan@navidrome.org> * fix(server): scrobble buffer mapping. fix #3583 Signed-off-by: Deluan <deluan@navidrome.org> * refactor: more participations renaming Signed-off-by: Deluan <deluan@navidrome.org> * fix: listenbrainz scrobbling Signed-off-by: Deluan <deluan@navidrome.org> * feat: send release_group_mbid to listenbrainz Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): implement OpenSubsonic explicitStatus field (#3597) * feat: implement OpenSubsonic explicitStatus field * fix(subsonic): fix failing snapshot tests * refactor: create helper for setting explicitStatus * fix: store smaller values for explicit-status on database * test: ToAlbum explicitStatus * refactor: rename explicitStatus helper function --------- Co-authored-by: Deluan Quintão <deluan@navidrome.org> * fix: handle album and track tags in the DB based on the mappings.yaml file Signed-off-by: Deluan <deluan@navidrome.org> * save similar artists as JSONB Signed-off-by: Deluan <deluan@navidrome.org> * fix: getAlbumList byGenre Signed-off-by: Deluan <deluan@navidrome.org> * detect changes in PID configuration Signed-off-by: Deluan <deluan@navidrome.org> * set default album PID to legacy_pid Signed-off-by: Deluan <deluan@navidrome.org> * fix tests Signed-off-by: Deluan <deluan@navidrome.org> * fix SIGSEGV Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't lose album stars/ratings when migrating Signed-off-by: Deluan <deluan@navidrome.org> * store full PID conf in properties Signed-off-by: Deluan <deluan@navidrome.org> * fix: keep album annotations when changing PID.Album config Signed-off-by: Deluan <deluan@navidrome.org> * fix: reassign album annotations Signed-off-by: Deluan <deluan@navidrome.org> * feat: use (display) albumArtist and add links to each artist Signed-off-by: Deluan <deluan@navidrome.org> * fix: not showing albums by albumartist Signed-off-by: Deluan <deluan@navidrome.org> * fix: error msgs Signed-off-by: Deluan <deluan@navidrome.org> * fix: hide PID from Native API Signed-off-by: Deluan <deluan@navidrome.org> * fix: album cover art resolution Signed-off-by: Deluan <deluan@navidrome.org> * fix: trim participant names Signed-off-by: Deluan <deluan@navidrome.org> * fix: reduce watcher log spam Signed-off-by: Deluan <deluan@navidrome.org> * fix: panic when initializing the watcher Signed-off-by: Deluan <deluan@navidrome.org> * fix: various artists Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't store empty lyrics in the DB Signed-off-by: Deluan <deluan@navidrome.org> * remove unused methods Signed-off-by: Deluan <deluan@navidrome.org> * drop full_text indexes, as they are not being used by SQLite Signed-off-by: Deluan <deluan@navidrome.org> * keep album created_at when upgrading Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): null pointer Signed-off-by: Deluan <deluan@navidrome.org> * fix: album artwork cache Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't expose missing files in Subsonic API Signed-off-by: Deluan <deluan@navidrome.org> * refactor: searchable interface Signed-off-by: Deluan <deluan@navidrome.org> * fix: filter out missing items from subsonic search * fix: filter out missing items from playlists * fix: filter out missing items from shares Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add filter by artist role Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): only return albumartists in getIndexes and getArtists endpoints Signed-off-by: Deluan <deluan@navidrome.org> * sort roles alphabetically Signed-off-by: Deluan <deluan@navidrome.org> * fix: artist playcounts Signed-off-by: Deluan <deluan@navidrome.org> * change default Album PID conf Signed-off-by: Deluan <deluan@navidrome.org> * fix albumartist link when it does not match any albumartists values Signed-off-by: Deluan <deluan@navidrome.org> * fix `Ignoring filter not whitelisted` (role) message Signed-off-by: Deluan <deluan@navidrome.org> * fix: trim any names/titles being imported Signed-off-by: Deluan <deluan@navidrome.org> * remove unused genre code Signed-off-by: Deluan <deluan@navidrome.org> * serialize calls to Last.fm's getArtist Signed-off-by: Deluan <deluan@navidrome.org> xxx Signed-off-by: Deluan <deluan@navidrome.org> * add counters to genres Signed-off-by: Deluan <deluan@navidrome.org> * nit: fix migration `notice` message Signed-off-by: Deluan <deluan@navidrome.org> * optimize similar artists query Signed-off-by: Deluan <deluan@navidrome.org> * fix: last.fm.getInfo when mbid does not exist Signed-off-by: Deluan <deluan@navidrome.org> * ui only show missing items for admins Signed-off-by: Deluan <deluan@navidrome.org> * don't allow interaction with missing items Signed-off-by: Deluan <deluan@navidrome.org> * Add Missing Files view (WIP) Signed-off-by: Deluan <deluan@navidrome.org> * refactor: merged tag_counts into tag table Signed-off-by: Deluan <deluan@navidrome.org> * add option to completely disable automatic scanner Signed-off-by: Deluan <deluan@navidrome.org> * add delete missing files functionality Signed-off-by: Deluan <deluan@navidrome.org> * fix: playlists not showing for regular users Signed-off-by: Deluan <deluan@navidrome.org> * reduce updateLastAccess frequency to once every minute Signed-off-by: Deluan <deluan@navidrome.org> * reduce update player frequency to once every minute Signed-off-by: Deluan <deluan@navidrome.org> * add timeout when updating player Signed-off-by: Deluan <deluan@navidrome.org> * remove dead code Signed-off-by: Deluan <deluan@navidrome.org> * fix duplicated roles in stats Signed-off-by: Deluan <deluan@navidrome.org> * add `; ` to artist splitters Signed-off-by: Deluan <deluan@navidrome.org> * fix stats query Signed-off-by: Deluan <deluan@navidrome.org> * more logs Signed-off-by: Deluan <deluan@navidrome.org> * fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP Signed-off-by: Deluan <deluan@navidrome.org> * fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP Signed-off-by: Deluan <deluan@navidrome.org> * fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP Signed-off-by: Deluan <deluan@navidrome.org> * fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP Signed-off-by: Deluan <deluan@navidrome.org> * add record label filter Signed-off-by: Deluan <deluan@navidrome.org> * add release type filter Signed-off-by: Deluan <deluan@navidrome.org> * fix purgeUnused tags Signed-off-by: Deluan <deluan@navidrome.org> * add grouping filter to albums Signed-off-by: Deluan <deluan@navidrome.org> * allow any album tags to be used in as filters in the API Signed-off-by: Deluan <deluan@navidrome.org> * remove empty tags from album info Signed-off-by: Deluan <deluan@navidrome.org> * comments in the migration Signed-off-by: Deluan <deluan@navidrome.org> * fix: Cannot read properties of undefined Signed-off-by: Deluan <deluan@navidrome.org> * fix: listenbrainz scrobbling (#3640) Signed-off-by: Deluan <deluan@navidrome.org> * fix: remove duplicated tag values Signed-off-by: Deluan <deluan@navidrome.org> * fix: don't ignore the taglib folder! Signed-off-by: Deluan <deluan@navidrome.org> * feat: show track subtitle tag Signed-off-by: Deluan <deluan@navidrome.org> * fix: show artists stats based on selected role Signed-off-by: Deluan <deluan@navidrome.org> * fix: inspect Signed-off-by: Deluan <deluan@navidrome.org> * add media type to album info/filters Signed-off-by: Deluan <deluan@navidrome.org> * fix: change format of subtitle in the UI Signed-off-by: Deluan <deluan@navidrome.org> * fix: subtitle in Subsonic API and search Signed-off-by: Deluan <deluan@navidrome.org> * fix: subtitle in UI's player Signed-off-by: Deluan <deluan@navidrome.org> * fix: split strings should be case-insensitive Signed-off-by: Deluan <deluan@navidrome.org> * disable ScanSchedule Signed-off-by: Deluan <deluan@navidrome.org> * increase default sessiontimeout Signed-off-by: Deluan <deluan@navidrome.org> * add sqlite command line tool to docker image Signed-off-by: Deluan <deluan@navidrome.org> * fix: resources override Signed-off-by: Deluan <deluan@navidrome.org> * fix: album PID conf Signed-off-by: Deluan <deluan@navidrome.org> * change migration to mark current artists as albumArtists Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): Allow filtering on multiple genres (#3679) * feat(ui): Allow filtering on multiple genres Signed-off-by: Henrik Nordvik <henrikno@gmail.com> Signed-off-by: Deluan <deluan@navidrome.org> * add multi-genre filter in Album list Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Henrik Nordvik <henrikno@gmail.com> Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Henrik Nordvik <henrikno@gmail.com> * add more multi-valued tag filters to Album and Song views Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): unselect missing files after removing Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): song filter Signed-off-by: Deluan <deluan@navidrome.org> * fix sharing tracks. fix #3687 Signed-off-by: Deluan <deluan@navidrome.org> * use rowids when using search for sync (ex: Symfonium) Signed-off-by: Deluan <deluan@navidrome.org> * fix "Report Real Paths" option for subsonic clients Signed-off-by: Deluan <deluan@navidrome.org> * fix "Report Real Paths" option for subsonic clients for search Signed-off-by: Deluan <deluan@navidrome.org> * add libraryPath to Native API /songs endpoint Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): add album version Signed-off-by: Deluan <deluan@navidrome.org> * made all tags lowercase as they are case-insensitive anyways. Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): Show full paths, extended properties for album/song (#3691) * feat(ui): Show full paths, extended properties for album/song - uses library path + os separator + path - show participants (album/song) and tags (song) - make album/participant clickable in show info * add source to path * fix pathSeparator in UI Signed-off-by: Deluan <deluan@navidrome.org> * fix local artist artwork (#3695) Signed-off-by: Deluan <deluan@navidrome.org> * fix: parse vorbis performers Signed-off-by: Deluan <deluan@navidrome.org> * refactor: clean function into smaller functions Signed-off-by: Deluan <deluan@navidrome.org> * fix translations for en and pt Signed-off-by: Deluan <deluan@navidrome.org> * add trace log to show annotations reassignment Signed-off-by: Deluan <deluan@navidrome.org> * add trace log to show annotations reassignment Signed-off-by: Deluan <deluan@navidrome.org> * fix: allow performers without instrument/subrole Signed-off-by: Deluan <deluan@navidrome.org> * refactor: metadata clean function again Signed-off-by: Deluan <deluan@navidrome.org> * refactor: optimize split function Signed-off-by: Deluan <deluan@navidrome.org> * refactor: split function is now a method of TagConf Signed-off-by: Deluan <deluan@navidrome.org> * fix: humanize Artist total size Signed-off-by: Deluan <deluan@navidrome.org> * add album version to album details Signed-off-by: Deluan <deluan@navidrome.org> * don't display album-level tags in SongInfo Signed-off-by: Deluan <deluan@navidrome.org> * fix genre clicking in Album Page Signed-off-by: Deluan <deluan@navidrome.org> * don't use mbids in Last.fm api calls. From https://discord.com/channels/671335427726114836/704303730660737113/1337574018143879248: With MBID: ``` GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&mbid=a41ac10f-0a56-4672-9161-b83f9b223559&method=artist.getInfo { artist: { name: "Bee Gees", mbid: "bf0f7e29-dfe1-416c-b5c6-f9ebc19ea810", url: "https://www.last.fm/music/Bee+Gees", } ``` Without MBID: ``` GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&method=artist.getInfo { artist: { name: "Van Morrison", mbid: "a41ac10f-0a56-4672-9161-b83f9b223559", url: "https://www.last.fm/music/Van+Morrison", } ``` Signed-off-by: Deluan <deluan@navidrome.org> * better logging for when the artist folder is not found Signed-off-by: Deluan <deluan@navidrome.org> * fix various issues with artist image resolution Signed-off-by: Deluan <deluan@navidrome.org> * hide "Additional Tags" header if there are none. Signed-off-by: Deluan <deluan@navidrome.org> * simplify tag rendering Signed-off-by: Deluan <deluan@navidrome.org> * enhance logging for artist folder detection Signed-off-by: Deluan <deluan@navidrome.org> * make folderID consistent for relative and absolute folderPaths Signed-off-by: Deluan <deluan@navidrome.org> * handle more folder paths scenarios Signed-off-by: Deluan <deluan@navidrome.org> * filter out other roles when SubsonicArtistParticipations = true Signed-off-by: Deluan <deluan@navidrome.org> * fix "Cannot read properties of undefined" Signed-off-by: Deluan <deluan@navidrome.org> * fix lyrics and comments being truncated (#3701) * fix lyrics and comments being truncated * specifically test for lyrics and comment length * reorder assertions Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan <deluan@navidrome.org> * fix(server): Expose library_path for playlist (#3705) Allows showing absolute path for UI, and makes "report real path" work for playlists (Subsonic) * fix BFR on Windows (#3704) * fix potential reflected cross-site scripting vulnerability Signed-off-by: Deluan <deluan@navidrome.org> * hack to make it work on Windows * ignore windows executables * try fixing the pipeline Signed-off-by: Deluan <deluan@navidrome.org> * allow MusicFolder in other drives * move windows local drive logic to local storage implementation --------- Signed-off-by: Deluan <deluan@navidrome.org> * increase pagination sizes for missing files Signed-off-by: Deluan <deluan@navidrome.org> * reduce level of "already scanning" watcher log message Signed-off-by: Deluan <deluan@navidrome.org> * only count folders with audio files in it See https://github.com/navidrome/navidrome/discussions/3676#discussioncomment-11990930 Signed-off-by: Deluan <deluan@navidrome.org> * add album version and catalog number to search Signed-off-by: Deluan <deluan@navidrome.org> * add `organization` alias for `recordlabel` Signed-off-by: Deluan <deluan@navidrome.org> * remove mbid from Last.fm agent Signed-off-by: Deluan <deluan@navidrome.org> * feat: support inspect in ui (#3726) * inspect in ui * address round 1 * add catalogNum to AlbumInfo Signed-off-by: Deluan <deluan@navidrome.org> * remove dependency on metadata_old (deprecated) package Signed-off-by: Deluan <deluan@navidrome.org> * add `RawTags` to model Signed-off-by: Deluan <deluan@navidrome.org> * support parsing MBIDs for roles (from the https://github.com/kgarner7/picard-all-mbids plugin) (#3698) * parse standard roles, vorbis/m4a work for now * fix djmixer * working roles, use DJ-mix * add performers to file * map mbids * add a few more tests * add test Signed-off-by: Deluan <deluan@navidrome.org> * try to simplify the performers logic Signed-off-by: Deluan <deluan@navidrome.org> * stylistic changes --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan <deluan@navidrome.org> * remove param mutation Signed-off-by: Deluan <deluan@navidrome.org> * run automated SQLite optimizations Signed-off-by: Deluan <deluan@navidrome.org> * fix playlists import/export on Windows * fix import playlists * fix export playlists * better handling of Windows volumes Signed-off-by: Deluan <deluan@navidrome.org> * handle more album ID reassignments Signed-off-by: Deluan <deluan@navidrome.org> * allow adding/overriding tags in the config file Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): Fix playlist track id, handle missing tracks better (#3734) - Use `mediaFileId` instead of `id` for playlist tracks - Only fetch if the file is not missing - If extractor fails to get the file, also error (rather than panic) * optimize DB after each scan. Signed-off-by: Deluan <deluan@navidrome.org> * remove sortable from AlbumSongs columns Signed-off-by: Deluan <deluan@navidrome.org> * simplify query to get missing tracks Signed-off-by: Deluan <deluan@navidrome.org> * mark Scanner.Extractor as deprecated Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Signed-off-by: Henrik Nordvik <henrikno@gmail.com> Co-authored-by: Caio Cotts <caio@cotts.com.br> Co-authored-by: Henrik Nordvik <henrikno@gmail.com> Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2025-02-19 17:35:17 -08:00
userAccessLimiter := utils.Limiter{Interval: consts.UpdateLastAccessFrequency}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
usr, ok := request.UserFrom(ctx)
if ok {
userAccessLimiter.Do(usr.ID, func() {
start := time.Now()
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
refactor(persistence): stateless repositories with per-call context (#6149) * refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00
err := ds.User().UpdateLastAccessAt(ctx, usr.ID)
if err != nil {
log.Warn(ctx, "Could not update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "Update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start))
}
})
}
next.ServeHTTP(w, r)
})
}
}