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) => {