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:
@@ -0,0 +1,70 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// These are the legacy ID functions that were used in the original Navidrome ID generation.
|
||||
// They are kept here for backwards compatibility with existing databases.
|
||||
|
||||
func legacyTrackID(mf model.MediaFile) string {
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(mf.Path)))
|
||||
}
|
||||
|
||||
func legacyAlbumID(md Metadata) string {
|
||||
releaseDate := legacyReleaseDate(md)
|
||||
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
|
||||
if !conf.Server.Scanner.GroupAlbumReleases {
|
||||
if len(releaseDate) != 0 {
|
||||
albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
|
||||
}
|
||||
|
||||
func legacyMapAlbumArtistName(md Metadata) string {
|
||||
values := []string{
|
||||
md.String(model.TagAlbumArtist),
|
||||
"",
|
||||
md.String(model.TagTrackArtist),
|
||||
consts.UnknownArtist,
|
||||
}
|
||||
if md.Bool(model.TagCompilation) {
|
||||
values[1] = consts.VariousArtists
|
||||
}
|
||||
return cmp.Or(values...)
|
||||
}
|
||||
|
||||
func legacyMapAlbumName(md Metadata) string {
|
||||
return cmp.Or(
|
||||
md.String(model.TagAlbum),
|
||||
consts.UnknownAlbum,
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the TaggedLikePicard logic for backwards compatibility
|
||||
func legacyReleaseDate(md Metadata) string {
|
||||
// Start with defaults
|
||||
date := md.Date(model.TagRecordingDate)
|
||||
year := date.Year()
|
||||
originalDate := md.Date(model.TagOriginalDate)
|
||||
originalYear := originalDate.Year()
|
||||
releaseDate := md.Date(model.TagReleaseDate)
|
||||
releaseYear := releaseDate.Year()
|
||||
|
||||
// MusicBrainz Picard writes the Release Date of an album to the Date tag, and leaves the Release Date tag empty
|
||||
taggedLikePicard := (originalYear != 0) &&
|
||||
(releaseYear == 0) &&
|
||||
(year >= originalYear)
|
||||
if taggedLikePicard {
|
||||
return string(date)
|
||||
}
|
||||
return string(releaseDate)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
mf := model.MediaFile{
|
||||
LibraryID: libID,
|
||||
FolderID: folderID,
|
||||
Tags: maps.Clone(md.tags),
|
||||
}
|
||||
|
||||
// Title and Album
|
||||
mf.Title = md.mapTrackTitle()
|
||||
mf.Album = md.mapAlbumName()
|
||||
mf.SortTitle = md.String(model.TagTitleSort)
|
||||
mf.SortAlbumName = md.String(model.TagAlbumSort)
|
||||
mf.OrderTitle = str.SanitizeFieldForSorting(mf.Title)
|
||||
mf.OrderAlbumName = str.SanitizeFieldForSortingNoArticle(mf.Album)
|
||||
mf.Compilation = md.Bool(model.TagCompilation)
|
||||
|
||||
// Disc and Track info
|
||||
mf.TrackNumber, _ = md.NumAndTotal(model.TagTrackNumber)
|
||||
mf.DiscNumber, _ = md.NumAndTotal(model.TagDiscNumber)
|
||||
mf.DiscSubtitle = md.String(model.TagDiscSubtitle)
|
||||
mf.CatalogNum = md.String(model.TagCatalogNumber)
|
||||
mf.Comment = md.String(model.TagComment)
|
||||
mf.BPM = int(math.Round(md.Float(model.TagBPM)))
|
||||
mf.Lyrics = md.mapLyrics()
|
||||
mf.ExplicitStatus = md.mapExplicitStatusTag()
|
||||
|
||||
// Dates
|
||||
origDate := md.Date(model.TagOriginalDate)
|
||||
mf.OriginalYear, mf.OriginalDate = origDate.Year(), string(origDate)
|
||||
relDate := md.Date(model.TagReleaseDate)
|
||||
mf.ReleaseYear, mf.ReleaseDate = relDate.Year(), string(relDate)
|
||||
date := md.Date(model.TagRecordingDate)
|
||||
mf.Year, mf.Date = date.Year(), string(date)
|
||||
|
||||
// MBIDs
|
||||
mf.MbzRecordingID = md.String(model.TagMusicBrainzRecordingID)
|
||||
mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID)
|
||||
mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID)
|
||||
mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID)
|
||||
|
||||
// ReplayGain
|
||||
mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1)
|
||||
mf.RGAlbumGain = md.mapGain(model.TagReplayGainAlbumGain, model.TagR128AlbumGain)
|
||||
mf.RGTrackPeak = md.Float(model.TagReplayGainTrackPeak, 1)
|
||||
mf.RGTrackGain = md.mapGain(model.TagReplayGainTrackGain, model.TagR128TrackGain)
|
||||
|
||||
// General properties
|
||||
mf.HasCoverArt = md.HasPicture()
|
||||
mf.Duration = md.Length()
|
||||
mf.BitRate = md.AudioProperties().BitRate
|
||||
mf.SampleRate = md.AudioProperties().SampleRate
|
||||
mf.BitDepth = md.AudioProperties().BitDepth
|
||||
mf.Channels = md.AudioProperties().Channels
|
||||
mf.Path = md.FilePath()
|
||||
mf.Suffix = md.Suffix()
|
||||
mf.Size = md.Size()
|
||||
mf.BirthTime = md.BirthTime()
|
||||
mf.UpdatedAt = md.ModTime()
|
||||
|
||||
mf.Participants = md.mapParticipants()
|
||||
mf.Artist = md.mapDisplayArtist(mf)
|
||||
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
|
||||
|
||||
// Persistent IDs
|
||||
mf.PID = md.trackPID(mf)
|
||||
mf.AlbumID = md.albumID(mf)
|
||||
|
||||
// BFR These IDs will go away once the UI handle multiple participants.
|
||||
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers
|
||||
mf.ArtistID = mf.Participants.First(model.RoleArtist).ID
|
||||
mf.AlbumArtistID = mf.Participants.First(model.RoleAlbumArtist).ID
|
||||
|
||||
// BFR What to do with sort/order artist names?
|
||||
mf.OrderArtistName = mf.Participants.First(model.RoleArtist).OrderArtistName
|
||||
mf.OrderAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).OrderArtistName
|
||||
mf.SortArtistName = mf.Participants.First(model.RoleArtist).SortArtistName
|
||||
mf.SortAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).SortArtistName
|
||||
|
||||
// Don't store tags that are first-class fields (and are not album-level tags) in the
|
||||
// MediaFile struct. This is to avoid redundancy in the DB
|
||||
//
|
||||
// Remove all tags from the main section that are not flagged as album tags
|
||||
for tag, conf := range model.TagMainMappings() {
|
||||
if !conf.Album {
|
||||
delete(mf.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
return mf
|
||||
}
|
||||
|
||||
func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string {
|
||||
getPID := createGetPID(id.NewHash)
|
||||
return getPID(mf, md, pidConf)
|
||||
}
|
||||
|
||||
func (md Metadata) mapGain(rg, r128 model.TagName) float64 {
|
||||
v := md.Gain(rg)
|
||||
if v != 0 {
|
||||
return v
|
||||
}
|
||||
r128value := md.String(r128)
|
||||
if r128value != "" {
|
||||
var v, err = strconv.Atoi(r128value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
// Convert Q7.8 to float
|
||||
var value = float64(v) / 256.0
|
||||
// Adding 5 dB to normalize with ReplayGain level
|
||||
return value + 5
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (md Metadata) mapLyrics() string {
|
||||
rawLyrics := md.Pairs(model.TagLyrics)
|
||||
|
||||
lyricList := make(model.LyricList, 0, len(rawLyrics))
|
||||
|
||||
for _, raw := range rawLyrics {
|
||||
lang := raw.Key()
|
||||
text := raw.Value()
|
||||
|
||||
lyrics, err := model.ToLyrics(lang, text)
|
||||
if err != nil {
|
||||
log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err)
|
||||
continue
|
||||
}
|
||||
if !lyrics.IsEmpty() {
|
||||
lyricList = append(lyricList, *lyrics)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := json.Marshal(lyricList)
|
||||
if err != nil {
|
||||
log.Warn("Unexpected error occurred when serializing lyrics", "file", md.filePath, err)
|
||||
return ""
|
||||
}
|
||||
return string(res)
|
||||
}
|
||||
|
||||
func (md Metadata) mapExplicitStatusTag() string {
|
||||
switch md.first(model.TagExplicitStatus) {
|
||||
case "1", "4":
|
||||
return "e"
|
||||
case "2":
|
||||
return "c"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package metadata_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ToMediaFile", func() {
|
||||
var (
|
||||
props metadata.Info
|
||||
md metadata.Metadata
|
||||
mf model.MediaFile
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
_, filePath, _ := tests.TempFile(GinkgoT(), "test", ".mp3")
|
||||
fileInfo, _ := os.Stat(filePath)
|
||||
props = metadata.Info{
|
||||
FileInfo: testFileInfo{fileInfo},
|
||||
}
|
||||
})
|
||||
|
||||
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
||||
props.Tags = tags
|
||||
md = metadata.New("filepath", props)
|
||||
return md.ToMediaFile(1, "folderID")
|
||||
}
|
||||
|
||||
Describe("Dates", func() {
|
||||
It("should parse the dates like Picard", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ORIGINALDATE": {"1978-09-10"},
|
||||
"DATE": {"1977-03-04"},
|
||||
"RELEASEDATE": {"2002-01-02"},
|
||||
})
|
||||
|
||||
Expect(mf.Year).To(Equal(1977))
|
||||
Expect(mf.Date).To(Equal("1977-03-04"))
|
||||
Expect(mf.OriginalYear).To(Equal(1978))
|
||||
Expect(mf.OriginalDate).To(Equal("1978-09-10"))
|
||||
Expect(mf.ReleaseYear).To(Equal(2002))
|
||||
Expect(mf.ReleaseDate).To(Equal("2002-01-02"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Lyrics", func() {
|
||||
It("should parse the lyrics", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"LYRICS:XXX": {"Lyrics"},
|
||||
"LYRICS:ENG": {
|
||||
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||
},
|
||||
})
|
||||
var actual model.LyricList
|
||||
err := json.Unmarshal([]byte(mf.Lyrics), &actual)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
expected := model.LyricList{
|
||||
{Lang: "eng", Line: []model.Line{
|
||||
{Value: "This is", Start: P(int64(0))},
|
||||
{Value: "English SYLT", Start: P(int64(2500))},
|
||||
}, Synced: true},
|
||||
{Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false},
|
||||
}
|
||||
sort.Slice(actual, func(i, j int) bool { return actual[i].Lang < actual[j].Lang })
|
||||
sort.Slice(expected, func(i, j int) bool { return expected[i].Lang < expected[j].Lang })
|
||||
Expect(actual).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type roleTags struct {
|
||||
name model.TagName
|
||||
sort model.TagName
|
||||
mbid model.TagName
|
||||
}
|
||||
|
||||
var roleMappings = map[model.Role]roleTags{
|
||||
model.RoleComposer: {name: model.TagComposer, sort: model.TagComposerSort, mbid: model.TagMusicBrainzComposerID},
|
||||
model.RoleLyricist: {name: model.TagLyricist, sort: model.TagLyricistSort, mbid: model.TagMusicBrainzLyricistID},
|
||||
model.RoleConductor: {name: model.TagConductor, mbid: model.TagMusicBrainzConductorID},
|
||||
model.RoleArranger: {name: model.TagArranger, mbid: model.TagMusicBrainzArrangerID},
|
||||
model.RoleDirector: {name: model.TagDirector, mbid: model.TagMusicBrainzDirectorID},
|
||||
model.RoleProducer: {name: model.TagProducer, mbid: model.TagMusicBrainzProducerID},
|
||||
model.RoleEngineer: {name: model.TagEngineer, mbid: model.TagMusicBrainzEngineerID},
|
||||
model.RoleMixer: {name: model.TagMixer, mbid: model.TagMusicBrainzMixerID},
|
||||
model.RoleRemixer: {name: model.TagRemixer, mbid: model.TagMusicBrainzRemixerID},
|
||||
model.RoleDJMixer: {name: model.TagDJMixer, mbid: model.TagMusicBrainzDJMixerID},
|
||||
}
|
||||
|
||||
func (md Metadata) mapParticipants() model.Participants {
|
||||
participants := make(model.Participants)
|
||||
|
||||
// Parse track artists
|
||||
artists := md.parseArtists(
|
||||
model.TagTrackArtist, model.TagTrackArtists,
|
||||
model.TagTrackArtistSort, model.TagTrackArtistsSort,
|
||||
model.TagMusicBrainzArtistID,
|
||||
)
|
||||
participants.Add(model.RoleArtist, artists...)
|
||||
|
||||
// Parse album artists
|
||||
albumArtists := md.parseArtists(
|
||||
model.TagAlbumArtist, model.TagAlbumArtists,
|
||||
model.TagAlbumArtistSort, model.TagAlbumArtistsSort,
|
||||
model.TagMusicBrainzAlbumArtistID,
|
||||
)
|
||||
if len(albumArtists) == 1 && albumArtists[0].Name == consts.UnknownArtist {
|
||||
if md.Bool(model.TagCompilation) {
|
||||
albumArtists = md.buildArtists([]string{consts.VariousArtists}, nil, []string{consts.VariousArtistsMbzId})
|
||||
} else {
|
||||
albumArtists = artists
|
||||
}
|
||||
}
|
||||
participants.Add(model.RoleAlbumArtist, albumArtists...)
|
||||
|
||||
// Parse all other roles
|
||||
for role, info := range roleMappings {
|
||||
names := md.getRoleValues(info.name)
|
||||
if len(names) > 0 {
|
||||
sorts := md.Strings(info.sort)
|
||||
mbids := md.Strings(info.mbid)
|
||||
artists := md.buildArtists(names, sorts, mbids)
|
||||
participants.Add(role, artists...)
|
||||
}
|
||||
}
|
||||
|
||||
rolesMbzIdMap := md.buildRoleMbidMaps()
|
||||
md.processPerformers(participants, rolesMbzIdMap)
|
||||
md.syncMissingMbzIDs(participants)
|
||||
|
||||
return participants
|
||||
}
|
||||
|
||||
// buildRoleMbidMaps creates a map of roles to MBZ IDs
|
||||
func (md Metadata) buildRoleMbidMaps() map[string][]string {
|
||||
titleCaser := cases.Title(language.Und)
|
||||
rolesMbzIdMap := make(map[string][]string)
|
||||
for _, mbid := range md.Pairs(model.TagMusicBrainzPerformerID) {
|
||||
role := titleCaser.String(mbid.Key())
|
||||
rolesMbzIdMap[role] = append(rolesMbzIdMap[role], mbid.Value())
|
||||
}
|
||||
|
||||
return rolesMbzIdMap
|
||||
}
|
||||
|
||||
func (md Metadata) processPerformers(participants model.Participants, rolesMbzIdMap map[string][]string) {
|
||||
// roleIdx keeps track of the index of the MBZ ID for each role
|
||||
roleIdx := make(map[string]int)
|
||||
for role := range rolesMbzIdMap {
|
||||
roleIdx[role] = 0
|
||||
}
|
||||
|
||||
titleCaser := cases.Title(language.Und)
|
||||
for _, performer := range md.Pairs(model.TagPerformer) {
|
||||
name := performer.Value()
|
||||
subRole := titleCaser.String(performer.Key())
|
||||
|
||||
artist := model.Artist{
|
||||
ID: md.artistID(name),
|
||||
Name: name,
|
||||
OrderArtistName: str.SanitizeFieldForSortingNoArticle(name),
|
||||
MbzArtistID: md.getPerformerMbid(subRole, rolesMbzIdMap, roleIdx),
|
||||
}
|
||||
participants.AddWithSubRole(model.RolePerformer, subRole, artist)
|
||||
}
|
||||
}
|
||||
|
||||
// getPerformerMbid returns the MBZ ID for a performer, based on the subrole
|
||||
func (md Metadata) getPerformerMbid(subRole string, rolesMbzIdMap map[string][]string, roleIdx map[string]int) string {
|
||||
if mbids, exists := rolesMbzIdMap[subRole]; exists && roleIdx[subRole] < len(mbids) {
|
||||
defer func() { roleIdx[subRole]++ }()
|
||||
return mbids[roleIdx[subRole]]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// syncMissingMbzIDs fills in missing MBZ IDs for artists that have been previously parsed
|
||||
func (md Metadata) syncMissingMbzIDs(participants model.Participants) {
|
||||
artistMbzIDMap := make(map[string]string)
|
||||
for _, artist := range append(participants[model.RoleArtist], participants[model.RoleAlbumArtist]...) {
|
||||
if artist.MbzArtistID != "" {
|
||||
artistMbzIDMap[artist.Name] = artist.MbzArtistID
|
||||
}
|
||||
}
|
||||
|
||||
for role, list := range participants {
|
||||
for i, artist := range list {
|
||||
if artist.MbzArtistID == "" {
|
||||
if mbzID, exists := artistMbzIDMap[artist.Name]; exists {
|
||||
participants[role][i].MbzArtistID = mbzID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (md Metadata) parseArtists(
|
||||
name model.TagName, names model.TagName, sort model.TagName,
|
||||
sorts model.TagName, mbid model.TagName,
|
||||
) []model.Artist {
|
||||
nameValues := md.getArtistValues(name, names)
|
||||
sortValues := md.getArtistValues(sort, sorts)
|
||||
mbids := md.Strings(mbid)
|
||||
if len(nameValues) == 0 {
|
||||
nameValues = []string{consts.UnknownArtist}
|
||||
}
|
||||
return md.buildArtists(nameValues, sortValues, mbids)
|
||||
}
|
||||
|
||||
func (md Metadata) buildArtists(names, sorts, mbids []string) []model.Artist {
|
||||
var artists []model.Artist
|
||||
for i, name := range names {
|
||||
id := md.artistID(name)
|
||||
artist := model.Artist{
|
||||
ID: id,
|
||||
Name: name,
|
||||
OrderArtistName: str.SanitizeFieldForSortingNoArticle(name),
|
||||
}
|
||||
if i < len(sorts) {
|
||||
artist.SortArtistName = sorts[i]
|
||||
}
|
||||
if i < len(mbids) {
|
||||
artist.MbzArtistID = mbids[i]
|
||||
}
|
||||
artists = append(artists, artist)
|
||||
}
|
||||
return artists
|
||||
}
|
||||
|
||||
// getRoleValues returns the values of a role tag, splitting them if necessary
|
||||
func (md Metadata) getRoleValues(role model.TagName) []string {
|
||||
values := md.Strings(role)
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
if conf := model.TagRolesConf(); len(conf.Split) > 0 {
|
||||
values = conf.SplitTagValue(values)
|
||||
return filterDuplicatedOrEmptyValues(values)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// getArtistValues returns the values of a single or multi artist tag, splitting them if necessary
|
||||
func (md Metadata) getArtistValues(single, multi model.TagName) []string {
|
||||
vMulti := md.Strings(multi)
|
||||
if len(vMulti) > 0 {
|
||||
return vMulti
|
||||
}
|
||||
vSingle := md.Strings(single)
|
||||
if len(vSingle) != 1 {
|
||||
return vSingle
|
||||
}
|
||||
if conf := model.TagArtistsConf(); len(conf.Split) > 0 {
|
||||
vSingle = conf.SplitTagValue(vSingle)
|
||||
return filterDuplicatedOrEmptyValues(vSingle)
|
||||
}
|
||||
return vSingle
|
||||
}
|
||||
|
||||
func (md Metadata) getTags(tagNames ...model.TagName) []string {
|
||||
for _, tagName := range tagNames {
|
||||
values := md.Strings(tagName)
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (md Metadata) mapDisplayRole(mf model.MediaFile, role model.Role, tagNames ...model.TagName) string {
|
||||
artistNames := md.getTags(tagNames...)
|
||||
values := []string{
|
||||
"",
|
||||
mf.Participants.First(role).Name,
|
||||
consts.UnknownArtist,
|
||||
}
|
||||
if len(artistNames) == 1 {
|
||||
values[0] = artistNames[0]
|
||||
}
|
||||
return cmp.Or(values...)
|
||||
}
|
||||
|
||||
func (md Metadata) mapDisplayArtist(mf model.MediaFile) string {
|
||||
return md.mapDisplayRole(mf, model.RoleArtist, model.TagTrackArtist, model.TagTrackArtists)
|
||||
}
|
||||
|
||||
func (md Metadata) mapDisplayAlbumArtist(mf model.MediaFile) string {
|
||||
return md.mapDisplayRole(mf, model.RoleAlbumArtist, model.TagAlbumArtist, model.TagAlbumArtists)
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
package metadata_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gstruct"
|
||||
"github.com/onsi/gomega/types"
|
||||
)
|
||||
|
||||
var _ = Describe("Participants", func() {
|
||||
var (
|
||||
props metadata.Info
|
||||
md metadata.Metadata
|
||||
mf model.MediaFile
|
||||
mbid1, mbid2, mbid3 string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
_, filePath, _ := tests.TempFile(GinkgoT(), "test", ".mp3")
|
||||
fileInfo, _ := os.Stat(filePath)
|
||||
mbid1 = uuid.NewString()
|
||||
mbid2 = uuid.NewString()
|
||||
mbid3 = uuid.NewString()
|
||||
props = metadata.Info{
|
||||
FileInfo: testFileInfo{fileInfo},
|
||||
}
|
||||
})
|
||||
|
||||
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
||||
props.Tags = tags
|
||||
md = metadata.New("filepath", props)
|
||||
return md.ToMediaFile(1, "folderID")
|
||||
}
|
||||
|
||||
Describe("ARTIST(S) tags", func() {
|
||||
Context("No ARTIST/ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{})
|
||||
})
|
||||
|
||||
It("should set artist to Unknown Artist", func() {
|
||||
Expect(mf.Artist).To(Equal("[Unknown Artist]"))
|
||||
})
|
||||
|
||||
It("should add an Unknown Artist to participants", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
|
||||
artist := participants[model.RoleArtist][0]
|
||||
Expect(artist.ID).ToNot(BeEmpty())
|
||||
Expect(artist.Name).To(Equal("[Unknown Artist]"))
|
||||
Expect(artist.OrderArtistName).To(Equal("[unknown artist]"))
|
||||
Expect(artist.SortArtistName).To(BeEmpty())
|
||||
Expect(artist.MbzArtistID).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Single-valued ARTIST tags, no ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name"},
|
||||
"ARTISTSORT": {"Name, Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the artist tag as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should populate the participants", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
|
||||
))
|
||||
Expect(mf.Artist).To(Equal("Artist Name"))
|
||||
|
||||
artist := participants[model.RoleArtist][0]
|
||||
|
||||
Expect(artist.ID).ToNot(BeEmpty())
|
||||
Expect(artist.Name).To(Equal("Artist Name"))
|
||||
Expect(artist.OrderArtistName).To(Equal("artist name"))
|
||||
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(artist.MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
Context("Multiple values in a Single-valued ARTIST tags, no ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name feat. Someone Else"},
|
||||
"ARTISTSORT": {"Name, Artist feat. Else, Someone"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should split the tag", func() {
|
||||
By("keeping the first artist as the display name")
|
||||
Expect(mf.Artist).To(Equal("Artist Name feat. Someone Else"))
|
||||
Expect(mf.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(mf.OrderArtistName).To(Equal("artist name"))
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
By("adding the first artist to the participants")
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("Artist Name"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("artist name"))
|
||||
Expect(artist0.SortArtistName).To(Equal("Name, Artist"))
|
||||
|
||||
By("assuming the MBID is for the first artist")
|
||||
Expect(artist0.MbzArtistID).To(Equal(mbid1))
|
||||
|
||||
By("adding the second artist to the participants")
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Someone Else"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("someone else"))
|
||||
Expect(artist1.SortArtistName).To(Equal("Else, Someone"))
|
||||
Expect(artist1.MbzArtistID).To(BeEmpty())
|
||||
})
|
||||
It("should split the tag using case-insensitive separators", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"A1 FEAT. A2"},
|
||||
})
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
artist1 := participants[model.RoleArtist][0]
|
||||
Expect(artist1.Name).To(Equal("A1"))
|
||||
artist2 := participants[model.RoleArtist][1]
|
||||
Expect(artist2.Name).To(Equal("A2"))
|
||||
})
|
||||
|
||||
It("should not add an empty artist after split", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"John Doe / / Jane Doe"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleArtist, HaveLen(2)))
|
||||
artists := participants[model.RoleArtist]
|
||||
Expect(artists[0].Name).To(Equal("John Doe"))
|
||||
Expect(artists[1].Name).To(Equal("Jane Doe"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Multi-valued ARTIST tags, no ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"First Artist", "Second Artist"},
|
||||
"ARTISTSORT": {"Name, First Artist", "Name, Second Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the first artist name as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist"))
|
||||
})
|
||||
|
||||
It("should populate the participants with all artists", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("First Artist"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("first artist"))
|
||||
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
|
||||
Expect(artist0.MbzArtistID).To(Equal(mbid1))
|
||||
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Second Artist"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("second artist"))
|
||||
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
|
||||
Expect(artist1.MbzArtistID).To(Equal(mbid2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Single-valued ARTIST tags, multi-valued ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"First Artist & Second Artist"},
|
||||
"ARTISTSORT": {"Name, First Artist & Name, Second Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
|
||||
"ARTISTS": {"First Artist", "Second Artist"},
|
||||
"ARTISTSSORT": {"Name, First Artist", "Name, Second Artist"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the single-valued tag as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist & Second Artist"))
|
||||
})
|
||||
|
||||
It("should prioritize multi-valued tags over single-valued tags", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("First Artist"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("first artist"))
|
||||
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
|
||||
Expect(artist0.MbzArtistID).To(Equal(mbid1))
|
||||
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Second Artist"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("second artist"))
|
||||
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
|
||||
Expect(artist1.MbzArtistID).To(Equal(mbid2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Multi-valued ARTIST tags, multi-valued ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"First Artist", "Second Artist"},
|
||||
"ARTISTSORT": {"Name, First Artist", "Name, Second Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
|
||||
"ARTISTS": {"First Artist 2", "Second Artist 2"},
|
||||
"ARTISTSSORT": {"2, First Artist Name", "2, Second Artist Name"},
|
||||
})
|
||||
})
|
||||
|
||||
XIt("should use the values concatenated as a display name ", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist + Second Artist"))
|
||||
})
|
||||
|
||||
// TODO: remove when the above is implemented
|
||||
It("should use the first artist name as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist 2"))
|
||||
})
|
||||
|
||||
It("should prioritize ARTISTS tags", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("First Artist 2"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("first artist 2"))
|
||||
Expect(artist0.SortArtistName).To(Equal("2, First Artist Name"))
|
||||
Expect(artist0.MbzArtistID).To(Equal(mbid1))
|
||||
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Second Artist 2"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("second artist 2"))
|
||||
Expect(artist1.SortArtistName).To(Equal("2, Second Artist Name"))
|
||||
Expect(artist1.MbzArtistID).To(Equal(mbid2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ALBUMARTIST(S) tags", func() {
|
||||
Context("No ALBUMARTIST/ALBUMARTISTS tags", func() {
|
||||
When("the COMPILATION tag is not set", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name"},
|
||||
"ARTISTSORT": {"Name, Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ARTIST as ALBUMARTIST", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should add the ARTIST to participants as ALBUMARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2))
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
albumArtist := participants[model.RoleAlbumArtist][0]
|
||||
Expect(albumArtist.ID).ToNot(BeEmpty())
|
||||
Expect(albumArtist.Name).To(Equal("Artist Name"))
|
||||
Expect(albumArtist.OrderArtistName).To(Equal("artist name"))
|
||||
Expect(albumArtist.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(albumArtist.MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
When("the COMPILATION tag is true", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPILATION": {"1"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the Various Artists as display name", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Various Artists"))
|
||||
})
|
||||
|
||||
It("should add the Various Artists to participants as ALBUMARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2))
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
albumArtist := participants[model.RoleAlbumArtist][0]
|
||||
Expect(albumArtist.ID).ToNot(BeEmpty())
|
||||
Expect(albumArtist.Name).To(Equal("Various Artists"))
|
||||
Expect(albumArtist.OrderArtistName).To(Equal("various artists"))
|
||||
Expect(albumArtist.SortArtistName).To(BeEmpty())
|
||||
Expect(albumArtist.MbzArtistID).To(Equal(consts.VariousArtistsMbzId))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("ALBUMARTIST tag is set", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Track Artist Name"},
|
||||
"ARTISTSORT": {"Name, Track Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
"ALBUMARTIST": {"Album Artist Name"},
|
||||
"ALBUMARTISTSORT": {"Album Artist Sort Name"},
|
||||
"MUSICBRAINZ_ALBUMARTISTID": {mbid2},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ALBUMARTIST as display name", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Album Artist Name"))
|
||||
})
|
||||
|
||||
It("should populate the participants with the ALBUMARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2))
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
albumArtist := participants[model.RoleAlbumArtist][0]
|
||||
Expect(albumArtist.ID).ToNot(BeEmpty())
|
||||
Expect(albumArtist.Name).To(Equal("Album Artist Name"))
|
||||
Expect(albumArtist.OrderArtistName).To(Equal("album artist name"))
|
||||
Expect(albumArtist.SortArtistName).To(Equal("Album Artist Sort Name"))
|
||||
Expect(albumArtist.MbzArtistID).To(Equal(mbid2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("COMPOSER and LYRICIST tags (with sort names)", func() {
|
||||
DescribeTable("should return the correct participation",
|
||||
func(role model.Role, nameTag, sortTag string) {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
nameTag: {"First Name", "Second Name"},
|
||||
sortTag: {"Name, First", "Name, Second"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(role, HaveLen(2)))
|
||||
|
||||
p := participants[role]
|
||||
Expect(p[0].ID).ToNot(BeEmpty())
|
||||
Expect(p[0].Name).To(Equal("First Name"))
|
||||
Expect(p[0].SortArtistName).To(Equal("Name, First"))
|
||||
Expect(p[0].OrderArtistName).To(Equal("first name"))
|
||||
Expect(p[1].ID).ToNot(BeEmpty())
|
||||
Expect(p[1].Name).To(Equal("Second Name"))
|
||||
Expect(p[1].SortArtistName).To(Equal("Name, Second"))
|
||||
Expect(p[1].OrderArtistName).To(Equal("second name"))
|
||||
},
|
||||
Entry("COMPOSER", model.RoleComposer, "COMPOSER", "COMPOSERSORT"),
|
||||
Entry("LYRICIST", model.RoleLyricist, "LYRICIST", "LYRICISTSORT"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("PERFORMER tags", func() {
|
||||
When("PERFORMER tag is set", func() {
|
||||
matchPerformer := func(name, orderName, subRole string) types.GomegaMatcher {
|
||||
return MatchFields(IgnoreExtras, Fields{
|
||||
"Artist": MatchFields(IgnoreExtras, Fields{
|
||||
"Name": Equal(name),
|
||||
"OrderArtistName": Equal(orderName),
|
||||
}),
|
||||
"SubRole": Equal(subRole),
|
||||
})
|
||||
}
|
||||
|
||||
It("should return the correct participation", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
|
||||
"PERFORMER:BASS": {"Nathan East"},
|
||||
"PERFORMER:HAMMOND ORGAN": {"Tim Carmon"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4)))
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Eric Clapton", "eric clapton", "Guitar"),
|
||||
matchPerformer("B.B. King", "b.b. king", "Guitar"),
|
||||
matchPerformer("Nathan East", "nathan east", "Bass"),
|
||||
matchPerformer("Tim Carmon", "tim carmon", "Hammond Organ"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Other tags", func() {
|
||||
DescribeTable("should return the correct participation",
|
||||
func(role model.Role, tag string) {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
tag: {"John Doe", "Jane Doe"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(role, HaveLen(2)))
|
||||
|
||||
p := participants[role]
|
||||
Expect(p[0].ID).ToNot(BeEmpty())
|
||||
Expect(p[0].Name).To(Equal("John Doe"))
|
||||
Expect(p[0].OrderArtistName).To(Equal("john doe"))
|
||||
Expect(p[1].ID).ToNot(BeEmpty())
|
||||
Expect(p[1].Name).To(Equal("Jane Doe"))
|
||||
Expect(p[1].OrderArtistName).To(Equal("jane doe"))
|
||||
},
|
||||
Entry("CONDUCTOR", model.RoleConductor, "CONDUCTOR"),
|
||||
Entry("ARRANGER", model.RoleArranger, "ARRANGER"),
|
||||
Entry("PRODUCER", model.RoleProducer, "PRODUCER"),
|
||||
Entry("ENGINEER", model.RoleEngineer, "ENGINEER"),
|
||||
Entry("MIXER", model.RoleMixer, "MIXER"),
|
||||
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
|
||||
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
|
||||
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
|
||||
// TODO PERFORMER
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Role value splitting", func() {
|
||||
When("the tag is single valued", func() {
|
||||
It("should split the values by the configured separator", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPOSER": {"John Doe/Someone Else/The Album Artist"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers[0].Name).To(Equal("John Doe"))
|
||||
Expect(composers[1].Name).To(Equal("Someone Else"))
|
||||
Expect(composers[2].Name).To(Equal("The Album Artist"))
|
||||
})
|
||||
It("should not add an empty participant after split", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPOSER": {"John Doe/"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(1)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers[0].Name).To(Equal("John Doe"))
|
||||
})
|
||||
It("should trim the values", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPOSER": {"John Doe / Someone Else / The Album Artist"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers[0].Name).To(Equal("John Doe"))
|
||||
Expect(composers[1].Name).To(Equal("Someone Else"))
|
||||
Expect(composers[2].Name).To(Equal("The Album Artist"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MBID tags", func() {
|
||||
It("should set the MBID for the artist based on the track/album artist", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"John Doe", "Jane Doe"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
|
||||
"ALBUMARTIST": {"The Album Artist"},
|
||||
"MUSICBRAINZ_ALBUMARTISTID": {mbid3},
|
||||
"COMPOSER": {"John Doe", "Someone Else", "The Album Artist"},
|
||||
"PRODUCER": {"Jane Doe", "John Doe"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers[0].MbzArtistID).To(Equal(mbid1))
|
||||
Expect(composers[1].MbzArtistID).To(BeEmpty())
|
||||
Expect(composers[2].MbzArtistID).To(Equal(mbid3))
|
||||
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleProducer, HaveLen(2)))
|
||||
producers := participants[model.RoleProducer]
|
||||
Expect(producers[0].MbzArtistID).To(Equal(mbid2))
|
||||
Expect(producers[1].MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Non-standard MBID tags", func() {
|
||||
var allMappings = map[model.Role]model.TagName{
|
||||
model.RoleComposer: model.TagMusicBrainzComposerID,
|
||||
model.RoleLyricist: model.TagMusicBrainzLyricistID,
|
||||
model.RoleConductor: model.TagMusicBrainzConductorID,
|
||||
model.RoleArranger: model.TagMusicBrainzArrangerID,
|
||||
model.RoleDirector: model.TagMusicBrainzDirectorID,
|
||||
model.RoleProducer: model.TagMusicBrainzProducerID,
|
||||
model.RoleEngineer: model.TagMusicBrainzEngineerID,
|
||||
model.RoleMixer: model.TagMusicBrainzMixerID,
|
||||
model.RoleRemixer: model.TagMusicBrainzRemixerID,
|
||||
model.RoleDJMixer: model.TagMusicBrainzDJMixerID,
|
||||
}
|
||||
|
||||
It("should handle more artists than mbids", func() {
|
||||
for key := range allMappings {
|
||||
mf = toMediaFile(map[string][]string{
|
||||
key.String(): {"a", "b", "c"},
|
||||
allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(key, HaveLen(3)))
|
||||
roles := participants[key]
|
||||
|
||||
Expect(roles[0].Name).To(Equal("a"))
|
||||
Expect(roles[1].Name).To(Equal("b"))
|
||||
Expect(roles[2].Name).To(Equal("c"))
|
||||
|
||||
Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759"))
|
||||
Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12"))
|
||||
Expect(roles[2].MbzArtistID).To(Equal(""))
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle more mbids than artists", func() {
|
||||
for key := range allMappings {
|
||||
mf = toMediaFile(map[string][]string{
|
||||
key.String(): {"a", "b"},
|
||||
allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(key, HaveLen(2)))
|
||||
roles := participants[key]
|
||||
|
||||
Expect(roles[0].Name).To(Equal("a"))
|
||||
Expect(roles[1].Name).To(Equal("b"))
|
||||
|
||||
Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759"))
|
||||
Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12"))
|
||||
}
|
||||
})
|
||||
|
||||
It("should refuse duplicate names if no mbid specified", func() {
|
||||
for key := range allMappings {
|
||||
mf = toMediaFile(map[string][]string{
|
||||
key.String(): {"a", "b", "a", "a"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(key, HaveLen(2)))
|
||||
roles := participants[key]
|
||||
|
||||
Expect(roles[0].Name).To(Equal("a"))
|
||||
Expect(roles[0].MbzArtistID).To(Equal(""))
|
||||
Expect(roles[1].Name).To(Equal("b"))
|
||||
Expect(roles[1].MbzArtistID).To(Equal(""))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,373 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"io/fs"
|
||||
"math"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
FileInfo FileInfo
|
||||
Tags model.RawTags
|
||||
AudioProperties AudioProperties
|
||||
HasPicture bool
|
||||
}
|
||||
|
||||
type FileInfo interface {
|
||||
fs.FileInfo
|
||||
BirthTime() time.Time
|
||||
}
|
||||
|
||||
type AudioProperties struct {
|
||||
Duration time.Duration
|
||||
BitRate int
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Channels int
|
||||
}
|
||||
|
||||
type Date string
|
||||
|
||||
func (d Date) Year() int {
|
||||
if d == "" {
|
||||
return 0
|
||||
}
|
||||
y, _ := strconv.Atoi(string(d[:4]))
|
||||
return y
|
||||
}
|
||||
|
||||
type Pair string
|
||||
|
||||
func (p Pair) Key() string { return p.parse(0) }
|
||||
func (p Pair) Value() string { return p.parse(1) }
|
||||
func (p Pair) parse(i int) string {
|
||||
parts := strings.SplitN(string(p), consts.Zwsp, 2)
|
||||
if len(parts) > i {
|
||||
return parts[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (p Pair) String() string {
|
||||
return string(p)
|
||||
}
|
||||
func NewPair(key, value string) string {
|
||||
return key + consts.Zwsp + value
|
||||
}
|
||||
|
||||
func New(filePath string, info Info) Metadata {
|
||||
return Metadata{
|
||||
filePath: filePath,
|
||||
fileInfo: info.FileInfo,
|
||||
tags: clean(filePath, info.Tags),
|
||||
audioProps: info.AudioProperties,
|
||||
hasPicture: info.HasPicture,
|
||||
}
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
filePath string
|
||||
fileInfo FileInfo
|
||||
tags model.Tags
|
||||
audioProps AudioProperties
|
||||
hasPicture bool
|
||||
}
|
||||
|
||||
func (md Metadata) FilePath() string { return md.filePath }
|
||||
func (md Metadata) ModTime() time.Time { return md.fileInfo.ModTime() }
|
||||
func (md Metadata) BirthTime() time.Time { return md.fileInfo.BirthTime() }
|
||||
func (md Metadata) Size() int64 { return md.fileInfo.Size() }
|
||||
func (md Metadata) Suffix() string {
|
||||
return strings.ToLower(strings.TrimPrefix(path.Ext(md.filePath), "."))
|
||||
}
|
||||
func (md Metadata) AudioProperties() AudioProperties { return md.audioProps }
|
||||
func (md Metadata) Length() float32 { return float32(md.audioProps.Duration.Milliseconds()) / 1000 }
|
||||
func (md Metadata) HasPicture() bool { return md.hasPicture }
|
||||
func (md Metadata) All() model.Tags { return md.tags }
|
||||
func (md Metadata) Strings(key model.TagName) []string { return md.tags[key] }
|
||||
func (md Metadata) String(key model.TagName) string { return md.first(key) }
|
||||
func (md Metadata) Int(key model.TagName) int64 { v, _ := strconv.Atoi(md.first(key)); return int64(v) }
|
||||
func (md Metadata) Bool(key model.TagName) bool { v, _ := strconv.ParseBool(md.first(key)); return v }
|
||||
func (md Metadata) Date(key model.TagName) Date { return md.date(key) }
|
||||
func (md Metadata) NumAndTotal(key model.TagName) (int, int) { return md.tuple(key) }
|
||||
func (md Metadata) Float(key model.TagName, def ...float64) float64 {
|
||||
return float(md.first(key), def...)
|
||||
}
|
||||
func (md Metadata) Gain(key model.TagName) float64 {
|
||||
v := strings.TrimSpace(strings.Replace(md.first(key), "dB", "", 1))
|
||||
return float(v)
|
||||
}
|
||||
func (md Metadata) Pairs(key model.TagName) []Pair {
|
||||
values := md.tags[key]
|
||||
return slice.Map(values, func(v string) Pair { return Pair(v) })
|
||||
}
|
||||
func (md Metadata) first(key model.TagName) string {
|
||||
if v, ok := md.tags[key]; ok && len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func float(value string, def ...float64) float64 {
|
||||
v, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || v == math.Inf(-1) || v == math.Inf(1) {
|
||||
if len(def) > 0 {
|
||||
return def[0]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Used for tracks and discs
|
||||
func (md Metadata) tuple(key model.TagName) (int, int) {
|
||||
tag := md.first(key)
|
||||
if tag == "" {
|
||||
return 0, 0
|
||||
}
|
||||
tuple := strings.Split(tag, "/")
|
||||
t1, t2 := 0, 0
|
||||
t1, _ = strconv.Atoi(tuple[0])
|
||||
if len(tuple) > 1 {
|
||||
t2, _ = strconv.Atoi(tuple[1])
|
||||
} else {
|
||||
t2tag := md.first(key + "total")
|
||||
t2, _ = strconv.Atoi(t2tag)
|
||||
}
|
||||
return t1, t2
|
||||
}
|
||||
|
||||
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)
|
||||
|
||||
func (md Metadata) date(tagName model.TagName) Date {
|
||||
return Date(md.first(tagName))
|
||||
}
|
||||
|
||||
// date tries to parse a date from a tag, it tries to get at least the year. See the tests for examples.
|
||||
func parseDate(filePath string, tagName model.TagName, tagValue string) string {
|
||||
if len(tagValue) < 4 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// first get just the year
|
||||
match := dateRegex.FindStringSubmatch(tagValue)
|
||||
if len(match) == 0 {
|
||||
log.Debug("Error parsing date", "file", filePath, "tag", tagName, "date", tagValue)
|
||||
return ""
|
||||
}
|
||||
|
||||
// if the tag is just the year, return it
|
||||
if len(tagValue) < 5 {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
// if the tag is too long, truncate it
|
||||
tagValue = tagValue[:min(10, len(tagValue))]
|
||||
|
||||
// then try to parse the full date
|
||||
for _, mask := range []string{"2006-01-02", "2006-01"} {
|
||||
_, err := time.Parse(mask, tagValue)
|
||||
if err == nil {
|
||||
return tagValue
|
||||
}
|
||||
}
|
||||
log.Debug("Error parsing month and day from date", "file", filePath, "tag", tagName, "date", tagValue)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
// clean filters out tags that are not in the mappings or are empty,
|
||||
// combine equivalent tags and remove duplicated values.
|
||||
// It keeps the order of the tags names as they are defined in the mappings.
|
||||
func clean(filePath string, tags model.RawTags) model.Tags {
|
||||
lowered := lowerTags(tags)
|
||||
mappings := model.TagMappings()
|
||||
cleaned := make(model.Tags, len(mappings))
|
||||
|
||||
for name, mapping := range mappings {
|
||||
var values []string
|
||||
switch mapping.Type {
|
||||
case model.TagTypePair:
|
||||
values = processPairMapping(name, mapping, lowered)
|
||||
default:
|
||||
values = processRegularMapping(mapping, lowered)
|
||||
}
|
||||
cleaned[name] = values
|
||||
}
|
||||
|
||||
cleaned = filterEmptyTags(cleaned)
|
||||
return sanitizeAll(filePath, cleaned)
|
||||
}
|
||||
|
||||
func processRegularMapping(mapping model.TagConf, lowered model.Tags) []string {
|
||||
var values []string
|
||||
for _, alias := range mapping.Aliases {
|
||||
if vs, ok := lowered[model.TagName(alias)]; ok {
|
||||
splitValues := mapping.SplitTagValue(vs)
|
||||
values = append(values, splitValues...)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func lowerTags(tags model.RawTags) model.Tags {
|
||||
lowered := make(model.Tags, len(tags))
|
||||
for k, v := range tags {
|
||||
lowered[model.TagName(strings.ToLower(k))] = v
|
||||
}
|
||||
return lowered
|
||||
}
|
||||
|
||||
func processPairMapping(name model.TagName, mapping model.TagConf, lowered model.Tags) []string {
|
||||
var aliasValues []string
|
||||
for _, alias := range mapping.Aliases {
|
||||
if vs, ok := lowered[model.TagName(alias)]; ok {
|
||||
aliasValues = append(aliasValues, vs...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(aliasValues) > 0 {
|
||||
return parseVorbisPairs(aliasValues)
|
||||
}
|
||||
return parseID3Pairs(name, lowered)
|
||||
}
|
||||
|
||||
func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
|
||||
var pairs []string
|
||||
prefix := string(name) + ":"
|
||||
for tagKey, tagValues := range lowered {
|
||||
keyStr := string(tagKey)
|
||||
if strings.HasPrefix(keyStr, prefix) {
|
||||
keyPart := strings.TrimPrefix(keyStr, prefix)
|
||||
if keyPart == string(name) {
|
||||
keyPart = ""
|
||||
}
|
||||
for _, v := range tagValues {
|
||||
pairs = append(pairs, NewPair(keyPart, v))
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
var vorbisPairRegex = regexp.MustCompile(`\(([^()]+(?:\([^()]*\)[^()]*)*)\)`)
|
||||
|
||||
// parseVorbisPairs, from
|
||||
//
|
||||
// "Salaam Remi (drums (drum set) and organ)",
|
||||
//
|
||||
// to
|
||||
//
|
||||
// "drums (drum set) and organ" -> "Salaam Remi",
|
||||
func parseVorbisPairs(values []string) []string {
|
||||
pairs := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
matches := vorbisPairRegex.FindAllStringSubmatch(value, -1)
|
||||
if len(matches) == 0 {
|
||||
pairs = append(pairs, NewPair("", value))
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(matches[0][1])
|
||||
key = strings.ToLower(key)
|
||||
valueWithoutKey := strings.TrimSpace(strings.Replace(value, "("+matches[0][1]+")", "", 1))
|
||||
pairs = append(pairs, NewPair(key, valueWithoutKey))
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
func filterEmptyTags(tags model.Tags) model.Tags {
|
||||
for k, v := range tags {
|
||||
clean := filterDuplicatedOrEmptyValues(v)
|
||||
if len(clean) == 0 {
|
||||
delete(tags, k)
|
||||
} else {
|
||||
tags[k] = clean
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func filterDuplicatedOrEmptyValues(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
var result []string
|
||||
for _, v := range values {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
result = append(result, v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sanitizeAll(filePath string, tags model.Tags) model.Tags {
|
||||
cleaned := model.Tags{}
|
||||
for k, v := range tags {
|
||||
tag, found := model.TagMappings()[k]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
var values []string
|
||||
for _, value := range v {
|
||||
cleanedValue := sanitize(filePath, k, tag, value)
|
||||
if cleanedValue != "" {
|
||||
values = append(values, cleanedValue)
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
cleaned[k] = values
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const defaultMaxTagLength = 1024
|
||||
|
||||
func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value string) string {
|
||||
// First truncate the value to the maximum length
|
||||
maxLength := cmp.Or(tag.MaxLength, defaultMaxTagLength)
|
||||
if len(value) > maxLength {
|
||||
log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength)
|
||||
value = value[:maxLength]
|
||||
}
|
||||
|
||||
switch tag.Type {
|
||||
case model.TagTypeDate:
|
||||
value = parseDate(filePath, tagName, value)
|
||||
if value == "" {
|
||||
log.Trace("Invalid date tag value", "tag", tagName, "value", value)
|
||||
}
|
||||
case model.TagTypeInteger:
|
||||
_, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
log.Trace("Invalid integer tag value", "tag", tagName, "value", value)
|
||||
return ""
|
||||
}
|
||||
case model.TagTypeFloat:
|
||||
_, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
log.Trace("Invalid float tag value", "tag", tagName, "value", value)
|
||||
return ""
|
||||
}
|
||||
case model.TagTypeUUID:
|
||||
_, err := uuid.Parse(value)
|
||||
if err != nil {
|
||||
log.Trace("Invalid UUID tag value", "tag", tagName, "value", value)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package metadata_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/djherbis/times"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMetadata(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Metadata Suite")
|
||||
}
|
||||
|
||||
type testFileInfo struct {
|
||||
fs.FileInfo
|
||||
}
|
||||
|
||||
func (t testFileInfo) BirthTime() time.Time {
|
||||
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
|
||||
return ts.BirthTime()
|
||||
}
|
||||
return t.FileInfo.ModTime()
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package metadata_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Metadata", func() {
|
||||
var (
|
||||
filePath string
|
||||
fileInfo os.FileInfo
|
||||
props metadata.Info
|
||||
md metadata.Metadata
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// It is easier to have a real file to test the mod and birth times
|
||||
filePath = utils.TempFileName("test", ".mp3")
|
||||
f, _ := os.Create(filePath)
|
||||
DeferCleanup(func() {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(filePath)
|
||||
})
|
||||
|
||||
fileInfo, _ = os.Stat(filePath)
|
||||
props = metadata.Info{
|
||||
AudioProperties: metadata.AudioProperties{
|
||||
Duration: time.Minute * 3,
|
||||
BitRate: 320,
|
||||
},
|
||||
HasPicture: true,
|
||||
FileInfo: testFileInfo{fileInfo},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Metadata", func() {
|
||||
Describe("New", func() {
|
||||
It("should create a new Metadata object with the correct properties", func() {
|
||||
props.Tags = model.RawTags{
|
||||
"©ART": {"First Artist", "Second Artist"},
|
||||
"----:com.apple.iTunes:CATALOGNUMBER": {"1234"},
|
||||
"tbpm": {"120.6"},
|
||||
"WM/IsCompilation": {"1"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.FilePath()).To(Equal(filePath))
|
||||
Expect(md.ModTime()).To(Equal(fileInfo.ModTime()))
|
||||
Expect(md.BirthTime()).To(BeTemporally("~", md.ModTime(), time.Second))
|
||||
Expect(md.Size()).To(Equal(fileInfo.Size()))
|
||||
Expect(md.Suffix()).To(Equal("mp3"))
|
||||
Expect(md.AudioProperties()).To(Equal(props.AudioProperties))
|
||||
Expect(md.Length()).To(Equal(float32(3 * 60)))
|
||||
Expect(md.HasPicture()).To(Equal(props.HasPicture))
|
||||
Expect(md.Strings(model.TagTrackArtist)).To(Equal([]string{"First Artist", "Second Artist"}))
|
||||
Expect(md.String(model.TagTrackArtist)).To(Equal("First Artist"))
|
||||
Expect(md.Int(model.TagCatalogNumber)).To(Equal(int64(1234)))
|
||||
Expect(md.Float(model.TagBPM)).To(Equal(120.6))
|
||||
Expect(md.Bool(model.TagCompilation)).To(BeTrue())
|
||||
Expect(md.All()).To(SatisfyAll(
|
||||
HaveLen(4),
|
||||
HaveKeyWithValue(model.TagTrackArtist, []string{"First Artist", "Second Artist"}),
|
||||
HaveKeyWithValue(model.TagBPM, []string{"120.6"}),
|
||||
HaveKeyWithValue(model.TagCompilation, []string{"1"}),
|
||||
HaveKeyWithValue(model.TagCatalogNumber, []string{"1234"}),
|
||||
))
|
||||
|
||||
})
|
||||
|
||||
It("should clean the tags map correctly", func() {
|
||||
const unknownTag = "UNKNOWN_TAG"
|
||||
props.Tags = model.RawTags{
|
||||
"TPE1": {"Artist Name", "Artist Name", ""},
|
||||
"©ART": {"Second Artist"},
|
||||
"CatalogNumber": {""},
|
||||
"Album": {"Album Name", "", "Album Name"},
|
||||
"Date": {"2022-10-02 12:15:01"},
|
||||
"Year": {"2022", "2022", ""},
|
||||
"Genre": {"Pop", "", "Pop", "Rock"},
|
||||
"Track": {"1/10", "1/10", ""},
|
||||
unknownTag: {"value"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(SatisfyAll(
|
||||
HaveLen(5),
|
||||
Not(HaveKey(unknownTag)),
|
||||
HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}),
|
||||
HaveKeyWithValue(model.TagAlbum, []string{"Album Name"}),
|
||||
HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02", "2022"}),
|
||||
HaveKeyWithValue(model.TagGenre, []string{"Pop", "Rock"}),
|
||||
HaveKeyWithValue(model.TagTrackNumber, []string{"1/10"}),
|
||||
))
|
||||
})
|
||||
|
||||
It("should truncate long strings", func() {
|
||||
props.Tags = model.RawTags{
|
||||
"Title": {strings.Repeat("a", 2048)},
|
||||
"Comment": {strings.Repeat("a", 8192)},
|
||||
"lyrics:xxx": {strings.Repeat("a", 60000)},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.String(model.TagTitle)).To(HaveLen(1024))
|
||||
Expect(md.String(model.TagComment)).To(HaveLen(4096))
|
||||
pair := md.Pairs(model.TagLyrics)
|
||||
|
||||
Expect(pair).To(HaveLen(1))
|
||||
Expect(pair[0].Key()).To(Equal("xxx"))
|
||||
|
||||
// Note: a total of 6 characters are lost from maxLength from
|
||||
// the key portion and separator
|
||||
Expect(pair[0].Value()).To(HaveLen(32762))
|
||||
})
|
||||
|
||||
It("should split multiple values", func() {
|
||||
props.Tags = model.RawTags{
|
||||
"Genre": {"Rock/Pop;;Punk"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("Date",
|
||||
func(value string, expectedYear int, expectedDate string) {
|
||||
props.Tags = model.RawTags{
|
||||
"date": {value},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
testDate := md.Date(model.TagRecordingDate)
|
||||
Expect(string(testDate)).To(Equal(expectedDate))
|
||||
Expect(testDate.Year()).To(Equal(expectedYear))
|
||||
},
|
||||
Entry(nil, "1985", 1985, "1985"),
|
||||
Entry(nil, "2002-01", 2002, "2002-01"),
|
||||
Entry(nil, "1969.06", 1969, "1969"),
|
||||
Entry(nil, "1980.07.25", 1980, "1980"),
|
||||
Entry(nil, "2004-00-00", 2004, "2004"),
|
||||
Entry(nil, "2016-12-31", 2016, "2016-12-31"),
|
||||
Entry(nil, "2016-12-31 12:15", 2016, "2016-12-31"),
|
||||
Entry(nil, "2013-May-12", 2013, "2013"),
|
||||
Entry(nil, "May 12, 2016", 2016, "2016"),
|
||||
Entry(nil, "01/10/1990", 1990, "1990"),
|
||||
Entry(nil, "invalid", 0, ""),
|
||||
)
|
||||
|
||||
DescribeTable("NumAndTotal",
|
||||
func(num, total string, expectedNum int, expectedTotal int) {
|
||||
props.Tags = model.RawTags{
|
||||
"Track": {num},
|
||||
"TrackTotal": {total},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
testNum, testTotal := md.NumAndTotal(model.TagTrackNumber)
|
||||
Expect(testNum).To(Equal(expectedNum))
|
||||
Expect(testTotal).To(Equal(expectedTotal))
|
||||
},
|
||||
Entry(nil, "2", "", 2, 0),
|
||||
Entry(nil, "2", "10", 2, 10),
|
||||
Entry(nil, "2/10", "", 2, 10),
|
||||
Entry(nil, "", "", 0, 0),
|
||||
Entry(nil, "A", "", 0, 0),
|
||||
)
|
||||
|
||||
Describe("Performers", func() {
|
||||
Describe("ID3", func() {
|
||||
BeforeEach(func() {
|
||||
props.Tags = model.RawTags{
|
||||
"PERFORMER:GUITAR": {"Guitarist 1", "Guitarist 2"},
|
||||
"PERFORMER:BACKGROUND VOCALS": {"Backing Singer"},
|
||||
"PERFORMER:PERFORMER": {"Wonderlove", "Lovewonder"},
|
||||
}
|
||||
})
|
||||
|
||||
It("should return the performers", func() {
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(HaveKey(model.TagPerformer))
|
||||
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
|
||||
metadata.NewPair("guitar", "Guitarist 1"),
|
||||
metadata.NewPair("guitar", "Guitarist 2"),
|
||||
metadata.NewPair("background vocals", "Backing Singer"),
|
||||
metadata.NewPair("", "Wonderlove"),
|
||||
metadata.NewPair("", "Lovewonder"),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Vorbis", func() {
|
||||
BeforeEach(func() {
|
||||
props.Tags = model.RawTags{
|
||||
"PERFORMER": {
|
||||
"John Adams (Rhodes piano)",
|
||||
"Vincent Henry (alto saxophone, baritone saxophone and tenor saxophone)",
|
||||
"Salaam Remi (drums (drum set) and organ)",
|
||||
"Amy Winehouse (guitar)",
|
||||
"Amy Winehouse (vocals)",
|
||||
"Wonderlove",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("should return the performers", func() {
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(HaveKey(model.TagPerformer))
|
||||
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
|
||||
metadata.NewPair("rhodes piano", "John Adams"),
|
||||
metadata.NewPair("alto saxophone, baritone saxophone and tenor saxophone", "Vincent Henry"),
|
||||
metadata.NewPair("drums (drum set) and organ", "Salaam Remi"),
|
||||
metadata.NewPair("guitar", "Amy Winehouse"),
|
||||
metadata.NewPair("vocals", "Amy Winehouse"),
|
||||
metadata.NewPair("", "Wonderlove"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Lyrics", func() {
|
||||
BeforeEach(func() {
|
||||
props.Tags = model.RawTags{
|
||||
"LYRICS:POR": {"Letras"},
|
||||
"LYRICS:ENG": {"Lyrics"},
|
||||
}
|
||||
})
|
||||
|
||||
It("should return the lyrics", func() {
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(HaveKey(model.TagLyrics))
|
||||
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
|
||||
metadata.NewPair("por", "Letras"),
|
||||
metadata.NewPair("eng", "Lyrics"),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReplayGain", func() {
|
||||
createMF := func(tag, tagValue string) model.MediaFile {
|
||||
props.Tags = model.RawTags{
|
||||
tag: {tagValue},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
return md.ToMediaFile(0, "0")
|
||||
}
|
||||
|
||||
DescribeTable("Gain",
|
||||
func(tagValue string, expected float64) {
|
||||
mf := createMF("replaygain_track_gain", tagValue)
|
||||
Expect(mf.RGTrackGain).To(Equal(expected))
|
||||
},
|
||||
Entry("0", "0", 0.0),
|
||||
Entry("1.2dB", "1.2dB", 1.2),
|
||||
Entry("Infinity", "Infinity", 0.0),
|
||||
Entry("Invalid value", "INVALID VALUE", 0.0),
|
||||
)
|
||||
DescribeTable("Peak",
|
||||
func(tagValue string, expected float64) {
|
||||
mf := createMF("replaygain_track_peak", tagValue)
|
||||
Expect(mf.RGTrackPeak).To(Equal(expected))
|
||||
},
|
||||
Entry("0", "0", 0.0),
|
||||
Entry("0.5", "0.5", 0.5),
|
||||
Entry("Invalid dB suffix", "0.7dB", 1.0),
|
||||
Entry("Infinity", "Infinity", 1.0),
|
||||
Entry("Invalid value", "INVALID VALUE", 1.0),
|
||||
)
|
||||
DescribeTable("getR128GainValue",
|
||||
func(tagValue string, expected float64) {
|
||||
mf := createMF("r128_track_gain", tagValue)
|
||||
Expect(mf.RGTrackGain).To(Equal(expected))
|
||||
|
||||
},
|
||||
Entry("0", "0", 5.0),
|
||||
Entry("-3776", "-3776", -9.75),
|
||||
Entry("Infinity", "Infinity", 0.0),
|
||||
Entry("Invalid value", "INVALID VALUE", 0.0),
|
||||
)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type hashFunc = func(...string) string
|
||||
|
||||
// getPID returns the persistent ID for a given spec, getting the referenced values from the metadata
|
||||
// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes
|
||||
// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc.
|
||||
// For each field, it gets all its attributes values and concatenates them, then hashes the result.
|
||||
// If a field is empty, it is skipped and the function looks for the next field.
|
||||
func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec string) string {
|
||||
var getPID func(mf model.MediaFile, md Metadata, spec string) string
|
||||
getAttr := func(mf model.MediaFile, md Metadata, attr string) string {
|
||||
switch attr {
|
||||
case "albumid":
|
||||
return getPID(mf, md, conf.Server.PID.Album)
|
||||
case "folder":
|
||||
return filepath.Dir(mf.Path)
|
||||
case "albumartistid":
|
||||
return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
|
||||
case "title":
|
||||
return mf.Title
|
||||
case "album":
|
||||
return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
|
||||
}
|
||||
return md.String(model.TagName(attr))
|
||||
}
|
||||
getPID = func(mf model.MediaFile, md Metadata, spec string) string {
|
||||
pid := ""
|
||||
fields := strings.Split(spec, "|")
|
||||
for _, field := range fields {
|
||||
attributes := strings.Split(field, ",")
|
||||
hasValue := false
|
||||
values := slice.Map(attributes, func(attr string) string {
|
||||
v := getAttr(mf, md, attr)
|
||||
if v != "" {
|
||||
hasValue = true
|
||||
}
|
||||
return v
|
||||
})
|
||||
if hasValue {
|
||||
pid += strings.Join(values, "\\")
|
||||
break
|
||||
}
|
||||
}
|
||||
return hash(pid)
|
||||
}
|
||||
|
||||
return func(mf model.MediaFile, md Metadata, spec string) string {
|
||||
switch spec {
|
||||
case "track_legacy":
|
||||
return legacyTrackID(mf)
|
||||
case "album_legacy":
|
||||
return legacyAlbumID(md)
|
||||
}
|
||||
return getPID(mf, md, spec)
|
||||
}
|
||||
}
|
||||
|
||||
func (md Metadata) trackPID(mf model.MediaFile) string {
|
||||
return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track)
|
||||
}
|
||||
|
||||
func (md Metadata) albumID(mf model.MediaFile) string {
|
||||
return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Album)
|
||||
}
|
||||
|
||||
// BFR Must be configurable?
|
||||
func (md Metadata) artistID(name string) string {
|
||||
mf := model.MediaFile{AlbumArtist: name}
|
||||
return createGetPID(id.NewHash)(mf, md, "albumartistid")
|
||||
}
|
||||
|
||||
func (md Metadata) mapTrackTitle() string {
|
||||
if title := md.String(model.TagTitle); title != "" {
|
||||
return title
|
||||
}
|
||||
return utils.BaseName(md.FilePath())
|
||||
}
|
||||
|
||||
func (md Metadata) mapAlbumName() string {
|
||||
return cmp.Or(
|
||||
md.String(model.TagAlbum),
|
||||
consts.UnknownAlbum,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("getPID", func() {
|
||||
var (
|
||||
md Metadata
|
||||
mf model.MediaFile
|
||||
sum hashFunc
|
||||
getPID func(mf model.MediaFile, md Metadata, spec string) string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
|
||||
getPID = createGetPID(sum)
|
||||
})
|
||||
|
||||
Context("attributes are tags", func() {
|
||||
spec := "musicbrainz_trackid|album,discnumber,tracknumber"
|
||||
When("no attributes were present", func() {
|
||||
It("should return empty pid", func() {
|
||||
md.tags = map[model.TagName][]string{}
|
||||
pid := getPID(mf, md, spec)
|
||||
Expect(pid).To(Equal("()"))
|
||||
})
|
||||
})
|
||||
When("all fields are present", func() {
|
||||
It("should return the pid", func() {
|
||||
md.tags = map[model.TagName][]string{
|
||||
"musicbrainz_trackid": {"mbtrackid"},
|
||||
"album": {"album name"},
|
||||
"discnumber": {"1"},
|
||||
"tracknumber": {"1"},
|
||||
}
|
||||
Expect(getPID(mf, md, spec)).To(Equal("(mbtrackid)"))
|
||||
})
|
||||
})
|
||||
When("only first field is present", func() {
|
||||
It("should return the pid", func() {
|
||||
md.tags = map[model.TagName][]string{
|
||||
"musicbrainz_trackid": {"mbtrackid"},
|
||||
}
|
||||
Expect(getPID(mf, md, spec)).To(Equal("(mbtrackid)"))
|
||||
})
|
||||
})
|
||||
When("first is empty, but second field is present", func() {
|
||||
It("should return the pid", func() {
|
||||
md.tags = map[model.TagName][]string{
|
||||
"album": {"album name"},
|
||||
"discnumber": {"1"},
|
||||
}
|
||||
Expect(getPID(mf, md, spec)).To(Equal("(album name\\1\\)"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("calculated attributes", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,version,releasedate"
|
||||
})
|
||||
When("field is title", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "title|folder"
|
||||
md.tags = map[model.TagName][]string{"title": {"title"}}
|
||||
md.filePath = "/path/to/file.mp3"
|
||||
mf.Title = "Title"
|
||||
Expect(getPID(mf, md, spec)).To(Equal("(Title)"))
|
||||
})
|
||||
})
|
||||
When("field is folder", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "folder|title"
|
||||
md.tags = map[model.TagName][]string{"title": {"title"}}
|
||||
mf.Path = "/path/to/file.mp3"
|
||||
Expect(getPID(mf, md, spec)).To(Equal("(/path/to)"))
|
||||
})
|
||||
})
|
||||
When("field is albumid", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "albumid|title"
|
||||
md.tags = map[model.TagName][]string{
|
||||
"title": {"title"},
|
||||
"album": {"album name"},
|
||||
"version": {"version"},
|
||||
"releasedate": {"2021-01-01"},
|
||||
}
|
||||
mf.AlbumArtist = "Album Artist"
|
||||
Expect(getPID(mf, md, spec)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))"))
|
||||
})
|
||||
})
|
||||
When("field is albumartistid", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "musicbrainz_albumartistid|albumartistid"
|
||||
md.tags = map[model.TagName][]string{
|
||||
"albumartist": {"Album Artist"},
|
||||
}
|
||||
mf.AlbumArtist = "Album Artist"
|
||||
Expect(getPID(mf, md, spec)).To(Equal("((album artist))"))
|
||||
})
|
||||
})
|
||||
When("field is album", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "album|title"
|
||||
md.tags = map[model.TagName][]string{"album": {"Album Name"}}
|
||||
Expect(getPID(mf, md, spec)).To(Equal("(album name)"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user