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
+11
View File
@@ -38,6 +38,7 @@ import useChangeThemeColor from './useChangeThemeColor'
import SharePlayer from './share/SharePlayer'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { DndProvider } from 'react-dnd'
import missing from './missing/index.js'
const history = createHashHistory()
@@ -119,8 +120,18 @@ const Admin = (props) => {
) : (
<Resource name="transcoding" />
),
permissions === 'admin' ? (
<Resource
name="missing"
{...missing}
options={{ subMenu: 'settings' }}
/>
) : null,
<Resource name="translation" />,
<Resource name="genre" />,
<Resource name="tag" />,
<Resource name="playlistTrack" />,
<Resource name="keepalive" />,
<Resource name="insights" />,
+27 -14
View File
@@ -5,6 +5,7 @@ import {
Button,
sanitizeListRestProps,
TopToolbar,
useRecordContext,
useTranslate,
} from 'react-admin'
import { useMediaQuery, makeStyles } from '@material-ui/core'
@@ -32,6 +33,15 @@ const useStyles = makeStyles({
toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' },
})
const AlbumButton = ({ children, ...rest }) => {
const record = useRecordContext(rest) || {}
return (
<Button {...rest} disabled={record.missing}>
{children}
</Button>
)
}
const AlbumActions = ({
className,
ids,
@@ -78,43 +88,46 @@ const AlbumActions = ({
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
<div className={classes.toolbar}>
<div>
<Button
<AlbumButton
onClick={handlePlay}
label={translate('resources.album.actions.playAll')}
>
<PlayArrowIcon />
</Button>
<Button
</AlbumButton>
<AlbumButton
onClick={handleShuffle}
label={translate('resources.album.actions.shuffle')}
>
<ShuffleIcon />
</Button>
<Button
</AlbumButton>
<AlbumButton
onClick={handlePlayNext}
label={translate('resources.album.actions.playNext')}
>
<RiPlayList2Fill />
</Button>
<Button
</AlbumButton>
<AlbumButton
onClick={handlePlayLater}
label={translate('resources.album.actions.addToQueue')}
>
<RiPlayListAddFill />
</Button>
<Button
</AlbumButton>
<AlbumButton
onClick={handleAddToPlaylist}
label={translate('resources.album.actions.addToPlaylist')}
>
<PlaylistAddIcon />
</Button>
</AlbumButton>
{config.enableSharing && (
<Button onClick={handleShare} label={translate('ra.action.share')}>
<AlbumButton
onClick={handleShare}
label={translate('ra.action.share')}
>
<ShareIcon />
</Button>
</AlbumButton>
)}
{config.enableDownloads && (
<Button
<AlbumButton
onClick={handleDownload}
label={
translate('ra.action.download') +
@@ -122,7 +135,7 @@ const AlbumActions = ({
}
>
<CloudDownloadOutlinedIcon />
</Button>
</AlbumButton>
)}
</div>
<div>{isNotSmall && <ToggleFieldsMenu resource="albumSong" />}</div>
+4 -1
View File
@@ -110,7 +110,7 @@ const useGetHandleGenreClick = (width) => {
const [perPage] = useAlbumsPerPage(width)
return (id) => {
return `/album?filter={"genre_id":"${id}"}&order=ASC&sort=name&perPage=${perPage}`
return `/album?filter={"genre_id":["${id}"]}&order=ASC&sort=name&perPage=${perPage}`
}
}
@@ -284,6 +284,9 @@ const AlbumDetails = (props) => {
color="primary"
/>
</Typography>
<Typography component={'h6'} className={classes.recordArtist}>
{record?.tags?.['albumversion']}
</Typography>
<Typography component={'h6'} className={classes.recordArtist}>
<ArtistLinkField record={record} />
</Typography>
+31 -7
View File
@@ -20,6 +20,7 @@ import {
RangeDoubleField,
} from '../common'
import { DraggableTypes } from '../consts'
import clsx from 'clsx'
const useStyles = makeStyles(
(theme) => ({
@@ -55,6 +56,16 @@ const useStyles = makeStyles(
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
missingAlbum: {
opacity: 0.3,
},
albumVersion: {
fontSize: '12px',
color: theme.palette.type === 'dark' ? '#c5c5c5' : '#696969',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
albumSubtitle: {
fontSize: '12px',
color: theme.palette.type === 'dark' ? '#c5c5c5' : '#696969',
@@ -135,8 +146,12 @@ const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => {
if (!record) {
return null
}
const computedClasses = clsx(
classes.albumContainer,
record.missing && classes.missingAlbum,
)
return (
<div className={classes.albumContainer}>
<div className={computedClasses}>
<Link
className={classes.link}
to={linkToRecord(basePath, record.id, 'show')}
@@ -145,11 +160,13 @@ const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => {
<GridListTileBar
className={isDesktop ? classes.tileBar : classes.tileBarMobile}
subtitle={
<PlayButton
className={classes.albumPlayButton}
record={record}
size="small"
/>
!record.missing && (
<PlayButton
className={classes.albumPlayButton}
record={record}
size="small"
/>
)
}
actionIcon={<AlbumContextMenu record={record} color={'white'} />}
/>
@@ -158,7 +175,14 @@ const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => {
className={classes.albumLink}
to={linkToRecord(basePath, record.id, 'show')}
>
<Typography className={classes.albumName}>{record.name}</Typography>
<span>
<Typography className={classes.albumName}>{record.name}</Typography>
{record.tags && record.tags['albumversion'] && (
<Typography className={classes.albumVersion}>
{record.tags['albumversion']}
</Typography>
)}
</span>
</Link>
{showArtist ? (
<ArtistLinkField record={record} className={classes.albumSubtitle} />
+53 -3
View File
@@ -9,13 +9,18 @@ import {
BooleanField,
ChipField,
DateField,
FunctionField,
SingleFieldList,
TextField,
useRecordContext,
useTranslate,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import { MultiLineTextField } from '../common'
import {
ArtistLinkField,
MultiLineTextField,
ParticipantsInfo,
} from '../common'
const useStyles = makeStyles({
tableCell: {
@@ -29,7 +34,9 @@ const AlbumInfo = (props) => {
const record = useRecordContext(props)
const data = {
album: <TextField source={'name'} />,
albumArtist: <TextField source={'albumArtist'} />,
albumArtist: (
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
),
genre: (
<ArrayField source={'genres'}>
<SingleFieldList linkType={false}>
@@ -37,16 +44,58 @@ const AlbumInfo = (props) => {
</SingleFieldList>
</ArrayField>
),
recordLabel: (
<FunctionField
source={'recordLabel'}
render={(record) => record.tags?.recordlabel?.join(', ')}
/>
),
catalogNum: <TextField source={'catalogNum'} />,
releaseType: (
<FunctionField
source={'releaseType'}
render={(record) => record.tags?.releasetype?.join(', ')}
/>
),
media: (
<FunctionField
source={'media'}
render={(record) => record.tags?.media?.join(', ')}
/>
),
grouping: (
<FunctionField
source={'grouping'}
render={(record) => record.tags?.grouping?.join(', ')}
/>
),
mood: (
<FunctionField
source={'mood'}
render={(record) => record.tags?.mood?.join(', ')}
/>
),
compilation: <BooleanField source={'compilation'} />,
updatedAt: <DateField source={'updatedAt'} showTime />,
comment: <MultiLineTextField source={'comment'} />,
}
const optionalFields = ['comment', 'genre']
const optionalFields = ['comment', 'genre', 'catalogNum']
optionalFields.forEach((field) => {
!record[field] && delete data[field]
})
const optionalTags = [
'releaseType',
'recordLabel',
'grouping',
'mood',
'media',
]
optionalTags.forEach((field) => {
!record?.tags?.[field.toLowerCase()] && delete data[field]
})
return (
<TableContainer>
<Table aria-label="album details" size="small">
@@ -68,6 +117,7 @@ const AlbumInfo = (props) => {
</TableRow>
)
})}
<ParticipantsInfo record={record} classes={classes} />
</TableBody>
</Table>
</TableContainer>
+94 -2
View File
@@ -1,11 +1,13 @@
import { useSelector } from 'react-redux'
import { Redirect, useLocation } from 'react-router-dom'
import {
AutocompleteArrayInput,
AutocompleteInput,
Filter,
NullableBooleanInput,
NumberInput,
Pagination,
ReferenceArrayInput,
ReferenceInput,
SearchInput,
useRefresh,
@@ -29,8 +31,18 @@ import albumLists, { defaultAlbumList } from './albumLists'
import config from '../config'
import AlbumInfo from './AlbumInfo'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import inflection from 'inflection'
import { makeStyles } from '@material-ui/core/styles'
const useStyles = makeStyles({
chip: {
margin: 0,
height: '24px',
},
})
const AlbumFilter = (props) => {
const classes = useStyles()
const translate = useTranslate()
return (
<Filter {...props} variant={'outlined'}>
@@ -44,7 +56,7 @@ const AlbumFilter = (props) => {
>
<AutocompleteInput emptyText="-- None --" />
</ReferenceInput>
<ReferenceInput
<ReferenceArrayInput
label={translate('resources.album.fields.genre')}
source="genre_id"
reference="genre"
@@ -52,7 +64,87 @@ const AlbumFilter = (props) => {
sort={{ field: 'name', order: 'ASC' }}
filterToQuery={(searchText) => ({ name: [searchText] })}
>
<AutocompleteInput emptyText="-- None --" />
<AutocompleteArrayInput emptyText="-- None --" classes={classes} />
</ReferenceArrayInput>
<ReferenceInput
label={translate('resources.album.fields.recordLabel')}
source="recordlabel"
reference="tag"
perPage={0}
sort={{ field: 'tagValue', order: 'ASC' }}
filter={{ tag_name: 'recordlabel' }}
filterToQuery={(searchText) => ({
tag_value: [searchText],
})}
>
<AutocompleteInput emptyText="-- None --" optionText="tagValue" />
</ReferenceInput>
<ReferenceArrayInput
label={translate('resources.album.fields.grouping')}
source="grouping"
reference="tag"
perPage={0}
sort={{ field: 'tagValue', order: 'ASC' }}
filter={{ tag_name: 'grouping' }}
filterToQuery={(searchText) => ({
tag_value: [searchText],
})}
>
<AutocompleteArrayInput
emptyText="-- None --"
classes={classes}
optionText="tagValue"
/>
</ReferenceArrayInput>
<ReferenceArrayInput
label={translate('resources.album.fields.mood')}
source="mood"
reference="tag"
perPage={0}
sort={{ field: 'tagValue', order: 'ASC' }}
filter={{ tag_name: 'mood' }}
filterToQuery={(searchText) => ({
tag_value: [searchText],
})}
>
<AutocompleteArrayInput
emptyText="-- None --"
classes={classes}
optionText="tagValue"
/>
</ReferenceArrayInput>
<ReferenceInput
label={translate('resources.album.fields.media')}
source="media"
reference="tag"
perPage={0}
sort={{ field: 'tagValue', order: 'ASC' }}
filter={{ tag_name: 'media' }}
filterToQuery={(searchText) => ({
tag_value: [searchText],
})}
>
<AutocompleteInput emptyText="-- None --" optionText="tagValue" />
</ReferenceInput>
<ReferenceInput
label={translate('resources.album.fields.releaseType')}
source="releasetype"
reference="tag"
perPage={0}
sort={{ field: 'tagValue', order: 'ASC' }}
filter={{ tag_name: 'releasetype' }}
filterToQuery={(searchText) => ({
tag_value: [searchText],
})}
>
<AutocompleteInput
emptyText="-- None --"
optionText={(record) =>
record?.tagValue
? inflection.humanize(record?.tagValue)
: '-- None --'
}
/>
</ReferenceInput>
<NullableBooleanInput source="compilation" />
<NumberInput source="year" />
+2 -2
View File
@@ -107,13 +107,13 @@ const AlbumSongs = (props) => {
showTrackNumbers={!isDesktop}
/>
),
artist: isDesktop && <ArtistLinkField source="artist" />,
artist: isDesktop && <ArtistLinkField source="artist" sortable={false} />,
duration: <DurationField source="duration" sortable={false} />,
year: isDesktop && (
<FunctionField
source="year"
render={(r) => r.year || ''}
sortByOrder={'DESC'}
sortable={false}
/>
),
playCount: isDesktop && (
+14 -2
View File
@@ -23,6 +23,7 @@ import {
} from '../common'
import config from '../config'
import { DraggableTypes } from '../consts'
import clsx from 'clsx'
const useStyles = makeStyles({
columnIcon: {
@@ -40,6 +41,9 @@ const useStyles = makeStyles({
},
},
},
missingRow: {
opacity: 0.3,
},
tableCell: {
width: '17.5%',
},
@@ -52,7 +56,8 @@ const useStyles = makeStyles({
})
const AlbumDatagridRow = (props) => {
const { record } = props
const { record, className } = props
const classes = useStyles()
const [, dragAlbumRef] = useDrag(
() => ({
type: DraggableTypes.ALBUM,
@@ -61,7 +66,14 @@ const AlbumDatagridRow = (props) => {
}),
[record],
)
return <DatagridRow ref={dragAlbumRef} {...props} />
const computedClasses = clsx(
className,
classes.row,
record.missing && classes.missingRow,
)
return (
<DatagridRow ref={dragAlbumRef} {...props} className={computedClasses} />
)
}
const AlbumDatagridBody = (props) => (
+50 -29
View File
@@ -1,14 +1,14 @@
import React, { useMemo } from 'react'
import { useMemo } from 'react'
import { useHistory } from 'react-router-dom'
import {
AutocompleteInput,
Datagrid,
DatagridBody,
DatagridRow,
Filter,
FunctionField,
NumberField,
ReferenceInput,
SearchInput,
SelectInput,
TextField,
useTranslate,
} from 'react-admin'
@@ -22,15 +22,16 @@ import {
List,
QuickFilter,
useGetHandleArtistClick,
ArtistSimpleList,
RatingField,
useSelectedFields,
useResourceRefresh,
SizeField,
} from '../common'
import config from '../config'
import ArtistListActions from './ArtistListActions'
import ArtistSimpleList from './ArtistSimpleList'
import { DraggableTypes } from '../consts'
import en from '../i18n/en.json'
import { formatBytes } from '../utils/index.js'
const useStyles = makeStyles({
contextHeader: {
@@ -58,19 +59,21 @@ const useStyles = makeStyles({
const ArtistFilter = (props) => {
const translate = useTranslate()
const rolesObj = en?.resources?.artist?.roles
const roles = Object.keys(rolesObj).reduce((acc, role) => {
acc.push({
id: role,
name: translate(`resources.artist.roles.${role}`, {
smart_count: 2,
}),
})
return acc
}, [])
roles?.sort((a, b) => a.name.localeCompare(b.name))
return (
<Filter {...props} variant={'outlined'}>
<SearchInput id="search" source="name" alwaysOn />
<ReferenceInput
label={translate('resources.artist.fields.genre')}
source="genre_id"
reference="genre"
perPage={0}
sort={{ field: 'name', order: 'ASC' }}
filterToQuery={(searchText) => ({ name: [searchText] })}
>
<AutocompleteInput emptyText="-- None --" />
</ReferenceInput>
<SelectInput source="role" choices={roles} alwaysOn />
{config.enableFavourites && (
<QuickFilter
source="starred"
@@ -104,17 +107,25 @@ const ArtistDatagrid = (props) => (
)
const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
const { filterValues } = rest
const classes = useStyles()
const handleArtistLink = useGetHandleArtistClick(width)
const history = useHistory()
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
useResourceRefresh('artist')
const toggleableFields = useMemo(() => {
return {
albumCount: <NumberField source="albumCount" sortByOrder={'DESC'} />,
songCount: <NumberField source="songCount" sortByOrder={'DESC'} />,
size: !isXsmall && <SizeField source="size" />,
const role = filterValues?.role
const getCounter = (record, counter) =>
role ? record?.stats[role]?.[counter] : record?.[counter]
const getAlbumCount = (record) => getCounter(record, 'albumCount')
const getSongCount = (record) => getCounter(record, 'songCount')
const getSize = (record) => {
const size = getCounter(record, 'size')
return size ? formatBytes(size) : '0 MB'
}
const toggleableFields = useMemo(
() => ({
playCount: <NumberField source="playCount" sortByOrder={'DESC'} />,
rating: config.enableStarRating && (
<RatingField
@@ -124,17 +135,15 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
className={classes.ratingField}
/>
),
}
}, [classes.ratingField, isXsmall])
const columns = useSelectedFields(
{
resource: 'artist',
columns: toggleableFields,
},
['size'],
}),
[classes.ratingField],
)
const columns = useSelectedFields({
resource: 'artist',
columns: toggleableFields,
})
return isXsmall ? (
<ArtistSimpleList
linkType={(id) => history.push(handleArtistLink(id))}
@@ -143,6 +152,17 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
) : (
<ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}>
<TextField source="name" />
<FunctionField
source="albumCount"
sortByOrder={'DESC'}
render={getAlbumCount}
/>
<FunctionField
source="songCount"
sortByOrder={'DESC'}
render={getSongCount}
/>
<FunctionField source="size" sortByOrder={'DESC'} render={getSize} />
{columns}
<ArtistContextMenu
source={'starred_at'}
@@ -171,6 +191,7 @@ const ArtistList = (props) => {
exporter={false}
bulkActionButtons={false}
filters={<ArtistFilter />}
filterDefaultValues={{ role: 'albumartist' }}
actions={<ArtistListActions />}
>
<ArtistListView {...props} />
@@ -7,7 +7,7 @@ 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 { ArtistContextMenu, RatingField } from '../common'
import config from '../config'
const useStyles = makeStyles(
@@ -26,7 +26,7 @@ const useStyles = makeStyles(
{ name: 'RaArtistSimpleList' },
)
export const ArtistSimpleList = ({
const ArtistSimpleList = ({
linkType,
className,
classes: classesOverride,
@@ -89,3 +89,5 @@ ArtistSimpleList.defaultProps = {
hasBulkActions: false,
selectedIds: [],
}
export default ArtistSimpleList
+4 -3
View File
@@ -35,6 +35,9 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
rgTrackPeak: song.rgTrackPeak,
}
const subtitle = song.tags?.['subtitle']
const title = song.title + (subtitle ? ` (${subtitle})` : '')
return (
<Link
to={
@@ -46,9 +49,7 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
ref={dragSongRef}
>
<span>
<span className={clsx(classes.songTitle, 'songTitle')}>
{song.title}
</span>
<span className={clsx(classes.songTitle, 'songTitle')}>{title}</span>
{isDesktop && (
<QualityInfo
record={qi}
+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',
}
+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'
+2
View File
@@ -33,6 +33,8 @@ const defaultConfig = {
enableReplayGain: true,
defaultDownsamplingFormat: 'opus',
publicBaseUrl: '/share',
separator: '/',
enableInspect: true,
}
let config
+16 -1
View File
@@ -4,6 +4,11 @@ import { REST_URL } from '../consts'
const dataProvider = jsonServerProvider(REST_URL, httpClient)
const isAdmin = () => {
const role = localStorage.getItem('role')
return role === 'admin'
}
const mapResource = (resource, params) => {
switch (resource) {
case 'playlistTrack': {
@@ -11,9 +16,19 @@ const mapResource = (resource, params) => {
let plsId = '0'
if (params.filter) {
plsId = params.filter.playlist_id
if (!isAdmin()) {
params.filter.missing = false
}
}
return [`playlist/${plsId}/tracks`, params]
}
case 'album':
case 'song': {
if (params.filter && !isAdmin()) {
params.filter.missing = false
}
return [resource, params]
}
default:
return [resource, params]
}
@@ -63,7 +78,7 @@ const wrapperDataProvider = {
},
deleteMany: (resource, params) => {
const [r, p] = mapResource(resource, params)
if (r.endsWith('/tracks')) {
if (r.endsWith('/tracks') || resource === 'missing') {
return callDeleteMany(r, p)
}
return dataProvider.deleteMany(r, p)
+1 -1
View File
@@ -27,7 +27,7 @@ const ExpandInfoDialog = ({ title, content }) => {
onClose={handleClose}
aria-labelledby="info-dialog-album"
fullWidth={true}
maxWidth={'sm'}
maxWidth={'md'}
>
<DialogTitle id="info-dialog-album">
{translate(title || 'resources.song.actions.info')}
+49 -4
View File
@@ -26,7 +26,13 @@
"quality": "Quality",
"bpm": "BPM",
"playDate": "Last Played",
"createdAt": "Date added"
"createdAt": "Date added",
"grouping": "Grouping",
"mood": "Mood",
"participants": "Additional participants",
"tags": "Additional Tags",
"mappedTags": "Mapped tags",
"rawTags": "Raw tags"
},
"actions": {
"addToQueue": "Play Later",
@@ -35,7 +41,8 @@
"shuffleAll": "Shuffle All",
"download": "Download",
"playNext": "Play Next",
"info": "Get Info"
"info": "Get Info",
"inspect": "Show tag mapping"
}
},
"album": {
@@ -58,7 +65,13 @@
"updatedAt": "Updated at",
"comment": "Comment",
"rating": "Rating",
"createdAt": "Date added"
"createdAt": "Date added",
"recordLabel": "Label",
"catalogNum": "Catalog Number",
"releaseType": "Type",
"grouping": "Grouping",
"media": "Media",
"mood": "Mood"
},
"actions": {
"playAll": "Play",
@@ -89,7 +102,23 @@
"size": "Size",
"playCount": "Plays",
"rating": "Rating",
"genre": "Genre"
"genre": "Genre",
"role": "Role"
},
"roles": {
"albumartist": "Album Artist |||| Album Artists",
"artist": "Artist |||| Artists",
"composer": "Composer |||| Composers",
"conductor": "Conductor |||| Conductors",
"lyricist": "Lyricist |||| Lyricists",
"arranger": "Arranger |||| Arrangers",
"producer": "Producer |||| Producers",
"director": "Director |||| Directors",
"engineer": "Engineer |||| Engineers",
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
"performer": "Performer |||| Performers"
}
},
"user": {
@@ -200,6 +229,20 @@
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "Missing File|||| Missing Files",
"fields": {
"path": "Path",
"size": "Size",
"updatedAt": "Disappeared on"
},
"actions": {
"remove": "Remove"
},
"notifications": {
"removed": "Missing file(s) removed"
}
}
},
"ra": {
@@ -355,6 +398,8 @@
"noPlaylistsAvailable": "None available",
"delete_user_title": "Delete user '%{name}'",
"delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?",
"remove_missing_title": "Remove missing files",
"remove_missing_content": "Are you sure you want to remove the selected missing files from the database? This will remove permanently any references to them, including their play counts and ratings.",
"notifications_blocked": "You have blocked Notifications for this site in your browser's settings",
"notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https",
"lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
@@ -0,0 +1,78 @@
import React, { useState } from 'react'
import DeleteIcon from '@material-ui/icons/Delete'
import { makeStyles } from '@material-ui/core/styles'
import { fade } from '@material-ui/core/styles/colorManipulator'
import clsx from 'clsx'
import {
Button,
Confirm,
useNotify,
useDeleteMany,
useRefresh,
useUnselectAll,
} from 'react-admin'
const useStyles = makeStyles(
(theme) => ({
deleteButton: {
color: theme.palette.error.main,
'&:hover': {
backgroundColor: fade(theme.palette.error.main, 0.12),
// Reset on mouse devices
'@media (hover: none)': {
backgroundColor: 'transparent',
},
},
},
}),
{ name: 'RaDeleteWithConfirmButton' },
)
const DeleteMissingFilesButton = (props) => {
const { selectedIds, className } = props
const [open, setOpen] = useState(false)
const unselectAll = useUnselectAll()
const refresh = useRefresh()
const notify = useNotify()
const [deleteMany, { loading }] = useDeleteMany('missing', selectedIds, {
onSuccess: () => {
notify('resources.missing.notifications.removed')
refresh()
unselectAll('missing')
},
onFailure: (error) =>
notify('Error: missing files not deleted', { type: 'warning' }),
})
const handleClick = () => setOpen(true)
const handleDialogClose = () => setOpen(false)
const handleConfirm = () => {
deleteMany()
setOpen(false)
}
const classes = useStyles(props)
return (
<>
<Button
onClick={handleClick}
label="ra.action.remove"
key="button"
className={clsx('ra-delete-button', classes.deleteButton, className)}
>
<DeleteIcon />
</Button>
<Confirm
isOpen={open}
loading={loading}
title="message.remove_missing_title"
content="message.remove_missing_content"
onConfirm={handleConfirm}
onClose={handleDialogClose}
/>
</>
)
}
export default DeleteMissingFilesButton
+51
View File
@@ -0,0 +1,51 @@
import { List, SizeField } from '../common/index.js'
import {
Datagrid,
DateField,
TextField,
downloadCSV,
Pagination,
} from 'react-admin'
import jsonExport from 'jsonexport/dist'
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
const exporter = (files) => {
const filesToExport = files.map((file) => {
const { path } = file
return { path }
})
jsonExport(filesToExport, { includeHeaders: false }, (err, csv) => {
downloadCSV(csv, 'navidrome_missing_files')
})
}
const BulkActionButtons = (props) => (
<>
<DeleteMissingFilesButton {...props} />
</>
)
const MissingPagination = (props) => (
<Pagination rowsPerPageOptions={[50, 100, 200]} {...props} />
)
const MissingFilesList = (props) => {
return (
<List
{...props}
sort={{ field: 'updated_at', order: 'DESC' }}
exporter={exporter}
bulkActionButtons={<BulkActionButtons />}
perPage={50}
pagination={<MissingPagination />}
>
<Datagrid>
<TextField source={'path'} />
<SizeField source={'size'} />
<DateField source={'updatedAt'} showTime />
</Datagrid>
</List>
)
}
export default MissingFilesList
+6
View File
@@ -0,0 +1,6 @@
import { GrDocumentMissing } from 'react-icons/gr'
import MissingList from './MissingFilesList'
export default {
list: MissingList,
icon: GrDocumentMissing,
}
+2
View File
@@ -124,6 +124,7 @@ export const downloadMenuDialogReducer = (
export const expandInfoDialogReducer = (
previousState = {
open: false,
record: undefined,
},
payload,
) => {
@@ -139,6 +140,7 @@ export const expandInfoDialogReducer = (
return {
...previousState,
open: false,
record: undefined,
}
default:
return previousState
+17 -8
View File
@@ -1,15 +1,24 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Link } from 'react-admin'
import { useDispatch } from 'react-redux'
import { closeExtendedInfoDialog } from '../actions'
export const AlbumLinkField = (props) => (
<Link
to={`/album/${props.record.albumId}/show`}
onClick={(e) => e.stopPropagation()}
>
{props.record.album}
</Link>
)
export const AlbumLinkField = (props) => {
const dispatch = useDispatch()
return (
<Link
to={`/album/${props.record.albumId}/show`}
onClick={(e) => {
e.stopPropagation()
dispatch(closeExtendedInfoDialog())
}}
>
{props.record.album}
</Link>
)
}
AlbumLinkField.propTypes = {
sortBy: PropTypes.string,
+48 -8
View File
@@ -1,10 +1,10 @@
import React from 'react'
import { useMemo } from 'react'
import {
AutocompleteInput,
AutocompleteArrayInput,
Filter,
FunctionField,
NumberField,
ReferenceInput,
ReferenceArrayInput,
SearchInput,
TextField,
useTranslate,
@@ -24,6 +24,7 @@ import {
RatingField,
useResourceRefresh,
ArtistLinkField,
PathField,
} from '../common'
import { useDispatch } from 'react-redux'
import { makeStyles } from '@material-ui/core/styles'
@@ -57,14 +58,19 @@ const useStyles = makeStyles({
ratingField: {
visibility: 'hidden',
},
chip: {
margin: 0,
height: '24px',
},
})
const SongFilter = (props) => {
const classes = useStyles()
const translate = useTranslate()
return (
<Filter {...props} variant={'outlined'}>
<SearchInput source="title" alwaysOn />
<ReferenceInput
<ReferenceArrayInput
label={translate('resources.song.fields.genre')}
source="genre_id"
reference="genre"
@@ -72,8 +78,42 @@ const SongFilter = (props) => {
sort={{ field: 'name', order: 'ASC' }}
filterToQuery={(searchText) => ({ name: [searchText] })}
>
<AutocompleteInput emptyText="-- None --" />
</ReferenceInput>
<AutocompleteArrayInput emptyText="-- None --" classes={classes} />
</ReferenceArrayInput>
<ReferenceArrayInput
label={translate('resources.song.fields.grouping')}
source="grouping"
reference="tag"
perPage={0}
sort={{ field: 'tagValue', order: 'ASC' }}
filter={{ tag_name: 'grouping' }}
filterToQuery={(searchText) => ({
tag_value: [searchText],
})}
>
<AutocompleteArrayInput
emptyText="-- None --"
classes={classes}
optionText="tagValue"
/>
</ReferenceArrayInput>
<ReferenceArrayInput
label={translate('resources.song.fields.mood')}
source="mood"
reference="tag"
perPage={0}
sort={{ field: 'tagValue', order: 'ASC' }}
filter={{ tag_name: 'mood' }}
filterToQuery={(searchText) => ({
tag_value: [searchText],
})}
>
<AutocompleteArrayInput
emptyText="-- None --"
classes={classes}
optionText="tagValue"
/>
</ReferenceArrayInput>
{config.enableFavourites && (
<QuickFilter
source="starred"
@@ -96,7 +136,7 @@ const SongList = (props) => {
dispatch(setTrack(record))
}
const toggleableFields = React.useMemo(() => {
const toggleableFields = useMemo(() => {
return {
album: isDesktop && <AlbumLinkField source="album" sortByOrder={'ASC'} />,
artist: <ArtistLinkField source="artist" />,
@@ -129,7 +169,7 @@ const SongList = (props) => {
bpm: isDesktop && <NumberField source="bpm" />,
genre: <TextField source="genre" />,
comment: <TextField source="comment" />,
path: <TextField source="path" />,
path: <PathField source="path" />,
createdAt: <DateField source="createdAt" showTime />,
}
}, [isDesktop, classes.ratingField])
+1 -1
View File
@@ -62,7 +62,7 @@ const getCoverArtUrl = (record, size, square) => {
// TODO Move this logic to server. `song` and `album` should have a CoverArtID
if (record.album) {
return baseUrl(url('getCoverArt', 'mf-' + record.id, options))
} else if (record.artist) {
} else if (record.albumArtist) {
return baseUrl(url('getCoverArt', 'al-' + record.id, options))
} else {
return baseUrl(url('getCoverArt', 'ar-' + record.id, options))