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:
Deluan Quintão
2025-02-19 17:35:17 -08:00
committed by GitHub
parent 46a963a02a
commit c795bcfcf7
329 changed files with 16586 additions and 5852 deletions
+109 -20
View File
@@ -3,34 +3,122 @@ import PropTypes from 'prop-types'
import { Link } from 'react-admin'
import { withWidth } from '@material-ui/core'
import { useGetHandleArtistClick } from './useGetHandleArtistClick'
import { intersperse } from '../utils/index.js'
import { useDispatch } from 'react-redux'
import { closeExtendedInfoDialog } from '../actions/dialogs.js'
export const ArtistLinkField = withWidth()(({
record,
className,
width,
source,
}) => {
const ALink = withWidth()((props) => {
const { artist, width, ...rest } = props
const artistLink = useGetHandleArtistClick(width)
const dispatch = useDispatch()
const id = record[source + 'Id']
return (
<>
{id ? (
<Link
to={artistLink(id)}
onClick={(e) => e.stopPropagation()}
className={className}
>
{record[source]}
</Link>
) : (
record[source]
)}
</>
<Link
key={artist.id}
to={artistLink(artist.id)}
onClick={(e) => {
e.stopPropagation()
dispatch(closeExtendedInfoDialog())
}}
{...rest}
>
{artist.name}
</Link>
)
})
const parseAndReplaceArtists = (
displayAlbumArtist,
albumArtists,
className,
) => {
let result = []
let lastIndex = 0
albumArtists?.forEach((artist) => {
const index = displayAlbumArtist.indexOf(artist.name, lastIndex)
if (index !== -1) {
// Add text before the artist name
if (index > lastIndex) {
result.push(displayAlbumArtist.slice(lastIndex, index))
}
// Add the artist link
result.push(<ALink artist={artist} className={className} />)
lastIndex = index + artist.name.length
}
})
if (lastIndex === 0) {
return []
}
// Add any remaining text after the last artist name
if (lastIndex < displayAlbumArtist.length) {
result.push(displayAlbumArtist.slice(lastIndex))
}
return result
}
export const ArtistLinkField = ({ record, className, limit, source }) => {
const role = source.toLowerCase()
const artists = record['participants']
? record['participants'][role]
: [{ name: record[source], id: record[source + 'Id'] }]
// When showing artists for a track, add any remixers to the list of artists
if (
role === 'artist' &&
record['participants'] &&
record['participants']['remixer']
) {
record['participants']['remixer'].forEach((remixer) => {
artists.push(remixer)
})
}
if (role === 'albumartist') {
const artistsLinks = parseAndReplaceArtists(
record[source],
artists,
className,
)
if (artistsLinks.length > 0) {
return <div className={className}>{artistsLinks}</div>
}
}
// Dedupe artists, only shows the first 3
const seen = new Set()
const dedupedArtists = []
let limitedShow = false
for (const artist of artists ?? []) {
if (!seen.has(artist.id)) {
seen.add(artist.id)
if (dedupedArtists.length < limit) {
dedupedArtists.push(artist)
} else {
limitedShow = true
break
}
}
}
const artistsList = dedupedArtists.map((artist) => (
<ALink artist={artist} className={className} key={artist?.id} />
))
if (limitedShow) {
artistsList.push(<span>...</span>)
}
return <>{intersperse(artistsList, ' • ')}</>
}
ArtistLinkField.propTypes = {
limit: PropTypes.number,
record: PropTypes.object,
className: PropTypes.string,
source: PropTypes.string,
@@ -38,5 +126,6 @@ ArtistLinkField.propTypes = {
ArtistLinkField.defaultProps = {
addLabel: true,
limit: 3,
source: 'albumArtist',
}
-91
View File
@@ -1,91 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import ListItemText from '@material-ui/core/ListItemText'
import { makeStyles } from '@material-ui/core/styles'
import { sanitizeListRestProps } from 'react-admin'
import { ArtistContextMenu, RatingField } from './index'
import config from '../config'
const useStyles = makeStyles(
{
listItem: {
padding: '10px',
},
title: {
paddingRight: '10px',
width: '80%',
},
rightIcon: {
top: '26px',
},
},
{ name: 'RaArtistSimpleList' },
)
export const ArtistSimpleList = ({
linkType,
className,
classes: classesOverride,
data,
hasBulkActions,
ids,
loading,
selectedIds,
total,
...rest
}) => {
const classes = useStyles({ classes: classesOverride })
return (
(loading || total > 0) && (
<List className={className} {...sanitizeListRestProps(rest)}>
{ids.map(
(id) =>
data[id] && (
<span key={id} onClick={() => linkType(id)}>
<ListItem className={classes.listItem} button={true}>
<ListItemText
primary={
<>
<div className={classes.title}>{data[id].name}</div>
{config.enableStarRating && (
<RatingField
record={data[id]}
source={'rating'}
resource={'artist'}
size={'small'}
/>
)}
</>
}
/>
<ListItemSecondaryAction className={classes.rightIcon}>
<ListItemIcon>
<ArtistContextMenu record={data[id]} />
</ListItemIcon>
</ListItemSecondaryAction>
</ListItem>
</span>
),
)}
</List>
)
)
}
ArtistSimpleList.propTypes = {
className: PropTypes.string,
classes: PropTypes.object,
data: PropTypes.object,
hasBulkActions: PropTypes.bool.isRequired,
ids: PropTypes.array,
selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired,
}
ArtistSimpleList.defaultProps = {
hasBulkActions: false,
selectedIds: [],
}
+32 -7
View File
@@ -5,6 +5,7 @@ import IconButton from '@material-ui/core/IconButton'
import Menu from '@material-ui/core/Menu'
import MenuItem from '@material-ui/core/MenuItem'
import MoreVertIcon from '@material-ui/icons/MoreVert'
import { MdQuestionMark } from 'react-icons/md'
import { makeStyles } from '@material-ui/core/styles'
import { useDataProvider, useNotify, useTranslate } from 'react-admin'
import clsx from 'clsx'
@@ -33,6 +34,25 @@ const useStyles = makeStyles({
},
})
const MoreButton = ({ record, onClick, info, ...rest }) => {
const handleClick = record.missing
? (e) => {
e.preventDefault()
info.action(record)
e.stopPropagation()
}
: onClick
return (
<IconButton onClick={handleClick} size={'small'} {...rest}>
{record?.missing ? (
<MdQuestionMark fontSize={'large'} />
) : (
<MoreVertIcon fontSize={'small'} />
)}
</IconButton>
)
}
const ContextMenu = ({
resource,
showLove,
@@ -158,24 +178,29 @@ const ContextMenu = ({
const open = Boolean(anchorEl)
if (!record) {
return null
}
const present = !record.missing
return (
<span className={clsx(classes.noWrap, className)}>
<LoveButton
record={record}
resource={resource}
visible={config.enableFavourites && showLove}
visible={config.enableFavourites && showLove && present}
color={color}
/>
<IconButton
<MoreButton
record={record}
onClick={handleClick}
info={options.info}
aria-label="more"
aria-controls="context-menu"
aria-haspopup="true"
className={classes.menu}
onClick={handleClick}
size={'small'}
>
<MoreVertIcon fontSize={'small'} />
</IconButton>
/>
<Menu
id="context-menu"
anchorEl={anchorEl}
+1 -1
View File
@@ -46,7 +46,7 @@ export const LoveButton = ({
<Button
onClick={handleToggleLove}
size={'small'}
disabled={disabled || loading}
disabled={disabled || loading || record?.missing}
className={classes.love}
{...rest}
>
+54
View File
@@ -0,0 +1,54 @@
import { TableRow, TableCell } from '@material-ui/core'
import { humanize } from 'inflection'
import { useTranslate } from 'react-admin'
import en from '../i18n/en.json'
import { ArtistLinkField } from './index'
export const ParticipantsInfo = ({ classes, record }) => {
const translate = useTranslate()
const existingRoles = en?.resources?.artist?.roles ?? {}
const roles = []
if (record.participants) {
for (const name of Object.keys(record.participants)) {
if (name === 'albumartist' || name === 'artist') {
continue
}
roles.push([name, record.participants[name].length])
}
}
if (roles.length === 0) {
return null
}
return (
<>
{roles.length > 0 && (
<TableRow key={`${record.id}-separator`}>
<TableCell scope="row" className={classes.tableCell}></TableCell>
<TableCell align="left">
<h4>{translate(`resources.song.fields.participants`)}</h4>
</TableCell>
</TableRow>
)}
{roles.map(([role, count]) => (
<TableRow key={`${record.id}-${role}`}>
<TableCell scope="row" className={classes.tableCell}>
{role in existingRoles
? translate(`resources.artist.roles.${role}`, {
smart_count: count,
})
: humanize(role)}
:
</TableCell>
<TableCell align="left">
<ArtistLinkField source={role} record={record} limit={Infinity} />
</TableCell>
</TableRow>
))}
</>
)
}
+24
View File
@@ -0,0 +1,24 @@
import PropTypes from 'prop-types'
import React from 'react'
import { useRecordContext } from 'react-admin'
import config from '../config'
export const PathField = (props) => {
const record = useRecordContext(props)
return (
<span>
{record.libraryPath}
{config.separator}
{record.path}
</span>
)
}
PathField.propTypes = {
label: PropTypes.string,
record: PropTypes.object,
}
PathField.defaultProps = {
addLabel: true,
}
+1
View File
@@ -54,6 +54,7 @@ export const RatingField = ({
)}
value={rating}
size={size}
disabled={record?.missing}
emptyIcon={<StarBorderIcon fontSize="inherit" />}
onChange={(e, newValue) => handleRating(e, newValue)}
/>
+5 -1
View File
@@ -14,7 +14,11 @@ export const SizeField = ({ source, ...rest }) => {
const classes = useStyles()
const record = useRecordContext(rest)
if (!record) return null
return <span className={classes.root}>{formatBytes(record[source])}</span>
return (
<span className={classes.root}>
{record[source] ? formatBytes(record[source]) : '0 MB'}
</span>
)
}
SizeField.propTypes = {
+53 -6
View File
@@ -1,10 +1,11 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { useDispatch } from 'react-redux'
import { useTranslate } from 'react-admin'
import { useNotify, usePermissions, useTranslate } from 'react-admin'
import { IconButton, Menu, MenuItem } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import MoreVertIcon from '@material-ui/icons/MoreVert'
import { MdQuestionMark } from 'react-icons/md'
import clsx from 'clsx'
import {
playNext,
@@ -19,6 +20,7 @@ import {
import { LoveButton } from './LoveButton'
import config from '../config'
import { formatBytes } from '../utils'
import { httpClient } from '../dataProvider'
const useStyles = makeStyles({
noWrap: {
@@ -26,6 +28,24 @@ const useStyles = makeStyles({
},
})
const MoreButton = ({ record, onClick, info }) => {
const handleClick = record.missing
? (e) => {
info.action(record)
e.stopPropagation()
}
: onClick
return (
<IconButton onClick={handleClick} size={'small'}>
{record?.missing ? (
<MdQuestionMark fontSize={'large'} />
) : (
<MoreVertIcon fontSize={'small'} />
)}
</IconButton>
)
}
export const SongContextMenu = ({
resource,
record,
@@ -36,7 +56,10 @@ export const SongContextMenu = ({
const classes = useStyles()
const dispatch = useDispatch()
const translate = useTranslate()
const notify = useNotify()
const [anchorEl, setAnchorEl] = useState(null)
const { permissions } = usePermissions()
const options = {
playNow: {
enabled: true,
@@ -85,7 +108,27 @@ export const SongContextMenu = ({
info: {
enabled: true,
label: translate('resources.song.actions.info'),
action: (record) => dispatch(openExtendedInfoDialog(record)),
action: async (record) => {
let fullRecord = record
if (permissions === 'admin' && !record.missing) {
try {
let id = record.mediaFileId ?? record.id
const data = await httpClient(`/api/inspect?id=${id}`)
fullRecord = { ...record, rawTags: data.json.rawTags }
} catch (error) {
notify(
translate('ra.notification.http_error') + ': ' + error.message,
{
type: 'warning',
multiLine: true,
duration: 0,
},
)
}
}
dispatch(openExtendedInfoDialog(fullRecord))
},
},
}
@@ -109,16 +152,20 @@ export const SongContextMenu = ({
const open = Boolean(anchorEl)
if (!record) {
return null
}
const present = !record.missing
return (
<span className={clsx(classes.noWrap, className)}>
<LoveButton
record={record}
resource={resource}
visible={config.enableFavourites && showLove}
visible={config.enableFavourites && showLove && present}
/>
<IconButton onClick={handleClick} size={'small'}>
<MoreVertIcon fontSize={'small'} />
</IconButton>
<MoreButton record={record} onClick={handleClick} info={options.info} />
<Menu
id={'menu' + record.id}
anchorEl={anchorEl}
+20 -2
View File
@@ -41,6 +41,10 @@ const useStyles = makeStyles({
},
},
},
missingRow: {
cursor: 'inherit',
opacity: 0.3,
},
headerStyle: {
'& thead': {
boxShadow: '0px 3px 3px rgba(0, 0, 0, 0.15)',
@@ -195,6 +199,13 @@ export const SongDatagridRow = ({
return null
}
const rowClick = record.missing ? undefined : rest.rowClick
const computedClasses = clsx(
className,
classes.row,
record.missing && classes.missingRow,
)
const childCount = fields.length
return (
<>
@@ -220,7 +231,8 @@ export const SongDatagridRow = ({
ref={dragSongRef}
record={record}
{...rest}
className={clsx(className, classes.row)}
rowClick={rowClick}
className={computedClasses}
>
{fields}
</PureDatagridRow>
@@ -262,7 +274,12 @@ const SongDatagridBody = ({
} else {
idsToPlay = ids.filter((id) => data[id].releaseDate === releaseDate)
}
dispatch(playTracks(data, idsToPlay))
dispatch(
playTracks(
data,
idsToPlay?.filter((id) => !data[id].missing),
),
)
},
[dispatch, data, ids],
)
@@ -343,6 +360,7 @@ export const SongDatagrid = ({
return (
<Datagrid
className={classes.headerStyle}
isRowSelectable={(r) => !r?.missing}
{...rest}
body={
<SongDatagridBody
+119 -17
View File
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useState } from 'react'
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
@@ -14,10 +14,18 @@ import {
useRecordContext,
} from 'react-admin'
import inflection from 'inflection'
import { BitrateField, SizeField } from './index'
import {
ArtistLinkField,
BitrateField,
ParticipantsInfo,
PathField,
SizeField,
} from './index'
import { MultiLineTextField } from './MultiLineTextField'
import { makeStyles } from '@material-ui/core/styles'
import config from '../config'
import { AlbumLinkField } from '../song/AlbumLinkField'
import { Tab, Tabs } from '@material-ui/core'
const useStyles = makeStyles({
gain: {
@@ -34,13 +42,32 @@ export const SongInfo = (props) => {
const classes = useStyles({ gain: config.enableReplayGain })
const translate = useTranslate()
const record = useRecordContext(props)
const [tab, setTab] = useState(0)
// These are already displayed in other fields or are album-level tags
const excludedTags = [
'genre',
'disctotal',
'tracktotal',
'releasetype',
'recordlabel',
'media',
'albumversion',
]
const data = {
path: <TextField source="path" />,
album: <TextField source="album" />,
path: <PathField />,
album: (
<AlbumLinkField source="album" sortByOrder={'ASC'} record={record} />
),
discSubtitle: <TextField source="discSubtitle" />,
albumArtist: <TextField source="albumArtist" />,
albumArtist: (
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
),
artist: (
<ArtistLinkField source="artist" record={record} limit={Infinity} />
),
genre: (
<FunctionField render={(r) => r.genres?.map((g) => g.name).join(', ')} />
<FunctionField render={(r) => r.genres?.map((g) => g.name).join(' ')} />
),
compilation: <BooleanField source="compilation" />,
bitRate: <BitrateField source="bitRate" />,
@@ -52,6 +79,15 @@ export const SongInfo = (props) => {
comment: <MultiLineTextField source="comment" />,
}
const roles = []
for (const name of Object.keys(record.participants)) {
if (name === 'albumartist' || name === 'artist') {
continue
}
roles.push([name, record.participants[name].length])
}
const optionalFields = ['discSubtitle', 'comment', 'bpm', 'genre']
optionalFields.forEach((field) => {
!record[field] && delete data[field]
@@ -69,23 +105,89 @@ export const SongInfo = (props) => {
)
}
const tags = Object.entries(record.tags ?? {}).filter(
(tag) => !excludedTags.includes(tag[0]),
)
return (
<TableContainer>
<Table aria-label="song details" size="small">
<TableBody>
{Object.keys(data).map((key) => {
return (
<TableRow key={`${record.id}-${key}`}>
<TableCell scope="row" className={classes.tableCell}>
{translate(`resources.song.fields.${key}`, {
_: inflection.humanize(inflection.underscore(key)),
})}
:
{record.rawTags && (
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
<Tab
label={translate(`resources.song.fields.mappedTags`)}
id="mapped-tags-tab"
aria-controls="mapped-tags-body"
/>
<Tab
label={translate(`resources.song.fields.rawTags`)}
id="raw-tags-tab"
aria-controls="raw-tags-body"
/>
</Tabs>
)}
<div
hidden={tab === 1}
id="mapped-tags-body"
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
>
{Object.keys(data).map((key) => {
return (
<TableRow key={`${record.id}-${key}`}>
<TableCell scope="row" className={classes.tableCell}>
{translate(`resources.song.fields.${key}`, {
_: inflection.humanize(inflection.underscore(key)),
})}
:
</TableCell>
<TableCell align="left">{data[key]}</TableCell>
</TableRow>
)
})}
<ParticipantsInfo classes={classes} record={record} />
{tags.length > 0 && (
<TableRow key={`${record.id}-separator`}>
<TableCell
scope="row"
className={classes.tableCell}
></TableCell>
<TableCell align="left">
<h4>{translate(`resources.song.fields.tags`)}</h4>
</TableCell>
<TableCell align="left">{data[key]}</TableCell>
</TableRow>
)
})}
)}
{tags.map(([name, values]) => (
<TableRow key={`${record.id}-tag-${name}`}>
<TableCell scope="row" className={classes.tableCell}>
{name}:
</TableCell>
<TableCell align="left">{values.join(' • ')}</TableCell>
</TableRow>
))}
</div>
{record.rawTags && (
<div
hidden={tab === 0}
id="raw-tags-body"
aria-labelledby="raw-tags-tab"
>
<TableRow key={`${record.id}-raw-path`}>
<TableCell scope="row" className={classes.tableCell}>
{translate(`resources.song.fields.path`)}:
</TableCell>
<TableCell align="left">{data.path}</TableCell>
</TableRow>
{Object.entries(record.rawTags).map(([key, value]) => (
<TableRow key={`${record.id}-raw-${key}`}>
<TableCell scope="row" className={classes.tableCell}>
{key}:
</TableCell>
<TableCell align="left">{value.join(' • ')}</TableCell>
</TableRow>
))}
</div>
)}
</TableBody>
</Table>
</TableContainer>
+13
View File
@@ -21,6 +21,9 @@ const useStyles = makeStyles({
text: {
verticalAlign: 'text-top',
},
subtitle: {
opacity: 0.5,
},
})
export const SongTitleField = ({ showTrackNumbers, ...props }) => {
@@ -33,11 +36,21 @@ export const SongTitleField = ({ showTrackNumbers, ...props }) => {
const isCurrent =
currentId && (currentId === record.id || currentId === record.mediaFileId)
const subtitle = record?.tags?.['subtitle']
const trackName = (r) => {
const name = r.title
if (r.trackNumber && showTrackNumbers) {
return r.trackNumber.toString().padStart(2, '0') + ' ' + name
}
if (subtitle) {
return (
<>
{name}
<span className={classes.subtitle}>{' (' + subtitle + ')'}</span>
</>
)
}
return name
}
+2 -1
View File
@@ -32,7 +32,6 @@ export * from './useToggleLove'
export * from './useTraceUpdate'
export * from './Writable'
export * from './SongSimpleList'
export * from './ArtistSimpleList'
export * from './RatingField'
export * from './useRating'
export * from './useSelectedFields'
@@ -40,3 +39,5 @@ export * from './ToggleFieldsMenu'
export * from './QualityInfo'
export * from './formatRange.js'
export * from './playlistUtils.js'
export * from './PathField.jsx'
export * from './ParticipantsInfo'