From 52b77e41944e924226f87cc38c5dfe7f87029bb5 Mon Sep 17 00:00:00 2001 From: certuna <62144283+certuna@users.noreply.github.com> Date: Fri, 19 May 2023 21:27:47 +0200 Subject: [PATCH] Support for Original Date, Release Date & splitting/grouping of album editions (#2162) * Update AlbumGridView.js * Update AlbumDetails.js * Update AlbumDetails.js * Create DoubleRangeField.js * Update and rename DoubleRangeField.js to RangeFieldDouble.js * Update RangeFieldDouble.js * Update AlbumGridView.js * Update AlbumDetails.js * Update RangeFieldDouble.js * Update index.js * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update AlbumDetails.js * Update RangeFieldDouble.js * Update AlbumDetails.js * Update RangeFieldDouble.js * Update AlbumDetails.js * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update AlbumDetails.js * Update AlbumDetails.js * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update AlbumDetails.js * Update RangeFieldDouble.js * Update AlbumDetails.js * Update en.json * Update en.json * Update AlbumDetails.js * Update RangeFieldDouble.js * Update AlbumGridView.js * Update AlbumDetails.js * Update AlbumSongs.js * Update ContextMenus.js * Update SongDatagrid.js * Update AlbumSongs.js * Update SongDatagrid.js * Update SongDatagrid.js * Update SongDatagrid.js * Update AlbumSongs.js * Update SongList.js * Update playlist_track_repository.go * Update 20230113000000_release_year.go * Update PlayButton.js * Update mediafile_repository.go * Update album.go * Update playlist_track_repository.go * Update playlist_track_repository.go * Update SongDatagrid.js * Update 20230113000000_release_year.go * Update SongDatagrid.js * Update AlbumSongs.js * Update SongDatagrid.js * Update SongDatagrid.js * Update SongDatagrid.js * Update SongDatagrid.js * Update AlbumDetails.js * Update AlbumSongs.js * Update AlbumSongs.js * Update RangeFieldDouble.js * Update SongDatagrid.js * Update 20230113000000_release_year.go * Update 20230113000000_release_year.go * Update 20230113000000_release_year.go * Update 20230113000000_release_year.go * Update AlbumSongs.js * Update AlbumSongs.js * Update mapping.go * Update RangeFieldDouble.js * Update AlbumGridView.js * Update AlbumSongs.js * Update en.json * Update SongDatagrid.js * Update SongDatagrid.js * Update metadata.go * Update mapping.go * Update AlbumDetails.js * Update AlbumGridView.js * Update RangeFieldDouble.js * Update mapping.go * Update metadata.go * Update mapping.go * Update AlbumDetails.js * Update 20230113000000_release_year.go * Update AlbumDetails.js * Update en.json * Update configuration.go * Update mapping.go * Update configuration.go * Update mediafile.go * Update metadata.go * Update RangeFieldDouble.js * Update 20230113000000_release_year.go * Update configuration.go * Update mapping.go * Update mediafile.go * Update mapping.go * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update RangeFieldDouble.js * Update 20230113000000_release_year.go * Update AlbumDetails.js * Update RangeFieldDouble.js * Update mapping.go * Update metadata.go * Update album.go * Update mediafile.go * Update mediafile.go * Update album.go * Update fields.go * Update mediafile_repository.go * Update playlist_track_repository.go * Update AlbumSongs.js * Update SongDatagrid.js * Update PlayButton.js * Update SongList.js * Update ContextMenus.js * Update SongDatagrid.js * Update metadata.go * Update ArtistShow.js * Update mapping.go * Update configuration.go * Update mapping.go * Update metadata.go * Update metadata.go * Update mapping.go * Update metadata.go * Update metadata.go * Update mapping.go * Update 20230113000000_release_year.go * Update 20230113000000_release_year.go * Update mapping.go * Update metadata.go * Update metadata.go * Update album.go * Update mediafile.go * Update AlbumDetails.js * Update AlbumSongs.js * Update album.go * Update mediafile.go * Update metadata.go * Update mediafile.go * Update 20230113000000_release_year.go * Update 20230113000000_release_year.go * Update album.go * Update mediafile.go * Update RangeFieldDouble.js * Update AlbumDetails.js * Update AlbumGridView.js * Update en.json * Update AlbumGridView.js * Update RangeFieldDouble.js * Update and rename 20230113000000_release_year.go to 20230113000000_release_date.go * Update album.go * Update mediafile.go * Update fields.go * Update playlist_track_repository.go * Update mediafile_repository.go * Update mapping.go * Update metadata.go * Update mapping.go * Update SongDatagrid.js * Update RangeFieldDouble.js * Update index.js * Update ContextMenus.js * Update PlayButton.js * Create FormatDate.js * Update SongList.js * Update AlbumDetails.js * Update AlbumSongs.js * Update AlbumSongs.js * Update en.json * Update AlbumDetails.js * Update album.go fixed conflict I think? * Update mediafile.go fixed conflict * Format with goimports * Update SongDatagrid.js only show Cat # in desktop view * Update metadata_internal_test.go * Update metadata_test.go * Delete test.mp3 * Add files via upload mp3 test file with Date, Original Date and Release Date * Update metadata_test.go * Update metadata_test.go * Update metadata_test.go * Update metadata_test.go * Update taglib_test.go * Delete test.mp3 * Add files via upload file with replaygain & dates * Update AlbumGridView.js * Update AlbumDetails.js * Update AlbumSongs.js * Update ContextMenus.js * Update FormatDate.js * Update PlayButton.js * Update RangeFieldDouble.js * Update SongDatagrid.js * Update AlbumSongs.js * Update SongDatagrid.js * Update AlbumSongs.js * Fix formatting * Update mapping.go * Update AlbumSongs.js * Update SongDatagrid.js * Update SongDatagrid.js prettier * Create RangeDoubleField.js rename of RangeFieldDouble.js * Update AlbumGridView.js RangeFieldDouble -> RangeDoubleField * Update mediafile.go AllOrNothing() -> allOrNothing() * Update metadata_internal_test.go getYear -> getDate * Update AlbumDetails.js wrote suggested changes * Update en.json Editions -> Releases & fixed the field name * Update configuration.go Rename Editions -> Releases * Update 20230113000000_release_date.go Editions -> Releases * Update album.go Editions -> Releases * Update mediafile.go Editions -> Releases * Update AlbumDetails.js Editions -> Releases * Update AlbumSongs.js Editions -> Releases * Update RangeDoubleField.js Editions -> Releases * Update SongDatagrid.js Editions -> Releases * Update index.js FormatFullDate and RangeDoubleField * Rename FormatDate.js to FormatFullDate.js * Delete RangeFieldDouble.js * Update mediafile.go AllOrNothing -> allOrNothing * Update mapping.go Editions -> Releases * Update AlbumDetails.js prettier * Update SongDatagrid.js showReleaseRow -> showReleaseDivider * Update AlbumSongs.js showReleaseRow -> showReleaseDivider for clarity * Update and rename 20230113000000_release_date.go to 20230515184510_add_release_date.go - rename the migration file - fixed the import to goose/v3 - additional db fields for original date & year * Update 20230515184510_add_release_date.go * Update fields.go * Update album.go * Update mediafile.go * Update mapping.go * Update AlbumDetails.js * Update en.json * Update AlbumDetails.js * Update AlbumDetails.js now hopefully prettier * Update mapping.go --------- Co-authored-by: Deluan --- conf/configuration.go | 6 +- .../20230515184510_add_release_date.go | 49 ++++++ model/album.go | 11 +- model/criteria/fields.go | 5 + model/mediafile.go | 60 +++++-- persistence/mediafile_repository.go | 4 +- persistence/playlist_track_repository.go | 4 +- scanner/mapping.go | 26 +++- scanner/metadata/metadata.go | 56 +++++-- scanner/metadata/metadata_internal_test.go | 32 +++- scanner/metadata/metadata_test.go | 10 +- scanner/metadata/taglib/taglib_test.go | 2 + ui/src/album/AlbumDetails.js | 56 ++++++- ui/src/album/AlbumGridView.js | 7 +- ui/src/album/AlbumSongs.js | 4 +- ui/src/artist/ArtistShow.js | 2 +- ui/src/common/ContextMenus.js | 13 +- ui/src/common/FormatFullDate.js | 29 ++++ ui/src/common/PlayButton.js | 8 +- ui/src/common/RangeDoubleField.js | 50 ++++++ ui/src/common/SongDatagrid.js | 147 +++++++++++++++--- ui/src/common/index.js | 2 + ui/src/i18n/en.json | 4 +- ui/src/song/SongList.js | 2 +- 24 files changed, 511 insertions(+), 78 deletions(-) create mode 100644 db/migration/20230515184510_add_release_date.go create mode 100644 ui/src/common/FormatFullDate.js create mode 100644 ui/src/common/RangeDoubleField.js diff --git a/conf/configuration.go b/conf/configuration.go index baf17590a..13e42b8ac 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -101,8 +101,9 @@ type configOptions struct { } type scannerOptions struct { - Extractor string - GenreSeparators string + Extractor string + GenreSeparators string + GroupAlbumReleases bool } type lastfmOptions struct { @@ -297,6 +298,7 @@ func init() { viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor) viper.SetDefault("scanner.genreseparators", ";/,") + viper.SetDefault("scanner.groupalbumreleases", true) viper.SetDefault("agents", "lastfm,spotify") viper.SetDefault("lastfm.enabled", true) diff --git a/db/migration/20230515184510_add_release_date.go b/db/migration/20230515184510_add_release_date.go new file mode 100644 index 000000000..f2aaebe4b --- /dev/null +++ b/db/migration/20230515184510_add_release_date.go @@ -0,0 +1,49 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upAddRelRecYear, downAddRelRecYear) +} + +func upAddRelRecYear(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + add date varchar(255) default '' not null; +alter table media_file + add original_year int default 0 not null; +alter table media_file + add original_date varchar(255) default '' not null; +alter table media_file + add release_year int default 0 not null; +alter table media_file + add release_date varchar(255) default '' not null; + +alter table album + add date varchar(255) default '' not null; +alter table album + add min_original_year int default 0 not null; +alter table album + add max_original_year int default 0 not null; +alter table album + add original_date varchar(255) default '' not null; +alter table album + add release_date varchar(255) default '' not null; +alter table album + add releases integer default 0 not null; +`) + if err != nil { + return err + } + + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) +} + +func downAddRelRecYear(tx *sql.Tx) error { + return nil +} diff --git a/model/album.go b/model/album.go index 17124748b..cae0f427a 100644 --- a/model/album.go +++ b/model/album.go @@ -20,6 +20,12 @@ type Album struct { AllArtistIDs string `structs:"all_artist_ids" json:"allArtistIds" orm:"column(all_artist_ids)"` MaxYear int `structs:"max_year" json:"maxYear"` MinYear int `structs:"min_year" json:"minYear"` + Date string `structs:"date" json:"date,omitempty"` + MaxOriginalYear int `structs:"max_original_year" json:"maxOriginalYear"` + MinOriginalYear int `structs:"min_original_year" json:"minOriginalYear"` + OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` + ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` + Releases int `structs:"releases" json:"releases"` Compilation bool `structs:"compilation" json:"compilation"` Comment string `structs:"comment" json:"comment,omitempty"` SongCount int `structs:"song_count" json:"songCount"` @@ -55,8 +61,9 @@ func (a Album) CoverArtID() ArtworkID { } type DiscID struct { - AlbumID string `json:"albumId"` - DiscNumber int `json:"discNumber"` + AlbumID string `json:"albumId"` + ReleaseDate string `json:"releaseDate"` + DiscNumber int `json:"discNumber"` } type Albums []Album diff --git a/model/criteria/fields.go b/model/criteria/fields.go index c3841ff20..beb0456df 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -15,6 +15,11 @@ var fieldMap = map[string]*mappedField{ "tracknumber": {field: "media_file.track_number"}, "discnumber": {field: "media_file.disc_number"}, "year": {field: "media_file.year"}, + "date": {field: "media_file.date"}, + "originalyear": {field: "media_file.original_year"}, + "originaldate": {field: "media_file.original_date"}, + "releaseyear": {field: "media_file.release_year"}, + "releasedate": {field: "media_file.release_date"}, "size": {field: "media_file.size"}, "compilation": {field: "media_file.compilation"}, "dateadded": {field: "media_file.created_at"}, diff --git a/model/mediafile.go b/model/mediafile.go index da74ec970..1af959bba 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -3,6 +3,7 @@ package model import ( "mime" "path/filepath" + "sort" "strings" "time" @@ -32,6 +33,11 @@ type MediaFile struct { DiscNumber int `structs:"disc_number" json:"discNumber"` DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"` Year int `structs:"year" json:"year"` + Date string `structs:"date" json:"date,omitempty"` + OriginalYear int `structs:"original_year" json:"originalYear"` + OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` + ReleaseYear int `structs:"release_year" json:"releaseYear"` + ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` Size int64 `structs:"size" json:"size"` Suffix string `structs:"suffix" json:"suffix"` Duration float32 `structs:"duration" json:"duration"` @@ -108,6 +114,11 @@ func (mfs MediaFiles) ToAlbum() Album { var songArtistIds []string var mbzAlbumIds []string var comments []string + var years []int + var dates []string + var originalYears []int + var originalDates []string + var releaseDates []string for _, m := range mfs { // We assume these attributes are all the same for all songs on an album a.ID = m.AlbumID @@ -130,12 +141,11 @@ func (mfs MediaFiles) ToAlbum() Album { // Calculated attributes based on aggregations a.Duration += m.Duration a.Size += m.Size - if a.MinYear == 0 { - a.MinYear = m.Year - } else if m.Year > 0 { - a.MinYear = number.Min(a.MinYear, m.Year) - } - a.MaxYear = number.Max(a.MaxYear, m.Year) + years = append(years, m.Year) + dates = append(dates, m.Date) + originalYears = append(originalYears, m.OriginalYear) + originalDates = append(originalDates, m.OriginalDate) + releaseDates = append(releaseDates, m.ReleaseDate) a.UpdatedAt = newer(a.UpdatedAt, m.UpdatedAt) a.CreatedAt = older(a.CreatedAt, m.CreatedAt) a.Genres = append(a.Genres, m.Genres...) @@ -151,11 +161,15 @@ func (mfs MediaFiles) ToAlbum() Album { a.EmbedArtPath = m.Path } } + a.Paths = strings.Join(mfs.Dirs(), consts.Zwsp) - comments = slices.Compact(comments) - if len(comments) == 1 { - a.Comment = comments[0] - } + a.Date, _ = allOrNothing(dates) + a.OriginalDate, _ = allOrNothing(originalDates) + a.ReleaseDate, a.Releases = allOrNothing(releaseDates) + a.MinYear, a.MaxYear = minMax(years) + a.MinOriginalYear, a.MaxOriginalYear = minMax(originalYears) + a.Comment, _ = allOrNothing(comments) + a.Comment, _ = allOrNothing(comments) a.Genre = slice.MostFrequent(a.Genres).Name slices.SortFunc(a.Genres, func(a, b Genre) bool { return a.ID < b.ID }) a.Genres = slices.Compact(a.Genres) @@ -169,6 +183,32 @@ func (mfs MediaFiles) ToAlbum() Album { return a } +func allOrNothing(items []string) (string, int) { + items = slices.Compact(items) + if len(items) == 1 { + return items[0], 1 + } + if len(items) > 1 { + sort.Strings(items) + return "", len(slices.Compact(items)) + } + return "", 0 +} + +func minMax(items []int) (int, int) { + var max int = items[0] + var min int = items[0] + for _, value := range items { + max = number.Max(max, value) + if min == 0 { + min = value + } else if value > 0 { + min = number.Min(min, value) + } + } + return min, max +} + func newer(t1, t2 time.Time) time.Time { if t1.After(t2) { return t1 diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 1b86d507f..d487b6445 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -26,8 +26,8 @@ func NewMediaFileRepository(ctx context.Context, o orm.QueryExecutor) *mediaFile r.ormer = o r.tableName = "media_file" r.sortMappings = map[string]string{ - "artist": "order_artist_name asc, order_album_name asc, disc_number asc, track_number asc", - "album": "order_album_name asc, disc_number asc, track_number asc, order_artist_name asc, title asc", + "artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc", + "album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc", "random": "RANDOM()", } r.filterMappings = map[string]filterFunc{ diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index b5aa4f90e..33017b90e 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -123,7 +123,7 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { } func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) { - sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, disc_number, track_number") + sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number") var ids []string err := r.queryAll(sq, &ids) if err != nil { @@ -147,7 +147,7 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) { } var clauses Or for _, d := range discs { - clauses = append(clauses, And{Eq{"album_id": d.AlbumID}, Eq{"disc_number": d.DiscNumber}}) + clauses = append(clauses, And{Eq{"album_id": d.AlbumID}, Eq{"release_date": d.ReleaseDate}, Eq{"disc_number": d.DiscNumber}}) } return r.addMediaFileIds(clauses) } diff --git a/scanner/mapping.go b/scanner/mapping.go index 39a134fed..565211a32 100644 --- a/scanner/mapping.go +++ b/scanner/mapping.go @@ -32,9 +32,10 @@ func newMediaFileMapper(rootFolder string, genres model.GenreRepository) *mediaF func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile { mf := &model.MediaFile{} mf.ID = s.trackID(md) + mf.Year, mf.Date, mf.OriginalYear, mf.OriginalDate, mf.ReleaseYear, mf.ReleaseDate = s.mapDates(md) mf.Title = s.mapTrackTitle(md) mf.Album = md.Album() - mf.AlbumID = s.albumID(md) + mf.AlbumID = s.albumID(md, mf.ReleaseDate) mf.Album = s.mapAlbumName(md) mf.ArtistID = s.artistID(md) mf.Artist = s.mapArtistName(md) @@ -42,7 +43,6 @@ func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile { mf.AlbumArtist = s.mapAlbumArtistName(md) mf.Genre, mf.Genres = s.mapGenres(md.Genres()) mf.Compilation = md.Compilation() - mf.Year = md.Year() mf.TrackNumber, _ = md.TrackNumber() mf.DiscNumber, _ = md.DiscNumber() mf.DiscSubtitle = md.DiscSubtitle() @@ -128,8 +128,13 @@ func (s mediaFileMapper) trackID(md metadata.Tags) string { return fmt.Sprintf("%x", md5.Sum([]byte(md.FilePath()))) } -func (s mediaFileMapper) albumID(md metadata.Tags) string { +func (s mediaFileMapper) albumID(md metadata.Tags, releaseDate string) string { albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(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))) } @@ -169,3 +174,18 @@ func (s mediaFileMapper) mapGenres(genres []string) (string, model.Genres) { } return result[0].Name, result } + +func (s mediaFileMapper) mapDates(md metadata.Tags) (int, string, int, string, int, string) { + year, date := md.Date() + originalYear, originalDate := md.OriginalDate() + releaseYear, releaseDate := md.ReleaseDate() + + // 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 originalYear, originalDate, originalYear, originalDate, year, date + } + return year, date, originalYear, originalDate, releaseYear, releaseDate +} diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index 29898ce3e..d6268c07f 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -94,17 +94,19 @@ func (t Tags) Artist() string { return t.getFirstTagValue("artist", "sort_artist func (t Tags) AlbumArtist() string { return t.getFirstTagValue("album_artist", "album artist", "albumartist") } -func (t Tags) SortTitle() string { return t.getSortTag("", "title", "name") } -func (t Tags) SortAlbum() string { return t.getSortTag("", "album") } -func (t Tags) SortArtist() string { return t.getSortTag("", "artist") } -func (t Tags) SortAlbumArtist() string { return t.getSortTag("tso2", "albumartist", "album_artist") } -func (t Tags) Genres() []string { return t.getAllTagValues("genre") } -func (t Tags) Year() int { return t.getYear("date") } -func (t Tags) Comment() string { return t.getFirstTagValue("comment") } -func (t Tags) Lyrics() string { return t.getFirstTagValue("lyrics", "lyrics-eng") } -func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation") } -func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") } -func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") } +func (t Tags) SortTitle() string { return t.getSortTag("", "title", "name") } +func (t Tags) SortAlbum() string { return t.getSortTag("", "album") } +func (t Tags) SortArtist() string { return t.getSortTag("", "artist") } +func (t Tags) SortAlbumArtist() string { return t.getSortTag("tso2", "albumartist", "album_artist") } +func (t Tags) Genres() []string { return t.getAllTagValues("genre") } +func (t Tags) Date() (int, string) { return t.getDate("date") } +func (t Tags) OriginalDate() (int, string) { return t.getDate("originaldate") } +func (t Tags) ReleaseDate() (int, string) { return t.getDate("releasedate") } +func (t Tags) Comment() string { return t.getFirstTagValue("comment") } +func (t Tags) Lyrics() string { return t.getFirstTagValue("lyrics", "lyrics-eng") } +func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation") } +func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") } +func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") } func (t Tags) DiscSubtitle() string { return t.getFirstTagValue("tsst", "discsubtitle", "setsubtitle") } @@ -217,18 +219,38 @@ func (t Tags) getSortTag(originalTag string, tagNames ...string) string { var dateRegex = regexp.MustCompile(`([12]\d\d\d)`) -func (t Tags) getYear(tagNames ...string) int { +func (t Tags) getDate(tagNames ...string) (int, string) { tag := t.getFirstTagValue(tagNames...) - if tag == "" { - return 0 + if len(tag) < 4 { + return 0, "" } + // first get just the year match := dateRegex.FindStringSubmatch(tag) if len(match) == 0 { - log.Warn("Error parsing year date field", "file", t.filePath, "date", tag) - return 0 + log.Warn("Error parsing "+tagNames[0]+" field for year", "file", t.filePath, "date", tag) + return 0, "" } year, _ := strconv.Atoi(match[1]) - return year + + if len(tag) < 5 { + return year, match[1] + } + + //then try YYYY-MM-DD + if len(tag) > 10 { + tag = tag[:10] + } + layout := "2006-01-02" + _, err := time.Parse(layout, tag) + if err != nil { + layout = "2006-01" + _, err = time.Parse(layout, tag) + if err != nil { + log.Warn("Error parsing "+tagNames[0]+" field for month + day", "file", t.filePath, "date", tag) + return year, match[1] + } + } + return year, tag } func (t Tags) getBool(tagNames ...string) bool { diff --git a/scanner/metadata/metadata_internal_test.go b/scanner/metadata/metadata_internal_test.go index 6a10ecdf0..d7d2eaa5b 100644 --- a/scanner/metadata/metadata_internal_test.go +++ b/scanner/metadata/metadata_internal_test.go @@ -6,29 +6,51 @@ import ( ) var _ = Describe("Tags", func() { - Describe("getYear", func() { + Describe("getDate", func() { It("parses the year correctly", func() { - var examples = map[string]int{ + var examplesYear = map[string]int{ "1985": 1985, "2002-01": 2002, "1969.06": 1969, "1980.07.25": 1980, "2004-00-00": 2004, + "2016-12-31": 2016, "2013-May-12": 2013, "May 12, 2016": 2016, "01/10/1990": 1990, } - for tag, expected := range examples { + for tag, expected := range examplesYear { md := &Tags{} md.tags = map[string][]string{"date": {tag}} - Expect(md.Year()).To(Equal(expected)) + testYear, _ := md.Date() + Expect(testYear).To(Equal(expected)) } }) + It("parses the date correctly", func() { + var examplesDate = map[string]string{ + "1985": "1985", + "2002-01": "2002-01", + "1969.06": "1969", + "1980.07.25": "1980", + "2004-00-00": "2004", + "2016-12-31": "2016-12-31", + "2013-May-12": "2013", + "May 12, 2016": "2016", + "01/10/1990": "1990", + } + for tag, expected := range examplesDate { + md := &Tags{} + md.tags = map[string][]string{"date": {tag}} + _, testDate := md.Date() + Expect(testDate).To(Equal(expected)) + } + }) It("returns 0 if year is invalid", func() { md := &Tags{} md.tags = map[string][]string{"date": {"invalid"}} - Expect(md.Year()).To(Equal(0)) + testYear, _ := md.Date() + Expect(testYear).To(Equal(0)) }) }) diff --git a/scanner/metadata/metadata_test.go b/scanner/metadata/metadata_test.go index de1935ce1..0b1a78554 100644 --- a/scanner/metadata/metadata_test.go +++ b/scanner/metadata/metadata_test.go @@ -27,7 +27,15 @@ var _ = Describe("Tags", func() { Expect(m.AlbumArtist()).To(Equal("Album Artist")) Expect(m.Compilation()).To(BeTrue()) Expect(m.Genres()).To(Equal([]string{"Rock"})) - Expect(m.Year()).To(Equal(2014)) + y, d := m.Date() + Expect(y).To(Equal(2014)) + Expect(d).To(Equal("2014-05-21")) + y, d = m.OriginalDate() + Expect(y).To(Equal(1996)) + Expect(d).To(Equal("1996-11-21")) + y, d = m.ReleaseDate() + Expect(y).To(Equal(2020)) + Expect(d).To(Equal("2020-12-31")) n, t := m.TrackNumber() Expect(n).To(Equal(2)) Expect(t).To(Equal(10)) diff --git a/scanner/metadata/taglib/taglib_test.go b/scanner/metadata/taglib/taglib_test.go index 87287fb4c..342cb03c8 100644 --- a/scanner/metadata/taglib/taglib_test.go +++ b/scanner/metadata/taglib/taglib_test.go @@ -42,6 +42,8 @@ var _ = Describe("Extractor", func() { Expect(m).To(HaveKeyWithValue("tcmp", []string{"1"})) // Compilation Expect(m).To(HaveKeyWithValue("genre", []string{"Rock"})) Expect(m).To(HaveKeyWithValue("date", []string{"2014-05-21", "2014"})) + Expect(m).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"})) + Expect(m).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"})) Expect(m).To(HaveKeyWithValue("discnumber", []string{"1/2"})) Expect(m).To(HaveKeyWithValue("has_picture", []string{"true"})) Expect(m).To(HaveKeyWithValue("duration", []string{"1.02"})) diff --git a/ui/src/album/AlbumDetails.js b/ui/src/album/AlbumDetails.js index 043f29664..9a790095c 100644 --- a/ui/src/album/AlbumDetails.js +++ b/ui/src/album/AlbumDetails.js @@ -25,6 +25,7 @@ import { ArtistLinkField, DurationField, formatRange, + FormatFullDate, SizeField, LoveButton, RatingField, @@ -195,8 +196,59 @@ const Details = (props) => { details.push({obj}) } - const year = formatRange(record, 'year') - year && addDetail(<>{year}) + const originalYearRange = formatRange(record, 'originalYear') + const originalDate = record.originalDate + ? FormatFullDate(record.originalDate) + : originalYearRange + const yearRange = formatRange(record, 'year') + const date = record.date ? FormatFullDate(record.date) : yearRange + const releaseDate = record.releaseDate + ? FormatFullDate(record.releaseDate) + : date + + const showReleaseDate = date !== releaseDate && releaseDate.length > 3 + const showOriginalDate = + date !== originalDate && + originalDate !== releaseDate && + originalDate.length > 3 + + showOriginalDate && + !isXsmall && + addDetail( + <> + {[translate('resources.album.fields.originalDate'), originalDate].join( + ' ' + )} + + ) + + yearRange && addDetail(<>{['♫', !isXsmall ? date : yearRange].join(' ')}) + + showReleaseDate && + addDetail( + <> + {(!isXsmall + ? [translate('resources.album.fields.releaseDate'), releaseDate] + : ['○', record.releaseDate.substring(0, 4)] + ).join(' ')} + + ) + + const showReleases = record.releases > 1 + showReleases && + addDetail( + <> + {!isXsmall + ? [ + record.releases, + translate('resources.album.fields.releases', { + smart_count: record.releases, + }), + ].join(' ') + : ['(', record.releases, ')))'].join(' ')} + + ) + addDetail( <> {record.songCount + diff --git a/ui/src/album/AlbumGridView.js b/ui/src/album/AlbumGridView.js index 2ac1db042..2c01e8517 100644 --- a/ui/src/album/AlbumGridView.js +++ b/ui/src/album/AlbumGridView.js @@ -17,7 +17,7 @@ import { AlbumContextMenu, PlayButton, ArtistLinkField, - RangeField, + RangeDoubleField, } from '../common' import { DraggableTypes } from '../consts' @@ -161,9 +161,12 @@ const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => { {showArtist ? ( ) : ( - { trackNumber: isDesktop && ( @@ -172,6 +172,7 @@ const AlbumSongs = (props) => { {...props} hasBulkActions={true} showDiscSubtitles={true} + showReleaseDivider={true} contextAlwaysVisible={!isDesktop} classes={{ row: classes.row }} > @@ -207,7 +208,6 @@ export const removeAlbumCommentsFromSongs = ({ album, data }) => { const SanitizedAlbumSongs = (props) => { removeAlbumCommentsFromSongs(props) - const { loaded, loading, total, ...rest } = useListContext(props) return <>{loaded && } } diff --git a/ui/src/artist/ArtistShow.js b/ui/src/artist/ArtistShow.js index 6e317afcf..60a57e4d9 100644 --- a/ui/src/artist/ArtistShow.js +++ b/ui/src/artist/ArtistShow.js @@ -60,7 +60,7 @@ const AlbumShowLayout = (props) => { addLabel={false} reference="album" target="artist_id" - sort={{ field: 'max_year', order: 'ASC' }} + sort={{ field: 'max_year asc,date asc', order: 'ASC' }} filter={{ artist_id: record?.id }} perPage={0} pagination={null} diff --git a/ui/src/common/ContextMenus.js b/ui/src/common/ContextMenus.js index 131a697c7..a492548a4 100644 --- a/ui/src/common/ContextMenus.js +++ b/ui/src/common/ContextMenus.js @@ -200,8 +200,12 @@ export const AlbumContextMenu = (props) => resource={'album'} songQueryParams={{ pagination: { page: 1, perPage: -1 }, - sort: { field: 'discNumber, trackNumber', order: 'ASC' }, - filter: { album_id: props.record.id, disc_number: props.discNumber }, + sort: { field: 'releaseDate, discNumber, trackNumber', order: 'ASC' }, + filter: { + album_id: props.record.id, + release_date: props.releaseDate, + disc_number: props.discNumber, + }, }} /> ) : null @@ -226,7 +230,10 @@ export const ArtistContextMenu = (props) => resource={'artist'} songQueryParams={{ pagination: { page: 1, perPage: 200 }, - sort: { field: 'album, discNumber, trackNumber', order: 'ASC' }, + sort: { + field: 'album, releaseDate, discNumber, trackNumber', + order: 'ASC', + }, filter: { album_artist_id: props.record.id }, }} /> diff --git a/ui/src/common/FormatFullDate.js b/ui/src/common/FormatFullDate.js new file mode 100644 index 000000000..1fd7a088f --- /dev/null +++ b/ui/src/common/FormatFullDate.js @@ -0,0 +1,29 @@ +export const FormatFullDate = (date) => { + const dashes = date.split('-').length - 1 + let options = { + year: 'numeric', + } + switch (dashes) { + case 2: + options = { + year: 'numeric', + month: 'long', + day: 'numeric', + } + return new Date(date).toLocaleDateString(undefined, options) + case 1: + options = { + year: 'numeric', + month: 'long', + } + return new Date(date).toLocaleDateString(undefined, options) + case 0: + if (date.length === 4) { + return new Date(date).toLocaleDateString(undefined, options) + } else { + return '' + } + default: + return '' + } +} diff --git a/ui/src/common/PlayButton.js b/ui/src/common/PlayButton.js index e8ee36112..e40927ef2 100644 --- a/ui/src/common/PlayButton.js +++ b/ui/src/common/PlayButton.js @@ -21,8 +21,12 @@ export const PlayButton = ({ record, size, className }) => { dataProvider .getList('song', { pagination: { page: 1, perPage: -1 }, - sort: { field: 'discNumber, trackNumber', order: 'ASC' }, - filter: { album_id: record.id, disc_number: record.discNumber }, + sort: { field: 'releaseDate, discNumber, trackNumber', order: 'ASC' }, + filter: { + album_id: record.id, + release_date: record.releaseDate, + disc_number: record.discNumber, + }, }) .then((response) => { let { data, ids } = extractSongsData(response) diff --git a/ui/src/common/RangeDoubleField.js b/ui/src/common/RangeDoubleField.js new file mode 100644 index 000000000..d388abeb7 --- /dev/null +++ b/ui/src/common/RangeDoubleField.js @@ -0,0 +1,50 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useRecordContext } from 'react-admin' +import { formatRange } from '../common' + +export const RangeDoubleField = ({ + className, + source, + symbol1, + symbol2, + separator, + ...rest +}) => { + const record = useRecordContext(rest) + const yearRange = formatRange(record, source).toString() + const releases = [record.releases] + const releaseDate = [record.releaseDate] + const releaseYear = releaseDate.toString().substring(0, 4) + let subtitle = yearRange + + if (releases > 1) { + subtitle = [ + [yearRange && symbol1, yearRange].join(' '), + ['(', releases, ')))'].join(' '), + ].join(separator) + } + + if ( + yearRange !== releaseYear && + yearRange.length > 0 && + releaseYear.length > 0 + ) { + subtitle = [ + [yearRange && symbol1, yearRange].join(' '), + [symbol2, releaseYear].join(' '), + ].join(separator) + } + + return {subtitle} +} + +RangeDoubleField.propTypes = { + label: PropTypes.string, + record: PropTypes.object, + source: PropTypes.string.isRequired, +} + +RangeDoubleField.defaultProps = { + addLabel: true, +} diff --git a/ui/src/common/SongDatagrid.js b/ui/src/common/SongDatagrid.js index c3b9709bc..ddc8cff0e 100644 --- a/ui/src/common/SongDatagrid.js +++ b/ui/src/common/SongDatagrid.js @@ -1,6 +1,11 @@ import React, { isValidElement, useMemo, useCallback, forwardRef } from 'react' import { useDispatch } from 'react-redux' -import { Datagrid, PureDatagridBody, PureDatagridRow } from 'react-admin' +import { + Datagrid, + PureDatagridBody, + PureDatagridRow, + useTranslate, +} from 'react-admin' import { TableCell, TableRow, @@ -13,7 +18,7 @@ import AlbumIcon from '@material-ui/icons/Album' import clsx from 'clsx' import { useDrag } from 'react-dnd' import { playTracks } from '../actions' -import { AlbumContextMenu } from '../common' +import { AlbumContextMenu, FormatFullDate } from '../common' import { DraggableTypes } from '../consts' const useStyles = makeStyles({ @@ -49,12 +54,57 @@ const useStyles = makeStyles({ }, }) +const ReleaseRow = forwardRef( + ({ record, onClick, colSpan, contextAlwaysVisible }, ref) => { + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + const classes = useStyles({ isDesktop }) + const translate = useTranslate() + const handlePlaySubset = (releaseDate) => () => { + onClick(releaseDate) + } + + let releaseTitle = [] + if (record.releaseDate) { + releaseTitle.push(translate('resources.album.fields.released')) + releaseTitle.push(FormatFullDate(record.releaseDate)) + if (record.catalogNum && isDesktop) { + releaseTitle.push('· Cat #') + releaseTitle.push(record.catalogNum) + } + } + + return ( + + + + {releaseTitle.join(' ')} + + + + + + + ) + } +) + const DiscSubtitleRow = forwardRef( ({ record, onClick, colSpan, contextAlwaysVisible }, ref) => { const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const classes = useStyles({ isDesktop }) - const handlePlayDisc = (discNumber) => () => { - onClick(discNumber) + const handlePlaySubset = (releaseDate, discNumber) => () => { + onClick(releaseDate, discNumber) } let subtitle = [] @@ -69,7 +119,7 @@ const DiscSubtitleRow = forwardRef( @@ -82,6 +132,7 @@ const DiscSubtitleRow = forwardRef( { @@ -110,7 +162,13 @@ export const SongDatagridRow = ({ () => ({ type: DraggableTypes.DISC, item: { - discs: [{ albumId: record?.albumId, discNumber: record?.discNumber }], + discs: [ + { + albumId: record?.albumId, + releaseDate: record?.releaseDate, + discNumber: record?.discNumber, + }, + ], }, options: { dropEffect: 'copy' }, }), @@ -133,11 +191,20 @@ export const SongDatagridRow = ({ const childCount = fields.length return ( <> - {firstTracks.has(record.id) && ( + {firstTracksOfReleases.has(record.id) && ( + + )} + {firstTracksOfDiscs.has(record.id) && ( @@ -157,32 +224,43 @@ export const SongDatagridRow = ({ SongDatagridRow.propTypes = { record: PropTypes.object, children: PropTypes.node, - firstTracks: PropTypes.instanceOf(Set), + firstTracksOfDiscs: PropTypes.instanceOf(Set), + firstTracksOfReleases: PropTypes.instanceOf(Set), contextAlwaysVisible: PropTypes.bool, - onClickDiscSubtitle: PropTypes.func, + onClickSubset: PropTypes.func, } SongDatagridRow.defaultProps = { - onClickDiscSubtitle: () => {}, + onClickSubset: () => {}, } const SongDatagridBody = ({ contextAlwaysVisible, showDiscSubtitles, + showReleaseDivider, ...rest }) => { const dispatch = useDispatch() const { ids, data } = rest - const playDisc = useCallback( - (discNumber) => { - const idsToPlay = ids.filter((id) => data[id].discNumber === discNumber) + const playSubset = useCallback( + (releaseDate, discNumber) => { + let idsToPlay = [] + if (discNumber !== undefined) { + idsToPlay = ids.filter( + (id) => + data[id].releaseDate === releaseDate && + data[id].discNumber === discNumber + ) + } else { + idsToPlay = ids.filter((id) => data[id].releaseDate === releaseDate) + } dispatch(playTracks(data, idsToPlay)) }, [dispatch, data, ids] ) - const firstTracks = useMemo(() => { + const firstTracksOfDiscs = useMemo(() => { if (!ids) { return new Set() } @@ -195,7 +273,8 @@ const SongDatagridBody = ({ foundSubtitle = foundSubtitle || data[id].discSubtitle if ( acc.length === 0 || - (last && data[id].discNumber !== data[last].discNumber) + (last && data[id].discNumber !== data[last].discNumber) || + (last && data[id].releaseDate !== data[last].releaseDate) ) { acc.push(id) } @@ -208,14 +287,39 @@ const SongDatagridBody = ({ return set }, [ids, data, showDiscSubtitles]) + const firstTracksOfReleases = useMemo(() => { + if (!ids) { + return new Set() + } + const set = new Set( + ids + .filter((i) => data[i]) + .reduce((acc, id) => { + const last = acc && acc[acc.length - 1] + if ( + acc.length === 0 || + (last && data[id].releaseDate !== data[last].releaseDate) + ) { + acc.push(id) + } + return acc + }, []) + ) + if (!showReleaseDivider || set.size < 2) { + set.clear() + } + return set + }, [ids, data, showReleaseDivider]) + return ( } /> @@ -225,6 +329,7 @@ const SongDatagridBody = ({ export const SongDatagrid = ({ contextAlwaysVisible, showDiscSubtitles, + showReleaseDivider, ...rest }) => { const classes = useStyles() @@ -236,6 +341,7 @@ export const SongDatagrid = ({ } /> @@ -245,5 +351,6 @@ export const SongDatagrid = ({ SongDatagrid.propTypes = { contextAlwaysVisible: PropTypes.bool, showDiscSubtitles: PropTypes.bool, + showReleaseDivider: PropTypes.bool, classes: PropTypes.object, } diff --git a/ui/src/common/index.js b/ui/src/common/index.js index ad9bb48c0..be9638706 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -4,6 +4,7 @@ export * from './BatchPlayButton' export * from './BitrateField' export * from './ContextMenus' export * from './DateField' +export * from './FormatFullDate' export * from './DocLink' export * from './DurationField' export * from './List' @@ -12,6 +13,7 @@ export * from './Pagination' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' +export * from './RangeDoubleField' export * from './ShuffleAllButton' export * from './SimpleList' export * from './SizeField' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 2c9c565e0..857e8fe45 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -51,7 +51,9 @@ "name": "Name", "genre": "Genre", "compilation": "Compilation", - "year": "Year", + "originalDate": "Original", + "releaseDate": "Released", + "releases": "Release |||| Releases", "updatedAt": "Updated at", "comment": "Comment", "rating": "Rating", diff --git a/ui/src/song/SongList.js b/ui/src/song/SongList.js index 11e46d866..8222896c8 100644 --- a/ui/src/song/SongList.js +++ b/ui/src/song/SongList.js @@ -102,7 +102,7 @@ const SongList = (props) => {