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 <deluan@navidrome.org>
This commit is contained in:
certuna 2023-05-19 21:27:47 +02:00 committed by GitHub
parent 010ba0d15c
commit 52b77e4194
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 511 additions and 78 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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"},

View File

@ -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

View File

@ -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{

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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))
})
})

View File

@ -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))

View File

@ -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"}))

View File

@ -25,6 +25,7 @@ import {
ArtistLinkField,
DurationField,
formatRange,
FormatFullDate,
SizeField,
LoveButton,
RatingField,
@ -195,8 +196,59 @@ const Details = (props) => {
details.push(<span key={`detail-${record.id}-${id}`}>{obj}</span>)
}
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 +

View File

@ -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 ? (
<ArtistLinkField record={record} className={classes.albumSubtitle} />
) : (
<RangeField
<RangeDoubleField
record={record}
source={'year'}
symbol1={'♫'}
symbol2={'○'}
separator={' · '}
sortBy={'max_year'}
sortByOrder={'DESC'}
className={classes.albumSubtitle}

View File

@ -99,7 +99,7 @@ const AlbumSongs = (props) => {
trackNumber: isDesktop && (
<TextField
source="trackNumber"
sortBy="discNumber asc, trackNumber asc"
sortBy="releaseDate asc, discNumber asc, trackNumber asc"
label="#"
sortable={false}
/>
@ -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 && <AlbumSongs {...rest} actions={props.actions} />}</>
}

View File

@ -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}

View File

@ -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 },
}}
/>

View File

@ -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 ''
}
}

View File

@ -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)

View File

@ -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 <span className={className}>{subtitle}</span>
}
RangeDoubleField.propTypes = {
label: PropTypes.string,
record: PropTypes.object,
source: PropTypes.string.isRequired,
}
RangeDoubleField.defaultProps = {
addLabel: true,
}

View File

@ -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 (
<TableRow
hover
ref={ref}
onClick={handlePlaySubset(record.releaseDate)}
className={classes.row}
>
<TableCell colSpan={colSpan}>
<Typography variant="h6" className={classes.subtitle}>
{releaseTitle.join(' ')}
</Typography>
</TableCell>
<TableCell>
<AlbumContextMenu
record={{ id: record.albumId }}
releaseDate={record.releaseDate}
showLove={false}
className={classes.contextMenu}
visible={contextAlwaysVisible}
/>
</TableCell>
</TableRow>
)
}
)
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(
<TableRow
hover
ref={ref}
onClick={handlePlayDisc(record.discNumber)}
onClick={handlePlaySubset(record.releaseDate, record.discNumber)}
className={classes.row}
>
<TableCell colSpan={colSpan}>
@ -82,6 +132,7 @@ const DiscSubtitleRow = forwardRef(
<AlbumContextMenu
record={{ id: record.albumId }}
discNumber={record.discNumber}
releaseDate={record.releaseDate}
showLove={false}
className={classes.contextMenu}
visible={contextAlwaysVisible}
@ -95,9 +146,10 @@ const DiscSubtitleRow = forwardRef(
export const SongDatagridRow = ({
record,
children,
firstTracks,
firstTracksOfDiscs,
firstTracksOfReleases,
contextAlwaysVisible,
onClickDiscSubtitle,
onClickSubset,
className,
...rest
}) => {
@ -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) && (
<ReleaseRow
ref={dragDiscRef}
record={record}
onClick={onClickSubset}
contextAlwaysVisible={contextAlwaysVisible}
colSpan={childCount + (rest.expand ? 1 : 0)}
/>
)}
{firstTracksOfDiscs.has(record.id) && (
<DiscSubtitleRow
ref={dragDiscRef}
record={record}
onClick={onClickDiscSubtitle}
onClick={onClickSubset}
contextAlwaysVisible={contextAlwaysVisible}
colSpan={childCount + (rest.expand ? 1 : 0)}
/>
@ -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 (
<PureDatagridBody
{...rest}
row={
<SongDatagridRow
firstTracks={firstTracks}
firstTracksOfDiscs={firstTracksOfDiscs}
firstTracksOfReleases={firstTracksOfReleases}
contextAlwaysVisible={contextAlwaysVisible}
onClickDiscSubtitle={playDisc}
onClickSubset={playSubset}
/>
}
/>
@ -225,6 +329,7 @@ const SongDatagridBody = ({
export const SongDatagrid = ({
contextAlwaysVisible,
showDiscSubtitles,
showReleaseDivider,
...rest
}) => {
const classes = useStyles()
@ -236,6 +341,7 @@ export const SongDatagrid = ({
<SongDatagridBody
contextAlwaysVisible={contextAlwaysVisible}
showDiscSubtitles={showDiscSubtitles}
showReleaseDivider={showReleaseDivider}
/>
}
/>
@ -245,5 +351,6 @@ export const SongDatagrid = ({
SongDatagrid.propTypes = {
contextAlwaysVisible: PropTypes.bool,
showDiscSubtitles: PropTypes.bool,
showReleaseDivider: PropTypes.bool,
classes: PropTypes.object,
}

View File

@ -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'

View File

@ -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",

View File

@ -102,7 +102,7 @@ const SongList = (props) => {
<AlbumLinkField
source="album"
sortBy={
'album, order_album_artist_name, disc_number, track_number, title'
'album, order_album_artist_name, release_date, disc_number, track_number, title'
}
sortByOrder={'ASC'}
/>