navidrome/model/metadata/map_participants_test.go
Deluan Quintão c795bcfcf7
feat(bfr): Big Refactor: new scanner, lots of new fields and tags, improvements and DB schema changes (#2709)
* fix(server): more race conditions when updating artist/album from external sources

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scanner): add .gitignore syntax to .ndignore. Resolves #1394

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): null

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): pass configfile option to child process

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): resume interrupted fullScans

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): remove old scanner code

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): rename old metadata package

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): move old metadata package

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: tests

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): update Go to 1.23.4

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: logs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(test):

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: log level

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: remove log message

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add config for scanner watcher

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: children playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: replace `interface{}` with `any`

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: smart playlists with genres

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: allow any tags in smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: artist names in playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: smart playlist's sort by tags

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add moods to child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add moods to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): use generic JSONArray for OS arrays

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): use https in test

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add releaseTypes to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add recordLabels to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): rename JSONArray to Array

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add artists to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add artists to Child

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): do not pre-populate smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): implement a simplified version of ArtistID3.

See https://github.com/opensubsonic/open-subsonic-api/discussions/120

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add artists to album child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add contributors to mediafile Child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add albumArtists to mediafile Child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add displayArtist and displayAlbumArtist

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add displayComposer to Child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add roles to ArtistID3

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): use " • " separator for displayComposer

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor:

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic):

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): respect `PreferSortTags` config option

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic):

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: optimize purging non-unused tags

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: don't run 'refresh artist stats' concurrently with other transactions

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor:

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: log message

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add Scanner.ScanOnStartup config option, default true

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: better json parsing error msg when importing NSPs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't update album's imported_time when updating external_metadata

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: handle interrupted scans and full scans after migrations

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: run `analyze` when migration requires a full rescan

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: run `PRAGMA optimize` at the end of the scan

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't update artist's updated_at when updating external_metadata

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: handle multiple artists and roles in smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): dim missing tracks

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album missing logic

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: error encoding in gob

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: separate warnings from errors

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: mark albums as missing if they were contained in a deleted folder

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: add participant names to media_file and album tables

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: use participations in criteria, instead of m2m relationship

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename participations to participants

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add moods to album child

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: albumartist role case

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scanner): run scanner as an external process by default

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): show albumArtist names

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): dim out missing albums

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: flaky test

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): scrobble buffer mapping. fix #3583

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: more participations renaming

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: listenbrainz scrobbling

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: send release_group_mbid to listenbrainz

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): implement OpenSubsonic explicitStatus field (#3597)

* feat: implement OpenSubsonic explicitStatus field

* fix(subsonic): fix failing snapshot tests

* refactor: create helper for setting explicitStatus

* fix: store smaller values for explicit-status on database

* test: ToAlbum explicitStatus

* refactor: rename explicitStatus helper function

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>

* fix: handle album and track tags in the DB based on the mappings.yaml file

Signed-off-by: Deluan <deluan@navidrome.org>

* save similar artists as JSONB

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: getAlbumList byGenre

Signed-off-by: Deluan <deluan@navidrome.org>

* detect changes in PID configuration

Signed-off-by: Deluan <deluan@navidrome.org>

* set default album PID to legacy_pid

Signed-off-by: Deluan <deluan@navidrome.org>

* fix tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix SIGSEGV

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't lose album stars/ratings when migrating

Signed-off-by: Deluan <deluan@navidrome.org>

* store full PID conf in properties

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: keep album annotations when changing PID.Album config

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: reassign album annotations

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: use (display) albumArtist and add links to each artist

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: not showing albums by albumartist

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: error msgs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: hide PID from Native API

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album cover art resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: trim participant names

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: reduce watcher log spam

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: panic when initializing the watcher

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: various artists

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't store empty lyrics in the DB

Signed-off-by: Deluan <deluan@navidrome.org>

* remove unused methods

Signed-off-by: Deluan <deluan@navidrome.org>

* drop full_text indexes, as they are not being used by SQLite

Signed-off-by: Deluan <deluan@navidrome.org>

* keep album created_at when upgrading

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): null pointer

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album artwork cache

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't expose missing files in Subsonic API

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: searchable interface

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: filter out missing items from subsonic search

* fix: filter out missing items from playlists

