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>
This commit is contained in:
+5
-5
@@ -16,12 +16,12 @@ import (
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/gravatar"
|
||||
"golang.org/x/text/cases"
|
||||
@@ -138,7 +138,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password
|
||||
now := time.Now()
|
||||
caser := cases.Title(language.Und)
|
||||
initialUser := model.User{
|
||||
ID: uuid.NewString(),
|
||||
ID: id.NewRandom(),
|
||||
UserName: username,
|
||||
Name: caser.String(username),
|
||||
Email: "",
|
||||
@@ -214,7 +214,7 @@ func UsernameFromReverseProxyHeader(r *http.Request) string {
|
||||
return username
|
||||
}
|
||||
|
||||
func UsernameFromConfig(r *http.Request) string {
|
||||
func UsernameFromConfig(*http.Request) string {
|
||||
return conf.Server.DevAutoLoginUsername
|
||||
}
|
||||
|
||||
@@ -293,11 +293,11 @@ func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]inte
|
||||
if user == nil || err != nil {
|
||||
log.Info(r, "User passed in header not found", "user", username)
|
||||
newUser := model.User{
|
||||
ID: uuid.NewString(),
|
||||
ID: id.NewRandom(),
|
||||
UserName: username,
|
||||
Name: username,
|
||||
Email: "",
|
||||
NewPassword: consts.PasswordAutogenPrefix + uuid.NewString(),
|
||||
NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(),
|
||||
IsAdmin: false,
|
||||
}
|
||||
err := userRepo.Put(&newUser)
|
||||
|
||||
+3
-5
@@ -11,14 +11,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -122,7 +120,7 @@ var _ = Describe("Auth", func() {
|
||||
})
|
||||
|
||||
It("creates user and sets auth data if user does not exist", func() {
|
||||
newUser := "NEW_USER_" + uuid.NewString()
|
||||
newUser := "NEW_USER_" + id.NewRandom()
|
||||
|
||||
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv4))
|
||||
req.Header.Set("Remote-User", newUser)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -8,6 +9,15 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type eventCtxKey string
|
||||
|
||||
const broadcastToAllKey eventCtxKey = "broadcastToAll"
|
||||
|
||||
// BroadcastToAll is a context key that can be used to broadcast an event to all clients
|
||||
func BroadcastToAll(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, broadcastToAllKey, true)
|
||||
}
|
||||
|
||||
type Event interface {
|
||||
Name(Event) string
|
||||
Data(Event) string
|
||||
|
||||
+17
-4
@@ -8,9 +8,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/pl"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
@@ -92,7 +92,7 @@ func (b *broker) prepareMessage(ctx context.Context, event Event) message {
|
||||
}
|
||||
|
||||
// writeEvent writes a message to the given io.Writer, formatted as a Server-Sent Event.
|
||||
// If the writer is an http.Flusher, it flushes the data immediately instead of buffering it.
|
||||
// If the writer is a http.Flusher, it flushes the data immediately instead of buffering it.
|
||||
func writeEvent(ctx context.Context, w io.Writer, event message, timeout time.Duration) error {
|
||||
if err := setWriteTimeout(w, timeout); err != nil {
|
||||
log.Debug(ctx, "Error setting write timeout", err)
|
||||
@@ -103,7 +103,7 @@ func writeEvent(ctx context.Context, w io.Writer, event message, timeout time.Du
|
||||
return err
|
||||
}
|
||||
|
||||
// If the writer is an http.Flusher, flush the data immediately.
|
||||
// If the writer is a http.Flusher, flush the data immediately.
|
||||
if flusher, ok := w.(http.Flusher); ok && flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func (b *broker) subscribe(r *http.Request) client {
|
||||
user, _ := request.UserFrom(ctx)
|
||||
clientUniqueId, _ := request.ClientUniqueIdFrom(ctx)
|
||||
c := client{
|
||||
id: uuid.NewString(),
|
||||
id: id.NewRandom(),
|
||||
username: user.UserName,
|
||||
address: r.RemoteAddr,
|
||||
userAgent: r.UserAgent(),
|
||||
@@ -187,6 +187,9 @@ func (b *broker) unsubscribe(c client) {
|
||||
}
|
||||
|
||||
func (b *broker) shouldSend(msg message, c client) bool {
|
||||
if broadcastToAll, ok := msg.senderCtx.Value(broadcastToAllKey).(bool); ok && broadcastToAll {
|
||||
return true
|
||||
}
|
||||
clientUniqueId, originatedFromClient := request.ClientUniqueIdFrom(msg.senderCtx)
|
||||
if !originatedFromClient {
|
||||
return true
|
||||
@@ -268,3 +271,13 @@ func sendOrDrop(client client, msg message) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NoopBroker() Broker {
|
||||
return noopBroker{}
|
||||
}
|
||||
|
||||
type noopBroker struct {
|
||||
http.Handler
|
||||
}
|
||||
|
||||
func (noopBroker) SendMessage(context.Context, Event) {}
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
func initialSetup(ds model.DataStore) {
|
||||
@@ -46,11 +46,11 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error {
|
||||
panic(fmt.Sprintf("Could not access User table: %s", err))
|
||||
}
|
||||
if c == 0 {
|
||||
id := uuid.NewString()
|
||||
newID := id.NewRandom()
|
||||
log.Warn("Creating initial admin user. This should only be used for development purposes!!",
|
||||
"user", consts.DevInitialUserName, "password", initialPassword, "id", id)
|
||||
"user", consts.DevInitialUserName, "password", initialPassword, "id", newID)
|
||||
initialUser := model.User{
|
||||
ID: id,
|
||||
ID: newID,
|
||||
UserName: consts.DevInitialUserName,
|
||||
Name: consts.DevInitialName,
|
||||
Email: "",
|
||||
|
||||
+2
-15
@@ -10,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -21,8 +20,8 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/unrolled/secure"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func requestLogger(next http.Handler) http.Handler {
|
||||
@@ -302,9 +301,8 @@ func URLParamsMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
var userAccessLimiter idLimiterMap
|
||||
|
||||
func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
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()
|
||||
@@ -329,14 +327,3 @@ func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// idLimiterMap is a thread-safe map that stores rate.Sometimes limiters for each user ID.
|
||||
// Used to make the map type and thread safe.
|
||||
type idLimiterMap struct {
|
||||
sm sync.Map
|
||||
}
|
||||
|
||||
func (m *idLimiterMap) Do(id string, f func()) {
|
||||
limiter, _ := m.sm.LoadOrStore(id, &rate.Sometimes{Interval: 2 * time.Second})
|
||||
limiter.(*rate.Sometimes).Do(f)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.InspectOutput, error) {
|
||||
file, err := ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if file.Missing {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID)
|
||||
}
|
||||
|
||||
func inspect(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
user, _ := request.UserFrom(ctx)
|
||||
if !user.IsAdmin {
|
||||
http.Error(w, "Inspect is only available to admin users", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := doInspect(ctx, ds, id)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "could not find file", "id", id)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error reading tags", "id", id, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
output.MappedTags = nil
|
||||
response, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error marshalling json", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if _, err := w.Write(response); err != nil {
|
||||
log.Error(ctx, "Error sending response to client", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/http"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
type missingRepository struct {
|
||||
model.ResourceRepository
|
||||
mfRepo model.MediaFileRepository
|
||||
}
|
||||
|
||||
func newMissingRepository(ds model.DataStore) rest.RepositoryConstructor {
|
||||
return func(ctx context.Context) rest.Repository {
|
||||
return &missingRepository{mfRepo: ds.MediaFile(ctx), ResourceRepository: ds.Resource(ctx, model.MediaFile{})}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *missingRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
opt := r.parseOptions(options)
|
||||
return r.ResourceRepository.Count(opt)
|
||||
}
|
||||
|
||||
func (r *missingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
opt := r.parseOptions(options)
|
||||
return r.ResourceRepository.ReadAll(opt)
|
||||
}
|
||||
|
||||
func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.QueryOptions {
|
||||
var opt rest.QueryOptions
|
||||
if len(options) > 0 {
|
||||
opt = options[0]
|
||||
opt.Filters = maps.Clone(opt.Filters)
|
||||
}
|
||||
opt.Filters["missing"] = "true"
|
||||
return opt
|
||||
}
|
||||
|
||||
func (r *missingRepository) Read(id string) (any, error) {
|
||||
all, err := r.mfRepo.GetAll(model.QueryOptions{Filters: squirrel.And{
|
||||
squirrel.Eq{"id": id},
|
||||
squirrel.Eq{"missing": true},
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return all[0], nil
|
||||
}
|
||||
|
||||
func (r *missingRepository) EntityName() string {
|
||||
return "missing_files"
|
||||
}
|
||||
|
||||
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
||||
repo := ds.MediaFile(r.Context())
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
return repo.DeleteMissing(ids)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = ds.GC(r.Context())
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error running GC after deleting missing tracks", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
}
|
||||
|
||||
var _ model.ResourceRepository = &missingRepository{}
|
||||
@@ -2,14 +2,19 @@ package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
@@ -47,12 +52,15 @@ func (n *Router) routes() http.Handler {
|
||||
n.R(r, "/player", model.Player{}, true)
|
||||
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||
n.R(r, "/radio", model.Radio{}, true)
|
||||
n.R(r, "/tag", model.Tag{}, true)
|
||||
if conf.Server.EnableSharing {
|
||||
n.RX(r, "/share", n.share.NewRepository, true)
|
||||
}
|
||||
|
||||
n.addPlaylistRoute(r)
|
||||
n.addPlaylistTrackRoute(r)
|
||||
n.addMissingFilesRoute(r)
|
||||
n.addInspectRoute(r)
|
||||
|
||||
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -145,3 +153,46 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteMissingFiles(n.ds, w, r)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []string) {
|
||||
var resp []byte
|
||||
var err error
|
||||
if len(ids) == 1 {
|
||||
resp = []byte(`{"id":"` + html.EscapeString(ids[0]) + `"}`)
|
||||
} else {
|
||||
resp, err = json.Marshal(&struct {
|
||||
Ids []string `json:"ids"`
|
||||
}{Ids: ids})
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error marshaling response", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
_, err = w.Write(resp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addInspectRoute(r chi.Router) {
|
||||
if conf.Server.Inspect.Enabled {
|
||||
r.Group(func(r chi.Router) {
|
||||
if conf.Server.Inspect.MaxRequests > 0 {
|
||||
log.Debug("Throttling inspect", "maxRequests", conf.Server.Inspect.MaxRequests,
|
||||
"backlogLimit", conf.Server.Inspect.BacklogLimit, "backlogTimeout",
|
||||
conf.Server.Inspect.BacklogTimeout)
|
||||
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
|
||||
}
|
||||
r.Get("/inspect", inspect(n.ds))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
pls, err := plsRepo.GetWithTracks(plsId, true)
|
||||
pls, err := plsRepo.GetWithTracks(plsId, true, false)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Playlist not found", "playlistId", plsId)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
@@ -114,22 +114,7 @@ func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var resp []byte
|
||||
if len(ids) == 1 {
|
||||
resp = []byte(`{"id":"` + ids[0] + `"}`)
|
||||
} else {
|
||||
resp, err = json.Marshal(&struct {
|
||||
Ids []string `json:"ids"`
|
||||
}{Ids: ids})
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error marshaling delete response", "playlistId", playlistId, "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
_, err = w.Write(resp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
writeDeleteManyResponse(w, r, ids)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -68,6 +69,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||
"enableExternalServices": conf.Server.EnableExternalServices,
|
||||
"enableReplayGain": conf.Server.EnableReplayGain,
|
||||
"defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat,
|
||||
"separator": string(os.PathSeparator),
|
||||
"enableInspect": conf.Server.Inspect.Enabled,
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)
|
||||
|
||||
+9
-10
@@ -82,7 +82,7 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
|
||||
addr = fmt.Sprintf("%s:%d", addr, port)
|
||||
listener, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating tcp listener: %w", err)
|
||||
return fmt.Errorf("creating tcp listener: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,20 +106,19 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
|
||||
// Measure server startup time
|
||||
startupTime := time.Since(consts.ServerStart)
|
||||
|
||||
// Wait a short time before checking if the server has started successfully
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
// Wait a short time to make sure the server has started successfully
|
||||
select {
|
||||
case err := <-errC:
|
||||
log.Error(ctx, "Could not start server. Aborting", err)
|
||||
return fmt.Errorf("error starting server: %w", err)
|
||||
default:
|
||||
return fmt.Errorf("starting server: %w", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
log.Info(ctx, "----> Navidrome server is ready!", "address", addr, "startupTime", startupTime, "tlsEnabled", tlsEnabled)
|
||||
}
|
||||
|
||||
// Wait for a signal to terminate
|
||||
select {
|
||||
case err := <-errC:
|
||||
return fmt.Errorf("error running server: %w", err)
|
||||
return fmt.Errorf("running server: %w", err)
|
||||
case <-ctx.Done():
|
||||
// If the context is done (i.e. the server should stop), proceed to shutting down the server
|
||||
}
|
||||
@@ -138,21 +137,21 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
|
||||
func createUnixSocketFile(socketPath string, socketPerm string) (net.Listener, error) {
|
||||
// Remove the socket file if it already exists
|
||||
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("error removing previous unix socket file: %w", err)
|
||||
return nil, fmt.Errorf("removing previous unix socket file: %w", err)
|
||||
}
|
||||
// Create listener
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating unix socket listener: %w", err)
|
||||
return nil, fmt.Errorf("creating unix socket listener: %w", err)
|
||||
}
|
||||
// Converts the socketPerm to uint and updates the permission of the unix socket file
|
||||
perm, err := strconv.ParseUint(socketPerm, 8, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing unix socket file permissions: %w", err)
|
||||
return nil, fmt.Errorf("parsing unix socket file permissions: %w", err)
|
||||
}
|
||||
err = os.Chmod(socketPath, os.FileMode(perm))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating permission of unix socket file: %w", err)
|
||||
return nil, fmt.Errorf("updating permission of unix socket file: %w", err)
|
||||
}
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
@@ -37,15 +37,15 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) {
|
||||
case "frequent":
|
||||
opts = filter.AlbumsByFrequent()
|
||||
case "starred":
|
||||
opts = filter.AlbumsByStarred()
|
||||
opts = filter.ByStarred()
|
||||
case "highest":
|
||||
opts = filter.AlbumsByRating()
|
||||
opts = filter.ByRating()
|
||||
case "byGenre":
|
||||
genre, err := p.String("genre")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
opts = filter.AlbumsByGenre(genre)
|
||||
opts = filter.ByGenre(genre)
|
||||
case "byYear":
|
||||
fromYear, err := p.Int("fromYear")
|
||||
if err != nil {
|
||||
@@ -63,7 +63,7 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) {
|
||||
|
||||
opts.Offset = p.IntOr("offset", 0)
|
||||
opts.Max = min(p.IntOr("size", 10), 500)
|
||||
albums, err := api.ds.Album(r.Context()).GetAllWithoutGenres(opts)
|
||||
albums, err := api.ds.Album(r.Context()).GetAll(opts)
|
||||
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving albums", err)
|
||||
@@ -111,13 +111,13 @@ func (api *Router) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*respo
|
||||
|
||||
func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
options := filter.Starred()
|
||||
artists, err := api.ds.Artist(ctx).GetAll(options)
|
||||
artists, err := api.ds.Artist(ctx).GetAll(filter.ArtistsByStarred())
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving starred artists", err)
|
||||
return nil, err
|
||||
}
|
||||
albums, err := api.ds.Album(ctx).GetAllWithoutGenres(options)
|
||||
options := filter.ByStarred()
|
||||
albums, err := api.ds.Album(ctx).GetAll(options)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving starred albums", err)
|
||||
return nil, err
|
||||
@@ -195,7 +195,8 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error)
|
||||
offset := p.IntOr("offset", 0)
|
||||
genre, _ := p.String("genre")
|
||||
|
||||
songs, err := api.getSongs(r.Context(), offset, count, filter.SongsByGenre(genre))
|
||||
ctx := r.Context()
|
||||
songs, err := api.getSongs(ctx, offset, count, filter.ByGenre(genre))
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", err)
|
||||
return nil, err
|
||||
@@ -203,7 +204,7 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error)
|
||||
|
||||
response := newResponse()
|
||||
response.SongsByGenre = &responses.Songs{}
|
||||
response.SongsByGenre.Songs = slice.MapWithArg(songs, r.Context(), childFromMediaFile)
|
||||
response.SongsByGenre.Songs = slice.MapWithArg(songs, ctx, childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -89,10 +89,9 @@ var _ = Describe("sendResponse", func() {
|
||||
|
||||
When("an error occurs during marshalling", func() {
|
||||
It("should return a fail response", func() {
|
||||
payload.Song = &responses.Child{
|
||||
// An +Inf value will cause an error when marshalling to JSON
|
||||
ReplayGain: responses.ReplayGain{TrackGain: math.Inf(1)},
|
||||
}
|
||||
payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}}
|
||||
// An +Inf value will cause an error when marshalling to JSON
|
||||
payload.Song.ReplayGain = responses.ReplayGain{TrackGain: math.Inf(1)}
|
||||
q := r.URL.Query()
|
||||
q.Add("f", "json")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
@@ -38,7 +38,7 @@ func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Ti
|
||||
|
||||
var indexes model.ArtistIndexes
|
||||
if lib.LastScanAt.After(ifModifiedSince) {
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex()
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex(model.RoleAlbumArtist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Indexes", err)
|
||||
return nil, 0, err
|
||||
@@ -252,7 +252,9 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) {
|
||||
|
||||
func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"})
|
||||
// TODO Put back when album_count is available
|
||||
//genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"})
|
||||
genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, name desc", Order: "desc"})
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
@@ -293,6 +295,9 @@ func (api *Router) GetArtistInfo(r *http.Request) (*responses.Subsonic, error) {
|
||||
response.ArtistInfo.MusicBrainzID = artist.MbzArtistID
|
||||
for _, s := range artist.SimilarArtists {
|
||||
similar := toArtist(r, s)
|
||||
if s.ID == "" {
|
||||
similar.Id = "-1"
|
||||
}
|
||||
response.ArtistInfo.SimilarArtist = append(response.ArtistInfo.SimilarArtist, similar)
|
||||
}
|
||||
return response, nil
|
||||
@@ -390,7 +395,7 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis
|
||||
dir.Starred = artist.StarredAt
|
||||
}
|
||||
|
||||
albums, err := api.ds.Album(ctx).GetAllWithoutGenres(filter.AlbumsByArtistID(artist.ID))
|
||||
albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -404,7 +409,7 @@ func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*response
|
||||
a := &responses.ArtistWithAlbumsID3{}
|
||||
a.ArtistID3 = toArtistID3(r, *artist)
|
||||
|
||||
albums, err := api.ds.Album(ctx).GetAllWithoutGenres(filter.AlbumsByArtistID(artist.ID))
|
||||
albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,66 +1,64 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
type Options = model.QueryOptions
|
||||
|
||||
var defaultFilters = Eq{"missing": false}
|
||||
|
||||
func addDefaultFilters(options Options) Options {
|
||||
if options.Filters == nil {
|
||||
options.Filters = defaultFilters
|
||||
} else {
|
||||
options.Filters = And{defaultFilters, options.Filters}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func AlbumsByNewest() Options {
|
||||
return Options{Sort: "recently_added", Order: "desc"}
|
||||
return addDefaultFilters(addDefaultFilters(Options{Sort: "recently_added", Order: "desc"}))
|
||||
}
|
||||
|
||||
func AlbumsByRecent() Options {
|
||||
return Options{Sort: "playDate", Order: "desc", Filters: squirrel.Gt{"play_date": time.Time{}}}
|
||||
return addDefaultFilters(Options{Sort: "playDate", Order: "desc", Filters: Gt{"play_date": time.Time{}}})
|
||||
}
|
||||
|
||||
func AlbumsByFrequent() Options {
|
||||
return Options{Sort: "playCount", Order: "desc", Filters: squirrel.Gt{"play_count": 0}}
|
||||
return addDefaultFilters(Options{Sort: "playCount", Order: "desc", Filters: Gt{"play_count": 0}})
|
||||
}
|
||||
|
||||
func AlbumsByRandom() Options {
|
||||
return Options{Sort: "random"}
|
||||
return addDefaultFilters(Options{Sort: "random"})
|
||||
}
|
||||
|
||||
func AlbumsByName() Options {
|
||||
return Options{Sort: "name"}
|
||||
return addDefaultFilters(Options{Sort: "name"})
|
||||
}
|
||||
|
||||
func AlbumsByArtist() Options {
|
||||
return Options{Sort: "artist"}
|
||||
}
|
||||
|
||||
func AlbumsByStarred() Options {
|
||||
return Options{Sort: "starred_at", Order: "desc", Filters: squirrel.Eq{"starred": true}}
|
||||
}
|
||||
|
||||
func AlbumsByRating() Options {
|
||||
return Options{Sort: "Rating", Order: "desc", Filters: squirrel.Gt{"rating": 0}}
|
||||
}
|
||||
|
||||
func AlbumsByGenre(genre string) Options {
|
||||
return Options{
|
||||
Sort: "genre.name asc, name asc",
|
||||
Filters: squirrel.Eq{"genre.name": genre},
|
||||
}
|
||||
return addDefaultFilters(Options{Sort: "artist"})
|
||||
}
|
||||
|
||||
func AlbumsByArtistID(artistId string) Options {
|
||||
var filters squirrel.Sqlizer
|
||||
filters := []Sqlizer{
|
||||
persistence.Exists("json_tree(Participants, '$.albumartist')", Eq{"value": artistId}),
|
||||
}
|
||||
if conf.Server.SubsonicArtistParticipations {
|
||||
filters = squirrel.Like{"all_artist_ids": fmt.Sprintf("%%%s%%", artistId)}
|
||||
} else {
|
||||
filters = squirrel.Eq{"album_artist_id": artistId}
|
||||
filters = append(filters,
|
||||
persistence.Exists("json_tree(Participants, '$.artist')", Eq{"value": artistId}),
|
||||
)
|
||||
}
|
||||
return Options{
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "max_year",
|
||||
Filters: filters,
|
||||
}
|
||||
Filters: Or(filters),
|
||||
})
|
||||
}
|
||||
|
||||
func AlbumsByYear(fromYear, toYear int) Options {
|
||||
@@ -69,61 +67,73 @@ func AlbumsByYear(fromYear, toYear int) Options {
|
||||
fromYear, toYear = toYear, fromYear
|
||||
sortOption = "max_year desc, name"
|
||||
}
|
||||
return Options{
|
||||
return addDefaultFilters(Options{
|
||||
Sort: sortOption,
|
||||
Filters: squirrel.Or{
|
||||
squirrel.And{
|
||||
squirrel.GtOrEq{"min_year": fromYear},
|
||||
squirrel.LtOrEq{"min_year": toYear},
|
||||
Filters: Or{
|
||||
And{
|
||||
GtOrEq{"min_year": fromYear},
|
||||
LtOrEq{"min_year": toYear},
|
||||
},
|
||||
squirrel.And{
|
||||
squirrel.GtOrEq{"max_year": fromYear},
|
||||
squirrel.LtOrEq{"max_year": toYear},
|
||||
And{
|
||||
GtOrEq{"max_year": fromYear},
|
||||
LtOrEq{"max_year": toYear},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func SongsByGenre(genre string) Options {
|
||||
return Options{
|
||||
Sort: "genre.name asc, title asc",
|
||||
Filters: squirrel.Eq{"genre.name": genre},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func SongsByAlbum(albumId string) Options {
|
||||
return Options{
|
||||
Filters: squirrel.Eq{"album_id": albumId},
|
||||
return addDefaultFilters(Options{
|
||||
Filters: Eq{"album_id": albumId},
|
||||
Sort: "album",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func SongsByRandom(genre string, fromYear, toYear int) Options {
|
||||
options := Options{
|
||||
Sort: "random",
|
||||
}
|
||||
ff := squirrel.And{}
|
||||
ff := And{}
|
||||
if genre != "" {
|
||||
ff = append(ff, squirrel.Eq{"genre.name": genre})
|
||||
ff = append(ff, Eq{"genre.name": genre})
|
||||
}
|
||||
if fromYear != 0 {
|
||||
ff = append(ff, squirrel.GtOrEq{"year": fromYear})
|
||||
ff = append(ff, GtOrEq{"year": fromYear})
|
||||
}
|
||||
if toYear != 0 {
|
||||
ff = append(ff, squirrel.LtOrEq{"year": toYear})
|
||||
ff = append(ff, LtOrEq{"year": toYear})
|
||||
}
|
||||
options.Filters = ff
|
||||
return options
|
||||
return addDefaultFilters(options)
|
||||
}
|
||||
|
||||
func Starred() Options {
|
||||
return Options{Sort: "starred_at", Order: "desc", Filters: squirrel.Eq{"starred": true}}
|
||||
}
|
||||
|
||||
func SongsWithLyrics(artist, title string) Options {
|
||||
return Options{
|
||||
func SongWithLyrics(artist, title string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "updated_at",
|
||||
Order: "desc",
|
||||
Filters: squirrel.And{squirrel.Eq{"artist": artist, "title": title}, squirrel.NotEq{"lyrics": ""}},
|
||||
}
|
||||
Max: 1,
|
||||
Filters: And{Eq{"artist": artist, "title": title}, NotEq{"lyrics": ""}},
|
||||
})
|
||||
}
|
||||
|
||||
func ByGenre(genre string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "name asc",
|
||||
Filters: persistence.Exists("json_tree(tags)", And{
|
||||
Like{"value": genre},
|
||||
NotEq{"atom": nil},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
func ByRating() Options {
|
||||
return addDefaultFilters(Options{Sort: "rating", Order: "desc", Filters: Gt{"rating": 0}})
|
||||
}
|
||||
|
||||
func ByStarred() Options {
|
||||
return addDefaultFilters(Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}})
|
||||
}
|
||||
|
||||
func ArtistsByStarred() Options {
|
||||
return Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}}
|
||||
}
|
||||
|
||||
+150
-33
@@ -1,6 +1,7 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -9,12 +10,14 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func newResponse() *responses.Subsonic {
|
||||
@@ -64,6 +67,16 @@ func getUser(ctx context.Context) model.User {
|
||||
return model.User{}
|
||||
}
|
||||
|
||||
func sortName(sortName, orderName string) string {
|
||||
if conf.Server.PreferSortTags {
|
||||
return cmp.Or(
|
||||
sortName,
|
||||
orderName,
|
||||
)
|
||||
}
|
||||
return orderName
|
||||
}
|
||||
|
||||
func toArtist(r *http.Request, a model.Artist) responses.Artist {
|
||||
artist := responses.Artist{
|
||||
Id: a.ID,
|
||||
@@ -87,15 +100,27 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 {
|
||||
CoverArt: a.CoverArtID().String(),
|
||||
ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600),
|
||||
UserRating: int32(a.Rating),
|
||||
MusicBrainzId: a.MbzArtistID,
|
||||
SortName: a.SortArtistName,
|
||||
}
|
||||
if a.Starred {
|
||||
artist.Starred = a.StarredAt
|
||||
}
|
||||
artist.OpenSubsonicArtistID3 = toOSArtistID3(r.Context(), a)
|
||||
return artist
|
||||
}
|
||||
|
||||
func toOSArtistID3(ctx context.Context, a model.Artist) *responses.OpenSubsonicArtistID3 {
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if strings.Contains(conf.Server.DevOpenSubsonicDisabledClients, player.Client) {
|
||||
return nil
|
||||
}
|
||||
artist := responses.OpenSubsonicArtistID3{
|
||||
MusicBrainzId: a.MbzArtistID,
|
||||
SortName: sortName(a.SortArtistName, a.OrderArtistName),
|
||||
}
|
||||
artist.Roles = slice.Map(a.Roles(), func(r model.Role) string { return r.String() })
|
||||
return &artist
|
||||
}
|
||||
|
||||
func toGenres(genres model.Genres) *responses.Genres {
|
||||
response := make([]responses.Genre, len(genres))
|
||||
for i, g := range genres {
|
||||
@@ -129,14 +154,13 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) {
|
||||
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = mf.ID
|
||||
child.Title = mf.Title
|
||||
child.Title = mf.FullTitle()
|
||||
child.IsDir = false
|
||||
child.Parent = mf.AlbumID
|
||||
child.Album = mf.Album
|
||||
child.Year = int32(mf.Year)
|
||||
child.Artist = mf.Artist
|
||||
child.Genre = mf.Genre
|
||||
child.Genres = toItemGenres(mf.Genres)
|
||||
child.Track = int32(mf.TrackNumber)
|
||||
child.Duration = int32(mf.Duration)
|
||||
child.Size = mf.Size
|
||||
@@ -146,19 +170,16 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
child.ContentType = mf.ContentType()
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
if ok && player.ReportRealPath {
|
||||
child.Path = mf.Path
|
||||
child.Path = mf.AbsolutePath()
|
||||
} else {
|
||||
child.Path = fakePath(mf)
|
||||
}
|
||||
child.DiscNumber = int32(mf.DiscNumber)
|
||||
child.Created = &mf.CreatedAt
|
||||
child.Created = &mf.BirthTime
|
||||
child.AlbumId = mf.AlbumID
|
||||
child.ArtistId = mf.ArtistID
|
||||
child.Type = "music"
|
||||
child.PlayCount = mf.PlayCount
|
||||
if mf.PlayCount > 0 {
|
||||
child.Played = mf.PlayDate
|
||||
}
|
||||
if mf.Starred {
|
||||
child.Starred = mf.StarredAt
|
||||
}
|
||||
@@ -170,20 +191,69 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
child.TranscodedContentType = mime.TypeByExtension("." + format)
|
||||
}
|
||||
child.BookmarkPosition = mf.BookmarkPosition
|
||||
child.OpenSubsonicChild = osChildFromMediaFile(ctx, mf)
|
||||
return child
|
||||
}
|
||||
|
||||
func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild {
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if strings.Contains(conf.Server.DevOpenSubsonicDisabledClients, player.Client) {
|
||||
return nil
|
||||
}
|
||||
child := responses.OpenSubsonicChild{}
|
||||
if mf.PlayCount > 0 {
|
||||
child.Played = mf.PlayDate
|
||||
}
|
||||
child.Comment = mf.Comment
|
||||
child.SortName = mf.SortTitle
|
||||
child.Bpm = int32(mf.Bpm)
|
||||
child.SortName = sortName(mf.SortTitle, mf.OrderTitle)
|
||||
child.BPM = int32(mf.BPM)
|
||||
child.MediaType = responses.MediaTypeSong
|
||||
child.MusicBrainzId = mf.MbzRecordingID
|
||||
child.ReplayGain = responses.ReplayGain{
|
||||
TrackGain: mf.RgTrackGain,
|
||||
AlbumGain: mf.RgAlbumGain,
|
||||
TrackPeak: mf.RgTrackPeak,
|
||||
AlbumPeak: mf.RgAlbumPeak,
|
||||
TrackGain: mf.RGTrackGain,
|
||||
AlbumGain: mf.RGAlbumGain,
|
||||
TrackPeak: mf.RGTrackPeak,
|
||||
AlbumPeak: mf.RGAlbumPeak,
|
||||
}
|
||||
child.ChannelCount = int32(mf.Channels)
|
||||
child.SamplingRate = int32(mf.SampleRate)
|
||||
return child
|
||||
child.BitDepth = int32(mf.BitDepth)
|
||||
child.Genres = toItemGenres(mf.Genres)
|
||||
child.Moods = mf.Tags.Values(model.TagMood)
|
||||
// BFR What if Child is an Album and not a Song?
|
||||
child.DisplayArtist = mf.Artist
|
||||
child.Artists = artistRefs(mf.Participants[model.RoleArtist])
|
||||
child.DisplayAlbumArtist = mf.AlbumArtist
|
||||
child.AlbumArtists = artistRefs(mf.Participants[model.RoleAlbumArtist])
|
||||
var contributors []responses.Contributor
|
||||
child.DisplayComposer = mf.Participants[model.RoleComposer].Join(" • ")
|
||||
for role, participants := range mf.Participants {
|
||||
if role == model.RoleArtist || role == model.RoleAlbumArtist {
|
||||
continue
|
||||
}
|
||||
for _, participant := range participants {
|
||||
contributors = append(contributors, responses.Contributor{
|
||||
Role: role.String(),
|
||||
SubRole: participant.SubRole,
|
||||
Artist: responses.ArtistID3Ref{
|
||||
Id: participant.ID,
|
||||
Name: participant.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
child.Contributors = contributors
|
||||
child.ExplicitStatus = mapExplicitStatus(mf.ExplicitStatus)
|
||||
return &child
|
||||
}
|
||||
|
||||
func artistRefs(participants model.ParticipantList) []responses.ArtistID3Ref {
|
||||
return slice.Map(participants, func(p model.Participant) responses.ArtistID3Ref {
|
||||
return responses.ArtistID3Ref{
|
||||
Id: p.ID,
|
||||
Name: p.Name,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func fakePath(mf model.MediaFile) string {
|
||||
@@ -196,7 +266,7 @@ func fakePath(mf model.MediaFile) string {
|
||||
if mf.TrackNumber != 0 {
|
||||
builder.WriteString(fmt.Sprintf("%02d - ", mf.TrackNumber))
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("%s.%s", sanitizeSlashes(mf.Title), mf.Suffix))
|
||||
builder.WriteString(fmt.Sprintf("%s.%s", sanitizeSlashes(mf.FullTitle()), mf.Suffix))
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@@ -204,7 +274,7 @@ func sanitizeSlashes(target string) string {
|
||||
return strings.ReplaceAll(target, "/", "_")
|
||||
}
|
||||
|
||||
func childFromAlbum(_ context.Context, al model.Album) responses.Child {
|
||||
func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = al.ID
|
||||
child.IsDir = true
|
||||
@@ -214,7 +284,6 @@ func childFromAlbum(_ context.Context, al model.Album) responses.Child {
|
||||
child.Artist = al.AlbumArtist
|
||||
child.Year = int32(al.MaxYear)
|
||||
child.Genre = al.Genre
|
||||
child.Genres = toItemGenres(al.Genres)
|
||||
child.CoverArt = al.CoverArtID().String()
|
||||
child.Created = &al.CreatedAt
|
||||
child.Parent = al.AlbumArtistID
|
||||
@@ -225,14 +294,30 @@ func childFromAlbum(_ context.Context, al model.Album) responses.Child {
|
||||
child.Starred = al.StarredAt
|
||||
}
|
||||
child.PlayCount = al.PlayCount
|
||||
child.UserRating = int32(al.Rating)
|
||||
child.OpenSubsonicChild = osChildFromAlbum(ctx, al)
|
||||
return child
|
||||
}
|
||||
|
||||
func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubsonicChild {
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if strings.Contains(conf.Server.DevOpenSubsonicDisabledClients, player.Client) {
|
||||
return nil
|
||||
}
|
||||
child := responses.OpenSubsonicChild{}
|
||||
if al.PlayCount > 0 {
|
||||
child.Played = al.PlayDate
|
||||
}
|
||||
child.UserRating = int32(al.Rating)
|
||||
child.SortName = al.SortAlbumName
|
||||
child.MediaType = responses.MediaTypeAlbum
|
||||
child.MusicBrainzId = al.MbzAlbumID
|
||||
return child
|
||||
child.Genres = toItemGenres(al.Genres)
|
||||
child.Moods = al.Tags.Values(model.TagMood)
|
||||
child.DisplayArtist = al.AlbumArtist
|
||||
child.Artists = artistRefs(al.Participants[model.RoleAlbumArtist])
|
||||
child.DisplayAlbumArtist = al.AlbumArtist
|
||||
child.AlbumArtists = artistRefs(al.Participants[model.RoleAlbumArtist])
|
||||
child.ExplicitStatus = mapExplicitStatus(al.ExplicitStatus)
|
||||
return &child
|
||||
}
|
||||
|
||||
// toItemDate converts a string date in the formats 'YYYY-MM-DD', 'YYYY-MM' or 'YYYY' to an OS ItemDate
|
||||
@@ -253,11 +338,11 @@ func toItemDate(date string) responses.ItemDate {
|
||||
return itemDate
|
||||
}
|
||||
|
||||
func buildDiscSubtitles(a model.Album) responses.DiscTitles {
|
||||
func buildDiscSubtitles(a model.Album) []responses.DiscTitle {
|
||||
if len(a.Discs) == 0 {
|
||||
return nil
|
||||
}
|
||||
discTitles := responses.DiscTitles{}
|
||||
var discTitles []responses.DiscTitle
|
||||
for num, title := range a.Discs {
|
||||
discTitles = append(discTitles, responses.DiscTitle{Disc: int32(num), Title: title})
|
||||
}
|
||||
@@ -277,26 +362,58 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
|
||||
dir.SongCount = int32(album.SongCount)
|
||||
dir.Duration = int32(album.Duration)
|
||||
dir.PlayCount = album.PlayCount
|
||||
if album.PlayCount > 0 {
|
||||
dir.Played = album.PlayDate
|
||||
}
|
||||
dir.Year = int32(album.MaxYear)
|
||||
dir.Genre = album.Genre
|
||||
dir.Genres = toItemGenres(album.Genres)
|
||||
dir.DiscTitles = buildDiscSubtitles(album)
|
||||
dir.UserRating = int32(album.Rating)
|
||||
if !album.CreatedAt.IsZero() {
|
||||
dir.Created = &album.CreatedAt
|
||||
}
|
||||
if album.Starred {
|
||||
dir.Starred = album.StarredAt
|
||||
}
|
||||
dir.OpenSubsonicAlbumID3 = buildOSAlbumID3(ctx, album)
|
||||
return dir
|
||||
}
|
||||
|
||||
func buildOSAlbumID3(ctx context.Context, album model.Album) *responses.OpenSubsonicAlbumID3 {
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if strings.Contains(conf.Server.DevOpenSubsonicDisabledClients, player.Client) {
|
||||
return nil
|
||||
}
|
||||
dir := responses.OpenSubsonicAlbumID3{}
|
||||
if album.PlayCount > 0 {
|
||||
dir.Played = album.PlayDate
|
||||
}
|
||||
dir.UserRating = int32(album.Rating)
|
||||
dir.RecordLabels = slice.Map(album.Tags.Values(model.TagRecordLabel), func(s string) responses.RecordLabel {
|
||||
return responses.RecordLabel{Name: s}
|
||||
})
|
||||
dir.MusicBrainzId = album.MbzAlbumID
|
||||
dir.IsCompilation = album.Compilation
|
||||
dir.SortName = album.SortAlbumName
|
||||
dir.Genres = toItemGenres(album.Genres)
|
||||
dir.Artists = artistRefs(album.Participants[model.RoleAlbumArtist])
|
||||
dir.DisplayArtist = album.AlbumArtist
|
||||
dir.ReleaseTypes = album.Tags.Values(model.TagReleaseType)
|
||||
dir.Moods = album.Tags.Values(model.TagMood)
|
||||
dir.SortName = sortName(album.SortAlbumName, album.OrderAlbumName)
|
||||
dir.OriginalReleaseDate = toItemDate(album.OriginalDate)
|
||||
dir.ReleaseDate = toItemDate(album.ReleaseDate)
|
||||
return dir
|
||||
dir.IsCompilation = album.Compilation
|
||||
dir.DiscTitles = buildDiscSubtitles(album)
|
||||
dir.ExplicitStatus = mapExplicitStatus(album.ExplicitStatus)
|
||||
if len(album.Tags.Values(model.TagAlbumVersion)) > 0 {
|
||||
dir.Version = album.Tags.Values(model.TagAlbumVersion)[0]
|
||||
}
|
||||
|
||||
return &dir
|
||||
}
|
||||
|
||||
func mapExplicitStatus(explicitStatus string) string {
|
||||
switch explicitStatus {
|
||||
case "c":
|
||||
return "clean"
|
||||
case "e":
|
||||
return "explicit"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -42,6 +44,38 @@ var _ = Describe("helpers", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sortName", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
When("PreferSortTags is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
})
|
||||
It("returns the order name even if sort name is provided", func() {
|
||||
Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Order Album Name"))
|
||||
})
|
||||
It("returns the order name if sort name is empty", func() {
|
||||
Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name"))
|
||||
})
|
||||
})
|
||||
When("PreferSortTags is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
})
|
||||
It("returns the sort name if provided", func() {
|
||||
Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Sort Album Name"))
|
||||
})
|
||||
|
||||
It("returns the order name if sort name is empty", func() {
|
||||
Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name"))
|
||||
})
|
||||
})
|
||||
It("returns an empty string if both sort name and order name are empty", func() {
|
||||
Expect(sortName("", "")).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("buildDiscTitles", func() {
|
||||
It("should return nil when album has no discs", func() {
|
||||
album := model.Album{}
|
||||
@@ -55,7 +89,7 @@ var _ = Describe("helpers", func() {
|
||||
2: "Disc 2",
|
||||
},
|
||||
}
|
||||
expected := responses.DiscTitles{
|
||||
expected := []responses.DiscTitle{
|
||||
{Disc: 1, Title: "Disc 1"},
|
||||
{Disc: 2, Title: "Disc 2"},
|
||||
}
|
||||
@@ -73,4 +107,13 @@ var _ = Describe("helpers", func() {
|
||||
Entry("19940201", "", responses.ItemDate{}),
|
||||
Entry("", "", responses.ItemDate{}),
|
||||
)
|
||||
|
||||
DescribeTable("mapExplicitStatus",
|
||||
func(explicitStatus string, expected string) {
|
||||
Expect(mapExplicitStatus(explicitStatus)).To(Equal(expected))
|
||||
},
|
||||
Entry("returns \"clean\" when the db value is \"c\"", "c", "clean"),
|
||||
Entry("returns \"explicit\" when the db value is \"e\"", "e", "explicit"),
|
||||
Entry("returns an empty string when the db value is \"\"", "", ""),
|
||||
Entry("returns an empty string when there are unexpected values on the db", "abc", ""))
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
@@ -12,10 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) {
|
||||
// TODO handle multiple libraries
|
||||
ctx := r.Context()
|
||||
mediaFolder := conf.Server.MusicFolder
|
||||
status, err := api.scanner.Status(mediaFolder)
|
||||
status, err := api.scanner.Status(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Scanner status", err)
|
||||
return nil, newError(responses.ErrorGeneric, "Internal Error")
|
||||
@@ -47,12 +44,12 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
|
||||
go func() {
|
||||
start := time.Now()
|
||||
log.Info(ctx, "Triggering manual scan", "fullScan", fullScan, "user", loggedUser.UserName)
|
||||
err := api.scanner.RescanAll(ctx, fullScan)
|
||||
_, err := api.scanner.ScanAll(ctx, fullScan)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error scanning", err)
|
||||
return
|
||||
}
|
||||
log.Info(ctx, "Manual scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start).Round(100*time.Millisecond))
|
||||
log.Info(ctx, "Manual scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
|
||||
}()
|
||||
|
||||
return api.GetScanStatus(r)
|
||||
|
||||
@@ -97,7 +97,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
response := newResponse()
|
||||
lyrics := responses.Lyrics{}
|
||||
response.Lyrics = &lyrics
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsWithLyrics(artist, title))
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -39,7 +39,7 @@ func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
|
||||
func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) {
|
||||
pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true)
|
||||
pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, err.Error(), "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "playlist not found")
|
||||
|
||||
+1
-10
@@ -10,16 +10,7 @@
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<albumList>
|
||||
<album id="1" isDir="false" title="title" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</album>
|
||||
<album id="1" isDir="false" title="title" isVideo="false"></album>
|
||||
</albumList>
|
||||
</subsonic-response>
|
||||
|
||||
+78
-3
@@ -9,7 +9,7 @@
|
||||
"name": "album",
|
||||
"artist": "artist",
|
||||
"genre": "rock",
|
||||
"userRating": 0,
|
||||
"userRating": 4,
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
@@ -45,6 +45,35 @@
|
||||
"month": 5,
|
||||
"day": 10
|
||||
},
|
||||
"releaseTypes": [
|
||||
"album",
|
||||
"live"
|
||||
],
|
||||
"recordLabels": [
|
||||
{
|
||||
"name": "label1"
|
||||
},
|
||||
{
|
||||
"name": "label2"
|
||||
}
|
||||
],
|
||||
"moods": [
|
||||
"happy",
|
||||
"sad"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
],
|
||||
"displayArtist": "artist1 \u0026 artist2",
|
||||
"explicitStatus": "clean",
|
||||
"version": "Deluxe Edition",
|
||||
"song": [
|
||||
{
|
||||
"id": "1",
|
||||
@@ -86,8 +115,54 @@
|
||||
"baseGain": 5,
|
||||
"fallbackGain": 6
|
||||
},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"channelCount": 2,
|
||||
"samplingRate": 44100,
|
||||
"bitDepth": 16,
|
||||
"moods": [
|
||||
"happy",
|
||||
"sad"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
],
|
||||
"displayArtist": "artist1 \u0026 artist2",
|
||||
"albumArtists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "album artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "album artist2"
|
||||
}
|
||||
],
|
||||
"displayAlbumArtist": "album artist1 \u0026 album artist2",
|
||||
"contributors": [
|
||||
{
|
||||
"role": "role1",
|
||||
"artist": {
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "role2",
|
||||
"subRole": "subrole4",
|
||||
"artist": {
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"displayComposer": "composer 1 \u0026 composer 2",
|
||||
"explicitStatus": "clean"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<album id="1" name="album" artist="artist" genre="rock" userRating="0" musicBrainzId="1234" isCompilation="true" sortName="sorted album">
|
||||
<album id="1" name="album" artist="artist" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 & artist2" explicitStatus="clean" version="Deluxe Edition">
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<discTitles disc="1" title="disc 1"></discTitles>
|
||||
@@ -7,10 +7,30 @@
|
||||
<discTitles disc="3" title=""></discTitles>
|
||||
<originalReleaseDate year="1994" month="2" day="4"></originalReleaseDate>
|
||||
<releaseDate year="2000" month="5" day="10"></releaseDate>
|
||||
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="0" samplingRate="0">
|
||||
<releaseTypes>album</releaseTypes>
|
||||
<releaseTypes>live</releaseTypes>
|
||||
<recordLabels name="label1"></recordLabels>
|
||||
<recordLabels name="label2"></recordLabels>
|
||||
<moods>happy</moods>
|
||||
<moods>sad</moods>
|
||||
<artists id="1" name="artist1"></artists>
|
||||
<artists id="2" name="artist2"></artists>
|
||||
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
|
||||
<moods>happy</moods>
|
||||
<moods>sad</moods>
|
||||
<artists id="1" name="artist1"></artists>
|
||||
<artists id="2" name="artist2"></artists>
|
||||
<albumArtists id="1" name="album artist1"></albumArtists>
|
||||
<albumArtists id="2" name="album artist2"></albumArtists>
|
||||
<contributors role="role1">
|
||||
<artist id="1" name="artist1"></artist>
|
||||
</contributors>
|
||||
<contributors role="role2" subRole="subrole4">
|
||||
<artist id="2" name="artist2"></artist>
|
||||
</contributors>
|
||||
</song>
|
||||
</album>
|
||||
</subsonic-response>
|
||||
|
||||
+1
-9
@@ -6,14 +6,6 @@
|
||||
"openSubsonic": true,
|
||||
"album": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"userRating": 0,
|
||||
"genres": [],
|
||||
"musicBrainzId": "",
|
||||
"isCompilation": false,
|
||||
"sortName": "",
|
||||
"discTitles": [],
|
||||
"originalReleaseDate": {},
|
||||
"releaseDate": {}
|
||||
"name": ""
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -1,6 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<album id="" name="" userRating="0" musicBrainzId="" isCompilation="false" sortName="">
|
||||
<originalReleaseDate></originalReleaseDate>
|
||||
<releaseDate></releaseDate>
|
||||
</album>
|
||||
<album id="" name=""></album>
|
||||
</subsonic-response>
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.8.0",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.0.0",
|
||||
"openSubsonic": true,
|
||||
"album": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"userRating": 0,
|
||||
"genres": [],
|
||||
"musicBrainzId": "",
|
||||
"isCompilation": false,
|
||||
"sortName": "",
|
||||
"discTitles": [],
|
||||
"originalReleaseDate": {},
|
||||
"releaseDate": {},
|
||||
"releaseTypes": [],
|
||||
"recordLabels": [],
|
||||
"moods": [],
|
||||
"artists": [],
|
||||
"displayArtist": "",
|
||||
"explicitStatus": "",
|
||||
"version": ""
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<album id="" name=""></album>
|
||||
</subsonic-response>
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.8.0",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.0.0",
|
||||
"openSubsonic": true,
|
||||
"artists": {
|
||||
"index": [
|
||||
{
|
||||
"name": "A",
|
||||
"artist": [
|
||||
{
|
||||
"id": "111",
|
||||
"name": "aaa",
|
||||
"albumCount": 2,
|
||||
"starred": "2016-03-02T20:30:00Z",
|
||||
"userRating": 3,
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
"musicBrainzId": "1234",
|
||||
"sortName": "sort name",
|
||||
"roles": [
|
||||
"role1",
|
||||
"role2"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A">
|
||||
<index name="A">
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name">
|
||||
<roles>role1</roles>
|
||||
<roles>role2</roles>
|
||||
</artist>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>
|
||||
+5
-1
@@ -17,7 +17,11 @@
|
||||
"userRating": 3,
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
"musicBrainzId": "1234",
|
||||
"sortName": "sort name"
|
||||
"sortName": "sort name",
|
||||
"roles": [
|
||||
"role1",
|
||||
"role2"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A">
|
||||
<index name="A">
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name"></artist>
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name">
|
||||
<roles>role1</roles>
|
||||
<roles>role2</roles>
|
||||
</artist>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
"albumCount": 2,
|
||||
"starred": "2016-03-02T20:30:00Z",
|
||||
"userRating": 3,
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
"musicBrainzId": "",
|
||||
"sortName": ""
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A">
|
||||
<index name="A">
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="" sortName=""></artist>
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"></artist>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"serverVersion": "v0.0.0",
|
||||
"openSubsonic": true,
|
||||
"artistInfo": {
|
||||
"biography": "Black Sabbath is an English \u003ca target='_blank' href=\"http://www.last.fm/tag/heavy%20metal\" class=\"bbcode_tag\" rel=\"tag\"\u003eheavy metal\u003c/a\u003e band",
|
||||
"biography": "Black Sabbath is an English \u003ca target='_blank' href=\"https://www.last.fm/tag/heavy%20metal\" class=\"bbcode_tag\" rel=\"tag\"\u003eheavy metal\u003c/a\u003e band",
|
||||
"musicBrainzId": "5182c1d9-c7d2-4dad-afa0-ccfeada921a8",
|
||||
"lastFmUrl": "https://www.last.fm/music/Black+Sabbath",
|
||||
"smallImageUrl": "https://userserve-ak.last.fm/serve/64/27904353.jpg",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<artistInfo>
|
||||
<biography>Black Sabbath is an English <a target='_blank' href="http://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band</biography>
|
||||
<biography>Black Sabbath is an English <a target='_blank' href="https://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band</biography>
|
||||
<musicBrainzId>5182c1d9-c7d2-4dad-afa0-ccfeada921a8</musicBrainzId>
|
||||
<lastFmUrl>https://www.last.fm/music/Black+Sabbath</lastFmUrl>
|
||||
<smallImageUrl>https://userserve-ak.last.fm/serve/64/27904353.jpg</smallImageUrl>
|
||||
|
||||
+1
-10
@@ -11,16 +11,7 @@
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
},
|
||||
"position": 123,
|
||||
"username": "user2",
|
||||
|
||||
+1
-3
@@ -1,9 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<bookmarks>
|
||||
<bookmark position="123" username="user2" comment="a comment" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</entry>
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
</bookmark>
|
||||
</bookmarks>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -47,7 +47,67 @@
|
||||
"fallbackGain": 6
|
||||
},
|
||||
"channelCount": 2,
|
||||
"samplingRate": 44100
|
||||
"samplingRate": 44100,
|
||||
"bitDepth": 16,
|
||||
"moods": [
|
||||
"happy",
|
||||
"sad"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
],
|
||||
"displayArtist": "artist 1 \u0026 artist 2",
|
||||
"albumArtists": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "album artist1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "album artist2"
|
||||
}
|
||||
],
|
||||
"displayAlbumArtist": "album artist 1 \u0026 album artist 2",
|
||||
"contributors": [
|
||||
{
|
||||
"role": "role1",
|
||||
"subRole": "subrole3",
|
||||
"artist": {
|
||||
"id": "1",
|
||||
"name": "artist1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "role2",
|
||||
"artist": {
|
||||
"id": "2",
|
||||
"name": "artist2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "composer",
|
||||
"artist": {
|
||||
"id": "3",
|
||||
"name": "composer1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"role": "composer",
|
||||
"artist": {
|
||||
"id": "4",
|
||||
"name": "composer2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"displayComposer": "composer 1 \u0026 composer 2",
|
||||
"explicitStatus": "clean"
|
||||
}
|
||||
],
|
||||
"id": "1",
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<directory id="1" name="N">
|
||||
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100">
|
||||
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
|
||||
<moods>happy</moods>
|
||||
<moods>sad</moods>
|
||||
<artists id="1" name="artist1"></artists>
|
||||
<artists id="2" name="artist2"></artists>
|
||||
<albumArtists id="1" name="album artist1"></albumArtists>
|
||||
<albumArtists id="2" name="album artist2"></albumArtists>
|
||||
<contributors role="role1" subRole="subrole3">
|
||||
<artist id="1" name="artist1"></artist>
|
||||
</contributors>
|
||||
<contributors role="role2">
|
||||
<artist id="2" name="artist2"></artist>
|
||||
</contributors>
|
||||
<contributors role="composer">
|
||||
<artist id="3" name="composer1"></artist>
|
||||
</contributors>
|
||||
<contributors role="composer">
|
||||
<artist id="4" name="composer2"></artist>
|
||||
</contributors>
|
||||
</child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
|
||||
+1
-10
@@ -9,16 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"id": "",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<directory id="" name="">
|
||||
<child id="1" isDir="false" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</child>
|
||||
<child id="1" isDir="false" isVideo="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.8.0",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.0.0",
|
||||
"openSubsonic": true,
|
||||
"directory": {
|
||||
"child": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0,
|
||||
"bitDepth": 0,
|
||||
"moods": [],
|
||||
"artists": [],
|
||||
"displayArtist": "",
|
||||
"albumArtists": [],
|
||||
"displayAlbumArtist": "",
|
||||
"contributors": [],
|
||||
"displayComposer": "",
|
||||
"explicitStatus": ""
|
||||
}
|
||||
],
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<directory id="" name="">
|
||||
<child id="1" isDir="false" isVideo="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
+1
-10
@@ -10,16 +10,7 @@
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"id": "1",
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<directory id="1" name="N">
|
||||
<child id="1" isDir="false" title="title" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</child>
|
||||
<child id="1" isDir="false" title="title" isVideo="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
|
||||
+1
-10
@@ -10,16 +10,7 @@
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"current": "111",
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<playQueue current="111" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</entry>
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
</playQueue>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -15,16 +15,7 @@
|
||||
"album": "album",
|
||||
"artist": "artist",
|
||||
"duration": 120,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
@@ -33,16 +24,7 @@
|
||||
"album": "album",
|
||||
"artist": "artist",
|
||||
"duration": 300,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"id": "ABC123",
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<shares>
|
||||
<share id="ABC123" url="http://localhost/p/ABC123" description="Check it out!" username="deluan" created="2016-03-02T20:30:00Z" expires="2016-03-02T20:30:00Z" lastVisited="2016-03-02T20:30:00Z" visitCount="2">
|
||||
<entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</entry>
|
||||
<entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</entry>
|
||||
<entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120" isVideo="false"></entry>
|
||||
<entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300" isVideo="false"></entry>
|
||||
</share>
|
||||
</shares>
|
||||
</subsonic-response>
|
||||
|
||||
+1
-10
@@ -10,16 +10,7 @@
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<similarSongs>
|
||||
<song id="1" isDir="false" title="title" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</song>
|
||||
<song id="1" isDir="false" title="title" isVideo="false"></song>
|
||||
</similarSongs>
|
||||
</subsonic-response>
|
||||
|
||||
+1
-10
@@ -10,16 +10,7 @@
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<similarSongs2>
|
||||
<song id="1" isDir="false" title="title" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</song>
|
||||
<song id="1" isDir="false" title="title" isVideo="false"></song>
|
||||
</similarSongs2>
|
||||
</subsonic-response>
|
||||
|
||||
+1
-10
@@ -10,16 +10,7 @@
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
"samplingRate": 0
|
||||
"isVideo": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<topSongs>
|
||||
<song id="1" isDir="false" title="title" isVideo="false" bpm="0" comment="" sortName="" mediaType="" musicBrainzId="" channelCount="0" samplingRate="0">
|
||||
<replayGain></replayGain>
|
||||
</song>
|
||||
<song id="1" isDir="false" title="title" isVideo="false"></song>
|
||||
</topSongs>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -57,8 +57,9 @@ type Subsonic struct {
|
||||
JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus,omitempty" json:"jukeboxStatus,omitempty"`
|
||||
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"`
|
||||
|
||||
// OpenSubsonic extensions
|
||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -165,17 +166,30 @@ type Child struct {
|
||||
/*
|
||||
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
|
||||
*/
|
||||
*OpenSubsonicChild `xml:",omitempty" json:",omitempty"`
|
||||
}
|
||||
|
||||
type OpenSubsonicChild struct {
|
||||
// OpenSubsonic extensions
|
||||
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
||||
Bpm int32 `xml:"bpm,attr" json:"bpm"`
|
||||
Comment string `xml:"comment,attr" json:"comment"`
|
||||
SortName string `xml:"sortName,attr" json:"sortName"`
|
||||
MediaType MediaType `xml:"mediaType,attr" json:"mediaType"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr" json:"musicBrainzId"`
|
||||
Genres ItemGenres `xml:"genres" json:"genres"`
|
||||
ReplayGain ReplayGain `xml:"replayGain" json:"replayGain"`
|
||||
ChannelCount int32 `xml:"channelCount,attr" json:"channelCount"`
|
||||
SamplingRate int32 `xml:"samplingRate,attr" json:"samplingRate"`
|
||||
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
||||
BPM int32 `xml:"bpm,attr,omitempty" json:"bpm"`
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment"`
|
||||
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
|
||||
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
||||
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
|
||||
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
|
||||
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
|
||||
SamplingRate int32 `xml:"samplingRate,attr,omitempty" json:"samplingRate"`
|
||||
BitDepth int32 `xml:"bitDepth,attr,omitempty" json:"bitDepth"`
|
||||
Moods Array[string] `xml:"moods,omitempty" json:"moods"`
|
||||
Artists Array[ArtistID3Ref] `xml:"artists,omitempty" json:"artists"`
|
||||
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist"`
|
||||
AlbumArtists Array[ArtistID3Ref] `xml:"albumArtists,omitempty" json:"albumArtists"`
|
||||
DisplayAlbumArtist string `xml:"displayAlbumArtist,attr,omitempty" json:"displayAlbumArtist"`
|
||||
Contributors Array[Contributor] `xml:"contributors,omitempty" json:"contributors"`
|
||||
DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"`
|
||||
ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"`
|
||||
}
|
||||
|
||||
type Songs struct {
|
||||
@@ -208,44 +222,65 @@ type Directory struct {
|
||||
*/
|
||||
}
|
||||
|
||||
type ArtistID3 struct {
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||
AlbumCount int32 `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"`
|
||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
|
||||
// ArtistID3Ref is a reference to an artist, a simplified version of ArtistID3. This is used to resolve the
|
||||
// documentation conflict in OpenSubsonic: https://github.com/opensubsonic/open-subsonic-api/discussions/120
|
||||
type ArtistID3Ref struct {
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
}
|
||||
|
||||
type ArtistID3 struct {
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||
AlbumCount int32 `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"`
|
||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
|
||||
*OpenSubsonicArtistID3 `xml:",omitempty" json:",omitempty"`
|
||||
}
|
||||
|
||||
type OpenSubsonicArtistID3 struct {
|
||||
// OpenSubsonic extensions
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr" json:"musicBrainzId"`
|
||||
SortName string `xml:"sortName,attr" json:"sortName"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
||||
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
|
||||
Roles Array[string] `xml:"roles,omitempty" json:"roles"`
|
||||
}
|
||||
|
||||
type AlbumID3 struct {
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
|
||||
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||
Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"`
|
||||
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
|
||||
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
|
||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
|
||||
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
|
||||
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||
Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"`
|
||||
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
|
||||
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
|
||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
|
||||
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
|
||||
*OpenSubsonicAlbumID3 `xml:",omitempty" json:",omitempty"`
|
||||
}
|
||||
|
||||
type OpenSubsonicAlbumID3 struct {
|
||||
// OpenSubsonic extensions
|
||||
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr" json:"userRating"`
|
||||
Genres ItemGenres `xml:"genres" json:"genres"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr" json:"musicBrainzId"`
|
||||
IsCompilation bool `xml:"isCompilation,attr" json:"isCompilation"`
|
||||
SortName string `xml:"sortName,attr" json:"sortName"`
|
||||
DiscTitles DiscTitles `xml:"discTitles" json:"discTitles"`
|
||||
OriginalReleaseDate ItemDate `xml:"originalReleaseDate" json:"originalReleaseDate"`
|
||||
ReleaseDate ItemDate `xml:"releaseDate" json:"releaseDate"`
|
||||
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating"`
|
||||
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
||||
IsCompilation bool `xml:"isCompilation,attr,omitempty" json:"isCompilation"`
|
||||
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
|
||||
DiscTitles Array[DiscTitle] `xml:"discTitles,omitempty" json:"discTitles"`
|
||||
OriginalReleaseDate ItemDate `xml:"originalReleaseDate,omitempty" json:"originalReleaseDate"`
|
||||
ReleaseDate ItemDate `xml:"releaseDate,omitempty" json:"releaseDate"`
|
||||
ReleaseTypes Array[string] `xml:"releaseTypes,omitempty" json:"releaseTypes"`
|
||||
RecordLabels Array[RecordLabel] `xml:"recordLabels,omitempty" json:"recordLabels"`
|
||||
Moods Array[string] `xml:"moods,omitempty" json:"moods"`
|
||||
Artists Array[ArtistID3Ref] `xml:"artists,omitempty" json:"artists"`
|
||||
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist"`
|
||||
ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"`
|
||||
Version string `xml:"version,attr,omitempty" json:"version"`
|
||||
}
|
||||
|
||||
type ArtistWithAlbumsID3 struct {
|
||||
@@ -497,13 +532,6 @@ type ItemGenre struct {
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
}
|
||||
|
||||
// ItemGenres holds a list of genres (OpenSubsonic). If it is null, it must be marshalled as an empty array.
|
||||
type ItemGenres []ItemGenre
|
||||
|
||||
func (i ItemGenres) MarshalJSON() ([]byte, error) {
|
||||
return marshalJSONArray(i)
|
||||
}
|
||||
|
||||
type ReplayGain struct {
|
||||
TrackGain float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"`
|
||||
AlbumGain float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"`
|
||||
@@ -513,15 +541,48 @@ type ReplayGain struct {
|
||||
FallbackGain float64 `xml:"fallbackGain,omitempty,attr" json:"fallbackGain,omitempty"`
|
||||
}
|
||||
|
||||
func (r ReplayGain) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if r.TrackGain == 0 && r.AlbumGain == 0 && r.TrackPeak == 0 && r.AlbumPeak == 0 && r.BaseGain == 0 && r.FallbackGain == 0 {
|
||||
return nil
|
||||
}
|
||||
type replayGain ReplayGain
|
||||
return e.EncodeElement(replayGain(r), start)
|
||||
}
|
||||
|
||||
type DiscTitle struct {
|
||||
Disc int32 `xml:"disc,attr" json:"disc"`
|
||||
Title string `xml:"title,attr" json:"title"`
|
||||
}
|
||||
|
||||
type DiscTitles []DiscTitle
|
||||
type ItemDate struct {
|
||||
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
|
||||
Month int32 `xml:"month,attr,omitempty" json:"month,omitempty"`
|
||||
Day int32 `xml:"day,attr,omitempty" json:"day,omitempty"`
|
||||
}
|
||||
|
||||
func (d DiscTitles) MarshalJSON() ([]byte, error) {
|
||||
return marshalJSONArray(d)
|
||||
func (d ItemDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if d.Year == 0 && d.Month == 0 && d.Day == 0 {
|
||||
return nil
|
||||
}
|
||||
type itemDate ItemDate
|
||||
return e.EncodeElement(itemDate(d), start)
|
||||
}
|
||||
|
||||
type RecordLabel struct {
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
}
|
||||
|
||||
type Contributor struct {
|
||||
Role string `xml:"role,attr" json:"role"`
|
||||
SubRole string `xml:"subRole,attr,omitempty" json:"subRole,omitempty"`
|
||||
Artist ArtistID3Ref `xml:"artist" json:"artist"`
|
||||
}
|
||||
|
||||
// Array is a generic type for marshalling slices to JSON. It is used to avoid marshalling empty slices as null.
|
||||
type Array[T any] []T
|
||||
|
||||
func (a Array[T]) MarshalJSON() ([]byte, error) {
|
||||
return marshalJSONArray(a)
|
||||
}
|
||||
|
||||
// marshalJSONArray marshals a slice of any type to JSON. If the slice is empty, it is marshalled as an
|
||||
@@ -530,12 +591,5 @@ func marshalJSONArray[T any](v []T) ([]byte, error) {
|
||||
if len(v) == 0 {
|
||||
return json.Marshal([]T{})
|
||||
}
|
||||
a := v
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type ItemDate struct {
|
||||
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
|
||||
Month int32 `xml:"month,attr,omitempty" json:"month,omitempty"`
|
||||
Day int32 `xml:"day,attr,omitempty" json:"day,omitempty"`
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data and MBID and Sort Name", func() {
|
||||
Context("with OpenSubsonic data", func() {
|
||||
BeforeEach(func() {
|
||||
artists := make([]ArtistID3, 1)
|
||||
t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
|
||||
@@ -170,9 +170,13 @@ var _ = Describe("Responses", func() {
|
||||
UserRating: 3,
|
||||
AlbumCount: 2,
|
||||
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
MusicBrainzId: "1234",
|
||||
SortName: "sort name",
|
||||
}
|
||||
artists[0].OpenSubsonicArtistID3 = &OpenSubsonicArtistID3{
|
||||
MusicBrainzId: "1234",
|
||||
SortName: "sort name",
|
||||
Roles: []string{"role1", "role2"},
|
||||
}
|
||||
|
||||
index := make([]IndexID3, 1)
|
||||
index[0] = IndexID3{Name: "A", Artists: artists}
|
||||
response.Artist.Index = index
|
||||
@@ -198,6 +202,14 @@ var _ = Describe("Responses", func() {
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match OpenSubsonic .XML", func() {
|
||||
response.Directory.Child[0].OpenSubsonicChild = &OpenSubsonicChild{}
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match OpenSubsonic .JSON", func() {
|
||||
response.Directory.Child[0].OpenSubsonicChild = &OpenSubsonicChild{}
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
@@ -208,10 +220,32 @@ var _ = Describe("Responses", func() {
|
||||
Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1,
|
||||
Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac",
|
||||
Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3",
|
||||
Duration: 146, BitRate: 320, Starred: &t, Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
Comment: "a comment", Bpm: 127, MediaType: MediaTypeSong, MusicBrainzId: "4321", ChannelCount: 2,
|
||||
SamplingRate: 44100, SortName: "sorted title",
|
||||
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
|
||||
Duration: 146, BitRate: 320, Starred: &t,
|
||||
}
|
||||
child[0].OpenSubsonicChild = &OpenSubsonicChild{
|
||||
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted title",
|
||||
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
|
||||
Moods: []string{"happy", "sad"},
|
||||
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
|
||||
DisplayArtist: "artist 1 & artist 2",
|
||||
Artists: []ArtistID3Ref{
|
||||
{Id: "1", Name: "artist1"},
|
||||
{Id: "2", Name: "artist2"},
|
||||
},
|
||||
DisplayAlbumArtist: "album artist 1 & album artist 2",
|
||||
AlbumArtists: []ArtistID3Ref{
|
||||
{Id: "1", Name: "album artist1"},
|
||||
{Id: "2", Name: "album artist2"},
|
||||
},
|
||||
DisplayComposer: "composer 1 & composer 2",
|
||||
Contributors: []Contributor{
|
||||
{Role: "role1", SubRole: "subrole3", Artist: ArtistID3Ref{Id: "1", Name: "artist1"}},
|
||||
{Role: "role2", Artist: ArtistID3Ref{Id: "2", Name: "artist2"}},
|
||||
{Role: "composer", Artist: ArtistID3Ref{Id: "3", Name: "composer1"}},
|
||||
{Role: "composer", Artist: ArtistID3Ref{Id: "4", Name: "composer2"}},
|
||||
},
|
||||
ExplicitStatus: "clean",
|
||||
}
|
||||
response.Directory.Child = child
|
||||
})
|
||||
@@ -236,27 +270,69 @@ var _ = Describe("Responses", func() {
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match OpenSubsonic .XML", func() {
|
||||
response.AlbumWithSongsID3.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{}
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match OpenSubsonic .JSON", func() {
|
||||
response.AlbumWithSongsID3.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{}
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
album := AlbumID3{
|
||||
Id: "1", Name: "album", Artist: "artist", Genre: "rock",
|
||||
}
|
||||
album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{
|
||||
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
UserRating: 4,
|
||||
MusicBrainzId: "1234", IsCompilation: true, SortName: "sorted album",
|
||||
DiscTitles: DiscTitles{{Disc: 1, Title: "disc 1"}, {Disc: 2, Title: "disc 2"}, {Disc: 3}},
|
||||
DiscTitles: Array[DiscTitle]{{Disc: 1, Title: "disc 1"}, {Disc: 2, Title: "disc 2"}, {Disc: 3}},
|
||||
OriginalReleaseDate: ItemDate{Year: 1994, Month: 2, Day: 4},
|
||||
ReleaseDate: ItemDate{Year: 2000, Month: 5, Day: 10},
|
||||
ReleaseTypes: []string{"album", "live"},
|
||||
RecordLabels: []RecordLabel{{Name: "label1"}, {Name: "label2"}},
|
||||
Moods: []string{"happy", "sad"},
|
||||
DisplayArtist: "artist1 & artist2",
|
||||
Artists: []ArtistID3Ref{
|
||||
{Id: "1", Name: "artist1"},
|
||||
{Id: "2", Name: "artist2"},
|
||||
},
|
||||
ExplicitStatus: "clean",
|
||||
Version: "Deluxe Edition",
|
||||
}
|
||||
t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
|
||||
songs := []Child{{
|
||||
Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1,
|
||||
Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac",
|
||||
Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3",
|
||||
Duration: 146, BitRate: 320, Starred: &t, Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
Comment: "a comment", Bpm: 127, MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song",
|
||||
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
|
||||
Duration: 146, BitRate: 320, Starred: &t,
|
||||
}}
|
||||
songs[0].OpenSubsonicChild = &OpenSubsonicChild{
|
||||
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song",
|
||||
Moods: []string{"happy", "sad"},
|
||||
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
|
||||
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
|
||||
DisplayArtist: "artist1 & artist2",
|
||||
Artists: []ArtistID3Ref{
|
||||
{Id: "1", Name: "artist1"},
|
||||
{Id: "2", Name: "artist2"},
|
||||
},
|
||||
DisplayAlbumArtist: "album artist1 & album artist2",
|
||||
AlbumArtists: []ArtistID3Ref{
|
||||
{Id: "1", Name: "album artist1"},
|
||||
{Id: "2", Name: "album artist2"},
|
||||
},
|
||||
Contributors: []Contributor{
|
||||
{Role: "role1", Artist: ArtistID3Ref{Id: "1", Name: "artist1"}},
|
||||
{Role: "role2", SubRole: "subrole4", Artist: ArtistID3Ref{Id: "2", Name: "artist2"}},
|
||||
},
|
||||
DisplayComposer: "composer 1 & composer 2",
|
||||
ExplicitStatus: "clean",
|
||||
}
|
||||
response.AlbumWithSongsID3.AlbumID3 = album
|
||||
response.AlbumWithSongsID3.Song = songs
|
||||
})
|
||||
@@ -515,8 +591,9 @@ var _ = Describe("Responses", func() {
|
||||
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
response.ArtistInfo.Biography = `Black Sabbath is an English <a target='_blank' href="http://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band`
|
||||
response.ArtistInfo.Biography = `Black Sabbath is an English <a target='_blank' href="https://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band`
|
||||
response.ArtistInfo.MusicBrainzID = "5182c1d9-c7d2-4dad-afa0-ccfeada921a8"
|
||||
|
||||
response.ArtistInfo.LastFmUrl = "https://www.last.fm/music/Black+Sabbath"
|
||||
response.ArtistInfo.SmallImageUrl = "https://userserve-ak.last.fm/serve/64/27904353.jpg"
|
||||
response.ArtistInfo.MediumImageUrl = "https://userserve-ak.last.fm/serve/126/27904353.jpg"
|
||||
|
||||
@@ -41,7 +41,7 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) {
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
type searchFunc[T any] func(q string, offset int, size int) (T, error)
|
||||
type searchFunc[T any] func(q string, offset int, size int, includeMissing bool) (T, error)
|
||||
|
||||
func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T) func() error {
|
||||
return func() error {
|
||||
@@ -51,7 +51,7 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, s
|
||||
typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.")
|
||||
var err error
|
||||
start := time.Now()
|
||||
*result, err = s(q, offset, size)
|
||||
*result, err = s(q, offset, size, false)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user