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:
@@ -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" />,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const LoveButton = ({
|
||||
<Button
|
||||
onClick={handleToggleLove}
|
||||
size={'small'}
|
||||
disabled={disabled || loading}
|
||||
disabled={disabled || loading || record?.missing}
|
||||
className={classes.love}
|
||||
{...rest}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export const RatingField = ({
|
||||
)}
|
||||
value={rating}
|
||||
size={size}
|
||||
disabled={record?.missing}
|
||||
emptyIcon={<StarBorderIcon fontSize="inherit" />}
|
||||
onChange={(e, newValue) => handleRating(e, newValue)}
|
||||
/>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -33,6 +33,8 @@ const defaultConfig = {
|
||||
enableReplayGain: true,
|
||||
defaultDownsamplingFormat: 'opus',
|
||||
publicBaseUrl: '/share',
|
||||
separator: '/',
|
||||
enableInspect: true,
|
||||
}
|
||||
|
||||
let config
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
import { GrDocumentMissing } from 'react-icons/gr'
|
||||
import MissingList from './MissingFilesList'
|
||||
export default {
|
||||
list: MissingList,
|
||||
icon: GrDocumentMissing,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user