* fix: filter out missing items from shares

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add filter by artist role

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): only return albumartists in getIndexes and getArtists endpoints

Signed-off-by: Deluan <deluan@navidrome.org>

* sort roles alphabetically

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: artist playcounts

Signed-off-by: Deluan <deluan@navidrome.org>

* change default Album PID conf

Signed-off-by: Deluan <deluan@navidrome.org>

* fix albumartist link when it does not match any albumartists values

Signed-off-by: Deluan <deluan@navidrome.org>

* fix `Ignoring filter not whitelisted` (role) message

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: trim any names/titles being imported

Signed-off-by: Deluan <deluan@navidrome.org>

* remove unused genre code

Signed-off-by: Deluan <deluan@navidrome.org>

* serialize calls to Last.fm's getArtist

Signed-off-by: Deluan <deluan@navidrome.org>

xxx

Signed-off-by: Deluan <deluan@navidrome.org>

* add counters to genres

Signed-off-by: Deluan <deluan@navidrome.org>

* nit: fix migration `notice` message

Signed-off-by: Deluan <deluan@navidrome.org>

* optimize similar artists query

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: last.fm.getInfo when mbid does not exist

Signed-off-by: Deluan <deluan@navidrome.org>

* ui only show missing items for admins

Signed-off-by: Deluan <deluan@navidrome.org>

* don't allow interaction with missing items

Signed-off-by: Deluan <deluan@navidrome.org>

* Add Missing Files view (WIP)

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: merged tag_counts into tag table

Signed-off-by: Deluan <deluan@navidrome.org>

* add option to completely disable automatic scanner

Signed-off-by: Deluan <deluan@navidrome.org>

* add delete missing files functionality

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: playlists not showing for regular users

Signed-off-by: Deluan <deluan@navidrome.org>

* reduce updateLastAccess frequency to once every minute

Signed-off-by: Deluan <deluan@navidrome.org>

* reduce update player frequency to once every minute

Signed-off-by: Deluan <deluan@navidrome.org>

* add timeout when updating player

Signed-off-by: Deluan <deluan@navidrome.org>

* remove dead code

Signed-off-by: Deluan <deluan@navidrome.org>

* fix duplicated roles in stats

Signed-off-by: Deluan <deluan@navidrome.org>

* add `; ` to artist splitters

Signed-off-by: Deluan <deluan@navidrome.org>

* fix stats query

Signed-off-by: Deluan <deluan@navidrome.org>

* more logs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* add record label filter

Signed-off-by: Deluan <deluan@navidrome.org>

* add release type filter

Signed-off-by: Deluan <deluan@navidrome.org>

* fix purgeUnused tags

Signed-off-by: Deluan <deluan@navidrome.org>

* add grouping filter to albums

Signed-off-by: Deluan <deluan@navidrome.org>

* allow any album tags to be used in as filters in the API

Signed-off-by: Deluan <deluan@navidrome.org>

* remove empty tags from album info

Signed-off-by: Deluan <deluan@navidrome.org>

* comments in the migration

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: Cannot read properties of undefined

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: listenbrainz scrobbling (#3640)

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: remove duplicated tag values

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't ignore the taglib folder!

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: show track subtitle tag

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: show artists stats based on selected role

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: inspect

Signed-off-by: Deluan <deluan@navidrome.org>

* add media type to album info/filters

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: change format of subtitle in the UI

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: subtitle in Subsonic API and search

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: subtitle in UI's player

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: split strings should be case-insensitive

Signed-off-by: Deluan <deluan@navidrome.org>

* disable ScanSchedule

Signed-off-by: Deluan <deluan@navidrome.org>

* increase default sessiontimeout

Signed-off-by: Deluan <deluan@navidrome.org>

* add sqlite command line tool to docker image

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: resources override

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album PID conf

Signed-off-by: Deluan <deluan@navidrome.org>

* change migration to mark current artists as albumArtists

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): Allow filtering on multiple genres (#3679)

* feat(ui): Allow filtering on multiple genres

Signed-off-by: Henrik Nordvik <henrikno@gmail.com>
Signed-off-by: Deluan <deluan@navidrome.org>

* add multi-genre filter in Album list

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Henrik Nordvik <henrikno@gmail.com>
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Henrik Nordvik <henrikno@gmail.com>

* add more multi-valued tag filters to Album and Song views

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): unselect missing files after removing

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): song filter

Signed-off-by: Deluan <deluan@navidrome.org>

* fix sharing tracks. fix #3687

Signed-off-by: Deluan <deluan@navidrome.org>

* use rowids when using search for sync (ex: Symfonium)

Signed-off-by: Deluan <deluan@navidrome.org>

* fix "Report Real Paths" option for subsonic clients

Signed-off-by: Deluan <deluan@navidrome.org>

* fix "Report Real Paths" option for subsonic clients for search

Signed-off-by: Deluan <deluan@navidrome.org>

* add libraryPath to Native API /songs endpoint

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add album version

Signed-off-by: Deluan <deluan@navidrome.org>

* made all tags lowercase as they are case-insensitive anyways.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): Show full paths, extended properties for album/song (#3691)

* feat(ui): Show full paths, extended properties for album/song

- uses library path + os separator + path
- show participants (album/song) and tags (song)
- make album/participant clickable in show info

* add source to path

* fix pathSeparator in UI

Signed-off-by: Deluan <deluan@navidrome.org>

* fix local artist artwork (#3695)

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: parse vorbis performers

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: clean function into smaller functions

Signed-off-by: Deluan <deluan@navidrome.org>

* fix translations for en and pt

Signed-off-by: Deluan <deluan@navidrome.org>

* add trace log to show annotations reassignment

Signed-off-by: Deluan <deluan@navidrome.org>

* add trace log to show annotations reassignment

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: allow performers without instrument/subrole

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: metadata clean function again

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: optimize split function

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: split function is now a method of TagConf

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: humanize Artist total size

Signed-off-by: Deluan <deluan@navidrome.org>

* add album version to album details

Signed-off-by: Deluan <deluan@navidrome.org>

* don't display album-level tags in SongInfo

Signed-off-by: Deluan <deluan@navidrome.org>

* fix genre clicking in Album Page

Signed-off-by: Deluan <deluan@navidrome.org>

* don't use mbids in Last.fm api calls.

From https://discord.com/channels/671335427726114836/704303730660737113/1337574018143879248:

With MBID:
```
GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&mbid=a41ac10f-0a56-4672-9161-b83f9b223559&method=artist.getInfo

{
artist: {
name: "Bee Gees",
mbid: "bf0f7e29-dfe1-416c-b5c6-f9ebc19ea810",
url: "https://www.last.fm/music/Bee+Gees",
}
```

Without MBID:
```
GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&method=artist.getInfo

{
artist: {
name: "Van Morrison",
mbid: "a41ac10f-0a56-4672-9161-b83f9b223559",
url: "https://www.last.fm/music/Van+Morrison",
}
```

Signed-off-by: Deluan <deluan@navidrome.org>

* better logging for when the artist folder is not found

Signed-off-by: Deluan <deluan@navidrome.org>

* fix various issues with artist image resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* hide "Additional Tags" header if there are none.

Signed-off-by: Deluan <deluan@navidrome.org>

* simplify tag rendering

Signed-off-by: Deluan <deluan@navidrome.org>

* enhance logging for artist folder detection

Signed-off-by: Deluan <deluan@navidrome.org>

* make folderID consistent for relative and absolute folderPaths

Signed-off-by: Deluan <deluan@navidrome.org>

* handle more folder paths scenarios

Signed-off-by: Deluan <deluan@navidrome.org>

* filter out other roles when SubsonicArtistParticipations = true

Signed-off-by: Deluan <deluan@navidrome.org>

* fix "Cannot read properties of undefined"

Signed-off-by: Deluan <deluan@navidrome.org>

* fix lyrics and comments being truncated (#3701)

* fix lyrics and comments being truncated

* specifically test for lyrics and comment length

* reorder assertions

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>

* fix(server): Expose library_path for playlist (#3705)

Allows showing absolute path for UI, and makes "report real path" work for playlists (Subsonic)

* fix BFR on Windows (#3704)

* fix potential reflected cross-site scripting vulnerability

Signed-off-by: Deluan <deluan@navidrome.org>

* hack to make it work on Windows

* ignore windows executables

* try fixing the pipeline

Signed-off-by: Deluan <deluan@navidrome.org>

* allow MusicFolder in other drives

* move windows local drive logic to local storage implementation

---------

Signed-off-by: Deluan <deluan@navidrome.org>

* increase pagination sizes for missing files

Signed-off-by: Deluan <deluan@navidrome.org>

* reduce level of "already scanning" watcher log message

Signed-off-by: Deluan <deluan@navidrome.org>

* only count folders with audio files in it

See https://github.com/navidrome/navidrome/discussions/3676#discussioncomment-11990930

Signed-off-by: Deluan <deluan@navidrome.org>

* add album version and catalog number to search

Signed-off-by: Deluan <deluan@navidrome.org>

* add `organization` alias for `recordlabel`

Signed-off-by: Deluan <deluan@navidrome.org>

* remove mbid from Last.fm agent

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: support inspect in ui (#3726)

* inspect in ui

* address round 1

* add catalogNum to AlbumInfo

Signed-off-by: Deluan <deluan@navidrome.org>

* remove dependency on metadata_old (deprecated) package

Signed-off-by: Deluan <deluan@navidrome.org>

* add `RawTags` to model

Signed-off-by: Deluan <deluan@navidrome.org>

* support parsing MBIDs for roles (from the https://github.com/kgarner7/picard-all-mbids plugin) (#3698)


* parse standard roles, vorbis/m4a work for now

* fix djmixer

* working roles, use DJ-mix

* add performers to file

* map mbids

* add a few more tests

* add test

Signed-off-by: Deluan <deluan@navidrome.org>

* try to simplify the performers logic

Signed-off-by: Deluan <deluan@navidrome.org>

* stylistic changes

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>

* remove param mutation

Signed-off-by: Deluan <deluan@navidrome.org>

* run automated SQLite optimizations

Signed-off-by: Deluan <deluan@navidrome.org>

* fix playlists import/export on Windows

* fix import playlists

* fix export playlists

* better handling of Windows volumes

Signed-off-by: Deluan <deluan@navidrome.org>

* handle more album ID reassignments

Signed-off-by: Deluan <deluan@navidrome.org>

* allow adding/overriding tags in the config file

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): Fix playlist track id, handle missing tracks better (#3734)

- Use `mediaFileId` instead of `id` for playlist tracks
- Only fetch if the file is not missing
- If extractor fails to get the file, also error (rather than panic)

* optimize DB after each scan.

Signed-off-by: Deluan <deluan@navidrome.org>

* remove sortable from AlbumSongs columns

Signed-off-by: Deluan <deluan@navidrome.org>

* simplify query to get missing tracks

Signed-off-by: Deluan <deluan@navidrome.org>

* mark Scanner.Extractor as deprecated

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Signed-off-by: Henrik Nordvik <henrikno@gmail.com>
Co-authored-by: Caio Cotts <caio@cotts.com.br>
Co-authored-by: Henrik Nordvik <henrikno@gmail.com>
Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2025-02-19 20:35:17 -05:00

594 lines
21 KiB
Go

package metadata_test
import (
"os"
"github.com/google/uuid"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
"github.com/onsi/gomega/types"
)
var _ = Describe("Participants", func() {
var (
props metadata.Info
md metadata.Metadata
mf model.MediaFile
mbid1, mbid2, mbid3 string
)
BeforeEach(func() {
_, filePath, _ := tests.TempFile(GinkgoT(), "test", ".mp3")
fileInfo, _ := os.Stat(filePath)
mbid1 = uuid.NewString()
mbid2 = uuid.NewString()
mbid3 = uuid.NewString()
props = metadata.Info{
FileInfo: testFileInfo{fileInfo},
}
})
var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags
md = metadata.New("filepath", props)
return md.ToMediaFile(1, "folderID")
}
Describe("ARTIST(S) tags", func() {
Context("No ARTIST/ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{})
})
It("should set artist to Unknown Artist", func() {
Expect(mf.Artist).To(Equal("[Unknown Artist]"))
})
It("should add an Unknown Artist to participants", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
artist := participants[model.RoleArtist][0]
Expect(artist.ID).ToNot(BeEmpty())
Expect(artist.Name).To(Equal("[Unknown Artist]"))
Expect(artist.OrderArtistName).To(Equal("[unknown artist]"))
Expect(artist.SortArtistName).To(BeEmpty())
Expect(artist.MbzArtistID).To(BeEmpty())
})
})
Context("Single-valued ARTIST tags, no ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name"},
"ARTISTSORT": {"Name, Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1},
})
})
It("should use the artist tag as display name", func() {
Expect(mf.Artist).To(Equal("Artist Name"))
})
It("should populate the participants", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
))
Expect(mf.Artist).To(Equal("Artist Name"))
artist := participants[model.RoleArtist][0]
Expect(artist.ID).ToNot(BeEmpty())
Expect(artist.Name).To(Equal("Artist Name"))
Expect(artist.OrderArtistName).To(Equal("artist name"))
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
Expect(artist.MbzArtistID).To(Equal(mbid1))
})
})
Context("Multiple values in a Single-valued ARTIST tags, no ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name feat. Someone Else"},
"ARTISTSORT": {"Name, Artist feat. Else, Someone"},
"MUSICBRAINZ_ARTISTID": {mbid1},
})
})
It("should split the tag", func() {
By("keeping the first artist as the display name")
Expect(mf.Artist).To(Equal("Artist Name feat. Someone Else"))
Expect(mf.SortArtistName).To(Equal("Name, Artist"))
Expect(mf.OrderArtistName).To(Equal("artist name"))
participants := mf.Participants
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
By("adding the first artist to the participants")
artist0 := participants[model.RoleArtist][0]
Expect(artist0.ID).ToNot(BeEmpty())
Expect(artist0.Name).To(Equal("Artist Name"))
Expect(artist0.OrderArtistName).To(Equal("artist name"))
Expect(artist0.SortArtistName).To(Equal("Name, Artist"))
By("assuming the MBID is for the first artist")
Expect(artist0.MbzArtistID).To(Equal(mbid1))
By("adding the second artist to the participants")
artist1 := participants[model.RoleArtist][1]
Expect(artist1.ID).ToNot(BeEmpty())
Expect(artist1.Name).To(Equal("Someone Else"))
Expect(artist1.OrderArtistName).To(Equal("someone else"))
Expect(artist1.SortArtistName).To(Equal("Else, Someone"))
Expect(artist1.MbzArtistID).To(BeEmpty())
})
It("should split the tag using case-insensitive separators", func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"A1 FEAT. A2"},
})
participants := mf.Participants
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
artist1 := participants[model.RoleArtist][0]
Expect(artist1.Name).To(Equal("A1"))
artist2 := participants[model.RoleArtist][1]
Expect(artist2.Name).To(Equal("A2"))
})
It("should not add an empty artist after split", func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"John Doe / / Jane Doe"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleArtist, HaveLen(2)))
artists := participants[model.RoleArtist]
Expect(artists[0].Name).To(Equal("John Doe"))
Expect(artists[1].Name).To(Equal("Jane Doe"))
})
})
Context("Multi-valued ARTIST tags, no ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"First Artist", "Second Artist"},
"ARTISTSORT": {"Name, First Artist", "Name, Second Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
})
})
It("should use the first artist name as display name", func() {
Expect(mf.Artist).To(Equal("First Artist"))
})
It("should populate the participants with all artists", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
artist0 := participants[model.RoleArtist][0]
Expect(artist0.ID).ToNot(BeEmpty())
Expect(artist0.Name).To(Equal("First Artist"))
Expect(artist0.OrderArtistName).To(Equal("first artist"))
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
Expect(artist0.MbzArtistID).To(Equal(mbid1))
artist1 := participants[model.RoleArtist][1]
Expect(artist1.ID).ToNot(BeEmpty())
Expect(artist1.Name).To(Equal("Second Artist"))
Expect(artist1.OrderArtistName).To(Equal("second artist"))
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
Expect(artist1.MbzArtistID).To(Equal(mbid2))
})
})
Context("Single-valued ARTIST tags, multi-valued ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"First Artist & Second Artist"},
"ARTISTSORT": {"Name, First Artist & Name, Second Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
"ARTISTS": {"First Artist", "Second Artist"},
"ARTISTSSORT": {"Name, First Artist", "Name, Second Artist"},
})
})
It("should use the single-valued tag as display name", func() {
Expect(mf.Artist).To(Equal("First Artist & Second Artist"))
})
It("should prioritize multi-valued tags over single-valued tags", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
artist0 := participants[model.RoleArtist][0]
Expect(artist0.ID).ToNot(BeEmpty())
Expect(artist0.Name).To(Equal("First Artist"))
Expect(artist0.OrderArtistName).To(Equal("first artist"))
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
Expect(artist0.MbzArtistID).To(Equal(mbid1))
artist1 := participants[model.RoleArtist][1]
Expect(artist1.ID).ToNot(BeEmpty())
Expect(artist1.Name).To(Equal("Second Artist"))
Expect(artist1.OrderArtistName).To(Equal("second artist"))
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
Expect(artist1.MbzArtistID).To(Equal(mbid2))
})
})
Context("Multi-valued ARTIST tags, multi-valued ARTISTS tags", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"First Artist", "Second Artist"},
"ARTISTSORT": {"Name, First Artist", "Name, Second Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
"ARTISTS": {"First Artist 2", "Second Artist 2"},
"ARTISTSSORT": {"2, First Artist Name", "2, Second Artist Name"},
})
})
XIt("should use the values concatenated as a display name ", func() {
Expect(mf.Artist).To(Equal("First Artist + Second Artist"))
})
// TODO: remove when the above is implemented
It("should use the first artist name as display name", func() {
Expect(mf.Artist).To(Equal("First Artist 2"))
})
It("should prioritize ARTISTS tags", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
))
artist0 := participants[model.RoleArtist][0]
Expect(artist0.ID).ToNot(BeEmpty())
Expect(artist0.Name).To(Equal("First Artist 2"))
Expect(artist0.OrderArtistName).To(Equal("first artist 2"))
Expect(artist0.SortArtistName).To(Equal("2, First Artist Name"))
Expect(artist0.MbzArtistID).To(Equal(mbid1))
artist1 := participants[model.RoleArtist][1]
Expect(artist1.ID).ToNot(BeEmpty())
Expect(artist1.Name).To(Equal("Second Artist 2"))
Expect(artist1.OrderArtistName).To(Equal("second artist 2"))
Expect(artist1.SortArtistName).To(Equal("2, Second Artist Name"))
Expect(artist1.MbzArtistID).To(Equal(mbid2))
})
})
})
Describe("ALBUMARTIST(S) tags", func() {
Context("No ALBUMARTIST/ALBUMARTISTS tags", func() {
When("the COMPILATION tag is not set", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Artist Name"},
"ARTISTSORT": {"Name, Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1},
})
})
It("should use the ARTIST as ALBUMARTIST", func() {
Expect(mf.AlbumArtist).To(Equal("Artist Name"))
})
It("should add the ARTIST to participants as ALBUMARTIST", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2))
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
))
albumArtist := participants[model.RoleAlbumArtist][0]
Expect(albumArtist.ID).ToNot(BeEmpty())
Expect(albumArtist.Name).To(Equal("Artist Name"))
Expect(albumArtist.OrderArtistName).To(Equal("artist name"))
Expect(albumArtist.SortArtistName).To(Equal("Name, Artist"))
Expect(albumArtist.MbzArtistID).To(Equal(mbid1))
})
})
When("the COMPILATION tag is true", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"COMPILATION": {"1"},
})
})
It("should use the Various Artists as display name", func() {
Expect(mf.AlbumArtist).To(Equal("Various Artists"))
})
It("should add the Various Artists to participants as ALBUMARTIST", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2))
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
))
albumArtist := participants[model.RoleAlbumArtist][0]
Expect(albumArtist.ID).ToNot(BeEmpty())
Expect(albumArtist.Name).To(Equal("Various Artists"))
Expect(albumArtist.OrderArtistName).To(Equal("various artists"))
Expect(albumArtist.SortArtistName).To(BeEmpty())
Expect(albumArtist.MbzArtistID).To(Equal(consts.VariousArtistsMbzId))
})
})
})
Context("ALBUMARTIST tag is set", func() {
BeforeEach(func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"Track Artist Name"},
"ARTISTSORT": {"Name, Track Artist"},
"MUSICBRAINZ_ARTISTID": {mbid1},
"ALBUMARTIST": {"Album Artist Name"},
"ALBUMARTISTSORT": {"Album Artist Sort Name"},
"MUSICBRAINZ_ALBUMARTISTID": {mbid2},
})
})
It("should use the ALBUMARTIST as display name", func() {
Expect(mf.AlbumArtist).To(Equal("Album Artist Name"))
})
It("should populate the participants with the ALBUMARTIST", func() {
participants := mf.Participants
Expect(participants).To(HaveLen(2))
Expect(participants).To(SatisfyAll(
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
))
albumArtist := participants[model.RoleAlbumArtist][0]
Expect(albumArtist.ID).ToNot(BeEmpty())
Expect(albumArtist.Name).To(Equal("Album Artist Name"))
Expect(albumArtist.OrderArtistName).To(Equal("album artist name"))
Expect(albumArtist.SortArtistName).To(Equal("Album Artist Sort Name"))
Expect(albumArtist.MbzArtistID).To(Equal(mbid2))
})
})
})
Describe("COMPOSER and LYRICIST tags (with sort names)", func() {
DescribeTable("should return the correct participation",
func(role model.Role, nameTag, sortTag string) {
mf = toMediaFile(model.RawTags{
nameTag: {"First Name", "Second Name"},
sortTag: {"Name, First", "Name, Second"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(role, HaveLen(2)))
p := participants[role]
Expect(p[0].ID).ToNot(BeEmpty())
Expect(p[0].Name).To(Equal("First Name"))
Expect(p[0].SortArtistName).To(Equal("Name, First"))
Expect(p[0].OrderArtistName).To(Equal("first name"))
Expect(p[1].ID).ToNot(BeEmpty())
Expect(p[1].Name).To(Equal("Second Name"))
Expect(p[1].SortArtistName).To(Equal("Name, Second"))
Expect(p[1].OrderArtistName).To(Equal("second name"))
},
Entry("COMPOSER", model.RoleComposer, "COMPOSER", "COMPOSERSORT"),
Entry("LYRICIST", model.RoleLyricist, "LYRICIST", "LYRICISTSORT"),
)
})
Describe("PERFORMER tags", func() {
When("PERFORMER tag is set", func() {
matchPerformer := func(name, orderName, subRole string) types.GomegaMatcher {
return MatchFields(IgnoreExtras, Fields{
"Artist": MatchFields(IgnoreExtras, Fields{
"Name": Equal(name),
"OrderArtistName": Equal(orderName),
}),
"SubRole": Equal(subRole),
})
}
It("should return the correct participation", func() {
mf = toMediaFile(model.RawTags{
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
"PERFORMER:BASS": {"Nathan East"},
"PERFORMER:HAMMOND ORGAN": {"Tim Carmon"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4)))
p := participants[model.RolePerformer]
Expect(p).To(ContainElements(
matchPerformer("Eric Clapton", "eric clapton", "Guitar"),
matchPerformer("B.B. King", "b.b. king", "Guitar"),
matchPerformer("Nathan East", "nathan east", "Bass"),
matchPerformer("Tim Carmon", "tim carmon", "Hammond Organ"),
))
})
})
})
Describe("Other tags", func() {
DescribeTable("should return the correct participation",
func(role model.Role, tag string) {
mf = toMediaFile(model.RawTags{
tag: {"John Doe", "Jane Doe"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(role, HaveLen(2)))
p := participants[role]
Expect(p[0].ID).ToNot(BeEmpty())
Expect(p[0].Name).To(Equal("John Doe"))
Expect(p[0].OrderArtistName).To(Equal("john doe"))
Expect(p[1].ID).ToNot(BeEmpty())
Expect(p[1].Name).To(Equal("Jane Doe"))
Expect(p[1].OrderArtistName).To(Equal("jane doe"))
},
Entry("CONDUCTOR", model.RoleConductor, "CONDUCTOR"),
Entry("ARRANGER", model.RoleArranger, "ARRANGER"),
Entry("PRODUCER", model.RoleProducer, "PRODUCER"),
Entry("ENGINEER", model.RoleEngineer, "ENGINEER"),
Entry("MIXER", model.RoleMixer, "MIXER"),
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
// TODO PERFORMER
)
})
Describe("Role value splitting", func() {
When("the tag is single valued", func() {
It("should split the values by the configured separator", func() {
mf = toMediaFile(model.RawTags{
"COMPOSER": {"John Doe/Someone Else/The Album Artist"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
composers := participants[model.RoleComposer]
Expect(composers[0].Name).To(Equal("John Doe"))
Expect(composers[1].Name).To(Equal("Someone Else"))
Expect(composers[2].Name).To(Equal("The Album Artist"))
})
It("should not add an empty participant after split", func() {
mf = toMediaFile(model.RawTags{
"COMPOSER": {"John Doe/"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(1)))
composers := participants[model.RoleComposer]
Expect(composers[0].Name).To(Equal("John Doe"))
})
It("should trim the values", func() {
mf = toMediaFile(model.RawTags{
"COMPOSER": {"John Doe / Someone Else / The Album Artist"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
composers := participants[model.RoleComposer]
Expect(composers[0].Name).To(Equal("John Doe"))
Expect(composers[1].Name).To(Equal("Someone Else"))
Expect(composers[2].Name).To(Equal("The Album Artist"))
})
})
})
Describe("MBID tags", func() {
It("should set the MBID for the artist based on the track/album artist", func() {
mf = toMediaFile(model.RawTags{
"ARTIST": {"John Doe", "Jane Doe"},
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
"ALBUMARTIST": {"The Album Artist"},
"MUSICBRAINZ_ALBUMARTISTID": {mbid3},
"COMPOSER": {"John Doe", "Someone Else", "The Album Artist"},
"PRODUCER": {"Jane Doe", "John Doe"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
composers := participants[model.RoleComposer]
Expect(composers[0].MbzArtistID).To(Equal(mbid1))
Expect(composers[1].MbzArtistID).To(BeEmpty())
Expect(composers[2].MbzArtistID).To(Equal(mbid3))
Expect(participants).To(HaveKeyWithValue(model.RoleProducer, HaveLen(2)))
producers := participants[model.RoleProducer]
Expect(producers[0].MbzArtistID).To(Equal(mbid2))
Expect(producers[1].MbzArtistID).To(Equal(mbid1))
})
})
Describe("Non-standard MBID tags", func() {
var allMappings = map[model.Role]model.TagName{
model.RoleComposer: model.TagMusicBrainzComposerID,
model.RoleLyricist: model.TagMusicBrainzLyricistID,
model.RoleConductor: model.TagMusicBrainzConductorID,
model.RoleArranger: model.TagMusicBrainzArrangerID,
model.RoleDirector: model.TagMusicBrainzDirectorID,
model.RoleProducer: model.TagMusicBrainzProducerID,
model.RoleEngineer: model.TagMusicBrainzEngineerID,
model.RoleMixer: model.TagMusicBrainzMixerID,
model.RoleRemixer: model.TagMusicBrainzRemixerID,
model.RoleDJMixer: model.TagMusicBrainzDJMixerID,
}
It("should handle more artists than mbids", func() {
for key := range allMappings {
mf = toMediaFile(map[string][]string{
key.String(): {"a", "b", "c"},
allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(key, HaveLen(3)))
roles := participants[key]
Expect(roles[0].Name).To(Equal("a"))
Expect(roles[1].Name).To(Equal("b"))
Expect(roles[2].Name).To(Equal("c"))
Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759"))
Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12"))
Expect(roles[2].MbzArtistID).To(Equal(""))
}
})
It("should handle more mbids than artists", func() {
for key := range allMappings {
mf = toMediaFile(map[string][]string{
key.String(): {"a", "b"},
allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(key, HaveLen(2)))
roles := participants[key]
Expect(roles[0].Name).To(Equal("a"))
Expect(roles[1].Name).To(Equal("b"))
Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759"))
Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12"))
}
})
It("should refuse duplicate names if no mbid specified", func() {
for key := range allMappings {
mf = toMediaFile(map[string][]string{
key.String(): {"a", "b", "a", "a"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(key, HaveLen(2)))
roles := participants[key]
Expect(roles[0].Name).To(Equal("a"))
Expect(roles[0].MbzArtistID).To(Equal(""))
Expect(roles[1].Name).To(Equal("b"))
Expect(roles[1].MbzArtistID).To(Equal(""))
}
})
})
})