update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
144
model/album.go
Normal file
144
model/album.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
)
|
||||
|
||||
type Album struct {
|
||||
Annotations `structs:"-" hash:"ignore"`
|
||||
|
||||
ID string `structs:"id" json:"id"`
|
||||
LibraryID int `structs:"library_id" json:"libraryId"`
|
||||
LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"`
|
||||
LibraryName string `structs:"-" json:"libraryName" hash:"ignore"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
EmbedArtPath string `structs:"embed_art_path" json:"-"`
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants
|
||||
// AlbumArtist is the display name used for the album artist.
|
||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
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"`
|
||||
Compilation bool `structs:"compilation" json:"compilation"`
|
||||
Comment string `structs:"comment" json:"comment,omitempty"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
Duration float32 `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
Discs Discs `structs:"discs" json:"discs,omitempty"`
|
||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
|
||||
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
|
||||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
||||
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
||||
MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"`
|
||||
MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"`
|
||||
MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
|
||||
MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
|
||||
MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
|
||||
FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album
|
||||
ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
|
||||
|
||||
// External metadata fields
|
||||
Description string `structs:"description" json:"description,omitempty" hash:"ignore"`
|
||||
SmallImageUrl string `structs:"small_image_url" json:"smallImageUrl,omitempty" hash:"ignore"`
|
||||
MediumImageUrl string `structs:"medium_image_url" json:"mediumImageUrl,omitempty" hash:"ignore"`
|
||||
LargeImageUrl string `structs:"large_image_url" json:"largeImageUrl,omitempty" hash:"ignore"`
|
||||
ExternalUrl string `structs:"external_url" json:"externalUrl,omitempty" hash:"ignore"`
|
||||
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt" hash:"ignore"`
|
||||
|
||||
Genre string `structs:"genre" json:"genre" hash:"ignore"` // Easy access to the most common genre
|
||||
Genres Genres `structs:"-" json:"genres" hash:"ignore"` // Easy access to all genres for this album
|
||||
Tags Tags `structs:"tags" json:"tags,omitempty" hash:"ignore"` // All imported tags for this album
|
||||
Participants Participants `structs:"participants" json:"participants" hash:"ignore"` // All artists that participated in this album
|
||||
|
||||
Missing bool `structs:"missing" json:"missing"` // If all file of the album ar missing
|
||||
ImportedAt time.Time `structs:"imported_at" json:"importedAt" hash:"ignore"` // When this album was imported/updated
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Oldest CreatedAt for all songs in this album
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Newest UpdatedAt for all songs in this album
|
||||
}
|
||||
|
||||
func (a Album) CoverArtID() ArtworkID {
|
||||
return artworkIDFromAlbum(a)
|
||||
}
|
||||
|
||||
// Equals compares two Album structs, ignoring calculated fields
|
||||
func (a Album) Equals(other Album) bool {
|
||||
// Normalize float32 values to avoid false negatives
|
||||
a.Duration = float32(math.Floor(float64(a.Duration)))
|
||||
other.Duration = float32(math.Floor(float64(other.Duration)))
|
||||
|
||||
opts := &hashstructure.HashOptions{
|
||||
IgnoreZeroValue: true,
|
||||
ZeroNil: true,
|
||||
}
|
||||
hash1, _ := hashstructure.Hash(a, opts)
|
||||
hash2, _ := hashstructure.Hash(other, opts)
|
||||
|
||||
return hash1 == hash2
|
||||
}
|
||||
|
||||
// AlbumLevelTags contains all Tags marked as `album: true` in the mappings.yml file. They are not
|
||||
// "first-class citizens" in the Album struct, but are still stored in the album table, in the `tags` column.
|
||||
var AlbumLevelTags = sync.OnceValue(func() map[TagName]struct{} {
|
||||
tags := make(map[TagName]struct{})
|
||||
m := TagMappings()
|
||||
for t, conf := range m {
|
||||
if conf.Album {
|
||||
tags[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
})
|
||||
|
||||
func (a *Album) SetTags(tags TagList) {
|
||||
a.Tags = tags.GroupByFrequency()
|
||||
for k := range a.Tags {
|
||||
if _, ok := AlbumLevelTags()[k]; !ok {
|
||||
delete(a.Tags, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Discs map[int]string
|
||||
|
||||
func (d Discs) Add(discNumber int, discSubtitle string) {
|
||||
d[discNumber] = discSubtitle
|
||||
}
|
||||
|
||||
type DiscID struct {
|
||||
AlbumID string `json:"albumId"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
}
|
||||
|
||||
type Albums []Album
|
||||
|
||||
type AlbumCursor iter.Seq2[Album, error]
|
||||
|
||||
type AlbumRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(*Album) error
|
||||
UpdateExternalInfo(*Album) error
|
||||
Get(id string) (*Album, error)
|
||||
GetAll(...QueryOptions) (Albums, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
Touch(ids ...string) error
|
||||
TouchByMissingFolder() (int64, error)
|
||||
GetTouchedAlbums(libID int) (AlbumCursor, error)
|
||||
RefreshPlayCounts() (int64, error)
|
||||
CopyAttributes(fromID, toID string, columns ...string) error
|
||||
|
||||
AnnotatedRepository
|
||||
SearchableRepository[Albums]
|
||||
}
|
||||
33
model/album_test.go
Normal file
33
model/album_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Albums", func() {
|
||||
var albums Albums
|
||||
|
||||
Context("JSON Marshalling", func() {
|
||||
When("we have a valid Albums object", func() {
|
||||
BeforeEach(func() {
|
||||
albums = Albums{
|
||||
{ID: "1", AlbumArtist: "Artist", AlbumArtistID: "11", SortAlbumArtistName: "SortAlbumArtistName", OrderAlbumArtistName: "OrderAlbumArtistName"},
|
||||
{ID: "2", AlbumArtist: "Artist", AlbumArtistID: "11", SortAlbumArtistName: "SortAlbumArtistName", OrderAlbumArtistName: "OrderAlbumArtistName"},
|
||||
}
|
||||
})
|
||||
It("marshals correctly", func() {
|
||||
data, err := json.Marshal(albums)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
var albums2 Albums
|
||||
err = json.Unmarshal(data, &albums2)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(albums2).To(Equal(albums))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
19
model/annotation.go
Normal file
19
model/annotation.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Annotations struct {
|
||||
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
||||
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
||||
Rating int `structs:"rating" json:"rating,omitempty" `
|
||||
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
|
||||
Starred bool `structs:"starred" json:"starred,omitempty" `
|
||||
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
||||
}
|
||||
|
||||
type AnnotatedRepository interface {
|
||||
IncPlayCount(itemID string, ts time.Time) error
|
||||
SetStar(starred bool, itemIDs ...string) error
|
||||
SetRating(rating int, itemID string) error
|
||||
ReassignAnnotation(prevID string, newID string) error
|
||||
}
|
||||
89
model/artist.go
Normal file
89
model/artist.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Artist struct {
|
||||
Annotations `structs:"-"`
|
||||
|
||||
ID string `structs:"id" json:"id"`
|
||||
|
||||
// Data based on tags
|
||||
Name string `structs:"name" json:"name"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName,omitempty"`
|
||||
MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"`
|
||||
|
||||
// Data calculated from files
|
||||
Stats map[Role]ArtistStats `structs:"-" json:"stats,omitempty"`
|
||||
Size int64 `structs:"-" json:"size,omitempty"`
|
||||
AlbumCount int `structs:"-" json:"albumCount,omitempty"`
|
||||
SongCount int `structs:"-" json:"songCount,omitempty"`
|
||||
|
||||
// Data imported from external sources
|
||||
Biography string `structs:"biography" json:"biography,omitempty"`
|
||||
SmallImageUrl string `structs:"small_image_url" json:"smallImageUrl,omitempty"`
|
||||
MediumImageUrl string `structs:"medium_image_url" json:"mediumImageUrl,omitempty"`
|
||||
LargeImageUrl string `structs:"large_image_url" json:"largeImageUrl,omitempty"`
|
||||
ExternalUrl string `structs:"external_url" json:"externalUrl,omitempty"`
|
||||
SimilarArtists Artists `structs:"similar_artists" json:"-"`
|
||||
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"`
|
||||
|
||||
Missing bool `structs:"missing" json:"missing"`
|
||||
|
||||
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ArtistStats struct {
|
||||
SongCount int `json:"songCount"`
|
||||
AlbumCount int `json:"albumCount"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
func (a Artist) ArtistImageUrl() string {
|
||||
if a.LargeImageUrl != "" {
|
||||
return a.LargeImageUrl
|
||||
}
|
||||
if a.MediumImageUrl != "" {
|
||||
return a.MediumImageUrl
|
||||
}
|
||||
return a.SmallImageUrl
|
||||
}
|
||||
|
||||
func (a Artist) CoverArtID() ArtworkID {
|
||||
return artworkIDFromArtist(a)
|
||||
}
|
||||
|
||||
// Roles returns the roles this artist has participated in., based on the Stats field
|
||||
func (a Artist) Roles() []Role {
|
||||
return slices.Collect(maps.Keys(a.Stats))
|
||||
}
|
||||
|
||||
type Artists []Artist
|
||||
|
||||
type ArtistIndex struct {
|
||||
ID string
|
||||
Artists Artists
|
||||
}
|
||||
type ArtistIndexes []ArtistIndex
|
||||
|
||||
type ArtistRepository interface {
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *Artist, colsToUpdate ...string) error
|
||||
UpdateExternalInfo(a *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetAll(options ...QueryOptions) (Artists, error)
|
||||
GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
RefreshPlayCounts() (int64, error)
|
||||
RefreshStats(allArtists bool) (int64, error)
|
||||
|
||||
AnnotatedRepository
|
||||
SearchableRepository[Artists]
|
||||
}
|
||||
13
model/artist_info.go
Normal file
13
model/artist_info.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
type ArtistInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
Biography string
|
||||
SmallImageUrl string
|
||||
MediumImageUrl string
|
||||
LargeImageUrl string
|
||||
LastFMUrl string
|
||||
SimilarArtists Artists
|
||||
}
|
||||
123
model/artwork_id.go
Normal file
123
model/artwork_id.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Kind struct {
|
||||
prefix string
|
||||
name string
|
||||
}
|
||||
|
||||
func (k Kind) String() string {
|
||||
return k.name
|
||||
}
|
||||
|
||||
var (
|
||||
KindMediaFileArtwork = Kind{"mf", "media_file"}
|
||||
KindArtistArtwork = Kind{"ar", "artist"}
|
||||
KindAlbumArtwork = Kind{"al", "album"}
|
||||
KindPlaylistArtwork = Kind{"pl", "playlist"}
|
||||
)
|
||||
|
||||
var artworkKindMap = map[string]Kind{
|
||||
KindMediaFileArtwork.prefix: KindMediaFileArtwork,
|
||||
KindArtistArtwork.prefix: KindArtistArtwork,
|
||||
KindAlbumArtwork.prefix: KindAlbumArtwork,
|
||||
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
|
||||
}
|
||||
|
||||
type ArtworkID struct {
|
||||
Kind Kind
|
||||
ID string
|
||||
LastUpdate time.Time
|
||||
}
|
||||
|
||||
func (id ArtworkID) String() string {
|
||||
if id.ID == "" {
|
||||
return ""
|
||||
}
|
||||
s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID)
|
||||
if lu := id.LastUpdate.Unix(); lu > 0 {
|
||||
return fmt.Sprintf("%s_%x", s, lu)
|
||||
}
|
||||
return s + "_0"
|
||||
}
|
||||
|
||||
func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID {
|
||||
artID := ArtworkID{kind, id, time.Time{}}
|
||||
if lastUpdate != nil {
|
||||
artID.LastUpdate = *lastUpdate
|
||||
}
|
||||
return artID
|
||||
}
|
||||
|
||||
func ParseArtworkID(id string) (ArtworkID, error) {
|
||||
parts := strings.SplitN(id, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return ArtworkID{}, errors.New("invalid artwork id")
|
||||
}
|
||||
kind, ok := artworkKindMap[parts[0]]
|
||||
if !ok {
|
||||
return ArtworkID{}, errors.New("invalid artwork kind")
|
||||
}
|
||||
parsedID := ArtworkID{
|
||||
Kind: kind,
|
||||
ID: parts[1],
|
||||
}
|
||||
parts = strings.SplitN(parts[1], "_", 2)
|
||||
if len(parts) == 2 {
|
||||
if parts[1] != "0" {
|
||||
lastUpdate, err := strconv.ParseInt(parts[1], 16, 64)
|
||||
if err != nil {
|
||||
return ArtworkID{}, err
|
||||
}
|
||||
parsedID.LastUpdate = time.Unix(lastUpdate, 0)
|
||||
}
|
||||
parsedID.ID = parts[0]
|
||||
}
|
||||
return parsedID, nil
|
||||
}
|
||||
|
||||
func MustParseArtworkID(id string) ArtworkID {
|
||||
artID, err := ParseArtworkID(id)
|
||||
if err != nil {
|
||||
panic(artID)
|
||||
}
|
||||
return artID
|
||||
}
|
||||
|
||||
func artworkIDFromAlbum(al Album) ArtworkID {
|
||||
return ArtworkID{
|
||||
Kind: KindAlbumArtwork,
|
||||
ID: al.ID,
|
||||
LastUpdate: al.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func artworkIDFromMediaFile(mf MediaFile) ArtworkID {
|
||||
return ArtworkID{
|
||||
Kind: KindMediaFileArtwork,
|
||||
ID: mf.ID,
|
||||
LastUpdate: mf.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func artworkIDFromPlaylist(pls Playlist) ArtworkID {
|
||||
return ArtworkID{
|
||||
Kind: KindPlaylistArtwork,
|
||||
ID: pls.ID,
|
||||
LastUpdate: pls.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func artworkIDFromArtist(ar Artist) ArtworkID {
|
||||
return ArtworkID{
|
||||
Kind: KindArtistArtwork,
|
||||
ID: ar.ID,
|
||||
}
|
||||
}
|
||||
59
model/artwork_id_test.go
Normal file
59
model/artwork_id_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ArtworkID", func() {
|
||||
Describe("NewArtworkID()", func() {
|
||||
It("creates a valid parseable ArtworkID", func() {
|
||||
now := time.Now()
|
||||
id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now)
|
||||
parsedId, err := model.ParseArtworkID(id.String())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(parsedId.Kind).To(Equal(id.Kind))
|
||||
Expect(parsedId.ID).To(Equal(id.ID))
|
||||
Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix()))
|
||||
})
|
||||
It("creates a valid ArtworkID without lastUpdate info", func() {
|
||||
id := model.NewArtworkID(model.KindPlaylistArtwork, "1234", nil)
|
||||
parsedId, err := model.ParseArtworkID(id.String())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(parsedId.Kind).To(Equal(id.Kind))
|
||||
Expect(parsedId.ID).To(Equal(id.ID))
|
||||
Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix()))
|
||||
})
|
||||
})
|
||||
Describe("ParseArtworkID()", func() {
|
||||
It("parses album artwork ids", func() {
|
||||
id, err := model.ParseArtworkID("al-1234")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id.Kind).To(Equal(model.KindAlbumArtwork))
|
||||
Expect(id.ID).To(Equal("1234"))
|
||||
})
|
||||
It("parses media file artwork ids", func() {
|
||||
id, err := model.ParseArtworkID("mf-a6f8d2b1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id.Kind).To(Equal(model.KindMediaFileArtwork))
|
||||
Expect(id.ID).To(Equal("a6f8d2b1"))
|
||||
})
|
||||
It("parses playlists artwork ids", func() {
|
||||
id, err := model.ParseArtworkID("pl-18690de0-151b-4d86-81cb-f418a907315a")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id.Kind).To(Equal(model.KindPlaylistArtwork))
|
||||
Expect(id.ID).To(Equal("18690de0-151b-4d86-81cb-f418a907315a"))
|
||||
})
|
||||
It("fails to parse malformed ids", func() {
|
||||
_, err := model.ParseArtworkID("a6f8d2b1")
|
||||
Expect(err).To(MatchError("invalid artwork id"))
|
||||
})
|
||||
It("fails to parse ids with invalid kind", func() {
|
||||
_, err := model.ParseArtworkID("xx-a6f8d2b1")
|
||||
Expect(err).To(MatchError("invalid artwork kind"))
|
||||
})
|
||||
})
|
||||
})
|
||||
24
model/bookmark.go
Normal file
24
model/bookmark.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Bookmarkable struct {
|
||||
BookmarkPosition int64 `structs:"-" json:"bookmarkPosition"`
|
||||
}
|
||||
|
||||
type BookmarkableRepository interface {
|
||||
AddBookmark(id, comment string, position int64) error
|
||||
DeleteBookmark(id string) error
|
||||
GetBookmarks() (Bookmarks, error)
|
||||
}
|
||||
|
||||
type Bookmark struct {
|
||||
Item MediaFile `structs:"item" json:"item"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
Position int64 `structs:"position" json:"position"`
|
||||
ChangedBy string `structs:"changed_by" json:"changed_by"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Bookmarks []Bookmark
|
||||
159
model/criteria/criteria.go
Normal file
159
model/criteria/criteria.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Package criteria implements a Criteria API based on Masterminds/squirrel
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type Expression = squirrel.Sqlizer
|
||||
|
||||
type Criteria struct {
|
||||
Expression
|
||||
Sort string
|
||||
Order string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func (c Criteria) OrderBy() string {
|
||||
if c.Sort == "" {
|
||||
c.Sort = "title"
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(c.Order))
|
||||
if order != "" && order != "asc" && order != "desc" {
|
||||
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
|
||||
order = ""
|
||||
}
|
||||
|
||||
parts := strings.Split(c.Sort, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
dir := "asc"
|
||||
if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") {
|
||||
if strings.HasPrefix(p, "-") {
|
||||
dir = "desc"
|
||||
}
|
||||
p = strings.TrimSpace(p[1:])
|
||||
}
|
||||
|
||||
sortField := strings.ToLower(p)
|
||||
f := fieldMap[sortField]
|
||||
if f == nil {
|
||||
log.Error("Invalid field in 'sort' field", "sort", sortField)
|
||||
continue
|
||||
}
|
||||
|
||||
var mapped string
|
||||
|
||||
if f.order != "" {
|
||||
mapped = f.order
|
||||
} else if f.isTag {
|
||||
// Use the actual field name (handles aliases like albumtype -> releasetype)
|
||||
tagName := sortField
|
||||
if f.field != "" {
|
||||
tagName = f.field
|
||||
}
|
||||
mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')"
|
||||
} else if f.isRole {
|
||||
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
|
||||
} else {
|
||||
mapped = f.field
|
||||
}
|
||||
if f.numeric {
|
||||
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
|
||||
}
|
||||
// If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction.
|
||||
// This ensures that the global order applies consistently across all fields.
|
||||
if order == "desc" {
|
||||
if dir == "asc" {
|
||||
dir = "desc"
|
||||
} else {
|
||||
dir = "asc"
|
||||
}
|
||||
}
|
||||
|
||||
fields = append(fields, mapped+" "+dir)
|
||||
}
|
||||
|
||||
return strings.Join(fields, ", ")
|
||||
}
|
||||
|
||||
func (c Criteria) ToSql() (sql string, args []any, err error) {
|
||||
return c.Expression.ToSql()
|
||||
}
|
||||
|
||||
func (c Criteria) ChildPlaylistIds() []string {
|
||||
if c.Expression == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil {
|
||||
return parent.ChildPlaylistIds()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Criteria) MarshalJSON() ([]byte, error) {
|
||||
aux := struct {
|
||||
All []Expression `json:"all,omitempty"`
|
||||
Any []Expression `json:"any,omitempty"`
|
||||
Sort string `json:"sort,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
}{
|
||||
Sort: c.Sort,
|
||||
Order: c.Order,
|
||||
Limit: c.Limit,
|
||||
Offset: c.Offset,
|
||||
}
|
||||
switch rules := c.Expression.(type) {
|
||||
case Any:
|
||||
aux.Any = rules
|
||||
case All:
|
||||
aux.All = rules
|
||||
default:
|
||||
aux.All = All{rules}
|
||||
}
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
func (c *Criteria) UnmarshalJSON(data []byte) error {
|
||||
var aux struct {
|
||||
All unmarshalConjunctionType `json:"all"`
|
||||
Any unmarshalConjunctionType `json:"any"`
|
||||
Sort string `json:"sort"`
|
||||
Order string `json:"order"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(aux.Any) > 0 {
|
||||
c.Expression = Any(aux.Any)
|
||||
} else if len(aux.All) > 0 {
|
||||
c.Expression = All(aux.All)
|
||||
} else {
|
||||
return errors.New("invalid criteria json. missing rules (key 'all' or 'any')")
|
||||
}
|
||||
c.Sort = aux.Sort
|
||||
c.Order = aux.Order
|
||||
c.Limit = aux.Limit
|
||||
c.Offset = aux.Offset
|
||||
return nil
|
||||
}
|
||||
17
model/criteria/criteria_suite_test.go
Normal file
17
model/criteria/criteria_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestCriteria(t *testing.T) {
|
||||
log.SetLevel(log.LevelFatal)
|
||||
gomega.RegisterFailHandler(Fail)
|
||||
// Register `genre` as a tag name, so we can use it in tests
|
||||
RunSpecs(t, "Criteria Suite")
|
||||
}
|
||||
248
model/criteria/criteria_test.go
Normal file
248
model/criteria/criteria_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Criteria", func() {
|
||||
var goObj Criteria
|
||||
var jsonObj string
|
||||
|
||||
Context("with a complex criteria", func() {
|
||||
BeforeEach(func() {
|
||||
goObj = Criteria{
|
||||
Expression: All{
|
||||
Contains{"title": "love"},
|
||||
NotContains{"title": "hate"},
|
||||
Any{
|
||||
IsNot{"artist": "u2"},
|
||||
Is{"album": "best of"},
|
||||
},
|
||||
All{
|
||||
StartsWith{"comment": "this"},
|
||||
InTheRange{"year": []int{1980, 1990}},
|
||||
IsNot{"genre": "Rock"},
|
||||
},
|
||||
},
|
||||
Sort: "title",
|
||||
Order: "asc",
|
||||
Limit: 20,
|
||||
Offset: 10,
|
||||
}
|
||||
var b bytes.Buffer
|
||||
err := json.Compact(&b, []byte(`
|
||||
{
|
||||
"all": [
|
||||
{ "contains": {"title": "love"} },
|
||||
{ "notContains": {"title": "hate"} },
|
||||
{ "any": [
|
||||
{ "isNot": {"artist": "u2"} },
|
||||
{ "is": {"album": "best of"} }
|
||||
]
|
||||
},
|
||||
{ "all": [
|
||||
{ "startsWith": {"comment": "this"} },
|
||||
{ "inTheRange": {"year":[1980,1990]} },
|
||||
{ "isNot": { "genre": "Rock" }}
|
||||
]
|
||||
}
|
||||
],
|
||||
"sort": "title",
|
||||
"order": "asc",
|
||||
"limit": 20,
|
||||
"offset": 10
|
||||
}
|
||||
`))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
jsonObj = b.String()
|
||||
})
|
||||
It("generates valid SQL", func() {
|
||||
sql, args, err := goObj.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal(
|
||||
`(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
|
||||
`AND (not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) ` +
|
||||
`OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
|
||||
`AND not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)))`))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock"))
|
||||
})
|
||||
It("marshals to JSON", func() {
|
||||
j, err := json.Marshal(goObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
|
||||
})
|
||||
It("is reversible to/from JSON", func() {
|
||||
var newObj Criteria
|
||||
err := json.Unmarshal([]byte(jsonObj), &newObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
j, err := json.Marshal(newObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
|
||||
})
|
||||
Describe("OrderBy", func() {
|
||||
It("sorts by regular fields", func() {
|
||||
gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc"))
|
||||
})
|
||||
|
||||
It("sorts by tag fields", func() {
|
||||
goObj.Sort = "genre"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal(
|
||||
"COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by role fields", func() {
|
||||
goObj.Sort = "artist"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal(
|
||||
"COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
It("casts numeric tags when sorting", func() {
|
||||
AddTagNames([]string{"rate"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
goObj.Sort = "rate"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by albumtype alias (resolves to releasetype)", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
goObj.Sort = "albumtype"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal(
|
||||
"COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
newObj := goObj
|
||||
newObj.Sort = "random"
|
||||
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
|
||||
})
|
||||
|
||||
It("sorts by multiple fields", func() {
|
||||
goObj.Sort = "title,-rating"
|
||||
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
|
||||
"media_file.title asc, COALESCE(annotation.rating, 0) desc",
|
||||
))
|
||||
})
|
||||
|
||||
It("reverts order when order is desc", func() {
|
||||
goObj.Sort = "-date,artist"
|
||||
goObj.Order = "desc"
|
||||
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
|
||||
"media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc",
|
||||
))
|
||||
})
|
||||
|
||||
It("ignores invalid sort fields", func() {
|
||||
goObj.Sort = "bogus,title"
|
||||
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
|
||||
"media_file.title asc",
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("with artist roles", func() {
|
||||
BeforeEach(func() {
|
||||
goObj = Criteria{
|
||||
Expression: All{
|
||||
Is{"artist": "The Beatles"},
|
||||
Contains{"composer": "Lennon"},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("generates valid SQL", func() {
|
||||
sql, args, err := goObj.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal(
|
||||
`(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` +
|
||||
`exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`,
|
||||
))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with child playlists", func() {
|
||||
var (
|
||||
topLevelInPlaylistID string
|
||||
topLevelNotInPlaylistID string
|
||||
nestedAnyInPlaylistID string
|
||||
nestedAnyNotInPlaylistID string
|
||||
nestedAllInPlaylistID string
|
||||
nestedAllNotInPlaylistID string
|
||||
)
|
||||
BeforeEach(func() {
|
||||
topLevelInPlaylistID = uuid.NewString()
|
||||
topLevelNotInPlaylistID = uuid.NewString()
|
||||
|
||||
nestedAnyInPlaylistID = uuid.NewString()
|
||||
nestedAnyNotInPlaylistID = uuid.NewString()
|
||||
|
||||
nestedAllInPlaylistID = uuid.NewString()
|
||||
nestedAllNotInPlaylistID = uuid.NewString()
|
||||
|
||||
goObj = Criteria{
|
||||
Expression: All{
|
||||
InPlaylist{"id": topLevelInPlaylistID},
|
||||
NotInPlaylist{"id": topLevelNotInPlaylistID},
|
||||
Any{
|
||||
InPlaylist{"id": nestedAnyInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
|
||||
},
|
||||
All{
|
||||
InPlaylist{"id": nestedAllInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAllNotInPlaylistID},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
It("extracts all child smart playlist IDs from expression criteria", func() {
|
||||
ids := goObj.ChildPlaylistIds()
|
||||
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
|
||||
})
|
||||
It("extracts child smart playlist IDs from deeply nested expression", func() {
|
||||
goObj = Criteria{
|
||||
Expression: Any{
|
||||
Any{
|
||||
All{
|
||||
Any{
|
||||
InPlaylist{"id": nestedAnyInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
|
||||
Any{
|
||||
All{
|
||||
InPlaylist{"id": nestedAllInPlaylistID},
|
||||
NotInPlaylist{"id": nestedAllNotInPlaylistID},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ids := goObj.ChildPlaylistIds()
|
||||
gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
|
||||
})
|
||||
It("returns empty list when no child playlist IDs are present", func() {
|
||||
ids := Criteria{}.ChildPlaylistIds()
|
||||
gomega.Expect(ids).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
5
model/criteria/export_test.go
Normal file
5
model/criteria/export_test.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package criteria
|
||||
|
||||
var StartOfPeriod = startOfPeriod
|
||||
|
||||
type UnmarshalConjunctionType = unmarshalConjunctionType
|
||||
243
model/criteria/fields.go
Normal file
243
model/criteria/fields.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
var fieldMap = map[string]*mappedField{
|
||||
"title": {field: "media_file.title"},
|
||||
"album": {field: "media_file.album"},
|
||||
"hascoverart": {field: "media_file.has_cover_art"},
|
||||
"tracknumber": {field: "media_file.track_number"},
|
||||
"discnumber": {field: "media_file.disc_number"},
|
||||
"year": {field: "media_file.year"},
|
||||
"date": {field: "media_file.date", alias: "recordingdate"},
|
||||
"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"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
"discsubtitle": {field: "media_file.disc_subtitle"},
|
||||
"comment": {field: "media_file.comment"},
|
||||
"lyrics": {field: "media_file.lyrics"},
|
||||
"sorttitle": {field: "media_file.sort_title"},
|
||||
"sortalbum": {field: "media_file.sort_album_name"},
|
||||
"sortartist": {field: "media_file.sort_artist_name"},
|
||||
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
|
||||
"albumcomment": {field: "media_file.mbz_album_comment"},
|
||||
"catalognumber": {field: "media_file.catalog_num"},
|
||||
"filepath": {field: "media_file.path"},
|
||||
"filetype": {field: "media_file.suffix"},
|
||||
"duration": {field: "media_file.duration"},
|
||||
"bitrate": {field: "media_file.bit_rate"},
|
||||
"bitdepth": {field: "media_file.bit_depth"},
|
||||
"bpm": {field: "media_file.bpm"},
|
||||
"channels": {field: "media_file.channels"},
|
||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||
"dateloved": {field: "annotation.starred_at"},
|
||||
"lastplayed": {field: "annotation.play_date"},
|
||||
"daterated": {field: "annotation.rated_at"},
|
||||
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
||||
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
||||
"mbz_album_id": {field: "media_file.mbz_album_id"},
|
||||
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
|
||||
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
|
||||
"mbz_recording_id": {field: "media_file.mbz_recording_id"},
|
||||
"mbz_release_track_id": {field: "media_file.mbz_release_track_id"},
|
||||
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
|
||||
"library_id": {field: "media_file.library_id", numeric: true},
|
||||
|
||||
// Backward compatibility: albumtype is an alias for releasetype tag
|
||||
"albumtype": {field: "releasetype", isTag: true},
|
||||
|
||||
// special fields
|
||||
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
|
||||
"value": {field: "value"}, // pseudo-field for tag and roles values
|
||||
}
|
||||
|
||||
type mappedField struct {
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
numeric bool // true if the field/tag should be treated as numeric
|
||||
}
|
||||
|
||||
func mapFields(expr map[string]any) map[string]any {
|
||||
m := make(map[string]any)
|
||||
for f, v := range expr {
|
||||
if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
|
||||
m[dbf.field] = v
|
||||
} else {
|
||||
log.Error("Invalid field in criteria", "field", f)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// mapExpr maps a normal field expression to a specific type of expression (tag or role).
|
||||
// This is required because tags are handled differently than other fields,
|
||||
// as they are stored as a JSON column in the database.
|
||||
func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer {
|
||||
rv := reflect.ValueOf(expr)
|
||||
if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
|
||||
log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
|
||||
}
|
||||
|
||||
// Extract into a generic map
|
||||
var k string
|
||||
m := make(map[string]any, rv.Len())
|
||||
for _, key := range rv.MapKeys() {
|
||||
// Save the key to build the expression, and use the provided keyName as the key
|
||||
k = key.String()
|
||||
m["value"] = rv.MapIndex(key).Interface()
|
||||
break // only one key is expected (and supported)
|
||||
}
|
||||
|
||||
// Clear the original map
|
||||
for _, key := range rv.MapKeys() {
|
||||
rv.SetMapIndex(key, reflect.Value{})
|
||||
}
|
||||
|
||||
// Write the updated map back into the original variable
|
||||
for key, val := range m {
|
||||
rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val))
|
||||
}
|
||||
|
||||
return exprFunc(k, expr, negate)
|
||||
}
|
||||
|
||||
// mapTagExpr maps a normal field expression to a tag expression.
|
||||
func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
return mapExpr(expr, negate, tagExpr)
|
||||
}
|
||||
|
||||
// mapRoleExpr maps a normal field expression to an artist role expression.
|
||||
func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
return mapExpr(expr, negate, roleExpr)
|
||||
}
|
||||
|
||||
func isTagExpr(expr map[string]any) bool {
|
||||
for f := range expr {
|
||||
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isRoleExpr(expr map[string]any) bool {
|
||||
for f := range expr {
|
||||
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
return tagCond{tag: tag, cond: cond, not: negate}
|
||||
}
|
||||
|
||||
type tagCond struct {
|
||||
tag string
|
||||
cond squirrel.Sqlizer
|
||||
not bool
|
||||
}
|
||||
|
||||
func (e tagCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
|
||||
// Resolve the actual tag name (handles aliases like albumtype -> releasetype)
|
||||
tagName := e.tag
|
||||
if fm, ok := fieldMap[e.tag]; ok {
|
||||
if fm.field != "" {
|
||||
tagName = fm.field
|
||||
}
|
||||
if fm.numeric {
|
||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||
}
|
||||
}
|
||||
|
||||
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
||||
tagName, cond)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
}
|
||||
return cond, args, err
|
||||
}
|
||||
|
||||
func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
|
||||
return roleCond{role: role, cond: cond, not: negate}
|
||||
}
|
||||
|
||||
type roleCond struct {
|
||||
role string
|
||||
cond squirrel.Sqlizer
|
||||
not bool
|
||||
}
|
||||
|
||||
func (e roleCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`,
|
||||
e.role, cond)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
}
|
||||
return cond, args, err
|
||||
}
|
||||
|
||||
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
|
||||
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
|
||||
func AddRoles(roles []string) {
|
||||
for _, role := range roles {
|
||||
name := strings.ToLower(role)
|
||||
if _, ok := fieldMap[name]; ok {
|
||||
continue
|
||||
}
|
||||
fieldMap[name] = &mappedField{field: name, isRole: true}
|
||||
}
|
||||
}
|
||||
|
||||
// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml`
|
||||
// file to the field map, so they can be used in smart playlists.
|
||||
// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent.
|
||||
func AddTagNames(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
if _, ok := fieldMap[name]; ok {
|
||||
continue
|
||||
}
|
||||
for _, fm := range fieldMap {
|
||||
if fm.alias == name {
|
||||
fieldMap[name] = fm
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, ok := fieldMap[name]; !ok {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddNumericTags marks the given tag names as numeric so they can be cast
|
||||
// when used in comparisons or sorting.
|
||||
func AddNumericTags(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
if fm, ok := fieldMap[name]; ok {
|
||||
fm.numeric = true
|
||||
} else {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
model/criteria/fields_test.go
Normal file
16
model/criteria/fields_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("fields", func() {
|
||||
Describe("mapFields", func() {
|
||||
It("ignores random fields", func() {
|
||||
m := map[string]any{"random": "123"}
|
||||
m = mapFields(m)
|
||||
gomega.Expect(m).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
121
model/criteria/json.go
Normal file
121
model/criteria/json.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type unmarshalConjunctionType []Expression
|
||||
|
||||
func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
|
||||
var raw []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var es unmarshalConjunctionType
|
||||
for _, e := range raw {
|
||||
for k, v := range e {
|
||||
k = strings.ToLower(k)
|
||||
expr := unmarshalExpression(k, v)
|
||||
if expr == nil {
|
||||
expr = unmarshalConjunction(k, v)
|
||||
}
|
||||
if expr == nil {
|
||||
return fmt.Errorf(`invalid expression key '%s'`, k)
|
||||
}
|
||||
es = append(es, expr)
|
||||
}
|
||||
}
|
||||
*uc = es
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
|
||||
m := make(map[string]any)
|
||||
err := json.Unmarshal(rawValue, &m)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch opName {
|
||||
case "is":
|
||||
return Is(m)
|
||||
case "isnot":
|
||||
return IsNot(m)
|
||||
case "gt":
|
||||
return Gt(m)
|
||||
case "lt":
|
||||
return Lt(m)
|
||||
case "contains":
|
||||
return Contains(m)
|
||||
case "notcontains":
|
||||
return NotContains(m)
|
||||
case "startswith":
|
||||
return StartsWith(m)
|
||||
case "endswith":
|
||||
return EndsWith(m)
|
||||
case "intherange":
|
||||
return InTheRange(m)
|
||||
case "before":
|
||||
return Before(m)
|
||||
case "after":
|
||||
return After(m)
|
||||
case "inthelast":
|
||||
return InTheLast(m)
|
||||
case "notinthelast":
|
||||
return NotInTheLast(m)
|
||||
case "inplaylist":
|
||||
return InPlaylist(m)
|
||||
case "notinplaylist":
|
||||
return NotInPlaylist(m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
|
||||
var items unmarshalConjunctionType
|
||||
err := json.Unmarshal(rawValue, &items)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch conjName {
|
||||
case "any":
|
||||
return Any(items)
|
||||
case "all":
|
||||
return All(items)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalExpression(name string, value map[string]any) ([]byte, error) {
|
||||
if len(value) != 1 {
|
||||
return nil, fmt.Errorf(`invalid %s expression length %d for values %v`, name, len(value), value)
|
||||
}
|
||||
b := strings.Builder{}
|
||||
b.WriteString(`{"` + name + `":{`)
|
||||
for f, v := range value {
|
||||
j, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.WriteString(`"` + f + `":`)
|
||||
b.Write(j)
|
||||
break
|
||||
}
|
||||
b.WriteString("}}")
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
func marshalConjunction(name string, conj []Expression) ([]byte, error) {
|
||||
aux := struct {
|
||||
All []Expression `json:"all,omitempty"`
|
||||
Any []Expression `json:"any,omitempty"`
|
||||
}{}
|
||||
if name == "any" {
|
||||
aux.Any = conj
|
||||
} else {
|
||||
aux.All = conj
|
||||
}
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
353
model/criteria/operators.go
Normal file
353
model/criteria/operators.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package criteria
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
)
|
||||
|
||||
type (
|
||||
All squirrel.And
|
||||
And = All
|
||||
)
|
||||
|
||||
func (all All) ToSql() (sql string, args []any, err error) {
|
||||
return squirrel.And(all).ToSql()
|
||||
}
|
||||
|
||||
func (all All) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("all", all)
|
||||
}
|
||||
|
||||
func (all All) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(all)
|
||||
}
|
||||
|
||||
type (
|
||||
Any squirrel.Or
|
||||
Or = Any
|
||||
)
|
||||
|
||||
func (any Any) ToSql() (sql string, args []any, err error) {
|
||||
return squirrel.Or(any).ToSql()
|
||||
}
|
||||
|
||||
func (any Any) MarshalJSON() ([]byte, error) {
|
||||
return marshalConjunction("any", any)
|
||||
}
|
||||
|
||||
func (any Any) ChildPlaylistIds() (ids []string) {
|
||||
return extractPlaylistIds(any)
|
||||
}
|
||||
|
||||
type Is squirrel.Eq
|
||||
type Eq = Is
|
||||
|
||||
func (is Is) ToSql() (sql string, args []any, err error) {
|
||||
if isRoleExpr(is) {
|
||||
return mapRoleExpr(is, false).ToSql()
|
||||
}
|
||||
if isTagExpr(is) {
|
||||
return mapTagExpr(is, false).ToSql()
|
||||
}
|
||||
return squirrel.Eq(mapFields(is)).ToSql()
|
||||
}
|
||||
|
||||
func (is Is) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("is", is)
|
||||
}
|
||||
|
||||
type IsNot squirrel.NotEq
|
||||
|
||||
func (in IsNot) ToSql() (sql string, args []any, err error) {
|
||||
if isRoleExpr(in) {
|
||||
return mapRoleExpr(squirrel.Eq(in), true).ToSql()
|
||||
}
|
||||
if isTagExpr(in) {
|
||||
return mapTagExpr(squirrel.Eq(in), true).ToSql()
|
||||
}
|
||||
return squirrel.NotEq(mapFields(in)).ToSql()
|
||||
}
|
||||
|
||||
func (in IsNot) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("isNot", in)
|
||||
}
|
||||
|
||||
type Gt squirrel.Gt
|
||||
|
||||
func (gt Gt) ToSql() (sql string, args []any, err error) {
|
||||
if isTagExpr(gt) {
|
||||
return mapTagExpr(gt, false).ToSql()
|
||||
}
|
||||
return squirrel.Gt(mapFields(gt)).ToSql()
|
||||
}
|
||||
|
||||
func (gt Gt) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("gt", gt)
|
||||
}
|
||||
|
||||
type Lt squirrel.Lt
|
||||
|
||||
func (lt Lt) ToSql() (sql string, args []any, err error) {
|
||||
if isTagExpr(lt) {
|
||||
return mapTagExpr(squirrel.Lt(lt), false).ToSql()
|
||||
}
|
||||
return squirrel.Lt(mapFields(lt)).ToSql()
|
||||
}
|
||||
|
||||
func (lt Lt) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("lt", lt)
|
||||
}
|
||||
|
||||
type Before squirrel.Lt
|
||||
|
||||
func (bf Before) ToSql() (sql string, args []any, err error) {
|
||||
return Lt(bf).ToSql()
|
||||
}
|
||||
|
||||
func (bf Before) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("before", bf)
|
||||
}
|
||||
|
||||
type After Gt
|
||||
|
||||
func (af After) ToSql() (sql string, args []any, err error) {
|
||||
return Gt(af).ToSql()
|
||||
}
|
||||
|
||||
func (af After) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("after", af)
|
||||
}
|
||||
|
||||
type Contains map[string]any
|
||||
|
||||
func (ct Contains) ToSql() (sql string, args []any, err error) {
|
||||
lk := squirrel.Like{}
|
||||
for f, v := range mapFields(ct) {
|
||||
lk[f] = fmt.Sprintf("%%%s%%", v)
|
||||
}
|
||||
if isRoleExpr(ct) {
|
||||
return mapRoleExpr(lk, false).ToSql()
|
||||
}
|
||||
if isTagExpr(ct) {
|
||||
return mapTagExpr(lk, false).ToSql()
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
|
||||
func (ct Contains) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("contains", ct)
|
||||
}
|
||||
|
||||
type NotContains map[string]any
|
||||
|
||||
func (nct NotContains) ToSql() (sql string, args []any, err error) {
|
||||
lk := squirrel.NotLike{}
|
||||
for f, v := range mapFields(nct) {
|
||||
lk[f] = fmt.Sprintf("%%%s%%", v)
|
||||
}
|
||||
if isRoleExpr(nct) {
|
||||
return mapRoleExpr(squirrel.Like(lk), true).ToSql()
|
||||
}
|
||||
if isTagExpr(nct) {
|
||||
return mapTagExpr(squirrel.Like(lk), true).ToSql()
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
|
||||
func (nct NotContains) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("notContains", nct)
|
||||
}
|
||||
|
||||
type StartsWith map[string]any
|
||||
|
||||
func (sw StartsWith) ToSql() (sql string, args []any, err error) {
|
||||
lk := squirrel.Like{}
|
||||
for f, v := range mapFields(sw) {
|
||||
lk[f] = fmt.Sprintf("%s%%", v)
|
||||
}
|
||||
if isRoleExpr(sw) {
|
||||
return mapRoleExpr(lk, false).ToSql()
|
||||
}
|
||||
if isTagExpr(sw) {
|
||||
return mapTagExpr(lk, false).ToSql()
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
|
||||
func (sw StartsWith) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("startsWith", sw)
|
||||
}
|
||||
|
||||
type EndsWith map[string]any
|
||||
|
||||
func (sw EndsWith) ToSql() (sql string, args []any, err error) {
|
||||
lk := squirrel.Like{}
|
||||
for f, v := range mapFields(sw) {
|
||||
lk[f] = fmt.Sprintf("%%%s", v)
|
||||
}
|
||||
if isRoleExpr(sw) {
|
||||
return mapRoleExpr(lk, false).ToSql()
|
||||
}
|
||||
if isTagExpr(sw) {
|
||||
return mapTagExpr(lk, false).ToSql()
|
||||
}
|
||||
return lk.ToSql()
|
||||
}
|
||||
|
||||
func (sw EndsWith) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("endsWith", sw)
|
||||
}
|
||||
|
||||
type InTheRange map[string]any
|
||||
|
||||
func (itr InTheRange) ToSql() (sql string, args []any, err error) {
|
||||
and := squirrel.And{}
|
||||
for f, v := range mapFields(itr) {
|
||||
s := reflect.ValueOf(v)
|
||||
if s.Kind() != reflect.Slice || s.Len() != 2 {
|
||||
return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v)
|
||||
}
|
||||
and = append(and,
|
||||
squirrel.GtOrEq{f: s.Index(0).Interface()},
|
||||
squirrel.LtOrEq{f: s.Index(1).Interface()},
|
||||
)
|
||||
}
|
||||
return and.ToSql()
|
||||
}
|
||||
|
||||
func (itr InTheRange) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inTheRange", itr)
|
||||
}
|
||||
|
||||
type InTheLast map[string]any
|
||||
|
||||
func (itl InTheLast) ToSql() (sql string, args []any, err error) {
|
||||
exp, err := inPeriod(itl, false)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return exp.ToSql()
|
||||
}
|
||||
|
||||
func (itl InTheLast) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inTheLast", itl)
|
||||
}
|
||||
|
||||
type NotInTheLast map[string]any
|
||||
|
||||
func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) {
|
||||
exp, err := inPeriod(nitl, true)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return exp.ToSql()
|
||||
}
|
||||
|
||||
func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("notInTheLast", nitl)
|
||||
}
|
||||
|
||||
func inPeriod(m map[string]any, negate bool) (Expression, error) {
|
||||
var field string
|
||||
var value any
|
||||
for f, v := range mapFields(m) {
|
||||
field, value = f, v
|
||||
break
|
||||
}
|
||||
str := fmt.Sprintf("%v", value)
|
||||
v, err := strconv.ParseInt(str, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstDate := startOfPeriod(v, time.Now())
|
||||
|
||||
if negate {
|
||||
return Or{
|
||||
squirrel.Lt{field: firstDate},
|
||||
squirrel.Eq{field: nil},
|
||||
}, nil
|
||||
}
|
||||
return squirrel.Gt{field: firstDate}, nil
|
||||
}
|
||||
|
||||
func startOfPeriod(numDays int64, from time.Time) string {
|
||||
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
|
||||
}
|
||||
|
||||
type InPlaylist map[string]any
|
||||
|
||||
func (ipl InPlaylist) ToSql() (sql string, args []any, err error) {
|
||||
return inList(ipl, false)
|
||||
}
|
||||
|
||||
func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("inPlaylist", ipl)
|
||||
}
|
||||
|
||||
type NotInPlaylist map[string]any
|
||||
|
||||
func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) {
|
||||
return inList(ipl, true)
|
||||
}
|
||||
|
||||
func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) {
|
||||
return marshalExpression("notInPlaylist", ipl)
|
||||
}
|
||||
|
||||
func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
|
||||
var playlistid string
|
||||
var ok bool
|
||||
if playlistid, ok = m["id"].(string); !ok {
|
||||
return "", nil, errors.New("playlist id not given")
|
||||
}
|
||||
|
||||
// Subquery to fetch all media files that are contained in given playlist
|
||||
// Only evaluate playlist if it is public
|
||||
subQuery := squirrel.Select("media_file_id").
|
||||
From("playlist_tracks pl").
|
||||
LeftJoin("playlist on pl.playlist_id = playlist.id").
|
||||
Where(squirrel.And{
|
||||
squirrel.Eq{"pl.playlist_id": playlistid},
|
||||
squirrel.Eq{"playlist.public": 1}})
|
||||
subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
|
||||
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if negate {
|
||||
return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil
|
||||
} else {
|
||||
return "media_file.id IN (" + subQText + ")", subQArgs, nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractPlaylistIds(inputRule any) (ids []string) {
|
||||
var id string
|
||||
var ok bool
|
||||
|
||||
switch rule := inputRule.(type) {
|
||||
case Any:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
}
|
||||
case All:
|
||||
for _, rules := range rule {
|
||||
ids = append(ids, extractPlaylistIds(rules)...)
|
||||
}
|
||||
case InPlaylist:
|
||||
if id, ok = rule["id"].(string); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
case NotInPlaylist:
|
||||
if id, ok = rule["id"].(string); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
192
model/criteria/operators_test.go
Normal file
192
model/criteria/operators_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package criteria_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "github.com/navidrome/navidrome/model/criteria"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
AddRoles([]string{"artist", "composer"})
|
||||
AddTagNames([]string{"genre"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
})
|
||||
|
||||
var _ = Describe("Operators", func() {
|
||||
rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local)
|
||||
rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local)
|
||||
|
||||
DescribeTable("ToSQL",
|
||||
func(op Expression, expectedSql string, expectedArgs ...any) {
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal(expectedSql))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...))
|
||||
},
|
||||
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
|
||||
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
|
||||
Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1),
|
||||
Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
|
||||
Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
|
||||
Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1),
|
||||
Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2),
|
||||
Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
|
||||
Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
|
||||
Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
|
||||
Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
|
||||
Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
|
||||
Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
|
||||
Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
|
||||
Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
|
||||
Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart),
|
||||
Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
|
||||
|
||||
// InPlaylist and NotInPlaylist are special cases
|
||||
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+
|
||||
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+
|
||||
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
|
||||
|
||||
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
|
||||
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
|
||||
|
||||
// Tag tests
|
||||
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"),
|
||||
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"),
|
||||
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
|
||||
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
|
||||
|
||||
// Artist roles tests
|
||||
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
|
||||
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
|
||||
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
|
||||
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
|
||||
)
|
||||
|
||||
// TODO Validate operators that are not valid for each field type.
|
||||
XDescribeTable("ToSQL - Invalid Operators",
|
||||
func(op Expression, expectedError string) {
|
||||
_, _, err := op.ToSql()
|
||||
gomega.Expect(err).To(gomega.MatchError(expectedError))
|
||||
},
|
||||
Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
|
||||
)
|
||||
|
||||
Describe("Custom Tags", func() {
|
||||
It("generates valid SQL", func() {
|
||||
AddTagNames([]string{"mood"})
|
||||
op := EndsWith{"mood": "Soft"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
|
||||
})
|
||||
It("casts numeric comparisons", func() {
|
||||
AddNumericTags([]string{"rate"})
|
||||
op := Lt{"rate": 6}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements(6))
|
||||
})
|
||||
It("skips unknown tag names", func() {
|
||||
op := EndsWith{"unknown": "value"}
|
||||
sql, args, _ := op.ToSql()
|
||||
gomega.Expect(sql).To(gomega.BeEmpty())
|
||||
gomega.Expect(args).To(gomega.BeEmpty())
|
||||
})
|
||||
It("supports releasetype as multi-valued tag", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := Contains{"releasetype": "soundtrack"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
|
||||
})
|
||||
It("supports albumtype as alias for releasetype", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := Contains{"albumtype": "live"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
|
||||
})
|
||||
It("supports albumtype alias with Is operator", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := Is{"albumtype": "album"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
// Should query $.releasetype, not $.albumtype
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("album"))
|
||||
})
|
||||
It("supports albumtype alias with IsNot operator", func() {
|
||||
AddTagNames([]string{"releasetype"})
|
||||
op := IsNot{"albumtype": "compilation"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
// Should query $.releasetype, not $.albumtype
|
||||
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Custom Roles", func() {
|
||||
It("generates valid SQL", func() {
|
||||
AddRoles([]string{"producer"})
|
||||
op := EndsWith{"producer": "Eno"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(participants, '$.producer') where key='name' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
|
||||
})
|
||||
It("skips unknown roles", func() {
|
||||
op := Contains{"groupie": "Penny Lane"}
|
||||
sql, args, _ := op.ToSql()
|
||||
gomega.Expect(sql).To(gomega.BeEmpty())
|
||||
gomega.Expect(args).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("JSON Marshaling",
|
||||
func(op Expression, jsonString string) {
|
||||
obj := And{op}
|
||||
newJs, err := json.Marshal(obj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(string(newJs)).To(gomega.Equal(fmt.Sprintf(`{"all":[%s]}`, jsonString)))
|
||||
|
||||
var unmarshalObj UnmarshalConjunctionType
|
||||
js := "[" + jsonString + "]"
|
||||
err = json.Unmarshal([]byte(js), &unmarshalObj)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(unmarshalObj[0]).To(gomega.Equal(op))
|
||||
},
|
||||
Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`),
|
||||
Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`),
|
||||
Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`),
|
||||
Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`),
|
||||
Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`),
|
||||
Entry("contains", Contains{"title": "Low Rider"}, `{"contains":{"title":"Low Rider"}}`),
|
||||
Entry("notContains", NotContains{"title": "Low Rider"}, `{"notContains":{"title":"Low Rider"}}`),
|
||||
Entry("startsWith", StartsWith{"title": "Low Rider"}, `{"startsWith":{"title":"Low Rider"}}`),
|
||||
Entry("endsWith", EndsWith{"title": "Low Rider"}, `{"endsWith":{"title":"Low Rider"}}`),
|
||||
Entry("inTheRange [number]", InTheRange{"year": []any{1980.0, 1990.0}}, `{"inTheRange":{"year":[1980,1990]}}`),
|
||||
Entry("inTheRange [date]", InTheRange{"lastPlayed": []any{"2021-10-01", "2021-11-01"}}, `{"inTheRange":{"lastPlayed":["2021-10-01","2021-11-01"]}}`),
|
||||
Entry("before", Before{"lastPlayed": "2021-10-01"}, `{"before":{"lastPlayed":"2021-10-01"}}`),
|
||||
Entry("after", After{"lastPlayed": "2021-10-01"}, `{"after":{"lastPlayed":"2021-10-01"}}`),
|
||||
Entry("inTheLast", InTheLast{"lastPlayed": 30.0}, `{"inTheLast":{"lastPlayed":30}}`),
|
||||
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`),
|
||||
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`),
|
||||
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`),
|
||||
)
|
||||
})
|
||||
49
model/datastore.go
Normal file
49
model/datastore.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
type QueryOptions struct {
|
||||
Sort string
|
||||
Order string
|
||||
Max int
|
||||
Offset int
|
||||
Filters squirrel.Sqlizer
|
||||
Seed string // for random sorting
|
||||
}
|
||||
|
||||
type ResourceRepository interface {
|
||||
rest.Repository
|
||||
}
|
||||
|
||||
type DataStore interface {
|
||||
Library(ctx context.Context) LibraryRepository
|
||||
Folder(ctx context.Context) FolderRepository
|
||||
Album(ctx context.Context) AlbumRepository
|
||||
Artist(ctx context.Context) ArtistRepository
|
||||
MediaFile(ctx context.Context) MediaFileRepository
|
||||
Genre(ctx context.Context) GenreRepository
|
||||
Tag(ctx context.Context) TagRepository
|
||||
Playlist(ctx context.Context) PlaylistRepository
|
||||
PlayQueue(ctx context.Context) PlayQueueRepository
|
||||
Transcoding(ctx context.Context) TranscodingRepository
|
||||
Player(ctx context.Context) PlayerRepository
|
||||
Radio(ctx context.Context) RadioRepository
|
||||
Share(ctx context.Context) ShareRepository
|
||||
Property(ctx context.Context) PropertyRepository
|
||||
User(ctx context.Context) UserRepository
|
||||
UserProps(ctx context.Context) UserPropsRepository
|
||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||
Scrobble(ctx context.Context) ScrobbleRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
WithTx(block func(tx DataStore) error, scope ...string) error
|
||||
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
|
||||
GC(ctx context.Context, libraryIDs ...int) error
|
||||
ReindexAll(ctx context.Context) error
|
||||
}
|
||||
12
model/errors.go
Normal file
12
model/errors.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("data not found")
|
||||
ErrInvalidAuth = errors.New("invalid authentication")
|
||||
ErrNotAuthorized = errors.New("not authorized")
|
||||
ErrExpired = errors.New("access expired")
|
||||
ErrNotAvailable = errors.New("functionality not available")
|
||||
ErrValidation = errors.New("validation error")
|
||||
)
|
||||
30
model/file_types.go
Normal file
30
model/file_types.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var excludeAudioType = []string{
|
||||
"audio/mpegurl",
|
||||
"audio/x-mpegurl",
|
||||
"audio/x-scpls",
|
||||
}
|
||||
|
||||
func IsAudioFile(filePath string) bool {
|
||||
extension := filepath.Ext(filePath)
|
||||
mimeType := mime.TypeByExtension(extension)
|
||||
return !slices.Contains(excludeAudioType, mimeType) && strings.HasPrefix(mimeType, "audio/")
|
||||
}
|
||||
|
||||
func IsImageFile(filePath string) bool {
|
||||
extension := filepath.Ext(filePath)
|
||||
return strings.HasPrefix(mime.TypeByExtension(extension), "image/")
|
||||
}
|
||||
|
||||
func IsValidPlaylist(filePath string) bool {
|
||||
extension := strings.ToLower(filepath.Ext(filePath))
|
||||
return extension == ".m3u" || extension == ".m3u8" || extension == ".nsp"
|
||||
}
|
||||
61
model/file_types_test.go
Normal file
61
model/file_types_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("File Types()", func() {
|
||||
Describe("IsAudioFile", func() {
|
||||
It("returns true for a MP3 file", func() {
|
||||
Expect(model.IsAudioFile(filepath.Join("path", "to", "test.mp3"))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true for a FLAC file", func() {
|
||||
Expect(model.IsAudioFile("test.flac")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for a non-audio file", func() {
|
||||
Expect(model.IsAudioFile("test.jpg")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for m3u files", func() {
|
||||
Expect(model.IsAudioFile("test.m3u")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for pls files", func() {
|
||||
Expect(model.IsAudioFile("test.pls")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsImageFile()", func() {
|
||||
It("returns true for a PNG file", func() {
|
||||
Expect(model.IsImageFile(filepath.Join("path", "to", "test.png"))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true for a JPEG file", func() {
|
||||
Expect(model.IsImageFile("test.JPEG")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for a non-image file", func() {
|
||||
Expect(model.IsImageFile("test.mp3")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsValidPlaylist()", func() {
|
||||
It("returns true for a M3U file", func() {
|
||||
Expect(model.IsValidPlaylist(filepath.Join("path", "to", "test.m3u"))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true for a M3U8 file", func() {
|
||||
Expect(model.IsValidPlaylist(filepath.Join("path", "to", "test.m3u8"))).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for a non-playlist file", func() {
|
||||
Expect(model.IsValidPlaylist("testm3u")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
92
model/folder.go
Normal file
92
model/folder.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
// Folder represents a folder in the library. Its path is relative to the library root.
|
||||
// ALWAYS use NewFolder to create a new instance.
|
||||
type Folder struct {
|
||||
ID string `structs:"id"`
|
||||
LibraryID int `structs:"library_id"`
|
||||
LibraryPath string `structs:"-" json:"-" hash:"ignore"`
|
||||
Path string `structs:"path"`
|
||||
Name string `structs:"name"`
|
||||
ParentID string `structs:"parent_id"`
|
||||
NumAudioFiles int `structs:"num_audio_files"`
|
||||
NumPlaylists int `structs:"num_playlists"`
|
||||
ImageFiles []string `structs:"image_files"`
|
||||
ImagesUpdatedAt time.Time `structs:"images_updated_at"`
|
||||
Hash string `structs:"hash"`
|
||||
Missing bool `structs:"missing"`
|
||||
UpdateAt time.Time `structs:"updated_at"`
|
||||
CreatedAt time.Time `structs:"created_at"`
|
||||
}
|
||||
|
||||
func (f Folder) AbsolutePath() string {
|
||||
return filepath.Join(f.LibraryPath, f.Path, f.Name)
|
||||
}
|
||||
|
||||
func (f Folder) String() string {
|
||||
return f.AbsolutePath()
|
||||
}
|
||||
|
||||
// FolderID generates a unique ID for a folder in a library.
|
||||
// The ID is generated based on the library ID and the folder path relative to the library root.
|
||||
// Any leading or trailing slashes are removed from the folder path.
|
||||
func FolderID(lib Library, path string) string {
|
||||
path = strings.TrimPrefix(path, lib.Path)
|
||||
path = strings.TrimPrefix(path, string(os.PathSeparator))
|
||||
path = filepath.Clean(path)
|
||||
key := fmt.Sprintf("%d:%s", lib.ID, path)
|
||||
return id.NewHash(key)
|
||||
}
|
||||
|
||||
func NewFolder(lib Library, folderPath string) *Folder {
|
||||
newID := FolderID(lib, folderPath)
|
||||
dir, name := path.Split(folderPath)
|
||||
dir = path.Clean(dir)
|
||||
var parentID string
|
||||
if dir == "." && name == "." {
|
||||
dir = ""
|
||||
parentID = ""
|
||||
} else {
|
||||
parentID = FolderID(lib, dir)
|
||||
}
|
||||
return &Folder{
|
||||
LibraryID: lib.ID,
|
||||
ID: newID,
|
||||
Path: dir,
|
||||
Name: name,
|
||||
ParentID: parentID,
|
||||
ImageFiles: []string{},
|
||||
UpdateAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
type FolderCursor iter.Seq2[Folder, error]
|
||||
|
||||
type FolderUpdateInfo struct {
|
||||
UpdatedAt time.Time
|
||||
Hash string
|
||||
}
|
||||
|
||||
type FolderRepository interface {
|
||||
Get(id string) (*Folder, error)
|
||||
GetByPath(lib Library, path string) (*Folder, error)
|
||||
GetAll(...QueryOptions) ([]Folder, error)
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error)
|
||||
Put(*Folder) error
|
||||
MarkMissing(missing bool, ids ...string) error
|
||||
GetTouchedWithPlaylists() (FolderCursor, error)
|
||||
}
|
||||
119
model/folder_test.go
Normal file
119
model/folder_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Folder", func() {
|
||||
var (
|
||||
lib model.Library
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
lib = model.Library{
|
||||
ID: 1,
|
||||
Path: filepath.FromSlash("/music"),
|
||||
}
|
||||
})
|
||||
|
||||
Describe("FolderID", func() {
|
||||
When("the folder path is the library root", func() {
|
||||
It("should return the correct folder ID", func() {
|
||||
folderPath := lib.Path
|
||||
expectedID := id.NewHash("1:.")
|
||||
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
|
||||
})
|
||||
})
|
||||
|
||||
When("the folder path is '.' (library root)", func() {
|
||||
It("should return the correct folder ID", func() {
|
||||
folderPath := "."
|
||||
expectedID := id.NewHash("1:.")
|
||||
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
|
||||
})
|
||||
})
|
||||
|
||||
When("the folder path is relative", func() {
|
||||
It("should return the correct folder ID", func() {
|
||||
folderPath := "rock"
|
||||
expectedID := id.NewHash("1:rock")
|
||||
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
|
||||
})
|
||||
})
|
||||
|
||||
When("the folder path starts with '.'", func() {
|
||||
It("should return the correct folder ID", func() {
|
||||
folderPath := "./rock"
|
||||
expectedID := id.NewHash("1:rock")
|
||||
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
|
||||
})
|
||||
})
|
||||
|
||||
When("the folder path is absolute", func() {
|
||||
It("should return the correct folder ID", func() {
|
||||
folderPath := filepath.FromSlash("/music/rock")
|
||||
expectedID := id.NewHash("1:rock")
|
||||
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
|
||||
})
|
||||
})
|
||||
|
||||
When("the folder has multiple subdirs", func() {
|
||||
It("should return the correct folder ID", func() {
|
||||
folderPath := filepath.FromSlash("/music/rock/metal")
|
||||
expectedID := id.NewHash("1:rock/metal")
|
||||
Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NewFolder", func() {
|
||||
It("should create a new SubFolder with the correct attributes", func() {
|
||||
folderPath := filepath.FromSlash("rock/metal")
|
||||
folder := model.NewFolder(lib, folderPath)
|
||||
|
||||
Expect(folder.LibraryID).To(Equal(lib.ID))
|
||||
Expect(folder.ID).To(Equal(model.FolderID(lib, folderPath)))
|
||||
Expect(folder.Path).To(Equal(path.Clean("rock")))
|
||||
Expect(folder.Name).To(Equal("metal"))
|
||||
Expect(folder.ParentID).To(Equal(model.FolderID(lib, "rock")))
|
||||
Expect(folder.ImageFiles).To(BeEmpty())
|
||||
Expect(folder.UpdateAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
Expect(folder.CreatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
})
|
||||
|
||||
It("should create a new Folder with the correct attributes", func() {
|
||||
folderPath := "rock"
|
||||
folder := model.NewFolder(lib, folderPath)
|
||||
|
||||
Expect(folder.LibraryID).To(Equal(lib.ID))
|
||||
Expect(folder.ID).To(Equal(model.FolderID(lib, folderPath)))
|
||||
Expect(folder.Path).To(Equal(path.Clean(".")))
|
||||
Expect(folder.Name).To(Equal("rock"))
|
||||
Expect(folder.ParentID).To(Equal(model.FolderID(lib, ".")))
|
||||
Expect(folder.ImageFiles).To(BeEmpty())
|
||||
Expect(folder.UpdateAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
Expect(folder.CreatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
})
|
||||
|
||||
It("should handle the root folder correctly", func() {
|
||||
folderPath := "."
|
||||
folder := model.NewFolder(lib, folderPath)
|
||||
|
||||
Expect(folder.LibraryID).To(Equal(lib.ID))
|
||||
Expect(folder.ID).To(Equal(model.FolderID(lib, folderPath)))
|
||||
Expect(folder.Path).To(Equal(""))
|
||||
Expect(folder.Name).To(Equal("."))
|
||||
Expect(folder.ParentID).To(Equal(""))
|
||||
Expect(folder.ImageFiles).To(BeEmpty())
|
||||
Expect(folder.UpdateAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
Expect(folder.CreatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
})
|
||||
})
|
||||
})
|
||||
14
model/genre.go
Normal file
14
model/genre.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
type Genre struct {
|
||||
ID string `structs:"id" json:"id,omitempty" toml:"id,omitempty" yaml:"id,omitempty"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
SongCount int `structs:"-" json:"-" toml:"-" yaml:"-"`
|
||||
AlbumCount int `structs:"-" json:"-" toml:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
type Genres []Genre
|
||||
|
||||
type GenreRepository interface {
|
||||
GetAll(...QueryOptions) (Genres, error)
|
||||
}
|
||||
26
model/get_entity.go
Normal file
26
model/get_entity.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// TODO: Should the type be encoded in the ID?
|
||||
func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, error) {
|
||||
ar, err := ds.Artist(ctx).Get(id)
|
||||
if err == nil {
|
||||
return ar, nil
|
||||
}
|
||||
al, err := ds.Album(ctx).Get(id)
|
||||
if err == nil {
|
||||
return al, nil
|
||||
}
|
||||
pls, err := ds.Playlist(ctx).Get(id)
|
||||
if err == nil {
|
||||
return pls, nil
|
||||
}
|
||||
mf, err := ds.MediaFile(ctx).Get(id)
|
||||
if err == nil {
|
||||
return mf, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
36
model/id/id.go
Normal file
36
model/id/id.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package id
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func NewRandom() string {
|
||||
id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22)
|
||||
if err != nil {
|
||||
log.Error("Could not generate new ID", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func NewHash(data ...string) string {
|
||||
hash := md5.New()
|
||||
for _, d := range data {
|
||||
hash.Write([]byte(d))
|
||||
hash.Write([]byte(string('\u200b')))
|
||||
}
|
||||
h := hash.Sum(nil)
|
||||
bi := big.NewInt(0)
|
||||
bi.SetBytes(h)
|
||||
s := bi.Text(62)
|
||||
return fmt.Sprintf("%022s", s)
|
||||
}
|
||||
|
||||
func NewTagID(name, value string) string {
|
||||
return NewHash(strings.ToLower(name), strings.ToLower(value))
|
||||
}
|
||||
61
model/library.go
Normal file
61
model/library.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Library struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Path string `json:"path" db:"path"`
|
||||
RemotePath string `json:"remotePath" db:"remote_path"`
|
||||
LastScanAt time.Time `json:"lastScanAt" db:"last_scan_at"`
|
||||
LastScanStartedAt time.Time `json:"lastScanStartedAt" db:"last_scan_started_at"`
|
||||
FullScanInProgress bool `json:"fullScanInProgress" db:"full_scan_in_progress"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
TotalSongs int `json:"totalSongs" db:"total_songs"`
|
||||
TotalAlbums int `json:"totalAlbums" db:"total_albums"`
|
||||
TotalArtists int `json:"totalArtists" db:"total_artists"`
|
||||
TotalFolders int `json:"totalFolders" db:"total_folders"`
|
||||
TotalFiles int `json:"totalFiles" db:"total_files"`
|
||||
TotalMissingFiles int `json:"totalMissingFiles" db:"total_missing_files"`
|
||||
TotalSize int64 `json:"totalSize" db:"total_size"`
|
||||
TotalDuration float64 `json:"totalDuration" db:"total_duration"`
|
||||
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultLibraryID = 1
|
||||
DefaultLibraryName = "Music Library"
|
||||
)
|
||||
|
||||
type Libraries []Library
|
||||
|
||||
func (l Libraries) IDs() []int {
|
||||
return slice.Map(l, func(lib Library) int { return lib.ID })
|
||||
}
|
||||
|
||||
type LibraryRepository interface {
|
||||
Get(id int) (*Library, error)
|
||||
// GetPath returns the path of the library with the given ID.
|
||||
// Its implementation must be optimized to avoid unnecessary queries.
|
||||
GetPath(id int) (string, error)
|
||||
GetAll(...QueryOptions) (Libraries, error)
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Put(*Library) error
|
||||
Delete(id int) error
|
||||
StoreMusicFolder() error
|
||||
AddArtist(id int, artistID string) error
|
||||
|
||||
// User-library association methods
|
||||
GetUsersWithLibraryAccess(libraryID int) (Users, error)
|
||||
|
||||
// TODO These methods should be moved to a core service
|
||||
ScanBegin(id int, fullScan bool) error
|
||||
ScanEnd(id int) error
|
||||
ScanInProgress() (bool, error)
|
||||
RefreshStats(id int) error
|
||||
}
|
||||
229
model/lyrics.go
Normal file
229
model/lyrics.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type Line struct {
|
||||
Start *int64 `structs:"start,omitempty" json:"start,omitempty"`
|
||||
Value string `structs:"value" json:"value"`
|
||||
}
|
||||
|
||||
type Lyrics struct {
|
||||
DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"`
|
||||
Lang string `structs:"lang" json:"lang"`
|
||||
Line []Line `structs:"line" json:"line"`
|
||||
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
|
||||
Synced bool `structs:"synced" json:"synced"`
|
||||
}
|
||||
|
||||
// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm]
|
||||
const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?\]`
|
||||
|
||||
var (
|
||||
// Should either be at the beginning of file, or beginning of line
|
||||
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
|
||||
timeRegex = regexp.MustCompile(timeRegexString)
|
||||
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
|
||||
)
|
||||
|
||||
func (l Lyrics) IsEmpty() bool {
|
||||
return len(l.Line) == 0
|
||||
}
|
||||
|
||||
func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
text = str.SanitizeText(text)
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
structuredLines := make([]Line, 0, len(lines)*2)
|
||||
|
||||
artist := ""
|
||||
title := ""
|
||||
var offset *int64 = nil
|
||||
|
||||
synced := syncRegex.MatchString(text)
|
||||
priorLine := ""
|
||||
validLine := false
|
||||
repeated := false
|
||||
var timestamps []int64
|
||||
|
||||
for _, line := range lines {
|
||||
line := strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
if validLine {
|
||||
priorLine += "\n"
|
||||
}
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
var time *int64 = nil
|
||||
|
||||
if synced {
|
||||
idTag := lrcIdRegex.FindStringSubmatch(line)
|
||||
if idTag != nil {
|
||||
switch idTag[1] {
|
||||
case "ar":
|
||||
artist = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
case "lang":
|
||||
language = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
case "offset":
|
||||
{
|
||||
off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64)
|
||||
if err != nil {
|
||||
log.Warn("Error parsing offset", "offset", idTag[2], "error", err)
|
||||
} else {
|
||||
offset = &off
|
||||
}
|
||||
}
|
||||
case "ti":
|
||||
title = str.SanitizeText(strings.TrimSpace(idTag[2]))
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
times := timeRegex.FindAllStringSubmatchIndex(line, -1)
|
||||
if len(times) > 1 {
|
||||
repeated = true
|
||||
}
|
||||
|
||||
// The second condition is for when there is a timestamp in the middle of
|
||||
// a line (after any text)
|
||||
if times == nil || times[0][0] != 0 {
|
||||
if validLine {
|
||||
priorLine += "\n" + line
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if validLine {
|
||||
for idx := range timestamps {
|
||||
structuredLines = append(structuredLines, Line{
|
||||
Start: ×tamps[idx],
|
||||
Value: strings.TrimSpace(priorLine),
|
||||
})
|
||||
}
|
||||
timestamps = nil
|
||||
}
|
||||
|
||||
end := 0
|
||||
|
||||
// [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd]
|
||||
for _, match := range times {
|
||||
// for multiple matches, we need to check that later matches are not
|
||||
// in the middle of the string
|
||||
if end != 0 {
|
||||
middle := strings.TrimSpace(line[end:match[0]])
|
||||
if middle != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
end = match[1]
|
||||
timeInMillis, err := parseTime(line, match)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timestamps = append(timestamps, timeInMillis)
|
||||
}
|
||||
|
||||
if end >= len(line) {
|
||||
priorLine = ""
|
||||
} else {
|
||||
priorLine = strings.TrimSpace(line[end:])
|
||||
}
|
||||
|
||||
validLine = true
|
||||
} else {
|
||||
text = line
|
||||
structuredLines = append(structuredLines, Line{
|
||||
Start: time,
|
||||
Value: text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if validLine {
|
||||
for idx := range timestamps {
|
||||
structuredLines = append(structuredLines, Line{
|
||||
Start: ×tamps[idx],
|
||||
Value: strings.TrimSpace(priorLine),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If there are repeated values, there is no guarantee that they are in order
|
||||
// In this, case, sort the lyrics by start time
|
||||
if repeated {
|
||||
slices.SortFunc(structuredLines, func(a, b Line) int {
|
||||
return cmp.Compare(*a.Start, *b.Start)
|
||||
})
|
||||
}
|
||||
|
||||
lyrics := Lyrics{
|
||||
DisplayArtist: artist,
|
||||
DisplayTitle: title,
|
||||
Lang: language,
|
||||
Line: structuredLines,
|
||||
Offset: offset,
|
||||
Synced: synced,
|
||||
}
|
||||
return &lyrics, nil
|
||||
}
|
||||
|
||||
func parseTime(line string, match []int) (int64, error) {
|
||||
var hours, millis int64
|
||||
var err error
|
||||
|
||||
hourStart := match[2]
|
||||
if hourStart != -1 {
|
||||
// subtract 1 because group has : at the end
|
||||
hourEnd := match[3] - 1
|
||||
hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
msStart := match[8]
|
||||
if msStart != -1 {
|
||||
msEnd := match[9]
|
||||
// +1 offset since this capture group contains .
|
||||
millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
length := msEnd - msStart
|
||||
|
||||
if length == 3 {
|
||||
millis *= 10
|
||||
} else if length == 2 {
|
||||
millis *= 100
|
||||
}
|
||||
}
|
||||
|
||||
timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis
|
||||
return timeInMillis, nil
|
||||
}
|
||||
|
||||
type LyricList []Lyrics
|
||||
119
model/lyrics_test.go
Normal file
119
model/lyrics_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ToLyrics", func() {
|
||||
It("should parse tags with spaces", func() {
|
||||
num := int64(1551)
|
||||
lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Lang).To(Equal("eng"))
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.DisplayArtist).To(Equal("An artist"))
|
||||
Expect(lyrics.DisplayTitle).To(Equal("A title"))
|
||||
Expect(lyrics.Offset).To(Equal(&num))
|
||||
})
|
||||
|
||||
It("Should ignore bad offset", func() {
|
||||
lyrics, err := ToLyrics("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Offset).To(BeNil())
|
||||
})
|
||||
|
||||
It("should accept lines with no text and weird times", func() {
|
||||
a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "Hi there"},
|
||||
{Start: &b, Value: ""},
|
||||
{Start: &c, Value: "Test"},
|
||||
{Start: &d, Value: "late"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Should support multiple timestamps per line", func() {
|
||||
a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "Repeated"},
|
||||
{Start: &b, Value: "Repeated"},
|
||||
{Start: &c, Value: ""},
|
||||
{Start: &d, Value: ""},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Should support parsing multiline string", func() {
|
||||
a, b := int64(0), int64(10*60*1000+1)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "This is\na multiline\n\n[:0] string"},
|
||||
{Start: &b, Value: "This is\nalso one"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Does not match timestamp in middle of line", func() {
|
||||
lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeFalse())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Value: "This could [00:00:00] be a synced file"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Allows timestamp in middle of line if also at beginning", func() {
|
||||
a, b := int64(0), int64(1000)
|
||||
lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "This is [00:00:00] be a synced file"},
|
||||
{Start: &b, Value: "Line 2"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Ignores lines in synchronized lyric prior to first timestamp", func() {
|
||||
a := int64(0)
|
||||
lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "Text"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Handles all possible ms cases", func() {
|
||||
a, b, c := int64(1), int64(10), int64(100)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "a"},
|
||||
{Start: &b, Value: "b"},
|
||||
{Start: &c, Value: "c"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("Properly sorts repeated lyrics out of order", func() {
|
||||
a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51)
|
||||
lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics.Synced).To(BeTrue())
|
||||
Expect(lyrics.Line).To(Equal([]Line{
|
||||
{Start: &a, Value: "Repeated"},
|
||||
{Start: &b, Value: "Test"},
|
||||
{Start: &c, Value: "Not repeated"},
|
||||
{Start: &d, Value: "Repeated"},
|
||||
{Start: &e, Value: "Test"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
377
model/mediafile.go
Normal file
377
model/mediafile.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type MediaFile struct {
|
||||
Annotations `structs:"-" hash:"ignore"`
|
||||
Bookmarkable `structs:"-" hash:"ignore"`
|
||||
|
||||
ID string `structs:"id" json:"id" hash:"ignore"`
|
||||
PID string `structs:"pid" json:"-" hash:"ignore"`
|
||||
LibraryID int `structs:"library_id" json:"libraryId" hash:"ignore"`
|
||||
LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"`
|
||||
LibraryName string `structs:"-" json:"libraryName" hash:"ignore"`
|
||||
FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"`
|
||||
Path string `structs:"path" json:"path" hash:"ignore"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
Album string `structs:"album" json:"album"`
|
||||
ArtistID string `structs:"artist_id" json:"artistId"` // Deprecated: Use Participants instead
|
||||
// Artist is the display name used for the artist.
|
||||
Artist string `structs:"artist" json:"artist"`
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
|
||||
// AlbumArtist is the display name used for the album artist.
|
||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
AlbumID string `structs:"album_id" json:"albumId"`
|
||||
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
||||
TrackNumber int `structs:"track_number" json:"trackNumber"`
|
||||
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"`
|
||||
BitRate int `structs:"bit_rate" json:"bitRate"`
|
||||
SampleRate int `structs:"sample_rate" json:"sampleRate"`
|
||||
BitDepth int `structs:"bit_depth" json:"bitDepth"`
|
||||
Channels int `structs:"channels" json:"channels"`
|
||||
Genre string `structs:"genre" json:"genre"`
|
||||
Genres Genres `structs:"-" json:"genres,omitempty"`
|
||||
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` // Deprecated: Use Participants instead
|
||||
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` // Deprecated: Use Participants instead
|
||||
OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"`
|
||||
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
|
||||
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` // Deprecated: Use Participants instead
|
||||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` // Deprecated: Use Participants instead
|
||||
Compilation bool `structs:"compilation" json:"compilation"`
|
||||
Comment string `structs:"comment" json:"comment,omitempty"`
|
||||
Lyrics string `structs:"lyrics" json:"lyrics"`
|
||||
BPM int `structs:"bpm" json:"bpm,omitempty"`
|
||||
ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
|
||||
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
|
||||
MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"`
|
||||
MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty"`
|
||||
MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"`
|
||||
MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
|
||||
MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"` // Deprecated: Use Participants instead
|
||||
MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` // Deprecated: Use Participants instead
|
||||
MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
|
||||
MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
|
||||
RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"`
|
||||
RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"`
|
||||
RGTrackGain *float64 `structs:"rg_track_gain" json:"rgTrackGain"`
|
||||
RGTrackPeak *float64 `structs:"rg_track_peak" json:"rgTrackPeak"`
|
||||
|
||||
Tags Tags `structs:"tags" json:"tags,omitempty" hash:"ignore"` // All imported tags from the original file
|
||||
Participants Participants `structs:"participants" json:"participants" hash:"ignore"` // All artists that participated in this track
|
||||
|
||||
Missing bool `structs:"missing" json:"missing" hash:"ignore"` // If the file is not found in the library's FS
|
||||
BirthTime time.Time `structs:"birth_time" json:"birthTime" hash:"ignore"` // Time of file creation (ctime)
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt" hash:"ignore"` // Time this entry was created in the DB
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt" hash:"ignore"` // Time of file last update (mtime)
|
||||
}
|
||||
|
||||
func (mf MediaFile) FullTitle() string {
|
||||
if conf.Server.Subsonic.AppendSubtitle && mf.Tags[TagSubtitle] != nil {
|
||||
return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
|
||||
}
|
||||
return mf.Title
|
||||
}
|
||||
|
||||
func (mf MediaFile) ContentType() string {
|
||||
return mime.TypeByExtension("." + mf.Suffix)
|
||||
}
|
||||
|
||||
func (mf MediaFile) CoverArtID() ArtworkID {
|
||||
// If it has a cover art, return it (if feature is disabled, skip)
|
||||
if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt {
|
||||
return artworkIDFromMediaFile(mf)
|
||||
}
|
||||
// if it does not have a coverArt, fallback to the album cover
|
||||
return mf.AlbumCoverArtID()
|
||||
}
|
||||
|
||||
func (mf MediaFile) AlbumCoverArtID() ArtworkID {
|
||||
return artworkIDFromAlbum(Album{ID: mf.AlbumID})
|
||||
}
|
||||
|
||||
func (mf MediaFile) StructuredLyrics() (LyricList, error) {
|
||||
lyrics := LyricList{}
|
||||
err := json.Unmarshal([]byte(mf.Lyrics), &lyrics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// String is mainly used for debugging
|
||||
func (mf MediaFile) String() string {
|
||||
return mf.Path
|
||||
}
|
||||
|
||||
// Hash returns a hash of the MediaFile based on its tags and audio properties
|
||||
func (mf MediaFile) Hash() string {
|
||||
opts := &hashstructure.HashOptions{
|
||||
IgnoreZeroValue: true,
|
||||
ZeroNil: true,
|
||||
}
|
||||
hash, _ := hashstructure.Hash(mf, opts)
|
||||
sum := md5.New()
|
||||
sum.Write([]byte(fmt.Sprintf("%d", hash)))
|
||||
sum.Write(mf.Tags.Hash())
|
||||
sum.Write(mf.Participants.Hash())
|
||||
return fmt.Sprintf("%x", sum.Sum(nil))
|
||||
}
|
||||
|
||||
// Equals compares two MediaFiles by their hash. It does not consider the ID, PID, Path and other identifier fields.
|
||||
// Check the structure for the fields that are marked with `hash:"ignore"`.
|
||||
func (mf MediaFile) Equals(other MediaFile) bool {
|
||||
return mf.Hash() == other.Hash()
|
||||
}
|
||||
|
||||
// IsEquivalent compares two MediaFiles by path only. Used for matching missing tracks.
|
||||
func (mf MediaFile) IsEquivalent(other MediaFile) bool {
|
||||
return utils.BaseName(mf.Path) == utils.BaseName(other.Path)
|
||||
}
|
||||
|
||||
func (mf MediaFile) AbsolutePath() string {
|
||||
return filepath.Join(mf.LibraryPath, mf.Path)
|
||||
}
|
||||
|
||||
type MediaFiles []MediaFile
|
||||
|
||||
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
|
||||
// It assumes all mediafiles have the same Album (same ID), or else results are unpredictable.
|
||||
func (mfs MediaFiles) ToAlbum() Album {
|
||||
if len(mfs) == 0 {
|
||||
return Album{}
|
||||
}
|
||||
a := Album{SongCount: len(mfs), Tags: make(Tags), Participants: make(Participants), Discs: Discs{1: ""}}
|
||||
|
||||
// Sorting the mediafiles ensure the results will be consistent
|
||||
slices.SortFunc(mfs, func(a, b MediaFile) int { return cmp.Compare(a.Path, b.Path) })
|
||||
|
||||
mbzAlbumIds := make([]string, 0, len(mfs))
|
||||
mbzReleaseGroupIds := make([]string, 0, len(mfs))
|
||||
comments := make([]string, 0, len(mfs))
|
||||
years := make([]int, 0, len(mfs))
|
||||
dates := make([]string, 0, len(mfs))
|
||||
originalYears := make([]int, 0, len(mfs))
|
||||
originalDates := make([]string, 0, len(mfs))
|
||||
releaseDates := make([]string, 0, len(mfs))
|
||||
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
|
||||
|
||||
a.Missing = true
|
||||
embedArtPath := ""
|
||||
embedArtDisc := 0
|
||||
for _, m := range mfs {
|
||||
// We assume these attributes are all the same for all songs in an album
|
||||
a.ID = m.AlbumID
|
||||
a.LibraryID = m.LibraryID
|
||||
a.Name = m.Album
|
||||
a.AlbumArtist = m.AlbumArtist
|
||||
a.AlbumArtistID = m.AlbumArtistID
|
||||
a.SortAlbumName = m.SortAlbumName
|
||||
a.SortAlbumArtistName = m.SortAlbumArtistName
|
||||
a.OrderAlbumName = m.OrderAlbumName
|
||||
a.OrderAlbumArtistName = m.OrderAlbumArtistName
|
||||
a.MbzAlbumArtistID = m.MbzAlbumArtistID
|
||||
a.MbzAlbumType = m.MbzAlbumType
|
||||
a.MbzAlbumComment = m.MbzAlbumComment
|
||||
a.CatalogNum = m.CatalogNum
|
||||
a.Compilation = a.Compilation || m.Compilation
|
||||
|
||||
// Calculated attributes based on aggregations
|
||||
a.Duration += m.Duration
|
||||
a.Size += m.Size
|
||||
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)
|
||||
comments = append(comments, m.Comment)
|
||||
mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID)
|
||||
mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID)
|
||||
if m.DiscNumber > 0 {
|
||||
a.Discs.Add(m.DiscNumber, m.DiscSubtitle)
|
||||
}
|
||||
tags = append(tags, m.Tags.FlattenAll()...)
|
||||
a.Participants.Merge(m.Participants)
|
||||
|
||||
// Find the MediaFile with cover art and the lowest disc number to use for album cover
|
||||
embedArtPath, embedArtDisc = firstArtPath(embedArtPath, embedArtDisc, m)
|
||||
|
||||
if m.ExplicitStatus == "c" && a.ExplicitStatus != "e" {
|
||||
a.ExplicitStatus = "c"
|
||||
} else if m.ExplicitStatus == "e" {
|
||||
a.ExplicitStatus = "e"
|
||||
}
|
||||
|
||||
a.UpdatedAt = newer(a.UpdatedAt, m.UpdatedAt)
|
||||
a.CreatedAt = older(a.CreatedAt, m.BirthTime)
|
||||
a.Missing = a.Missing && m.Missing
|
||||
}
|
||||
|
||||
a.EmbedArtPath = embedArtPath
|
||||
a.SetTags(tags)
|
||||
a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID }))
|
||||
a.Date, _ = allOrNothing(dates)
|
||||
a.OriginalDate, _ = allOrNothing(originalDates)
|
||||
a.ReleaseDate, _ = allOrNothing(releaseDates)
|
||||
a.MinYear, a.MaxYear = minMax(years)
|
||||
a.MinOriginalYear, a.MaxOriginalYear = minMax(originalYears)
|
||||
a.Comment, _ = allOrNothing(comments)
|
||||
a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds)
|
||||
a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds)
|
||||
fixAlbumArtist(&a)
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
func allOrNothing(items []string) (string, int) {
|
||||
if len(items) == 0 {
|
||||
return "", 0
|
||||
}
|
||||
items = slice.Unique(items)
|
||||
if len(items) != 1 {
|
||||
return "", len(items)
|
||||
}
|
||||
return items[0], 1
|
||||
}
|
||||
|
||||
func minMax(items []int) (int, int) {
|
||||
var mn, mx = items[0], items[0]
|
||||
for _, value := range items {
|
||||
mx = max(mx, value)
|
||||
if mn == 0 {
|
||||
mn = value
|
||||
} else if value > 0 {
|
||||
mn = min(mn, value)
|
||||
}
|
||||
}
|
||||
return mn, mx
|
||||
}
|
||||
|
||||
func newer(t1, t2 time.Time) time.Time {
|
||||
if t1.After(t2) {
|
||||
return t1
|
||||
}
|
||||
return t2
|
||||
}
|
||||
|
||||
func older(t1, t2 time.Time) time.Time {
|
||||
if t1.IsZero() {
|
||||
return t2
|
||||
}
|
||||
if t1.After(t2) {
|
||||
return t2
|
||||
}
|
||||
return t1
|
||||
}
|
||||
|
||||
// fixAlbumArtist sets the AlbumArtist to "Various Artists" if the album has more than one artist
|
||||
// or if it is a compilation
|
||||
func fixAlbumArtist(a *Album) {
|
||||
if !a.Compilation {
|
||||
if a.AlbumArtistID == "" {
|
||||
artist := a.Participants.First(RoleArtist)
|
||||
a.AlbumArtistID = artist.ID
|
||||
a.AlbumArtist = artist.Name
|
||||
}
|
||||
return
|
||||
}
|
||||
albumArtistIds := slice.Map(a.Participants[RoleAlbumArtist], func(p Participant) string { return p.ID })
|
||||
if len(slice.Unique(albumArtistIds)) > 1 {
|
||||
a.AlbumArtist = consts.VariousArtists
|
||||
a.AlbumArtistID = consts.VariousArtistsID
|
||||
}
|
||||
}
|
||||
|
||||
// firstArtPath determines which media file path should be used for album artwork
|
||||
// based on disc number (preferring lower disc numbers) and path (for consistency)
|
||||
func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int) {
|
||||
if !m.HasCoverArt {
|
||||
return currentPath, currentDisc
|
||||
}
|
||||
|
||||
// If current has no disc number (currentDisc == 0) or new file has lower disc number
|
||||
if currentDisc == 0 || (m.DiscNumber < currentDisc && m.DiscNumber > 0) {
|
||||
return m.Path, m.DiscNumber
|
||||
}
|
||||
|
||||
// If disc numbers are equal, use path for ordering
|
||||
if m.DiscNumber == currentDisc {
|
||||
if m.Path < currentPath || currentPath == "" {
|
||||
return m.Path, m.DiscNumber
|
||||
}
|
||||
}
|
||||
|
||||
return currentPath, currentDisc
|
||||
}
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
||||
func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string {
|
||||
buf := strings.Builder{}
|
||||
buf.WriteString("#EXTM3U\n")
|
||||
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", title))
|
||||
for _, t := range mfs {
|
||||
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
|
||||
if absolutePaths {
|
||||
buf.WriteString(t.AbsolutePath() + "\n")
|
||||
} else {
|
||||
buf.WriteString(t.Path + "\n")
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
type MediaFileCursor iter.Seq2[MediaFile, error]
|
||||
|
||||
type MediaFileRepository interface {
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *MediaFile) error
|
||||
Get(id string) (*MediaFile, error)
|
||||
GetWithParticipants(id string) (*MediaFile, error)
|
||||
GetAll(options ...QueryOptions) (MediaFiles, error)
|
||||
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
|
||||
Delete(id string) error
|
||||
DeleteMissing(ids []string) error
|
||||
DeleteAllMissing() (int64, error)
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
MarkMissing(bool, ...*MediaFile) error
|
||||
MarkMissingByFolder(missing bool, folderIDs ...string) error
|
||||
GetMissingAndMatching(libId int) (MediaFileCursor, error)
|
||||
FindRecentFilesByMBZTrackID(missing MediaFile, since time.Time) (MediaFiles, error)
|
||||
FindRecentFilesByProperties(missing MediaFile, since time.Time) (MediaFiles, error)
|
||||
|
||||
AnnotatedRepository
|
||||
BookmarkableRepository
|
||||
SearchableRepository[MediaFiles]
|
||||
}
|
||||
55
model/mediafile_internal_test.go
Normal file
55
model/mediafile_internal_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("fixAlbumArtist", func() {
|
||||
var album Album
|
||||
BeforeEach(func() {
|
||||
album = Album{Participants: Participants{}}
|
||||
})
|
||||
Context("Non-Compilations", func() {
|
||||
BeforeEach(func() {
|
||||
album.Compilation = false
|
||||
album.Participants.Add(RoleArtist, Artist{ID: "ar-123", Name: "Sparks"})
|
||||
})
|
||||
It("returns the track artist if no album artist is specified", func() {
|
||||
fixAlbumArtist(&album)
|
||||
Expect(album.AlbumArtistID).To(Equal("ar-123"))
|
||||
Expect(album.AlbumArtist).To(Equal("Sparks"))
|
||||
})
|
||||
It("returns the album artist if it is specified", func() {
|
||||
album.AlbumArtist = "Sparks Brothers"
|
||||
album.AlbumArtistID = "ar-345"
|
||||
fixAlbumArtist(&album)
|
||||
Expect(album.AlbumArtistID).To(Equal("ar-345"))
|
||||
Expect(album.AlbumArtist).To(Equal("Sparks Brothers"))
|
||||
})
|
||||
})
|
||||
Context("Compilations", func() {
|
||||
BeforeEach(func() {
|
||||
album.Compilation = true
|
||||
album.Name = "Sgt. Pepper Knew My Father"
|
||||
album.AlbumArtistID = "ar-000"
|
||||
album.AlbumArtist = "The Beatles"
|
||||
})
|
||||
|
||||
It("returns VariousArtists if there's more than one album artist", func() {
|
||||
album.Participants.Add(RoleAlbumArtist, Artist{ID: "ar-123", Name: "Sparks"})
|
||||
album.Participants.Add(RoleAlbumArtist, Artist{ID: "ar-345", Name: "The Beach"})
|
||||
fixAlbumArtist(&album)
|
||||
Expect(album.AlbumArtistID).To(Equal(consts.VariousArtistsID))
|
||||
Expect(album.AlbumArtist).To(Equal(consts.VariousArtists))
|
||||
})
|
||||
|
||||
It("returns the sole album artist if they are the same", func() {
|
||||
album.Participants.Add(RoleAlbumArtist, Artist{ID: "ar-000", Name: "The Beatles"})
|
||||
fixAlbumArtist(&album)
|
||||
Expect(album.AlbumArtistID).To(Equal("ar-000"))
|
||||
Expect(album.AlbumArtist).To(Equal("The Beatles"))
|
||||
})
|
||||
})
|
||||
})
|
||||
510
model/mediafile_test.go
Normal file
510
model/mediafile_test.go
Normal file
@@ -0,0 +1,510 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaFiles", func() {
|
||||
var mfs MediaFiles
|
||||
|
||||
Describe("ToAlbum", func() {
|
||||
Context("Simple attributes", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
ID: "1", AlbumID: "AlbumID", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist",
|
||||
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
||||
OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||
MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "/music1/file1.mp3", FolderID: "Folder1",
|
||||
},
|
||||
{
|
||||
ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID",
|
||||
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
||||
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||
MbzReleaseGroupID: "MbzReleaseGroupID",
|
||||
Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music2/file2.mp3", FolderID: "Folder2",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("sets the single values correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.ID).To(Equal("AlbumID"))
|
||||
Expect(album.Name).To(Equal("Album"))
|
||||
Expect(album.AlbumArtist).To(Equal("AlbumArtist"))
|
||||
Expect(album.AlbumArtistID).To(Equal("AlbumArtistID"))
|
||||
Expect(album.SortAlbumName).To(Equal("SortAlbumName"))
|
||||
Expect(album.SortAlbumArtistName).To(Equal("SortAlbumArtistName"))
|
||||
Expect(album.OrderAlbumName).To(Equal("OrderAlbumName"))
|
||||
Expect(album.OrderAlbumArtistName).To(Equal("OrderAlbumArtistName"))
|
||||
Expect(album.MbzAlbumArtistID).To(Equal("MbzAlbumArtistID"))
|
||||
Expect(album.MbzAlbumType).To(Equal("MbzAlbumType"))
|
||||
Expect(album.MbzAlbumComment).To(Equal("MbzAlbumComment"))
|
||||
Expect(album.MbzReleaseGroupID).To(Equal("MbzReleaseGroupID"))
|
||||
Expect(album.CatalogNum).To(Equal("CatalogNum"))
|
||||
Expect(album.Compilation).To(BeTrue())
|
||||
Expect(album.EmbedArtPath).To(Equal("/music2/file2.mp3"))
|
||||
Expect(album.FolderIDs).To(ConsistOf("Folder1", "Folder2"))
|
||||
})
|
||||
})
|
||||
Context("Aggregated attributes", func() {
|
||||
When("we don't have any songs", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{}
|
||||
})
|
||||
It("returns an empty album", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Duration).To(Equal(float32(0)))
|
||||
Expect(album.Size).To(Equal(int64(0)))
|
||||
Expect(album.MinYear).To(Equal(0))
|
||||
Expect(album.MaxYear).To(Equal(0))
|
||||
Expect(album.Date).To(BeEmpty())
|
||||
Expect(album.UpdatedAt).To(BeZero())
|
||||
Expect(album.CreatedAt).To(BeZero())
|
||||
})
|
||||
})
|
||||
When("we have only one song", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")},
|
||||
}
|
||||
})
|
||||
It("calculates the aggregates correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Duration).To(Equal(float32(100.2)))
|
||||
Expect(album.Size).To(Equal(int64(1024)))
|
||||
Expect(album.MinYear).To(Equal(1985))
|
||||
Expect(album.MaxYear).To(Equal(1985))
|
||||
Expect(album.Date).To(Equal("1985-01-02"))
|
||||
Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:30")))
|
||||
Expect(album.CreatedAt).To(Equal(t("2022-12-19 08:30")))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have multiple songs with different dates", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")},
|
||||
{Duration: 200.2, Size: 2048, Year: 0, Date: "", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 08:30")},
|
||||
{Duration: 150.6, Size: 1000, Year: 1986, Date: "1986-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 07:30")},
|
||||
}
|
||||
})
|
||||
It("calculates the aggregates correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Duration).To(Equal(float32(451.0)))
|
||||
Expect(album.Size).To(Equal(int64(4072)))
|
||||
Expect(album.MinYear).To(Equal(1985))
|
||||
Expect(album.MaxYear).To(Equal(1986))
|
||||
Expect(album.Date).To(BeEmpty())
|
||||
Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:45")))
|
||||
Expect(album.CreatedAt).To(Equal(t("2022-12-19 07:30")))
|
||||
})
|
||||
Context("MinYear", func() {
|
||||
It("returns 0 when all values are 0", func() {
|
||||
mfs = MediaFiles{{Year: 0}, {Year: 0}, {Year: 0}}
|
||||
a := mfs.ToAlbum()
|
||||
Expect(a.MinYear).To(Equal(0))
|
||||
})
|
||||
It("returns the smallest value from the list, not counting 0", func() {
|
||||
mfs = MediaFiles{{Year: 2000}, {Year: 0}, {Year: 1999}}
|
||||
a := mfs.ToAlbum()
|
||||
Expect(a.MinYear).To(Equal(1999))
|
||||
})
|
||||
})
|
||||
})
|
||||
When("we have multiple songs with same dates", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")},
|
||||
{Duration: 200.2, Size: 2048, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 08:30")},
|
||||
{Duration: 150.6, Size: 1000, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 07:30")},
|
||||
}
|
||||
})
|
||||
It("sets the date field correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Date).To(Equal("1985-01-02"))
|
||||
Expect(album.MinYear).To(Equal(1985))
|
||||
Expect(album.MaxYear).To(Equal(1985))
|
||||
})
|
||||
})
|
||||
DescribeTable("explicitStatus",
|
||||
func(mfs MediaFiles, status string) {
|
||||
Expect(mfs.ToAlbum().ExplicitStatus).To(Equal(status))
|
||||
},
|
||||
Entry("sets the album to clean when a clean song is present", MediaFiles{{ExplicitStatus: ""}, {ExplicitStatus: "c"}, {ExplicitStatus: ""}}, "c"),
|
||||
Entry("sets the album to explicit when an explicit song is present", MediaFiles{{ExplicitStatus: ""}, {ExplicitStatus: "e"}, {ExplicitStatus: ""}}, "e"),
|
||||
Entry("takes precedence of explicit songs over clean ones", MediaFiles{{ExplicitStatus: "e"}, {ExplicitStatus: "c"}, {ExplicitStatus: ""}}, "e"),
|
||||
)
|
||||
})
|
||||
Context("Calculated attributes", func() {
|
||||
Context("Discs", func() {
|
||||
When("we have no discs info", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Album: "Album1"}, {Album: "Album1"}, {Album: "Album1"}}
|
||||
})
|
||||
It("adds 1 disc without subtitle", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Discs).To(Equal(Discs{1: ""}))
|
||||
})
|
||||
})
|
||||
When("we have only one disc", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}}
|
||||
})
|
||||
It("sets the correct Discs", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle"}))
|
||||
})
|
||||
})
|
||||
When("we have multiple discs", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}, {DiscNumber: 2, DiscSubtitle: "DiscSubtitle2"}, {DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}}
|
||||
})
|
||||
It("sets the correct Discs", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle", 2: "DiscSubtitle2"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("Genres/tags", func() {
|
||||
When("we don't have any tags", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{}}
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Tags).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
When("we have only one Genre", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Tags: Tags{"genre": []string{"Rock"}}}}
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Tags).To(HaveLen(1))
|
||||
Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock"}))
|
||||
})
|
||||
})
|
||||
When("we have multiple Genres", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Tags: Tags{"genre": []string{"Punk"}, "mood": []string{"Happy", "Chill"}}},
|
||||
{Tags: Tags{"genre": []string{"Rock"}}},
|
||||
{Tags: Tags{"genre": []string{"Alternative", "Rock"}}},
|
||||
}
|
||||
})
|
||||
It("sets the correct Genre, sorted by frequency, then alphabetically", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Tags).To(HaveLen(2))
|
||||
Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Alternative", "Punk"}))
|
||||
Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Chill", "Happy"}))
|
||||
})
|
||||
})
|
||||
When("we have tags with mismatching case", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Tags: Tags{"genre": []string{"synthwave"}}},
|
||||
{Tags: Tags{"genre": []string{"Synthwave"}}},
|
||||
}
|
||||
})
|
||||
It("normalizes the tags in just one", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Tags).To(HaveLen(1))
|
||||
Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Synthwave"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Comments", func() {
|
||||
When("we have only one Comment", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Comment: "comment1"}}
|
||||
})
|
||||
It("sets the correct Comment", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Comment).To(Equal("comment1"))
|
||||
})
|
||||
})
|
||||
When("we have multiple equal comments", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Comment: "comment1"}, {Comment: "comment1"}, {Comment: "comment1"}}
|
||||
})
|
||||
It("sets the correct Comment", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Comment).To(Equal("comment1"))
|
||||
})
|
||||
})
|
||||
When("we have different comments", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Comment: "comment1"}, {Comment: "not the same"}, {Comment: "comment1"}}
|
||||
})
|
||||
It("sets the correct comment", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Comment).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Participants", func() {
|
||||
var album Album
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Album: "Album1", AlbumArtistID: "AA1", AlbumArtist: "Display AlbumArtist1", Artist: "Artist1",
|
||||
DiscSubtitle: "DiscSubtitle1", SortAlbumName: "SortAlbumName1",
|
||||
Participants: Participants{
|
||||
RoleAlbumArtist: ParticipantList{_p("AA1", "AlbumArtist1", "SortAlbumArtistName1")},
|
||||
RoleArtist: ParticipantList{_p("A1", "Artist1", "SortArtistName1")},
|
||||
},
|
||||
},
|
||||
{
|
||||
Album: "Album1", AlbumArtistID: "AA1", AlbumArtist: "Display AlbumArtist1", Artist: "Artist2",
|
||||
DiscSubtitle: "DiscSubtitle2", SortAlbumName: "SortAlbumName1",
|
||||
Participants: Participants{
|
||||
RoleAlbumArtist: ParticipantList{_p("AA1", "AlbumArtist1", "SortAlbumArtistName1")},
|
||||
RoleArtist: ParticipantList{_p("A2", "Artist2", "SortArtistName2")},
|
||||
RoleComposer: ParticipantList{_p("C1", "Composer1")},
|
||||
},
|
||||
},
|
||||
}
|
||||
album = mfs.ToAlbum()
|
||||
})
|
||||
It("gets all participants from all tracks", func() {
|
||||
Expect(album.Participants).To(HaveKeyWithValue(RoleAlbumArtist, ParticipantList{_p("AA1", "AlbumArtist1", "SortAlbumArtistName1")}))
|
||||
Expect(album.Participants).To(HaveKeyWithValue(RoleComposer, ParticipantList{_p("C1", "Composer1")}))
|
||||
Expect(album.Participants).To(HaveKeyWithValue(RoleArtist, ParticipantList{
|
||||
_p("A1", "Artist1", "SortArtistName1"), _p("A2", "Artist2", "SortArtistName2"),
|
||||
}))
|
||||
})
|
||||
})
|
||||
Context("MbzAlbumID", func() {
|
||||
When("we have only one MbzAlbumID", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{MbzAlbumID: "id1"}}
|
||||
})
|
||||
It("sets the correct MbzAlbumID", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.MbzAlbumID).To(Equal("id1"))
|
||||
})
|
||||
})
|
||||
When("we have multiple MbzAlbumID", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{MbzAlbumID: "id1"}, {MbzAlbumID: "id2"}, {MbzAlbumID: "id1"}}
|
||||
})
|
||||
It("uses the most frequent MbzAlbumID", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.MbzAlbumID).To(Equal("id1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Album Art", func() {
|
||||
When("we have media files with cover art from multiple discs", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/Disc2/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc1/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc3/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 3,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the cover art from the lowest disc number", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have media files with cover art from the same disc number", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/Disc1/02.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc1/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the cover art with the lowest path alphabetically", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have media files with some missing cover art", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/Disc1/01.mp3",
|
||||
HasCoverArt: false,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc2/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the file with cover art even if from a higher disc number", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc2/01.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have media files with path names that don't correlate with disc numbers", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/file-z.mp3", // Path would be sorted last alphabetically
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1, // But it has lowest disc number
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/file-a.mp3", // Path would be sorted first alphabetically
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 2, // But it has higher disc number
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/file-m.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 3,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the cover art from the lowest disc number regardless of path", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/file-z.mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ToM3U8", func() {
|
||||
It("returns header only for empty MediaFiles", func() {
|
||||
mfs = MediaFiles{}
|
||||
result := mfs.ToM3U8("My Playlist", false)
|
||||
Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n"))
|
||||
})
|
||||
|
||||
DescribeTable("duration formatting",
|
||||
func(duration float32, expected string) {
|
||||
mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}}
|
||||
result := mfs.ToM3U8("Test", false)
|
||||
Expect(result).To(ContainSubstring(expected))
|
||||
},
|
||||
Entry("zero duration", float32(0.0), "#EXTINF:0,"),
|
||||
Entry("whole number", float32(120.0), "#EXTINF:120,"),
|
||||
Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"),
|
||||
Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"),
|
||||
)
|
||||
|
||||
Context("multiple tracks", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"},
|
||||
{Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"},
|
||||
{Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"},
|
||||
}
|
||||
})
|
||||
|
||||
DescribeTable("generates correct output",
|
||||
func(absolutePaths bool, expectedContent string) {
|
||||
result := mfs.ToM3U8("Multi Track", absolutePaths)
|
||||
Expect(result).To(Equal(expectedContent))
|
||||
},
|
||||
Entry("relative paths",
|
||||
false,
|
||||
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
|
||||
),
|
||||
Entry("absolute paths",
|
||||
true,
|
||||
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n",
|
||||
),
|
||||
Entry("special characters",
|
||||
false,
|
||||
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
Context("path variations", func() {
|
||||
It("handles different path structures", func() {
|
||||
mfs = MediaFiles{
|
||||
{Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
|
||||
{Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
|
||||
}
|
||||
|
||||
relativeResult := mfs.ToM3U8("Test", false)
|
||||
Expect(relativeResult).To(ContainSubstring("song.mp3\n"))
|
||||
Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n"))
|
||||
|
||||
absoluteResult := mfs.ToM3U8("Test", true)
|
||||
Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n"))
|
||||
Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("MediaFile", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
})
|
||||
Describe(".CoverArtId()", func() {
|
||||
It("returns its own id if it HasCoverArt", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||
id := mf.CoverArtID()
|
||||
Expect(id.Kind).To(Equal(KindMediaFileArtwork))
|
||||
Expect(id.ID).To(Equal(mf.ID))
|
||||
})
|
||||
It("returns its album id if HasCoverArt is false", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false}
|
||||
id := mf.CoverArtID()
|
||||
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||
})
|
||||
It("returns its album id if EnableMediaFileCoverArt is disabled", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||
id := mf.CoverArtID()
|
||||
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func t(v string) time.Time {
|
||||
var timeFormats = []string{"2006-01-02", "2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999 -0700 MST"}
|
||||
for _, f := range timeFormats {
|
||||
t, err := time.ParseInLocation(f, v, time.UTC)
|
||||
if err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
57
model/metadata/legacy_ids.go
Normal file
57
model/metadata/legacy_ids.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// These are the legacy ID functions that were used in the original Navidrome ID generation.
|
||||
// They are kept here for backwards compatibility with existing databases.
|
||||
|
||||
func legacyTrackID(mf model.MediaFile, prependLibId bool) string {
|
||||
id := mf.Path
|
||||
if prependLibId && mf.LibraryID != model.DefaultLibraryID {
|
||||
id = fmt.Sprintf("%d\\%s", mf.LibraryID, id)
|
||||
}
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(id)))
|
||||
}
|
||||
|
||||
func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string {
|
||||
_, _, releaseDate := md.mapDates()
|
||||
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
|
||||
if !conf.Server.Scanner.GroupAlbumReleases {
|
||||
if len(releaseDate) != 0 {
|
||||
albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate)
|
||||
}
|
||||
}
|
||||
if prependLibId && mf.LibraryID != model.DefaultLibraryID {
|
||||
albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath)
|
||||
}
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
|
||||
}
|
||||
|
||||
func legacyMapAlbumArtistName(md Metadata) string {
|
||||
values := []string{
|
||||
md.String(model.TagAlbumArtist),
|
||||
"",
|
||||
md.String(model.TagTrackArtist),
|
||||
consts.UnknownArtist,
|
||||
}
|
||||
if md.Bool(model.TagCompilation) {
|
||||
values[1] = consts.VariousArtists
|
||||
}
|
||||
return cmp.Or(values...)
|
||||
}
|
||||
|
||||
func legacyMapAlbumName(md Metadata) string {
|
||||
return cmp.Or(
|
||||
md.String(model.TagAlbum),
|
||||
consts.UnknownAlbum,
|
||||
)
|
||||
}
|
||||
185
model/metadata/map_mediafile.go
Normal file
185
model/metadata/map_mediafile.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
mf := model.MediaFile{
|
||||
LibraryID: libID,
|
||||
FolderID: folderID,
|
||||
Tags: maps.Clone(md.tags),
|
||||
}
|
||||
|
||||
// Title and Album
|
||||
mf.Title = md.mapTrackTitle()
|
||||
mf.Album = md.mapAlbumName()
|
||||
mf.SortTitle = md.String(model.TagTitleSort)
|
||||
mf.SortAlbumName = md.String(model.TagAlbumSort)
|
||||
mf.OrderTitle = str.SanitizeFieldForSorting(mf.Title)
|
||||
mf.OrderAlbumName = str.SanitizeFieldForSortingNoArticle(mf.Album)
|
||||
mf.Compilation = md.Bool(model.TagCompilation)
|
||||
|
||||
// Disc and Track info
|
||||
mf.TrackNumber, _ = md.NumAndTotal(model.TagTrackNumber)
|
||||
mf.DiscNumber, _ = md.NumAndTotal(model.TagDiscNumber)
|
||||
mf.DiscSubtitle = md.String(model.TagDiscSubtitle)
|
||||
mf.CatalogNum = md.String(model.TagCatalogNumber)
|
||||
mf.Comment = md.String(model.TagComment)
|
||||
mf.BPM = int(math.Round(md.Float(model.TagBPM)))
|
||||
mf.Lyrics = md.mapLyrics()
|
||||
mf.ExplicitStatus = md.mapExplicitStatusTag()
|
||||
|
||||
// Dates
|
||||
date, origDate, relDate := md.mapDates()
|
||||
mf.OriginalYear, mf.OriginalDate = origDate.Year(), string(origDate)
|
||||
mf.ReleaseYear, mf.ReleaseDate = relDate.Year(), string(relDate)
|
||||
mf.Year, mf.Date = date.Year(), string(date)
|
||||
|
||||
// MBIDs
|
||||
mf.MbzRecordingID = md.String(model.TagMusicBrainzRecordingID)
|
||||
mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID)
|
||||
mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID)
|
||||
mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID)
|
||||
mf.MbzAlbumType = md.String(model.TagReleaseType)
|
||||
|
||||
// ReplayGain
|
||||
mf.RGAlbumPeak = md.NullableFloat(model.TagReplayGainAlbumPeak)
|
||||
mf.RGAlbumGain = md.mapGain(model.TagReplayGainAlbumGain, model.TagR128AlbumGain)
|
||||
mf.RGTrackPeak = md.NullableFloat(model.TagReplayGainTrackPeak)
|
||||
mf.RGTrackGain = md.mapGain(model.TagReplayGainTrackGain, model.TagR128TrackGain)
|
||||
|
||||
// General properties
|
||||
mf.HasCoverArt = md.HasPicture()
|
||||
mf.Duration = md.Length()
|
||||
mf.BitRate = md.AudioProperties().BitRate
|
||||
mf.SampleRate = md.AudioProperties().SampleRate
|
||||
mf.BitDepth = md.AudioProperties().BitDepth
|
||||
mf.Channels = md.AudioProperties().Channels
|
||||
mf.Path = md.FilePath()
|
||||
mf.Suffix = md.Suffix()
|
||||
mf.Size = md.Size()
|
||||
mf.BirthTime = md.BirthTime()
|
||||
mf.UpdatedAt = md.ModTime()
|
||||
|
||||
mf.Participants = md.mapParticipants()
|
||||
mf.Artist = md.mapDisplayArtist()
|
||||
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
|
||||
|
||||
// Persistent IDs
|
||||
mf.PID = md.trackPID(mf)
|
||||
mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
|
||||
|
||||
// BFR These IDs will go away once the UI handle multiple participants.
|
||||
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers
|
||||
mf.ArtistID = mf.Participants.First(model.RoleArtist).ID
|
||||
mf.AlbumArtistID = mf.Participants.First(model.RoleAlbumArtist).ID
|
||||
|
||||
// BFR What to do with sort/order artist names?
|
||||
mf.OrderArtistName = mf.Participants.First(model.RoleArtist).OrderArtistName
|
||||
mf.OrderAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).OrderArtistName
|
||||
mf.SortArtistName = mf.Participants.First(model.RoleArtist).SortArtistName
|
||||
mf.SortAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).SortArtistName
|
||||
|
||||
// Don't store tags that are first-class fields (and are not album-level tags) in the
|
||||
// MediaFile struct. This is to avoid redundancy in the DB
|
||||
//
|
||||
// Remove all tags from the main section that are not flagged as album tags
|
||||
for tag, conf := range model.TagMainMappings() {
|
||||
if !conf.Album {
|
||||
delete(mf.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
return mf
|
||||
}
|
||||
|
||||
func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string {
|
||||
return md.albumID(mf, pidConf)
|
||||
}
|
||||
|
||||
func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
|
||||
v := md.Gain(rg)
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
r128value := md.String(r128)
|
||||
if r128value != "" {
|
||||
var v, err = strconv.Atoi(r128value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Convert Q7.8 to float
|
||||
value := float64(v) / 256.0
|
||||
// Adding 5 dB to normalize with ReplayGain level
|
||||
value += 5
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (md Metadata) mapLyrics() string {
|
||||
rawLyrics := md.Pairs(model.TagLyrics)
|
||||
|
||||
lyricList := make(model.LyricList, 0, len(rawLyrics))
|
||||
|
||||
for _, raw := range rawLyrics {
|
||||
lang := raw.Key()
|
||||
text := raw.Value()
|
||||
|
||||
lyrics, err := model.ToLyrics(lang, text)
|
||||
if err != nil {
|
||||
log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err)
|
||||
continue
|
||||
}
|
||||
if !lyrics.IsEmpty() {
|
||||
lyricList = append(lyricList, *lyrics)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := json.Marshal(lyricList)
|
||||
if err != nil {
|
||||
log.Warn("Unexpected error occurred when serializing lyrics", "file", md.filePath, err)
|
||||
return ""
|
||||
}
|
||||
return string(res)
|
||||
}
|
||||
|
||||
func (md Metadata) mapExplicitStatusTag() string {
|
||||
switch md.first(model.TagExplicitStatus) {
|
||||
case "1", "4":
|
||||
return "e"
|
||||
case "2":
|
||||
return "c"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (md Metadata) mapDates() (date Date, originalDate Date, releaseDate Date) {
|
||||
// Start with defaults
|
||||
date = md.Date(model.TagRecordingDate)
|
||||
originalDate = md.Date(model.TagOriginalDate)
|
||||
releaseDate = md.Date(model.TagReleaseDate)
|
||||
|
||||
// For some historic reason, taggers have been writing the Release Date of an album to the Date tag,
|
||||
// and leave the Release Date tag empty.
|
||||
legacyMappings := (originalDate != "") &&
|
||||
(releaseDate == "") &&
|
||||
(date >= originalDate)
|
||||
if legacyMappings {
|
||||
return originalDate, originalDate, date
|
||||
}
|
||||
// when there's no Date, first fall back to Original Date, then to Release Date.
|
||||
date = cmp.Or(date, originalDate, releaseDate)
|
||||
return date, originalDate, releaseDate
|
||||
}
|
||||
121
model/metadata/map_mediafile_test.go
Normal file
121
model/metadata/map_mediafile_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package metadata_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ToMediaFile", func() {
|
||||
var (
|
||||
props metadata.Info
|
||||
md metadata.Metadata
|
||||
mf model.MediaFile
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
_, filePath, _ := tests.TempFile(GinkgoT(), "test", ".mp3")
|
||||
fileInfo, _ := os.Stat(filePath)
|
||||
props = metadata.Info{
|
||||
FileInfo: testFileInfo{fileInfo},
|
||||
}
|
||||
})
|
||||
|
||||
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
||||
props.Tags = tags
|
||||
md = metadata.New("filepath", props)
|
||||
return md.ToMediaFile(1, "folderID")
|
||||
}
|
||||
|
||||
Describe("Dates", func() {
|
||||
It("should parse properly tagged dates ", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ORIGINALDATE": {"1978-09-10"},
|
||||
"DATE": {"1977-03-04"},
|
||||
"RELEASEDATE": {"2002-01-02"},
|
||||
})
|
||||
|
||||
Expect(mf.Year).To(Equal(1977))
|
||||
Expect(mf.Date).To(Equal("1977-03-04"))
|
||||
Expect(mf.OriginalYear).To(Equal(1978))
|
||||
Expect(mf.OriginalDate).To(Equal("1978-09-10"))
|
||||
Expect(mf.ReleaseYear).To(Equal(2002))
|
||||
Expect(mf.ReleaseDate).To(Equal("2002-01-02"))
|
||||
})
|
||||
|
||||
It("should parse dates with only year", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ORIGINALYEAR": {"1978"},
|
||||
"DATE": {"1977"},
|
||||
"RELEASEDATE": {"2002"},
|
||||
})
|
||||
|
||||
Expect(mf.Year).To(Equal(1977))
|
||||
Expect(mf.Date).To(Equal("1977"))
|
||||
Expect(mf.OriginalYear).To(Equal(1978))
|
||||
Expect(mf.OriginalDate).To(Equal("1978"))
|
||||
Expect(mf.ReleaseYear).To(Equal(2002))
|
||||
Expect(mf.ReleaseDate).To(Equal("2002"))
|
||||
})
|
||||
|
||||
It("should parse dates tagged the legacy way (no release date)", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"DATE": {"2014"},
|
||||
"ORIGINALDATE": {"1966"},
|
||||
})
|
||||
|
||||
Expect(mf.Year).To(Equal(1966))
|
||||
Expect(mf.OriginalYear).To(Equal(1966))
|
||||
Expect(mf.ReleaseYear).To(Equal(2014))
|
||||
})
|
||||
DescribeTable("legacyReleaseDate (TaggedLikePicard old behavior)",
|
||||
func(recordingDate, originalDate, releaseDate, expected string) {
|
||||
mf := toMediaFile(model.RawTags{
|
||||
"DATE": {recordingDate},
|
||||
"ORIGINALDATE": {originalDate},
|
||||
"RELEASEDATE": {releaseDate},
|
||||
})
|
||||
|
||||
Expect(mf.ReleaseDate).To(Equal(expected))
|
||||
},
|
||||
Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
|
||||
Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
|
||||
Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
|
||||
Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
|
||||
Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
|
||||
Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Lyrics", func() {
|
||||
It("should parse the lyrics", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"LYRICS:XXX": {"Lyrics"},
|
||||
"LYRICS:ENG": {
|
||||
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||
},
|
||||
})
|
||||
var actual model.LyricList
|
||||
err := json.Unmarshal([]byte(mf.Lyrics), &actual)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
expected := model.LyricList{
|
||||
{Lang: "eng", Line: []model.Line{
|
||||
{Value: "This is", Start: P(int64(0))},
|
||||
{Value: "English SYLT", Start: P(int64(2500))},
|
||||
}, Synced: true},
|
||||
{Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false},
|
||||
}
|
||||
sort.Slice(actual, func(i, j int) bool { return actual[i].Lang < actual[j].Lang })
|
||||
sort.Slice(expected, func(i, j int) bool { return expected[i].Lang < expected[j].Lang })
|
||||
Expect(actual).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
})
|
||||
236
model/metadata/map_participants.go
Normal file
236
model/metadata/map_participants.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type roleTags struct {
|
||||
name model.TagName
|
||||
sort model.TagName
|
||||
mbid model.TagName
|
||||
}
|
||||
|
||||
var roleMappings = map[model.Role]roleTags{
|
||||
model.RoleComposer: {name: model.TagComposer, sort: model.TagComposerSort, mbid: model.TagMusicBrainzComposerID},
|
||||
model.RoleLyricist: {name: model.TagLyricist, sort: model.TagLyricistSort, mbid: model.TagMusicBrainzLyricistID},
|
||||
model.RoleConductor: {name: model.TagConductor, mbid: model.TagMusicBrainzConductorID},
|
||||
model.RoleArranger: {name: model.TagArranger, mbid: model.TagMusicBrainzArrangerID},
|
||||
model.RoleDirector: {name: model.TagDirector, mbid: model.TagMusicBrainzDirectorID},
|
||||
model.RoleProducer: {name: model.TagProducer, mbid: model.TagMusicBrainzProducerID},
|
||||
model.RoleEngineer: {name: model.TagEngineer, mbid: model.TagMusicBrainzEngineerID},
|
||||
model.RoleMixer: {name: model.TagMixer, mbid: model.TagMusicBrainzMixerID},
|
||||
model.RoleRemixer: {name: model.TagRemixer, mbid: model.TagMusicBrainzRemixerID},
|
||||
model.RoleDJMixer: {name: model.TagDJMixer, mbid: model.TagMusicBrainzDJMixerID},
|
||||
}
|
||||
|
||||
func (md Metadata) mapParticipants() model.Participants {
|
||||
participants := make(model.Participants)
|
||||
|
||||
// Parse track artists
|
||||
artists := md.parseArtists(
|
||||
model.TagTrackArtist, model.TagTrackArtists,
|
||||
model.TagTrackArtistSort, model.TagTrackArtistsSort,
|
||||
model.TagMusicBrainzArtistID,
|
||||
)
|
||||
participants.Add(model.RoleArtist, artists...)
|
||||
|
||||
// Parse album artists
|
||||
albumArtists := md.parseArtists(
|
||||
model.TagAlbumArtist, model.TagAlbumArtists,
|
||||
model.TagAlbumArtistSort, model.TagAlbumArtistsSort,
|
||||
model.TagMusicBrainzAlbumArtistID,
|
||||
)
|
||||
if len(albumArtists) == 1 && albumArtists[0].Name == consts.UnknownArtist {
|
||||
if md.Bool(model.TagCompilation) {
|
||||
albumArtists = md.buildArtists([]string{consts.VariousArtists}, nil, []string{consts.VariousArtistsMbzId})
|
||||
} else {
|
||||
albumArtists = artists
|
||||
}
|
||||
}
|
||||
participants.Add(model.RoleAlbumArtist, albumArtists...)
|
||||
|
||||
// Parse all other roles
|
||||
for role, info := range roleMappings {
|
||||
names := md.getRoleValues(info.name)
|
||||
if len(names) > 0 {
|
||||
sorts := md.Strings(info.sort)
|
||||
mbids := md.Strings(info.mbid)
|
||||
artists := md.buildArtists(names, sorts, mbids)
|
||||
participants.Add(role, artists...)
|
||||
}
|
||||
}
|
||||
|
||||
rolesMbzIdMap := md.buildRoleMbidMaps()
|
||||
md.processPerformers(participants, rolesMbzIdMap)
|
||||
md.syncMissingMbzIDs(participants)
|
||||
|
||||
return participants
|
||||
}
|
||||
|
||||
// buildRoleMbidMaps creates a map of roles to MBZ IDs
|
||||
func (md Metadata) buildRoleMbidMaps() map[string][]string {
|
||||
titleCaser := cases.Title(language.Und)
|
||||
rolesMbzIdMap := make(map[string][]string)
|
||||
for _, mbid := range md.Pairs(model.TagMusicBrainzPerformerID) {
|
||||
role := titleCaser.String(mbid.Key())
|
||||
rolesMbzIdMap[role] = append(rolesMbzIdMap[role], mbid.Value())
|
||||
}
|
||||
|
||||
return rolesMbzIdMap
|
||||
}
|
||||
|
||||
func (md Metadata) processPerformers(participants model.Participants, rolesMbzIdMap map[string][]string) {
|
||||
// roleIdx keeps track of the index of the MBZ ID for each role
|
||||
roleIdx := make(map[string]int)
|
||||
for role := range rolesMbzIdMap {
|
||||
roleIdx[role] = 0
|
||||
}
|
||||
|
||||
titleCaser := cases.Title(language.Und)
|
||||
for _, performer := range md.Pairs(model.TagPerformer) {
|
||||
name := performer.Value()
|
||||
subRole := titleCaser.String(performer.Key())
|
||||
|
||||
artist := model.Artist{
|
||||
ID: md.artistID(name),
|
||||
Name: name,
|
||||
OrderArtistName: str.SanitizeFieldForSortingNoArticle(name),
|
||||
MbzArtistID: md.getPerformerMbid(subRole, rolesMbzIdMap, roleIdx),
|
||||
}
|
||||
participants.AddWithSubRole(model.RolePerformer, subRole, artist)
|
||||
}
|
||||
}
|
||||
|
||||
// getPerformerMbid returns the MBZ ID for a performer, based on the subrole
|
||||
func (md Metadata) getPerformerMbid(subRole string, rolesMbzIdMap map[string][]string, roleIdx map[string]int) string {
|
||||
if mbids, exists := rolesMbzIdMap[subRole]; exists && roleIdx[subRole] < len(mbids) {
|
||||
defer func() { roleIdx[subRole]++ }()
|
||||
return mbids[roleIdx[subRole]]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// syncMissingMbzIDs fills in missing MBZ IDs for artists that have been previously parsed
|
||||
func (md Metadata) syncMissingMbzIDs(participants model.Participants) {
|
||||
artistMbzIDMap := make(map[string]string)
|
||||
for _, artist := range append(participants[model.RoleArtist], participants[model.RoleAlbumArtist]...) {
|
||||
if artist.MbzArtistID != "" {
|
||||
artistMbzIDMap[artist.Name] = artist.MbzArtistID
|
||||
}
|
||||
}
|
||||
|
||||
for role, list := range participants {
|
||||
for i, artist := range list {
|
||||
if artist.MbzArtistID == "" {
|
||||
if mbzID, exists := artistMbzIDMap[artist.Name]; exists {
|
||||
participants[role][i].MbzArtistID = mbzID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (md Metadata) parseArtists(
|
||||
name model.TagName, names model.TagName, sort model.TagName,
|
||||
sorts model.TagName, mbid model.TagName,
|
||||
) []model.Artist {
|
||||
nameValues := md.getArtistValues(name, names)
|
||||
sortValues := md.getArtistValues(sort, sorts)
|
||||
mbids := md.Strings(mbid)
|
||||
if len(nameValues) == 0 {
|
||||
nameValues = []string{consts.UnknownArtist}
|
||||
}
|
||||
return md.buildArtists(nameValues, sortValues, mbids)
|
||||
}
|
||||
|
||||
func (md Metadata) buildArtists(names, sorts, mbids []string) []model.Artist {
|
||||
var artists []model.Artist
|
||||
for i, name := range names {
|
||||
id := md.artistID(name)
|
||||
artist := model.Artist{
|
||||
ID: id,
|
||||
Name: name,
|
||||
OrderArtistName: str.SanitizeFieldForSortingNoArticle(name),
|
||||
}
|
||||
if i < len(sorts) {
|
||||
artist.SortArtistName = sorts[i]
|
||||
}
|
||||
if i < len(mbids) {
|
||||
artist.MbzArtistID = mbids[i]
|
||||
}
|
||||
artists = append(artists, artist)
|
||||
}
|
||||
return artists
|
||||
}
|
||||
|
||||
// getRoleValues returns the values of a role tag, splitting them if necessary
|
||||
func (md Metadata) getRoleValues(role model.TagName) []string {
|
||||
values := md.Strings(role)
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
conf := model.TagMainMappings()[role]
|
||||
if conf.Split == nil {
|
||||
conf = model.TagRolesConf()
|
||||
}
|
||||
if len(conf.Split) > 0 {
|
||||
values = conf.SplitTagValue(values)
|
||||
return filterDuplicatedOrEmptyValues(values)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// getArtistValues returns the values of a single or multi artist tag, splitting them if necessary
|
||||
func (md Metadata) getArtistValues(single, multi model.TagName) []string {
|
||||
vMulti := md.Strings(multi)
|
||||
if len(vMulti) > 0 {
|
||||
return vMulti
|
||||
}
|
||||
vSingle := md.Strings(single)
|
||||
if len(vSingle) != 1 {
|
||||
return vSingle
|
||||
}
|
||||
conf := model.TagMainMappings()[single]
|
||||
if conf.Split == nil {
|
||||
conf = model.TagArtistsConf()
|
||||
}
|
||||
if len(conf.Split) > 0 {
|
||||
vSingle = conf.SplitTagValue(vSingle)
|
||||
return filterDuplicatedOrEmptyValues(vSingle)
|
||||
}
|
||||
return vSingle
|
||||
}
|
||||
|
||||
func (md Metadata) mapDisplayName(singularTagName, pluralTagName model.TagName) string {
|
||||
return cmp.Or(
|
||||
strings.Join(md.tags[singularTagName], conf.Server.Scanner.ArtistJoiner),
|
||||
strings.Join(md.tags[pluralTagName], conf.Server.Scanner.ArtistJoiner),
|
||||
)
|
||||
}
|
||||
|
||||
func (md Metadata) mapDisplayArtist() string {
|
||||
return cmp.Or(
|
||||
md.mapDisplayName(model.TagTrackArtist, model.TagTrackArtists),
|
||||
consts.UnknownArtist,
|
||||
)
|
||||
}
|
||||
|
||||
func (md Metadata) mapDisplayAlbumArtist(mf model.MediaFile) string {
|
||||
fallbackName := consts.UnknownArtist
|
||||
if md.Bool(model.TagCompilation) {
|
||||
fallbackName = consts.VariousArtists
|
||||
}
|
||||
return cmp.Or(
|
||||
md.mapDisplayName(model.TagAlbumArtist, model.TagAlbumArtists),
|
||||
mf.Participants.First(model.RoleAlbumArtist).Name,
|
||||
fallbackName,
|
||||
)
|
||||
}
|
||||
785
model/metadata/map_participants_test.go
Normal file
785
model/metadata/map_participants_test.go
Normal file
@@ -0,0 +1,785 @@
|
||||
package metadata_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gstruct"
|
||||
"github.com/onsi/gomega/types"
|
||||
)
|
||||
|
||||
var _ = Describe("Participants", func() {
|
||||
var (
|
||||
props metadata.Info
|
||||
md metadata.Metadata
|
||||
mf model.MediaFile
|
||||
mbid1, mbid2, mbid3 string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
_, filePath, _ := tests.TempFile(GinkgoT(), "test", ".mp3")
|
||||
fileInfo, _ := os.Stat(filePath)
|
||||
mbid1 = uuid.NewString()
|
||||
mbid2 = uuid.NewString()
|
||||
mbid3 = uuid.NewString()
|
||||
props = metadata.Info{
|
||||
FileInfo: testFileInfo{fileInfo},
|
||||
}
|
||||
})
|
||||
|
||||
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
||||
props.Tags = tags
|
||||
md = metadata.New("filepath", props)
|
||||
return md.ToMediaFile(1, "folderID")
|
||||
}
|
||||
|
||||
Describe("ARTIST(S) tags", func() {
|
||||
Context("No ARTIST/ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{})
|
||||
})
|
||||
|
||||
It("should set the display name to Unknown Artist", func() {
|
||||
Expect(mf.Artist).To(Equal("[Unknown Artist]"))
|
||||
})
|
||||
|
||||
It("should set artist to Unknown Artist", func() {
|
||||
Expect(mf.Artist).To(Equal("[Unknown Artist]"))
|
||||
})
|
||||
|
||||
It("should add an Unknown Artist to participants", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
|
||||
artist := participants[model.RoleArtist][0]
|
||||
Expect(artist.ID).ToNot(BeEmpty())
|
||||
Expect(artist.Name).To(Equal("[Unknown Artist]"))
|
||||
Expect(artist.OrderArtistName).To(Equal("[unknown artist]"))
|
||||
Expect(artist.SortArtistName).To(BeEmpty())
|
||||
Expect(artist.MbzArtistID).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Single-valued ARTIST tags, no ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name"},
|
||||
"ARTISTSORT": {"Name, Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the artist tag as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should populate the participants", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
|
||||
))
|
||||
Expect(mf.Artist).To(Equal("Artist Name"))
|
||||
|
||||
artist := participants[model.RoleArtist][0]
|
||||
|
||||
Expect(artist.ID).ToNot(BeEmpty())
|
||||
Expect(artist.Name).To(Equal("Artist Name"))
|
||||
Expect(artist.OrderArtistName).To(Equal("artist name"))
|
||||
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(artist.MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Multiple values in a Single-valued ARTIST tags, no ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name feat. Someone Else"},
|
||||
"ARTISTSORT": {"Name, Artist feat. Else, Someone"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the full string as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("Artist Name feat. Someone Else"))
|
||||
Expect(mf.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(mf.OrderArtistName).To(Equal("artist name"))
|
||||
})
|
||||
|
||||
It("should split the tag", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
By("adding the first artist to the participants")
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("Artist Name"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("artist name"))
|
||||
Expect(artist0.SortArtistName).To(Equal("Name, Artist"))
|
||||
|
||||
By("assuming the MBID is for the first artist")
|
||||
Expect(artist0.MbzArtistID).To(Equal(mbid1))
|
||||
|
||||
By("adding the second artist to the participants")
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Someone Else"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("someone else"))
|
||||
Expect(artist1.SortArtistName).To(Equal("Else, Someone"))
|
||||
Expect(artist1.MbzArtistID).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should split the tag using case-insensitive separators", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"A1 FEAT. A2"},
|
||||
})
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
artist1 := participants[model.RoleArtist][0]
|
||||
Expect(artist1.Name).To(Equal("A1"))
|
||||
artist2 := participants[model.RoleArtist][1]
|
||||
Expect(artist2.Name).To(Equal("A2"))
|
||||
})
|
||||
|
||||
It("should not add an empty artist after split", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"John Doe / / Jane Doe"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleArtist, HaveLen(2)))
|
||||
artists := participants[model.RoleArtist]
|
||||
Expect(artists[0].Name).To(Equal("John Doe"))
|
||||
Expect(artists[1].Name).To(Equal("Jane Doe"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Multi-valued ARTIST tags, no ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"First Artist", "Second Artist"},
|
||||
"ARTISTSORT": {"Name, First Artist", "Name, Second Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
|
||||
})
|
||||
})
|
||||
|
||||
It("should concatenate all ARTIST values as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist • Second Artist"))
|
||||
})
|
||||
|
||||
It("should populate the participants with all artists", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("First Artist"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("first artist"))
|
||||
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
|
||||
Expect(artist0.MbzArtistID).To(Equal(mbid1))
|
||||
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Second Artist"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("second artist"))
|
||||
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
|
||||
Expect(artist1.MbzArtistID).To(Equal(mbid2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Single-valued ARTIST tag, single-valued ARTISTS tag, same values", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name"},
|
||||
"ARTISTS": {"Artist Name"},
|
||||
"ARTISTSORT": {"Name, Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ARTIST tag as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should populate the participants with the ARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
artist := participants[model.RoleArtist][0]
|
||||
Expect(artist.ID).ToNot(BeEmpty())
|
||||
Expect(artist.Name).To(Equal("Artist Name"))
|
||||
Expect(artist.OrderArtistName).To(Equal("artist name"))
|
||||
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(artist.MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Single-valued ARTIST tag, single-valued ARTISTS tag, different values", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name"},
|
||||
"ARTISTS": {"Artist Name 2"},
|
||||
"ARTISTSORT": {"Name, Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ARTIST tag as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should use only artists from ARTISTS", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
artist := participants[model.RoleArtist][0]
|
||||
Expect(artist.ID).ToNot(BeEmpty())
|
||||
Expect(artist.Name).To(Equal("Artist Name 2"))
|
||||
Expect(artist.OrderArtistName).To(Equal("artist name 2"))
|
||||
Expect(artist.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(artist.MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("No ARTIST tag, multi-valued ARTISTS tag", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTISTS": {"First Artist", "Second Artist"},
|
||||
"ARTISTSSORT": {"Name, First Artist", "Name, Second Artist"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should concatenate ARTISTS as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist • Second Artist"))
|
||||
})
|
||||
|
||||
It("should populate the participants with all artists", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("First Artist"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("first artist"))
|
||||
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
|
||||
Expect(artist0.MbzArtistID).To(BeEmpty())
|
||||
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Second Artist"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("second artist"))
|
||||
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
|
||||
Expect(artist1.MbzArtistID).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Single-valued ARTIST tags, multi-valued ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"First Artist & Second Artist"},
|
||||
"ARTISTSORT": {"Name, First Artist & Name, Second Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
|
||||
"ARTISTS": {"First Artist", "Second Artist"},
|
||||
"ARTISTSSORT": {"Name, First Artist", "Name, Second Artist"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the single-valued tag as display name", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist & Second Artist"))
|
||||
})
|
||||
|
||||
It("should prioritize multi-valued tags over single-valued tags", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("First Artist"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("first artist"))
|
||||
Expect(artist0.SortArtistName).To(Equal("Name, First Artist"))
|
||||
Expect(artist0.MbzArtistID).To(Equal(mbid1))
|
||||
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Second Artist"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("second artist"))
|
||||
Expect(artist1.SortArtistName).To(Equal("Name, Second Artist"))
|
||||
Expect(artist1.MbzArtistID).To(Equal(mbid2))
|
||||
})
|
||||
})
|
||||
|
||||
// Not a good tagging strategy, but supported anyway.
|
||||
Context("Multi-valued ARTIST tags, multi-valued ARTISTS tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"First Artist", "Second Artist"},
|
||||
"ARTISTSORT": {"Name, First Artist", "Name, Second Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
|
||||
"ARTISTS": {"First Artist 2", "Second Artist 2"},
|
||||
"ARTISTSSORT": {"2, First Artist Name", "2, Second Artist Name"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use ARTIST values concatenated as a display name ", func() {
|
||||
Expect(mf.Artist).To(Equal("First Artist • Second Artist"))
|
||||
})
|
||||
|
||||
It("should prioritize ARTISTS tags", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleArtist, HaveLen(2)),
|
||||
))
|
||||
artist0 := participants[model.RoleArtist][0]
|
||||
Expect(artist0.ID).ToNot(BeEmpty())
|
||||
Expect(artist0.Name).To(Equal("First Artist 2"))
|
||||
Expect(artist0.OrderArtistName).To(Equal("first artist 2"))
|
||||
Expect(artist0.SortArtistName).To(Equal("2, First Artist Name"))
|
||||
Expect(artist0.MbzArtistID).To(Equal(mbid1))
|
||||
|
||||
artist1 := participants[model.RoleArtist][1]
|
||||
Expect(artist1.ID).ToNot(BeEmpty())
|
||||
Expect(artist1.Name).To(Equal("Second Artist 2"))
|
||||
Expect(artist1.OrderArtistName).To(Equal("second artist 2"))
|
||||
Expect(artist1.SortArtistName).To(Equal("2, Second Artist Name"))
|
||||
Expect(artist1.MbzArtistID).To(Equal(mbid2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ALBUMARTIST(S) tags", func() {
|
||||
// Only test specific scenarios for ALBUMARTIST(S) tags, as the logic is the same as for ARTIST(S) tags.
|
||||
Context("No ALBUMARTIST/ALBUMARTISTS tags", func() {
|
||||
When("the COMPILATION tag is not set", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name"},
|
||||
"ARTISTSORT": {"Name, Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ARTIST as ALBUMARTIST", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should add the ARTIST to participants as ALBUMARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2))
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
albumArtist := participants[model.RoleAlbumArtist][0]
|
||||
Expect(albumArtist.ID).ToNot(BeEmpty())
|
||||
Expect(albumArtist.Name).To(Equal("Artist Name"))
|
||||
Expect(albumArtist.OrderArtistName).To(Equal("artist name"))
|
||||
Expect(albumArtist.SortArtistName).To(Equal("Name, Artist"))
|
||||
Expect(albumArtist.MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
When("the COMPILATION tag is not set and there is no ALBUMARTIST tag", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Artist Name", "Another Artist"},
|
||||
"ARTISTSORT": {"Name, Artist", "Artist, Another"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the first ARTIST as ALBUMARTIST", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("should add the ARTIST to participants as ALBUMARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2))
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(2)),
|
||||
))
|
||||
|
||||
albumArtist := participants[model.RoleAlbumArtist][0]
|
||||
Expect(albumArtist.Name).To(Equal("Artist Name"))
|
||||
Expect(albumArtist.SortArtistName).To(Equal("Name, Artist"))
|
||||
|
||||
albumArtist = participants[model.RoleAlbumArtist][1]
|
||||
Expect(albumArtist.Name).To(Equal("Another Artist"))
|
||||
Expect(albumArtist.SortArtistName).To(Equal("Artist, Another"))
|
||||
})
|
||||
})
|
||||
|
||||
When("the COMPILATION tag is true", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPILATION": {"1"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the Various Artists as display name", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Various Artists"))
|
||||
})
|
||||
|
||||
It("should add the Various Artists to participants as ALBUMARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2))
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
albumArtist := participants[model.RoleAlbumArtist][0]
|
||||
Expect(albumArtist.ID).ToNot(BeEmpty())
|
||||
Expect(albumArtist.Name).To(Equal("Various Artists"))
|
||||
Expect(albumArtist.OrderArtistName).To(Equal("various artists"))
|
||||
Expect(albumArtist.SortArtistName).To(BeEmpty())
|
||||
Expect(albumArtist.MbzArtistID).To(Equal(consts.VariousArtistsMbzId))
|
||||
})
|
||||
})
|
||||
|
||||
When("the COMPILATION tag is true and there are ALBUMARTIST tags", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPILATION": {"1"},
|
||||
"ALBUMARTIST": {"Album Artist Name 1", "Album Artist Name 2"},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ALBUMARTIST names as display name", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Album Artist Name 1 • Album Artist Name 2"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("ALBUMARTIST tag is set", func() {
|
||||
BeforeEach(func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"Track Artist Name"},
|
||||
"ARTISTSORT": {"Name, Track Artist"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1},
|
||||
"ALBUMARTIST": {"Album Artist Name"},
|
||||
"ALBUMARTISTSORT": {"Album Artist Sort Name"},
|
||||
"MUSICBRAINZ_ALBUMARTISTID": {mbid2},
|
||||
})
|
||||
})
|
||||
|
||||
It("should use the ALBUMARTIST as display name", func() {
|
||||
Expect(mf.AlbumArtist).To(Equal("Album Artist Name"))
|
||||
})
|
||||
|
||||
It("should populate the participants with the ALBUMARTIST", func() {
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveLen(2))
|
||||
Expect(participants).To(SatisfyAll(
|
||||
HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)),
|
||||
))
|
||||
|
||||
albumArtist := participants[model.RoleAlbumArtist][0]
|
||||
Expect(albumArtist.ID).ToNot(BeEmpty())
|
||||
Expect(albumArtist.Name).To(Equal("Album Artist Name"))
|
||||
Expect(albumArtist.OrderArtistName).To(Equal("album artist name"))
|
||||
Expect(albumArtist.SortArtistName).To(Equal("Album Artist Sort Name"))
|
||||
Expect(albumArtist.MbzArtistID).To(Equal(mbid2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("COMPOSER and LYRICIST tags (with sort names)", func() {
|
||||
DescribeTable("should return the correct participation",
|
||||
func(role model.Role, nameTag, sortTag string) {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
nameTag: {"First Name", "Second Name"},
|
||||
sortTag: {"Name, First", "Name, Second"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(role, HaveLen(2)))
|
||||
|
||||
p := participants[role]
|
||||
Expect(p[0].ID).ToNot(BeEmpty())
|
||||
Expect(p[0].Name).To(Equal("First Name"))
|
||||
Expect(p[0].SortArtistName).To(Equal("Name, First"))
|
||||
Expect(p[0].OrderArtistName).To(Equal("first name"))
|
||||
Expect(p[1].ID).ToNot(BeEmpty())
|
||||
Expect(p[1].Name).To(Equal("Second Name"))
|
||||
Expect(p[1].SortArtistName).To(Equal("Name, Second"))
|
||||
Expect(p[1].OrderArtistName).To(Equal("second name"))
|
||||
},
|
||||
Entry("COMPOSER", model.RoleComposer, "COMPOSER", "COMPOSERSORT"),
|
||||
Entry("LYRICIST", model.RoleLyricist, "LYRICIST", "LYRICISTSORT"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("PERFORMER tags", func() {
|
||||
When("PERFORMER tag is set", func() {
|
||||
matchPerformer := func(name, orderName, subRole string) types.GomegaMatcher {
|
||||
return MatchFields(IgnoreExtras, Fields{
|
||||
"Artist": MatchFields(IgnoreExtras, Fields{
|
||||
"Name": Equal(name),
|
||||
"OrderArtistName": Equal(orderName),
|
||||
}),
|
||||
"SubRole": Equal(subRole),
|
||||
})
|
||||
}
|
||||
|
||||
It("should return the correct participation", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
|
||||
"PERFORMER:BASS": {"Nathan East"},
|
||||
"PERFORMER:HAMMOND ORGAN": {"Tim Carmon"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4)))
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Eric Clapton", "eric clapton", "Guitar"),
|
||||
matchPerformer("B.B. King", "b.b. king", "Guitar"),
|
||||
matchPerformer("Nathan East", "nathan east", "Bass"),
|
||||
matchPerformer("Tim Carmon", "tim carmon", "Hammond Organ"),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
When("MUSICBRAINZ_PERFORMERID tag is set", func() {
|
||||
matchPerformer := func(name, orderName, subRole, mbid string) types.GomegaMatcher {
|
||||
return MatchFields(IgnoreExtras, Fields{
|
||||
"Artist": MatchFields(IgnoreExtras, Fields{
|
||||
"Name": Equal(name),
|
||||
"OrderArtistName": Equal(orderName),
|
||||
"MbzArtistID": Equal(mbid),
|
||||
}),
|
||||
"SubRole": Equal(subRole),
|
||||
})
|
||||
}
|
||||
|
||||
It("should map MBIDs to the correct performer", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
|
||||
"PERFORMER:BASS": {"Nathan East"},
|
||||
"MUSICBRAINZ_PERFORMERID:GUITAR": {"mbid1", "mbid2"},
|
||||
"MUSICBRAINZ_PERFORMERID:BASS": {"mbid3"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3)))
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Eric Clapton", "eric clapton", "Guitar", "mbid1"),
|
||||
matchPerformer("B.B. King", "b.b. king", "Guitar", "mbid2"),
|
||||
matchPerformer("Nathan East", "nathan east", "Bass", "mbid3"),
|
||||
))
|
||||
})
|
||||
|
||||
It("should handle mismatched performer names and MBIDs for sub-roles", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:VOCALS": {"Singer A", "Singer B", "Singer C"},
|
||||
"MUSICBRAINZ_PERFORMERID:VOCALS": {"mbid_vocals_a", "mbid_vocals_b"}, // Fewer MBIDs
|
||||
"PERFORMER:DRUMS": {"Drummer X"},
|
||||
"MUSICBRAINZ_PERFORMERID:DRUMS": {"mbid_drums_x", "mbid_drums_y"}, // More MBIDs
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) // 3 vocalists + 1 drummer
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Singer A", "singer a", "Vocals", "mbid_vocals_a"),
|
||||
matchPerformer("Singer B", "singer b", "Vocals", "mbid_vocals_b"),
|
||||
matchPerformer("Singer C", "singer c", "Vocals", ""),
|
||||
matchPerformer("Drummer X", "drummer x", "Drums", "mbid_drums_x"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Other tags", func() {
|
||||
DescribeTable("should return the correct participation",
|
||||
func(role model.Role, tag string) {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
tag: {"John Doe", "Jane Doe"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(role, HaveLen(2)))
|
||||
|
||||
p := participants[role]
|
||||
Expect(p[0].ID).ToNot(BeEmpty())
|
||||
Expect(p[0].Name).To(Equal("John Doe"))
|
||||
Expect(p[0].OrderArtistName).To(Equal("john doe"))
|
||||
Expect(p[1].ID).ToNot(BeEmpty())
|
||||
Expect(p[1].Name).To(Equal("Jane Doe"))
|
||||
Expect(p[1].OrderArtistName).To(Equal("jane doe"))
|
||||
},
|
||||
Entry("CONDUCTOR", model.RoleConductor, "CONDUCTOR"),
|
||||
Entry("ARRANGER", model.RoleArranger, "ARRANGER"),
|
||||
Entry("PRODUCER", model.RoleProducer, "PRODUCER"),
|
||||
Entry("ENGINEER", model.RoleEngineer, "ENGINEER"),
|
||||
Entry("MIXER", model.RoleMixer, "MIXER"),
|
||||
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
|
||||
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
|
||||
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Role value splitting", func() {
|
||||
When("the tag is single valued", func() {
|
||||
It("should split the values by the configured separator", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPOSER": {"John Doe/Someone Else/The Album Artist"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers[0].Name).To(Equal("John Doe"))
|
||||
Expect(composers[1].Name).To(Equal("Someone Else"))
|
||||
Expect(composers[2].Name).To(Equal("The Album Artist"))
|
||||
})
|
||||
It("should not add an empty participant after split", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPOSER": {"John Doe/"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(1)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers[0].Name).To(Equal("John Doe"))
|
||||
})
|
||||
It("should trim the values", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"COMPOSER": {"John Doe / Someone Else / The Album Artist"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers[0].Name).To(Equal("John Doe"))
|
||||
Expect(composers[1].Name).To(Equal("Someone Else"))
|
||||
Expect(composers[2].Name).To(Equal("The Album Artist"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MBID tags", func() {
|
||||
It("should set the MBID for the artist based on the track/album artist", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"ARTIST": {"John Doe", "Jane Doe"},
|
||||
"MUSICBRAINZ_ARTISTID": {mbid1, mbid2},
|
||||
"ALBUMARTIST": {"The Album Artist"},
|
||||
"MUSICBRAINZ_ALBUMARTISTID": {mbid3},
|
||||
"COMPOSER": {"John Doe", "Someone Else", "The Album Artist"},
|
||||
"PRODUCER": {"Jane Doe", "John Doe"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3)))
|
||||
composers := participants[model.RoleComposer]
|
||||
Expect(composers[0].MbzArtistID).To(Equal(mbid1))
|
||||
Expect(composers[1].MbzArtistID).To(BeEmpty())
|
||||
Expect(composers[2].MbzArtistID).To(Equal(mbid3))
|
||||
|
||||
Expect(participants).To(HaveKeyWithValue(model.RoleProducer, HaveLen(2)))
|
||||
producers := participants[model.RoleProducer]
|
||||
Expect(producers[0].MbzArtistID).To(Equal(mbid2))
|
||||
Expect(producers[1].MbzArtistID).To(Equal(mbid1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Non-standard MBID tags", func() {
|
||||
var allMappings = map[model.Role]model.TagName{
|
||||
model.RoleComposer: model.TagMusicBrainzComposerID,
|
||||
model.RoleLyricist: model.TagMusicBrainzLyricistID,
|
||||
model.RoleConductor: model.TagMusicBrainzConductorID,
|
||||
model.RoleArranger: model.TagMusicBrainzArrangerID,
|
||||
model.RoleDirector: model.TagMusicBrainzDirectorID,
|
||||
model.RoleProducer: model.TagMusicBrainzProducerID,
|
||||
model.RoleEngineer: model.TagMusicBrainzEngineerID,
|
||||
model.RoleMixer: model.TagMusicBrainzMixerID,
|
||||
model.RoleRemixer: model.TagMusicBrainzRemixerID,
|
||||
model.RoleDJMixer: model.TagMusicBrainzDJMixerID,
|
||||
}
|
||||
|
||||
It("should handle more artists than mbids", func() {
|
||||
for key := range allMappings {
|
||||
mf = toMediaFile(map[string][]string{
|
||||
key.String(): {"a", "b", "c"},
|
||||
allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(key, HaveLen(3)))
|
||||
roles := participants[key]
|
||||
|
||||
Expect(roles[0].Name).To(Equal("a"))
|
||||
Expect(roles[1].Name).To(Equal("b"))
|
||||
Expect(roles[2].Name).To(Equal("c"))
|
||||
|
||||
Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759"))
|
||||
Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12"))
|
||||
Expect(roles[2].MbzArtistID).To(Equal(""))
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle more mbids than artists", func() {
|
||||
for key := range allMappings {
|
||||
mf = toMediaFile(map[string][]string{
|
||||
key.String(): {"a", "b"},
|
||||
allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(key, HaveLen(2)))
|
||||
roles := participants[key]
|
||||
|
||||
Expect(roles[0].Name).To(Equal("a"))
|
||||
Expect(roles[1].Name).To(Equal("b"))
|
||||
|
||||
Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759"))
|
||||
Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12"))
|
||||
}
|
||||
})
|
||||
|
||||
It("should refuse duplicate names if no mbid specified", func() {
|
||||
for key := range allMappings {
|
||||
mf = toMediaFile(map[string][]string{
|
||||
key.String(): {"a", "b", "a", "a"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(key, HaveLen(2)))
|
||||
roles := participants[key]
|
||||
|
||||
Expect(roles[0].Name).To(Equal("a"))
|
||||
Expect(roles[0].MbzArtistID).To(Equal(""))
|
||||
Expect(roles[1].Name).To(Equal("b"))
|
||||
Expect(roles[1].MbzArtistID).To(Equal(""))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
387
model/metadata/metadata.go
Normal file
387
model/metadata/metadata.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"io/fs"
|
||||
"math"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
FileInfo FileInfo
|
||||
Tags model.RawTags
|
||||
AudioProperties AudioProperties
|
||||
HasPicture bool
|
||||
}
|
||||
|
||||
type FileInfo interface {
|
||||
fs.FileInfo
|
||||
BirthTime() time.Time
|
||||
}
|
||||
|
||||
type AudioProperties struct {
|
||||
Duration time.Duration
|
||||
BitRate int
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Channels int
|
||||
}
|
||||
|
||||
type Date string
|
||||
|
||||
func (d Date) Year() int {
|
||||
if d == "" {
|
||||
return 0
|
||||
}
|
||||
y, _ := strconv.Atoi(string(d[:4]))
|
||||
return y
|
||||
}
|
||||
|
||||
type Pair string
|
||||
|
||||
func (p Pair) Key() string { return p.parse(0) }
|
||||
func (p Pair) Value() string { return p.parse(1) }
|
||||
func (p Pair) parse(i int) string {
|
||||
parts := strings.SplitN(string(p), consts.Zwsp, 2)
|
||||
if len(parts) > i {
|
||||
return parts[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (p Pair) String() string {
|
||||
return string(p)
|
||||
}
|
||||
func NewPair(key, value string) string {
|
||||
return key + consts.Zwsp + value
|
||||
}
|
||||
|
||||
func New(filePath string, info Info) Metadata {
|
||||
return Metadata{
|
||||
filePath: filePath,
|
||||
fileInfo: info.FileInfo,
|
||||
tags: clean(filePath, info.Tags),
|
||||
audioProps: info.AudioProperties,
|
||||
hasPicture: info.HasPicture,
|
||||
}
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
filePath string
|
||||
fileInfo FileInfo
|
||||
tags model.Tags
|
||||
audioProps AudioProperties
|
||||
hasPicture bool
|
||||
}
|
||||
|
||||
func (md Metadata) FilePath() string { return md.filePath }
|
||||
func (md Metadata) ModTime() time.Time { return md.fileInfo.ModTime() }
|
||||
func (md Metadata) BirthTime() time.Time { return md.fileInfo.BirthTime() }
|
||||
func (md Metadata) Size() int64 { return md.fileInfo.Size() }
|
||||
func (md Metadata) Suffix() string {
|
||||
return strings.ToLower(strings.TrimPrefix(path.Ext(md.filePath), "."))
|
||||
}
|
||||
func (md Metadata) AudioProperties() AudioProperties { return md.audioProps }
|
||||
func (md Metadata) Length() float32 { return float32(md.audioProps.Duration.Milliseconds()) / 1000 }
|
||||
func (md Metadata) HasPicture() bool { return md.hasPicture }
|
||||
func (md Metadata) All() model.Tags { return md.tags }
|
||||
func (md Metadata) Strings(key model.TagName) []string { return md.tags[key] }
|
||||
func (md Metadata) String(key model.TagName) string { return md.first(key) }
|
||||
func (md Metadata) Int(key model.TagName) int64 { v, _ := strconv.Atoi(md.first(key)); return int64(v) }
|
||||
func (md Metadata) Bool(key model.TagName) bool { v, _ := strconv.ParseBool(md.first(key)); return v }
|
||||
func (md Metadata) Date(key model.TagName) Date { return md.date(key) }
|
||||
func (md Metadata) NumAndTotal(key model.TagName) (int, int) { return md.tuple(key) }
|
||||
func (md Metadata) Float(key model.TagName, def ...float64) float64 {
|
||||
return float(md.first(key), def...)
|
||||
}
|
||||
func (md Metadata) NullableFloat(key model.TagName) *float64 { return nullableFloat(md.first(key)) }
|
||||
|
||||
func (md Metadata) Gain(key model.TagName) *float64 {
|
||||
v := strings.TrimSpace(strings.Replace(md.first(key), "dB", "", 1))
|
||||
return nullableFloat(v)
|
||||
}
|
||||
func (md Metadata) Pairs(key model.TagName) []Pair {
|
||||
values := md.tags[key]
|
||||
return slice.Map(values, func(v string) Pair { return Pair(v) })
|
||||
}
|
||||
func (md Metadata) first(key model.TagName) string {
|
||||
if v, ok := md.tags[key]; ok && len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func float(value string, def ...float64) float64 {
|
||||
v := nullableFloat(value)
|
||||
if v != nil {
|
||||
return *v
|
||||
}
|
||||
if len(def) > 0 {
|
||||
return def[0]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullableFloat(value string) *float64 {
|
||||
v, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || v == math.Inf(-1) || math.IsInf(v, 1) || math.IsNaN(v) {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// Used for tracks and discs
|
||||
func (md Metadata) tuple(key model.TagName) (int, int) {
|
||||
tag := md.first(key)
|
||||
if tag == "" {
|
||||
return 0, 0
|
||||
}
|
||||
tuple := strings.Split(tag, "/")
|
||||
t1, t2 := 0, 0
|
||||
t1, _ = strconv.Atoi(tuple[0])
|
||||
if len(tuple) > 1 {
|
||||
t2, _ = strconv.Atoi(tuple[1])
|
||||
} else {
|
||||
t2tag := md.first(key + "total")
|
||||
t2, _ = strconv.Atoi(t2tag)
|
||||
}
|
||||
return t1, t2
|
||||
}
|
||||
|
||||
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)
|
||||
|
||||
func (md Metadata) date(tagName model.TagName) Date {
|
||||
return Date(md.first(tagName))
|
||||
}
|
||||
|
||||
// date tries to parse a date from a tag, it tries to get at least the year. See the tests for examples.
|
||||
func parseDate(filePath string, tagName model.TagName, tagValue string) string {
|
||||
if len(tagValue) < 4 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// first get just the year
|
||||
match := dateRegex.FindStringSubmatch(tagValue)
|
||||
if len(match) == 0 {
|
||||
log.Debug("Error parsing date", "file", filePath, "tag", tagName, "date", tagValue)
|
||||
return ""
|
||||
}
|
||||
|
||||
// if the tag is just the year, return it
|
||||
if len(tagValue) < 5 {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
// if the tag is too long, truncate it
|
||||
tagValue = tagValue[:min(10, len(tagValue))]
|
||||
|
||||
// then try to parse the full date
|
||||
for _, mask := range []string{"2006-01-02", "2006-01"} {
|
||||
_, err := time.Parse(mask, tagValue)
|
||||
if err == nil {
|
||||
return tagValue
|
||||
}
|
||||
}
|
||||
log.Debug("Error parsing month and day from date", "file", filePath, "tag", tagName, "date", tagValue)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
// clean filters out tags that are not in the mappings or are empty,
|
||||
// combine equivalent tags and remove duplicated values.
|
||||
// It keeps the order of the tags names as they are defined in the mappings.
|
||||
func clean(filePath string, tags model.RawTags) model.Tags {
|
||||
lowered := lowerTags(tags)
|
||||
mappings := model.TagMappings()
|
||||
cleaned := make(model.Tags, len(mappings))
|
||||
|
||||
for name, mapping := range mappings {
|
||||
var values []string
|
||||
switch mapping.Type {
|
||||
case model.TagTypePair:
|
||||
values = processPairMapping(name, mapping, lowered)
|
||||
default:
|
||||
values = processRegularMapping(mapping, lowered)
|
||||
}
|
||||
cleaned[name] = values
|
||||
}
|
||||
|
||||
cleaned = filterEmptyTags(cleaned)
|
||||
return sanitizeAll(filePath, cleaned)
|
||||
}
|
||||
|
||||
func processRegularMapping(mapping model.TagConf, lowered model.Tags) []string {
|
||||
var values []string
|
||||
for _, alias := range mapping.Aliases {
|
||||
if vs, ok := lowered[model.TagName(alias)]; ok {
|
||||
splitValues := mapping.SplitTagValue(vs)
|
||||
values = append(values, splitValues...)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func lowerTags(tags model.RawTags) model.Tags {
|
||||
lowered := make(model.Tags, len(tags))
|
||||
for k, v := range tags {
|
||||
lowered[model.TagName(strings.ToLower(k))] = v
|
||||
}
|
||||
return lowered
|
||||
}
|
||||
|
||||
func processPairMapping(name model.TagName, mapping model.TagConf, lowered model.Tags) []string {
|
||||
var aliasValues []string
|
||||
for _, alias := range mapping.Aliases {
|
||||
if vs, ok := lowered[model.TagName(alias)]; ok {
|
||||
aliasValues = append(aliasValues, vs...)
|
||||
}
|
||||
}
|
||||
|
||||
// always parse id3 pairs. For lyrics, Taglib appears to always provide lyrics:xxx
|
||||
// Prefer that over format-specific tags
|
||||
id3Base := parseID3Pairs(name, lowered)
|
||||
|
||||
if len(aliasValues) > 0 {
|
||||
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
|
||||
}
|
||||
return id3Base
|
||||
}
|
||||
|
||||
func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
|
||||
var pairs []string
|
||||
prefix := string(name) + ":"
|
||||
for tagKey, tagValues := range lowered {
|
||||
keyStr := string(tagKey)
|
||||
if strings.HasPrefix(keyStr, prefix) {
|
||||
keyPart := strings.TrimPrefix(keyStr, prefix)
|
||||
if keyPart == string(name) {
|
||||
keyPart = ""
|
||||
}
|
||||
for _, v := range tagValues {
|
||||
pairs = append(pairs, NewPair(keyPart, v))
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
var vorbisPairRegex = regexp.MustCompile(`\(([^()]+(?:\([^()]*\)[^()]*)*)\)`)
|
||||
|
||||
// parseVorbisPairs, from
|
||||
//
|
||||
// "Salaam Remi (drums (drum set) and organ)",
|
||||
//
|
||||
// to
|
||||
//
|
||||
// "drums (drum set) and organ" -> "Salaam Remi",
|
||||
func parseVorbisPairs(values []string) []string {
|
||||
pairs := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
matches := vorbisPairRegex.FindAllStringSubmatch(value, -1)
|
||||
if len(matches) == 0 {
|
||||
pairs = append(pairs, NewPair("", value))
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(matches[0][1])
|
||||
key = strings.ToLower(key)
|
||||
valueWithoutKey := strings.TrimSpace(strings.Replace(value, "("+matches[0][1]+")", "", 1))
|
||||
pairs = append(pairs, NewPair(key, valueWithoutKey))
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
func filterEmptyTags(tags model.Tags) model.Tags {
|
||||
for k, v := range tags {
|
||||
clean := filterDuplicatedOrEmptyValues(v)
|
||||
if len(clean) == 0 {
|
||||
delete(tags, k)
|
||||
} else {
|
||||
tags[k] = clean
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func filterDuplicatedOrEmptyValues(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
var result []string
|
||||
for _, v := range values {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
result = append(result, v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sanitizeAll(filePath string, tags model.Tags) model.Tags {
|
||||
cleaned := model.Tags{}
|
||||
for k, v := range tags {
|
||||
tag, found := model.TagMappings()[k]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
var values []string
|
||||
for _, value := range v {
|
||||
cleanedValue := sanitize(filePath, k, tag, value)
|
||||
if cleanedValue != "" {
|
||||
values = append(values, cleanedValue)
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
cleaned[k] = values
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const defaultMaxTagLength = 1024
|
||||
|
||||
func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value string) string {
|
||||
// First truncate the value to the maximum length
|
||||
maxLength := cmp.Or(tag.MaxLength, defaultMaxTagLength)
|
||||
if len(value) > maxLength {
|
||||
log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength)
|
||||
value = value[:maxLength]
|
||||
}
|
||||
|
||||
switch tag.Type {
|
||||
case model.TagTypeDate:
|
||||
value = parseDate(filePath, tagName, value)
|
||||
if value == "" {
|
||||
log.Trace("Invalid date tag value", "tag", tagName, "value", value)
|
||||
}
|
||||
case model.TagTypeInteger:
|
||||
_, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
log.Trace("Invalid integer tag value", "tag", tagName, "value", value)
|
||||
return ""
|
||||
}
|
||||
case model.TagTypeFloat:
|
||||
_, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
log.Trace("Invalid float tag value", "tag", tagName, "value", value)
|
||||
return ""
|
||||
}
|
||||
case model.TagTypeUUID:
|
||||
_, err := uuid.Parse(value)
|
||||
if err != nil {
|
||||
log.Trace("Invalid UUID tag value", "tag", tagName, "value", value)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
32
model/metadata/metadata_suite_test.go
Normal file
32
model/metadata/metadata_suite_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package metadata_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/djherbis/times"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMetadata(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Metadata Suite")
|
||||
}
|
||||
|
||||
type testFileInfo struct {
|
||||
fs.FileInfo
|
||||
}
|
||||
|
||||
func (t testFileInfo) BirthTime() time.Time {
|
||||
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
|
||||
return ts.BirthTime()
|
||||
}
|
||||
return t.FileInfo.ModTime()
|
||||
}
|
||||
298
model/metadata/metadata_test.go
Normal file
298
model/metadata/metadata_test.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package metadata_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Metadata", func() {
|
||||
var (
|
||||
filePath string
|
||||
fileInfo os.FileInfo
|
||||
props metadata.Info
|
||||
md metadata.Metadata
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// It is easier to have a real file to test the mod and birth times
|
||||
filePath = utils.TempFileName("test", ".mp3")
|
||||
f, _ := os.Create(filePath)
|
||||
DeferCleanup(func() {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(filePath)
|
||||
})
|
||||
|
||||
fileInfo, _ = os.Stat(filePath)
|
||||
props = metadata.Info{
|
||||
AudioProperties: metadata.AudioProperties{
|
||||
Duration: time.Minute * 3,
|
||||
BitRate: 320,
|
||||
},
|
||||
HasPicture: true,
|
||||
FileInfo: testFileInfo{fileInfo},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Metadata", func() {
|
||||
Describe("New", func() {
|
||||
It("should create a new Metadata object with the correct properties", func() {
|
||||
props.Tags = model.RawTags{
|
||||
"©ART": {"First Artist", "Second Artist"},
|
||||
"----:com.apple.iTunes:CATALOGNUMBER": {"1234"},
|
||||
"tbpm": {"120.6"},
|
||||
"WM/IsCompilation": {"1"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.FilePath()).To(Equal(filePath))
|
||||
Expect(md.ModTime()).To(Equal(fileInfo.ModTime()))
|
||||
Expect(md.BirthTime()).To(BeTemporally("~", md.ModTime(), time.Second))
|
||||
Expect(md.Size()).To(Equal(fileInfo.Size()))
|
||||
Expect(md.Suffix()).To(Equal("mp3"))
|
||||
Expect(md.AudioProperties()).To(Equal(props.AudioProperties))
|
||||
Expect(md.Length()).To(Equal(float32(3 * 60)))
|
||||
Expect(md.HasPicture()).To(Equal(props.HasPicture))
|
||||
Expect(md.Strings(model.TagTrackArtist)).To(Equal([]string{"First Artist", "Second Artist"}))
|
||||
Expect(md.String(model.TagTrackArtist)).To(Equal("First Artist"))
|
||||
Expect(md.Int(model.TagCatalogNumber)).To(Equal(int64(1234)))
|
||||
Expect(md.Float(model.TagBPM)).To(Equal(120.6))
|
||||
Expect(md.Bool(model.TagCompilation)).To(BeTrue())
|
||||
Expect(md.All()).To(SatisfyAll(
|
||||
HaveLen(4),
|
||||
HaveKeyWithValue(model.TagTrackArtist, []string{"First Artist", "Second Artist"}),
|
||||
HaveKeyWithValue(model.TagBPM, []string{"120.6"}),
|
||||
HaveKeyWithValue(model.TagCompilation, []string{"1"}),
|
||||
HaveKeyWithValue(model.TagCatalogNumber, []string{"1234"}),
|
||||
))
|
||||
|
||||
})
|
||||
|
||||
It("should clean the tags map correctly", func() {
|
||||
const unknownTag = "UNKNOWN_TAG"
|
||||
props.Tags = model.RawTags{
|
||||
"TPE1": {"Artist Name", "Artist Name", ""},
|
||||
"©ART": {"Second Artist"},
|
||||
"CatalogNumber": {""},
|
||||
"Album": {"Album Name", "", "Album Name"},
|
||||
"Date": {"2022-10-02 12:15:01"},
|
||||
"Year": {"2022", "2022", ""},
|
||||
"Genre": {"Pop", "", "Pop", "Rock"},
|
||||
"Track": {"1/10", "1/10", ""},
|
||||
unknownTag: {"value"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(SatisfyAll(
|
||||
Not(HaveKey(unknownTag)),
|
||||
HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}),
|
||||
HaveKeyWithValue(model.TagAlbum, []string{"Album Name"}),
|
||||
HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02"}),
|
||||
HaveKeyWithValue(model.TagReleaseDate, []string{"2022"}),
|
||||
HaveKeyWithValue(model.TagGenre, []string{"Pop", "Rock"}),
|
||||
HaveKeyWithValue(model.TagTrackNumber, []string{"1/10"}),
|
||||
HaveLen(6),
|
||||
))
|
||||
})
|
||||
|
||||
It("should truncate long strings", func() {
|
||||
props.Tags = model.RawTags{
|
||||
"Title": {strings.Repeat("a", 2048)},
|
||||
"Comment": {strings.Repeat("a", 8192)},
|
||||
"lyrics:xxx": {strings.Repeat("a", 60000)},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.String(model.TagTitle)).To(HaveLen(1024))
|
||||
Expect(md.String(model.TagComment)).To(HaveLen(4096))
|
||||
pair := md.Pairs(model.TagLyrics)
|
||||
|
||||
Expect(pair).To(HaveLen(1))
|
||||
Expect(pair[0].Key()).To(Equal("xxx"))
|
||||
|
||||
// Note: a total of 6 characters are lost from maxLength from
|
||||
// the key portion and separator
|
||||
Expect(pair[0].Value()).To(HaveLen(32762))
|
||||
})
|
||||
|
||||
It("should split multiple values", func() {
|
||||
props.Tags = model.RawTags{
|
||||
"Genre": {"Rock/Pop;;Punk"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("Date",
|
||||
func(value string, expectedYear int, expectedDate string) {
|
||||
props.Tags = model.RawTags{
|
||||
"date": {value},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
testDate := md.Date(model.TagRecordingDate)
|
||||
Expect(string(testDate)).To(Equal(expectedDate))
|
||||
Expect(testDate.Year()).To(Equal(expectedYear))
|
||||
},
|
||||
Entry(nil, "1985", 1985, "1985"),
|
||||
Entry(nil, "2002-01", 2002, "2002-01"),
|
||||
Entry(nil, "1969.06", 1969, "1969"),
|
||||
Entry(nil, "1980.07.25", 1980, "1980"),
|
||||
Entry(nil, "2004-00-00", 2004, "2004"),
|
||||
Entry(nil, "2016-12-31", 2016, "2016-12-31"),
|
||||
Entry(nil, "2016-12-31 12:15", 2016, "2016-12-31"),
|
||||
Entry(nil, "2013-May-12", 2013, "2013"),
|
||||
Entry(nil, "May 12, 2016", 2016, "2016"),
|
||||
Entry(nil, "01/10/1990", 1990, "1990"),
|
||||
Entry(nil, "invalid", 0, ""),
|
||||
)
|
||||
|
||||
DescribeTable("NumAndTotal",
|
||||
func(num, total string, expectedNum int, expectedTotal int) {
|
||||
props.Tags = model.RawTags{
|
||||
"Track": {num},
|
||||
"TrackTotal": {total},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
testNum, testTotal := md.NumAndTotal(model.TagTrackNumber)
|
||||
Expect(testNum).To(Equal(expectedNum))
|
||||
Expect(testTotal).To(Equal(expectedTotal))
|
||||
},
|
||||
Entry(nil, "2", "", 2, 0),
|
||||
Entry(nil, "2", "10", 2, 10),
|
||||
Entry(nil, "2/10", "", 2, 10),
|
||||
Entry(nil, "", "", 0, 0),
|
||||
Entry(nil, "A", "", 0, 0),
|
||||
)
|
||||
|
||||
Describe("Performers", func() {
|
||||
Describe("ID3", func() {
|
||||
BeforeEach(func() {
|
||||
props.Tags = model.RawTags{
|
||||
"PERFORMER:GUITAR": {"Guitarist 1", "Guitarist 2"},
|
||||
"PERFORMER:BACKGROUND VOCALS": {"Backing Singer"},
|
||||
"PERFORMER:PERFORMER": {"Wonderlove", "Lovewonder"},
|
||||
}
|
||||
})
|
||||
|
||||
It("should return the performers", func() {
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(HaveKey(model.TagPerformer))
|
||||
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
|
||||
metadata.NewPair("guitar", "Guitarist 1"),
|
||||
metadata.NewPair("guitar", "Guitarist 2"),
|
||||
metadata.NewPair("background vocals", "Backing Singer"),
|
||||
metadata.NewPair("", "Wonderlove"),
|
||||
metadata.NewPair("", "Lovewonder"),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Vorbis", func() {
|
||||
BeforeEach(func() {
|
||||
props.Tags = model.RawTags{
|
||||
"PERFORMER": {
|
||||
"John Adams (Rhodes piano)",
|
||||
"Vincent Henry (alto saxophone, baritone saxophone and tenor saxophone)",
|
||||
"Salaam Remi (drums (drum set) and organ)",
|
||||
"Amy Winehouse (guitar)",
|
||||
"Amy Winehouse (vocals)",
|
||||
"Wonderlove",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("should return the performers", func() {
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(HaveKey(model.TagPerformer))
|
||||
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
|
||||
metadata.NewPair("rhodes piano", "John Adams"),
|
||||
metadata.NewPair("alto saxophone, baritone saxophone and tenor saxophone", "Vincent Henry"),
|
||||
metadata.NewPair("drums (drum set) and organ", "Salaam Remi"),
|
||||
metadata.NewPair("guitar", "Amy Winehouse"),
|
||||
metadata.NewPair("vocals", "Amy Winehouse"),
|
||||
metadata.NewPair("", "Wonderlove"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Lyrics", func() {
|
||||
BeforeEach(func() {
|
||||
props.Tags = model.RawTags{
|
||||
"LYRICS:POR": {"Letras"},
|
||||
"LYRICS:ENG": {"Lyrics"},
|
||||
}
|
||||
})
|
||||
|
||||
It("should return the lyrics", func() {
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(HaveKey(model.TagLyrics))
|
||||
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
|
||||
metadata.NewPair("por", "Letras"),
|
||||
metadata.NewPair("eng", "Lyrics"),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReplayGain", func() {
|
||||
createMF := func(tag, tagValue string) model.MediaFile {
|
||||
props.Tags = model.RawTags{
|
||||
tag: {tagValue},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
return md.ToMediaFile(0, "0")
|
||||
}
|
||||
|
||||
DescribeTable("Gain",
|
||||
func(tagValue string, expected *float64) {
|
||||
mf := createMF("replaygain_track_gain", tagValue)
|
||||
Expect(mf.RGTrackGain).To(Equal(expected))
|
||||
},
|
||||
Entry("0", "0", gg.P(0.0)),
|
||||
Entry("1.2dB", "1.2dB", gg.P(1.2)),
|
||||
Entry("Infinity", "Infinity", nil),
|
||||
Entry("Invalid value", "INVALID VALUE", nil),
|
||||
Entry("NaN", "NaN", nil),
|
||||
)
|
||||
DescribeTable("Peak",
|
||||
func(tagValue string, expected *float64) {
|
||||
mf := createMF("replaygain_track_peak", tagValue)
|
||||
Expect(mf.RGTrackPeak).To(Equal(expected))
|
||||
},
|
||||
Entry("0", "0", gg.P(0.0)),
|
||||
Entry("1.0", "1.0", gg.P(1.0)),
|
||||
Entry("0.5", "0.5", gg.P(0.5)),
|
||||
Entry("Invalid dB suffix", "0.7dB", nil),
|
||||
Entry("Infinity", "Infinity", nil),
|
||||
Entry("Invalid value", "INVALID VALUE", nil),
|
||||
Entry("NaN", "NaN", nil),
|
||||
)
|
||||
DescribeTable("getR128GainValue",
|
||||
func(tagValue string, expected *float64) {
|
||||
mf := createMF("r128_track_gain", tagValue)
|
||||
Expect(mf.RGTrackGain).To(Equal(expected))
|
||||
|
||||
},
|
||||
Entry("0", "0", gg.P(5.0)),
|
||||
Entry("-3776", "-3776", gg.P(-9.75)),
|
||||
Entry("Infinity", "Infinity", nil),
|
||||
Entry("Invalid value", "INVALID VALUE", nil),
|
||||
)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
106
model/metadata/persistent_ids.go
Normal file
106
model/metadata/persistent_ids.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
type hashFunc = func(...string) string
|
||||
|
||||
// createGetPID returns a function that calculates the persistent ID for a given spec, getting the referenced values from the metadata
|
||||
// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes
|
||||
// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc.
|
||||
// For each field, it gets all its attributes values and concatenates them, then hashes the result.
|
||||
// If a field is empty, it is skipped and the function looks for the next field.
|
||||
type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string
|
||||
|
||||
func createGetPID(hash hashFunc) getPIDFunc {
|
||||
var getPID getPIDFunc
|
||||
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string {
|
||||
attr = strings.TrimSpace(strings.ToLower(attr))
|
||||
switch attr {
|
||||
case "albumid":
|
||||
return getPID(mf, md, conf.Server.PID.Album, prependLibId)
|
||||
case "folder":
|
||||
return filepath.Dir(mf.Path)
|
||||
case "albumartistid":
|
||||
return hash(str.Clear(strings.ToLower(mf.AlbumArtist)))
|
||||
case "title":
|
||||
return mf.Title
|
||||
case "album":
|
||||
return str.Clear(strings.ToLower(md.String(model.TagAlbum)))
|
||||
}
|
||||
return md.String(model.TagName(attr))
|
||||
}
|
||||
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||
pid := ""
|
||||
fields := strings.Split(spec, "|")
|
||||
for _, field := range fields {
|
||||
attributes := strings.Split(field, ",")
|
||||
hasValue := false
|
||||
values := slice.Map(attributes, func(attr string) string {
|
||||
v := getAttr(mf, md, attr, prependLibId)
|
||||
if v != "" {
|
||||
hasValue = true
|
||||
}
|
||||
return v
|
||||
})
|
||||
if hasValue {
|
||||
pid += strings.Join(values, "\\")
|
||||
break
|
||||
}
|
||||
}
|
||||
if prependLibId {
|
||||
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
|
||||
}
|
||||
return hash(pid)
|
||||
}
|
||||
|
||||
return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||
switch spec {
|
||||
case "track_legacy":
|
||||
return legacyTrackID(mf, prependLibId)
|
||||
case "album_legacy":
|
||||
return legacyAlbumID(mf, md, prependLibId)
|
||||
}
|
||||
return getPID(mf, md, spec, prependLibId)
|
||||
}
|
||||
}
|
||||
|
||||
func (md Metadata) trackPID(mf model.MediaFile) string {
|
||||
return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true)
|
||||
}
|
||||
|
||||
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
|
||||
return createGetPID(id.NewHash)(mf, md, pidConf, true)
|
||||
}
|
||||
|
||||
// BFR Must be configurable?
|
||||
func (md Metadata) artistID(name string) string {
|
||||
mf := model.MediaFile{AlbumArtist: name}
|
||||
return createGetPID(id.NewHash)(mf, md, "albumartistid", false)
|
||||
}
|
||||
|
||||
func (md Metadata) mapTrackTitle() string {
|
||||
if title := md.String(model.TagTitle); title != "" {
|
||||
return title
|
||||
}
|
||||
return utils.BaseName(md.FilePath())
|
||||
}
|
||||
|
||||
func (md Metadata) mapAlbumName() string {
|
||||
return cmp.Or(
|
||||
md.String(model.TagAlbum),
|
||||
consts.UnknownAlbum,
|
||||
)
|
||||
}
|
||||
272
model/metadata/persistent_ids_test.go
Normal file
272
model/metadata/persistent_ids_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("getPID", func() {
|
||||
var (
|
||||
md Metadata
|
||||
mf model.MediaFile
|
||||
sum hashFunc
|
||||
getPID getPIDFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
|
||||
getPID = createGetPID(sum)
|
||||
})
|
||||
|
||||
Context("attributes are tags", func() {
|
||||
spec := "musicbrainz_trackid|album,discnumber,tracknumber"
|
||||
When("no attributes were present", func() {
|
||||
It("should return empty pid", func() {
|
||||
md.tags = map[model.TagName][]string{}
|
||||
pid := getPID(mf, md, spec, false)
|
||||
Expect(pid).To(Equal("()"))
|
||||
})
|
||||
})
|
||||
When("all fields are present", func() {
|
||||
It("should return the pid", func() {
|
||||
md.tags = map[model.TagName][]string{
|
||||
"musicbrainz_trackid": {"mbtrackid"},
|
||||
"album": {"album name"},
|
||||
"discnumber": {"1"},
|
||||
"tracknumber": {"1"},
|
||||
}
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)"))
|
||||
})
|
||||
})
|
||||
When("only first field is present", func() {
|
||||
It("should return the pid", func() {
|
||||
md.tags = map[model.TagName][]string{
|
||||
"musicbrainz_trackid": {"mbtrackid"},
|
||||
}
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)"))
|
||||
})
|
||||
})
|
||||
When("first is empty, but second field is present", func() {
|
||||
It("should return the pid", func() {
|
||||
md.tags = map[model.TagName][]string{
|
||||
"album": {"album name"},
|
||||
"discnumber": {"1"},
|
||||
}
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(album name\\1\\)"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("calculated attributes", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,version,releasedate"
|
||||
})
|
||||
When("field is title", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "title|folder"
|
||||
md.tags = map[model.TagName][]string{"title": {"title"}}
|
||||
md.filePath = "/path/to/file.mp3"
|
||||
mf.Title = "Title"
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(Title)"))
|
||||
})
|
||||
})
|
||||
When("field is folder", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "folder|title"
|
||||
md.tags = map[model.TagName][]string{"title": {"title"}}
|
||||
mf.Path = "/path/to/file.mp3"
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(/path/to)"))
|
||||
})
|
||||
})
|
||||
When("field is albumid", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "albumid|title"
|
||||
md.tags = map[model.TagName][]string{
|
||||
"title": {"title"},
|
||||
"album": {"album name"},
|
||||
"version": {"version"},
|
||||
"releasedate": {"2021-01-01"},
|
||||
}
|
||||
mf.AlbumArtist = "Album Artist"
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))"))
|
||||
})
|
||||
})
|
||||
When("field is albumartistid", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "musicbrainz_albumartistid|albumartistid"
|
||||
md.tags = map[model.TagName][]string{
|
||||
"albumartist": {"Album Artist"},
|
||||
}
|
||||
mf.AlbumArtist = "Album Artist"
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("((album artist))"))
|
||||
})
|
||||
})
|
||||
When("field is album", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "album|title"
|
||||
md.tags = map[model.TagName][]string{"album": {"Album Name"}}
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
When("the spec has spaces between groups", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "albumartist| Album"
|
||||
md.tags = map[model.TagName][]string{
|
||||
"album": {"album name"},
|
||||
}
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
|
||||
})
|
||||
})
|
||||
When("the spec has spaces", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "albumartist, album"
|
||||
md.tags = map[model.TagName][]string{
|
||||
"albumartist": {"Album Artist"},
|
||||
"album": {"album name"},
|
||||
}
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)"))
|
||||
})
|
||||
})
|
||||
When("the spec has mixed case fields", func() {
|
||||
It("should return the pid", func() {
|
||||
spec := "albumartist,Album"
|
||||
md.tags = map[model.TagName][]string{
|
||||
"albumartist": {"Album Artist"},
|
||||
"album": {"album name"},
|
||||
}
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("prependLibId functionality", func() {
|
||||
BeforeEach(func() {
|
||||
mf.LibraryID = 42
|
||||
})
|
||||
When("prependLibId is true", func() {
|
||||
It("should prepend library ID to the hash input", func() {
|
||||
spec := "album"
|
||||
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
|
||||
pid := getPID(mf, md, spec, true)
|
||||
// The hash function should receive "42\test album" as input
|
||||
Expect(pid).To(Equal("(42\\test album)"))
|
||||
})
|
||||
})
|
||||
When("prependLibId is false", func() {
|
||||
It("should not prepend library ID to the hash input", func() {
|
||||
spec := "album"
|
||||
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
|
||||
pid := getPID(mf, md, spec, false)
|
||||
// The hash function should receive "test album" as input
|
||||
Expect(pid).To(Equal("(test album)"))
|
||||
})
|
||||
})
|
||||
When("prependLibId is true with complex spec", func() {
|
||||
It("should prepend library ID to the final hash input", func() {
|
||||
spec := "musicbrainz_trackid|album,tracknumber"
|
||||
md.tags = map[model.TagName][]string{
|
||||
"album": {"Test Album"},
|
||||
"tracknumber": {"1"},
|
||||
}
|
||||
pid := getPID(mf, md, spec, true)
|
||||
// Should use the fallback field and prepend library ID
|
||||
Expect(pid).To(Equal("(42\\test album\\1)"))
|
||||
})
|
||||
})
|
||||
When("prependLibId is true with nested albumid", func() {
|
||||
It("should handle nested albumid calls correctly", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PID.Album = "album"
|
||||
spec := "albumid"
|
||||
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
|
||||
mf.AlbumArtist = "Test Artist"
|
||||
pid := getPID(mf, md, spec, true)
|
||||
// The albumid call should also use prependLibId=true
|
||||
Expect(pid).To(Equal("(42\\(42\\test album))"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("legacy specs", func() {
|
||||
Context("track_legacy", func() {
|
||||
When("library ID is default (1)", func() {
|
||||
It("should not prepend library ID even when prependLibId is true", func() {
|
||||
mf.Path = "/path/to/track.mp3"
|
||||
mf.LibraryID = 1 // Default library ID
|
||||
// With default library, both should be the same
|
||||
pidTrue := getPID(mf, md, "track_legacy", true)
|
||||
pidFalse := getPID(mf, md, "track_legacy", false)
|
||||
Expect(pidTrue).To(Equal(pidFalse))
|
||||
Expect(pidTrue).NotTo(BeEmpty())
|
||||
})
|
||||
})
|
||||
When("library ID is non-default", func() {
|
||||
It("should prepend library ID when prependLibId is true", func() {
|
||||
mf.Path = "/path/to/track.mp3"
|
||||
mf.LibraryID = 2 // Non-default library ID
|
||||
pidTrue := getPID(mf, md, "track_legacy", true)
|
||||
pidFalse := getPID(mf, md, "track_legacy", false)
|
||||
Expect(pidTrue).NotTo(Equal(pidFalse))
|
||||
Expect(pidTrue).NotTo(BeEmpty())
|
||||
Expect(pidFalse).NotTo(BeEmpty())
|
||||
})
|
||||
})
|
||||
When("library ID is non-default but prependLibId is false", func() {
|
||||
It("should not prepend library ID", func() {
|
||||
mf.Path = "/path/to/track.mp3"
|
||||
mf.LibraryID = 3
|
||||
mf2 := mf
|
||||
mf2.LibraryID = 1 // Default library
|
||||
pidNonDefault := getPID(mf, md, "track_legacy", false)
|
||||
pidDefault := getPID(mf2, md, "track_legacy", false)
|
||||
// Should be the same since prependLibId=false
|
||||
Expect(pidNonDefault).To(Equal(pidDefault))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("album_legacy", func() {
|
||||
When("library ID is default (1)", func() {
|
||||
It("should not prepend library ID even when prependLibId is true", func() {
|
||||
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
|
||||
mf.LibraryID = 1 // Default library ID
|
||||
pidTrue := getPID(mf, md, "album_legacy", true)
|
||||
pidFalse := getPID(mf, md, "album_legacy", false)
|
||||
Expect(pidTrue).To(Equal(pidFalse))
|
||||
Expect(pidTrue).NotTo(BeEmpty())
|
||||
})
|
||||
})
|
||||
When("library ID is non-default", func() {
|
||||
It("should prepend library ID when prependLibId is true", func() {
|
||||
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
|
||||
mf.LibraryID = 2 // Non-default library ID
|
||||
pidTrue := getPID(mf, md, "album_legacy", true)
|
||||
pidFalse := getPID(mf, md, "album_legacy", false)
|
||||
Expect(pidTrue).NotTo(Equal(pidFalse))
|
||||
Expect(pidTrue).NotTo(BeEmpty())
|
||||
Expect(pidFalse).NotTo(BeEmpty())
|
||||
})
|
||||
})
|
||||
When("library ID is non-default but prependLibId is false", func() {
|
||||
It("should not prepend library ID", func() {
|
||||
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
|
||||
mf.LibraryID = 3
|
||||
mf2 := mf
|
||||
mf2.LibraryID = 1 // Default library
|
||||
pidNonDefault := getPID(mf, md, "album_legacy", false)
|
||||
pidDefault := getPID(mf2, md, "album_legacy", false)
|
||||
// Should be the same since prependLibId=false
|
||||
Expect(pidNonDefault).To(Equal(pidDefault))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
18
model/model_suite_test.go
Normal file
18
model/model_suite_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestModel(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Model Suite")
|
||||
}
|
||||
199
model/participants.go
Normal file
199
model/participants.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
var (
|
||||
RoleInvalid = Role{"invalid"}
|
||||
RoleArtist = Role{"artist"}
|
||||
RoleAlbumArtist = Role{"albumartist"}
|
||||
RoleComposer = Role{"composer"}
|
||||
RoleConductor = Role{"conductor"}
|
||||
RoleLyricist = Role{"lyricist"}
|
||||
RoleArranger = Role{"arranger"}
|
||||
RoleProducer = Role{"producer"}
|
||||
RoleDirector = Role{"director"}
|
||||
RoleEngineer = Role{"engineer"}
|
||||
RoleMixer = Role{"mixer"}
|
||||
RoleRemixer = Role{"remixer"}
|
||||
RoleDJMixer = Role{"djmixer"}
|
||||
RolePerformer = Role{"performer"}
|
||||
// RoleMainCredit is a credit where the artist is an album artist or artist
|
||||
RoleMainCredit = Role{"maincredit"}
|
||||
)
|
||||
|
||||
var AllRoles = map[string]Role{
|
||||
RoleArtist.role: RoleArtist,
|
||||
RoleAlbumArtist.role: RoleAlbumArtist,
|
||||
RoleComposer.role: RoleComposer,
|
||||
RoleConductor.role: RoleConductor,
|
||||
RoleLyricist.role: RoleLyricist,
|
||||
RoleArranger.role: RoleArranger,
|
||||
RoleProducer.role: RoleProducer,
|
||||
RoleDirector.role: RoleDirector,
|
||||
RoleEngineer.role: RoleEngineer,
|
||||
RoleMixer.role: RoleMixer,
|
||||
RoleRemixer.role: RoleRemixer,
|
||||
RoleDJMixer.role: RoleDJMixer,
|
||||
RolePerformer.role: RolePerformer,
|
||||
RoleMainCredit.role: RoleMainCredit,
|
||||
}
|
||||
|
||||
// Role represents the role of an artist in a track or album.
|
||||
type Role struct {
|
||||
role string
|
||||
}
|
||||
|
||||
func (r Role) String() string {
|
||||
return r.role
|
||||
}
|
||||
|
||||
func (r Role) MarshalText() (text []byte, err error) {
|
||||
return []byte(r.role), nil
|
||||
}
|
||||
|
||||
func (r *Role) UnmarshalText(text []byte) error {
|
||||
role := RoleFromString(string(text))
|
||||
if role == RoleInvalid {
|
||||
return fmt.Errorf("invalid role: %s", text)
|
||||
}
|
||||
*r = role
|
||||
return nil
|
||||
}
|
||||
|
||||
func RoleFromString(role string) Role {
|
||||
if r, ok := AllRoles[role]; ok {
|
||||
return r
|
||||
}
|
||||
return RoleInvalid
|
||||
}
|
||||
|
||||
type Participant struct {
|
||||
Artist
|
||||
SubRole string `json:"subRole,omitempty"`
|
||||
}
|
||||
|
||||
type ParticipantList []Participant
|
||||
|
||||
func (p ParticipantList) Join(sep string) string {
|
||||
return strings.Join(slice.Map(p, func(p Participant) string {
|
||||
if p.SubRole != "" {
|
||||
return p.Name + " (" + p.SubRole + ")"
|
||||
}
|
||||
return p.Name
|
||||
}), sep)
|
||||
}
|
||||
|
||||
type Participants map[Role]ParticipantList
|
||||
|
||||
// Add adds the artists to the role, ignoring duplicates.
|
||||
func (p Participants) Add(role Role, artists ...Artist) {
|
||||
participants := slice.Map(artists, func(artist Artist) Participant {
|
||||
return Participant{Artist: artist}
|
||||
})
|
||||
p.add(role, participants...)
|
||||
}
|
||||
|
||||
// AddWithSubRole adds the artists to the role, ignoring duplicates.
|
||||
func (p Participants) AddWithSubRole(role Role, subRole string, artists ...Artist) {
|
||||
participants := slice.Map(artists, func(artist Artist) Participant {
|
||||
return Participant{Artist: artist, SubRole: subRole}
|
||||
})
|
||||
p.add(role, participants...)
|
||||
}
|
||||
|
||||
func (p Participants) Sort() {
|
||||
for _, artists := range p {
|
||||
slices.SortFunc(artists, func(a1, a2 Participant) int {
|
||||
return cmp.Compare(a1.Name, a2.Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// First returns the first artist for the role, or an empty artist if the role is not present.
|
||||
func (p Participants) First(role Role) Artist {
|
||||
if artists, ok := p[role]; ok && len(artists) > 0 {
|
||||
return artists[0].Artist
|
||||
}
|
||||
return Artist{}
|
||||
}
|
||||
|
||||
// Merge merges the other Participants into this one.
|
||||
func (p Participants) Merge(other Participants) {
|
||||
for role, artists := range other {
|
||||
p.add(role, artists...)
|
||||
}
|
||||
}
|
||||
|
||||
func (p Participants) add(role Role, participants ...Participant) {
|
||||
seen := make(map[string]struct{}, len(p[role]))
|
||||
for _, artist := range p[role] {
|
||||
seen[artist.ID+artist.SubRole] = struct{}{}
|
||||
}
|
||||
for _, participant := range participants {
|
||||
key := participant.ID + participant.SubRole
|
||||
if _, ok := seen[key]; !ok {
|
||||
seen[key] = struct{}{}
|
||||
p[role] = append(p[role], participant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AllArtists returns all artists found in the Participants.
|
||||
func (p Participants) AllArtists() []Artist {
|
||||
// First count the total number of artists to avoid reallocations.
|
||||
totalArtists := 0
|
||||
for _, roleArtists := range p {
|
||||
totalArtists += len(roleArtists)
|
||||
}
|
||||
artists := make(Artists, 0, totalArtists)
|
||||
for _, roleArtists := range p {
|
||||
artists = append(artists, slice.Map(roleArtists, func(p Participant) Artist { return p.Artist })...)
|
||||
}
|
||||
slices.SortStableFunc(artists, func(a1, a2 Artist) int {
|
||||
return cmp.Compare(a1.ID, a2.ID)
|
||||
})
|
||||
return slices.CompactFunc(artists, func(a1, a2 Artist) bool {
|
||||
return a1.ID == a2.ID
|
||||
})
|
||||
}
|
||||
|
||||
// AllIDs returns all artist IDs found in the Participants.
|
||||
func (p Participants) AllIDs() []string {
|
||||
artists := p.AllArtists()
|
||||
return slice.Map(artists, func(a Artist) string { return a.ID })
|
||||
}
|
||||
|
||||
// AllNames returns all artist names found in the Participants, including SortArtistNames.
|
||||
func (p Participants) AllNames() []string {
|
||||
names := make([]string, 0, len(p))
|
||||
for _, artists := range p {
|
||||
for _, artist := range artists {
|
||||
names = append(names, artist.Name)
|
||||
if artist.SortArtistName != "" {
|
||||
names = append(names, artist.SortArtistName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return slice.Unique(names)
|
||||
}
|
||||
|
||||
func (p Participants) Hash() []byte {
|
||||
flattened := make([]string, 0, len(p))
|
||||
for role, artists := range p {
|
||||
ids := slice.Map(artists, func(participant Participant) string { return participant.SubRole + ":" + participant.ID })
|
||||
slices.Sort(ids)
|
||||
flattened = append(flattened, role.String()+":"+strings.Join(ids, "/"))
|
||||
}
|
||||
slices.Sort(flattened)
|
||||
sum := md5.New()
|
||||
sum.Write([]byte(strings.Join(flattened, "|")))
|
||||
return sum.Sum(nil)
|
||||
}
|
||||
214
model/participants_test.go
Normal file
214
model/participants_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Participants", func() {
|
||||
Describe("JSON Marshalling", func() {
|
||||
When("we have a valid Albums object", func() {
|
||||
var participants Participants
|
||||
BeforeEach(func() {
|
||||
participants = Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")},
|
||||
RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")},
|
||||
}
|
||||
})
|
||||
|
||||
It("marshals correctly", func() {
|
||||
data, err := json.Marshal(participants)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
var afterConversion Participants
|
||||
err = json.Unmarshal(data, &afterConversion)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(afterConversion).To(Equal(participants))
|
||||
})
|
||||
|
||||
It("returns unmarshal error when the role is invalid", func() {
|
||||
err := json.Unmarshal([]byte(`{"unknown": []}`), &participants)
|
||||
Expect(err).To(MatchError("invalid role: unknown"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("First", func() {
|
||||
var participants Participants
|
||||
BeforeEach(func() {
|
||||
participants = Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")},
|
||||
RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")},
|
||||
}
|
||||
})
|
||||
It("returns the first artist of the role", func() {
|
||||
Expect(participants.First(RoleArtist)).To(Equal(Artist{ID: "1", Name: "Artist1"}))
|
||||
})
|
||||
It("returns an empty artist when the role is not present", func() {
|
||||
Expect(participants.First(RoleComposer)).To(Equal(Artist{}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Add", func() {
|
||||
var participants Participants
|
||||
BeforeEach(func() {
|
||||
participants = Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")},
|
||||
}
|
||||
})
|
||||
It("adds the artist to the role", func() {
|
||||
participants.Add(RoleArtist, Artist{ID: "5", Name: "Artist5"})
|
||||
Expect(participants).To(Equal(Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2"), _p("5", "Artist5")},
|
||||
}))
|
||||
})
|
||||
It("creates a new role if it doesn't exist", func() {
|
||||
participants.Add(RoleComposer, Artist{ID: "5", Name: "Artist5"})
|
||||
Expect(participants).To(Equal(Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")},
|
||||
RoleComposer: []Participant{_p("5", "Artist5")},
|
||||
}))
|
||||
})
|
||||
It("should not add duplicate artists", func() {
|
||||
participants.Add(RoleArtist, Artist{ID: "1", Name: "Artist1"})
|
||||
Expect(participants).To(Equal(Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")},
|
||||
}))
|
||||
})
|
||||
It("adds the artist with and without subrole", func() {
|
||||
participants = Participants{}
|
||||
participants.Add(RolePerformer, Artist{ID: "3", Name: "Artist3"})
|
||||
participants.AddWithSubRole(RolePerformer, "SubRole", Artist{ID: "3", Name: "Artist3"})
|
||||
|
||||
artist3 := _p("3", "Artist3")
|
||||
artist3WithSubRole := artist3
|
||||
artist3WithSubRole.SubRole = "SubRole"
|
||||
|
||||
Expect(participants[RolePerformer]).To(HaveLen(2))
|
||||
Expect(participants).To(Equal(Participants{
|
||||
RolePerformer: []Participant{
|
||||
artist3,
|
||||
artist3WithSubRole,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Merge", func() {
|
||||
var participations1, participations2 Participants
|
||||
BeforeEach(func() {
|
||||
participations1 = Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Duplicated Artist")},
|
||||
RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")},
|
||||
}
|
||||
participations2 = Participants{
|
||||
RoleArtist: []Participant{_p("5", "Artist3"), _p("6", "Artist4"), _p("2", "Duplicated Artist")},
|
||||
RoleAlbumArtist: []Participant{_p("7", "AlbumArtist3"), _p("8", "AlbumArtist4")},
|
||||
}
|
||||
})
|
||||
It("merges correctly, skipping duplicated artists", func() {
|
||||
participations1.Merge(participations2)
|
||||
Expect(participations1).To(Equal(Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Duplicated Artist"), _p("5", "Artist3"), _p("6", "Artist4")},
|
||||
RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2"), _p("7", "AlbumArtist3"), _p("8", "AlbumArtist4")},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Hash", func() {
|
||||
It("should return the same hash for the same participants", func() {
|
||||
p1 := Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")},
|
||||
RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")},
|
||||
}
|
||||
p2 := Participants{
|
||||
RoleArtist: []Participant{_p("2", "Artist2"), _p("1", "Artist1")},
|
||||
RoleAlbumArtist: []Participant{_p("4", "AlbumArtist2"), _p("3", "AlbumArtist1")},
|
||||
}
|
||||
Expect(p1.Hash()).To(Equal(p2.Hash()))
|
||||
})
|
||||
It("should return different hashes for different participants", func() {
|
||||
p1 := Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1")},
|
||||
}
|
||||
p2 := Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")},
|
||||
}
|
||||
Expect(p1.Hash()).ToNot(Equal(p2.Hash()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("All", func() {
|
||||
var participants Participants
|
||||
BeforeEach(func() {
|
||||
participants = Participants{
|
||||
RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")},
|
||||
RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")},
|
||||
RoleProducer: []Participant{_p("5", "Producer", "SortProducerName")},
|
||||
RoleComposer: []Participant{_p("1", "Artist1")},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("All", func() {
|
||||
It("returns all artists found in the Participants", func() {
|
||||
artists := participants.AllArtists()
|
||||
Expect(artists).To(ConsistOf(
|
||||
Artist{ID: "1", Name: "Artist1"},
|
||||
Artist{ID: "2", Name: "Artist2"},
|
||||
Artist{ID: "3", Name: "AlbumArtist1"},
|
||||
Artist{ID: "4", Name: "AlbumArtist2"},
|
||||
Artist{ID: "5", Name: "Producer", SortArtistName: "SortProducerName"},
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AllIDs", func() {
|
||||
It("returns all artist IDs found in the Participants", func() {
|
||||
ids := participants.AllIDs()
|
||||
Expect(ids).To(ConsistOf("1", "2", "3", "4", "5"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AllNames", func() {
|
||||
It("returns all artist names found in the Participants", func() {
|
||||
names := participants.AllNames()
|
||||
Expect(names).To(ConsistOf("Artist1", "Artist2", "AlbumArtist1", "AlbumArtist2",
|
||||
"Producer", "SortProducerName"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ParticipantList", func() {
|
||||
Describe("Join", func() {
|
||||
It("joins the participants with the given separator", func() {
|
||||
list := ParticipantList{
|
||||
_p("1", "Artist 1"),
|
||||
_p("3", "Artist 2"),
|
||||
}
|
||||
list[0].SubRole = "SubRole"
|
||||
Expect(list.Join(", ")).To(Equal("Artist 1 (SubRole), Artist 2"))
|
||||
})
|
||||
|
||||
It("returns the sole participant if there is only one", func() {
|
||||
list := ParticipantList{_p("1", "Artist 1")}
|
||||
Expect(list.Join(", ")).To(Equal("Artist 1"))
|
||||
})
|
||||
|
||||
It("returns empty string if there are no participants", func() {
|
||||
var list ParticipantList
|
||||
Expect(list.Join(", ")).To(Equal(""))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func _p(id, name string, sortName ...string) Participant {
|
||||
p := Participant{Artist: Artist{ID: id, Name: name}}
|
||||
if len(sortName) > 0 {
|
||||
p.Artist.SortArtistName = sortName[0]
|
||||
}
|
||||
return p
|
||||
}
|
||||
31
model/player.go
Normal file
31
model/player.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Player struct {
|
||||
Username string `structs:"-" json:"userName"`
|
||||
|
||||
ID string `structs:"id" json:"id"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
UserAgent string `structs:"user_agent" json:"userAgent"`
|
||||
UserId string `structs:"user_id" json:"userId"`
|
||||
Client string `structs:"client" json:"client"`
|
||||
IP string `structs:"ip" json:"ip"`
|
||||
LastSeen time.Time `structs:"last_seen" json:"lastSeen"`
|
||||
TranscodingId string `structs:"transcoding_id" json:"transcodingId"`
|
||||
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"`
|
||||
ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"`
|
||||
ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"`
|
||||
}
|
||||
|
||||
type Players []Player
|
||||
|
||||
type PlayerRepository interface {
|
||||
Get(id string) (*Player, error)
|
||||
FindMatch(userId, client, userAgent string) (*Player, error)
|
||||
Put(p *Player) error
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
CountByClient(...QueryOptions) (map[string]int64, error)
|
||||
}
|
||||
153
model/playlist.go
Normal file
153
model/playlist.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
type Playlist struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
Duration float32 `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
OwnerName string `structs:"-" json:"ownerName"`
|
||||
OwnerID string `structs:"owner_id" json:"ownerId"`
|
||||
Public bool `structs:"public" json:"public"`
|
||||
Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Sync bool `structs:"sync" json:"sync"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
|
||||
// SmartPlaylist attributes
|
||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
|
||||
}
|
||||
|
||||
func (pls Playlist) IsSmartPlaylist() bool {
|
||||
return pls.Rules != nil && pls.Rules.Expression != nil
|
||||
}
|
||||
|
||||
func (pls Playlist) MediaFiles() MediaFiles {
|
||||
if len(pls.Tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
return pls.Tracks.MediaFiles()
|
||||
}
|
||||
|
||||
func (pls *Playlist) refreshStats() {
|
||||
pls.SongCount = len(pls.Tracks)
|
||||
pls.Duration = 0
|
||||
pls.Size = 0
|
||||
for _, t := range pls.Tracks {
|
||||
pls.Duration += t.MediaFile.Duration
|
||||
pls.Size += t.MediaFile.Size
|
||||
}
|
||||
}
|
||||
|
||||
func (pls *Playlist) SetTracks(tracks PlaylistTracks) {
|
||||
pls.Tracks = tracks
|
||||
pls.refreshStats()
|
||||
}
|
||||
|
||||
func (pls *Playlist) RemoveTracks(idxToRemove []int) {
|
||||
var newTracks PlaylistTracks
|
||||
for i, t := range pls.Tracks {
|
||||
if slices.Contains(idxToRemove, i) {
|
||||
continue
|
||||
}
|
||||
newTracks = append(newTracks, t)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
pls.refreshStats()
|
||||
}
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format
|
||||
func (pls *Playlist) ToM3U8() string {
|
||||
return pls.MediaFiles().ToM3U8(pls.Name, true)
|
||||
}
|
||||
|
||||
func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) {
|
||||
pos := len(pls.Tracks)
|
||||
for _, mfId := range mediaFileIds {
|
||||
pos++
|
||||
t := PlaylistTrack{
|
||||
ID: strconv.Itoa(pos),
|
||||
MediaFileID: mfId,
|
||||
MediaFile: MediaFile{ID: mfId},
|
||||
PlaylistID: pls.ID,
|
||||
}
|
||||
pls.Tracks = append(pls.Tracks, t)
|
||||
}
|
||||
pls.refreshStats()
|
||||
}
|
||||
|
||||
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
|
||||
pos := len(pls.Tracks)
|
||||
for _, mf := range mfs {
|
||||
pos++
|
||||
t := PlaylistTrack{
|
||||
ID: strconv.Itoa(pos),
|
||||
MediaFileID: mf.ID,
|
||||
MediaFile: mf,
|
||||
PlaylistID: pls.ID,
|
||||
}
|
||||
pls.Tracks = append(pls.Tracks, t)
|
||||
}
|
||||
pls.refreshStats()
|
||||
}
|
||||
|
||||
func (pls Playlist) CoverArtID() ArtworkID {
|
||||
return artworkIDFromPlaylist(pls)
|
||||
}
|
||||
|
||||
type Playlists []Playlist
|
||||
|
||||
type PlaylistRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(pls *Playlist) error
|
||||
Get(id string) (*Playlist, error)
|
||||
GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error)
|
||||
GetAll(options ...QueryOptions) (Playlists, error)
|
||||
FindByPath(path string) (*Playlist, error)
|
||||
Delete(id string) error
|
||||
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
||||
GetPlaylists(mediaFileId string) (Playlists, error)
|
||||
}
|
||||
|
||||
type PlaylistTrack struct {
|
||||
ID string `json:"id"`
|
||||
MediaFileID string `json:"mediaFileId"`
|
||||
PlaylistID string `json:"playlistId"`
|
||||
MediaFile
|
||||
}
|
||||
|
||||
type PlaylistTracks []PlaylistTrack
|
||||
|
||||
func (plt PlaylistTracks) MediaFiles() MediaFiles {
|
||||
mfs := make(MediaFiles, len(plt))
|
||||
for i, t := range plt {
|
||||
mfs[i] = t.MediaFile
|
||||
}
|
||||
return mfs
|
||||
}
|
||||
|
||||
type PlaylistTrackRepository interface {
|
||||
ResourceRepository
|
||||
GetAll(options ...QueryOptions) (PlaylistTracks, error)
|
||||
GetAlbumIDs(options ...QueryOptions) ([]string, error)
|
||||
Add(mediaFileIds []string) (int, error)
|
||||
AddAlbums(albumIds []string) (int, error)
|
||||
AddArtists(artistIds []string) (int, error)
|
||||
AddDiscs(discs []DiscID) (int, error)
|
||||
Delete(id ...string) error
|
||||
DeleteAll() error
|
||||
Reorder(pos int, newPos int) error
|
||||
}
|
||||
44
model/playlist_test.go
Normal file
44
model/playlist_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlist", func() {
|
||||
Describe("ToM3U8()", func() {
|
||||
var pls model.Playlist
|
||||
BeforeEach(func() {
|
||||
pls = model.Playlist{Name: "Mellow sunset"}
|
||||
pls.Tracks = model.PlaylistTracks{
|
||||
{MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About",
|
||||
Duration: 377.84,
|
||||
LibraryPath: "/music/library", Path: "Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
|
||||
{MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)",
|
||||
Duration: 374.49,
|
||||
LibraryPath: "/music/library", Path: "Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
|
||||
{MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side",
|
||||
Duration: 253.1,
|
||||
LibraryPath: "/music/library", Path: "Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
|
||||
{MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home",
|
||||
Duration: 163.89,
|
||||
LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
|
||||
}
|
||||
})
|
||||
It("generates the correct M3U format", func() {
|
||||
expected := `#EXTM3U
|
||||
#PLAYLIST:Mellow sunset
|
||||
#EXTINF:378,Morcheeba feat. Kurt Wagner - What New York Couples Fight About
|
||||
/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3
|
||||
#EXTINF:374,A Tribe Called Quest - Description of a Fool (Groove Armada's Acoustic mix)
|
||||
/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3
|
||||
#EXTINF:253,Lou Reed - Walk on the Wild Side
|
||||
/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a
|
||||
#EXTINF:164,Legião Urbana - On the Way Home
|
||||
/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3
|
||||
`
|
||||
Expect(pls.ToM3U8()).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
})
|
||||
28
model/playqueue.go
Normal file
28
model/playqueue.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type PlayQueue struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
UserID string `structs:"user_id" json:"userId"`
|
||||
Current int `structs:"current" json:"current"`
|
||||
Position int64 `structs:"position" json:"position"`
|
||||
ChangedBy string `structs:"changed_by" json:"changedBy"`
|
||||
Items MediaFiles `structs:"-" json:"items,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PlayQueues []PlayQueue
|
||||
|
||||
type PlayQueueRepository interface {
|
||||
Store(queue *PlayQueue, colNames ...string) error
|
||||
// Retrieve returns the playqueue without loading the full MediaFiles
|
||||
// (Items only contain IDs)
|
||||
Retrieve(userId string) (*PlayQueue, error)
|
||||
// RetrieveWithMediaFiles returns the playqueue with full MediaFiles loaded
|
||||
RetrieveWithMediaFiles(userId string) (*PlayQueue, error)
|
||||
Clear(userId string) error
|
||||
}
|
||||
8
model/properties.go
Normal file
8
model/properties.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package model
|
||||
|
||||
type PropertyRepository interface {
|
||||
Put(id string, value string) error
|
||||
Get(id string) (string, error)
|
||||
Delete(id string) error
|
||||
DefaultGet(id string, defaultValue string) (string, error)
|
||||
}
|
||||
23
model/radio.go
Normal file
23
model/radio.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Radio struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
StreamUrl string `structs:"stream_url" json:"streamUrl"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
HomePageUrl string `structs:"home_page_url" json:"homePageUrl"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Radios []Radio
|
||||
|
||||
type RadioRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Delete(id string) error
|
||||
Get(id string) (*Radio, error)
|
||||
GetAll(options ...QueryOptions) (Radios, error)
|
||||
Put(u *Radio) error
|
||||
}
|
||||
127
model/request/request.go
Normal file
127
model/request/request.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
User = contextKey("user")
|
||||
Username = contextKey("username")
|
||||
Client = contextKey("client")
|
||||
Version = contextKey("version")
|
||||
Player = contextKey("player")
|
||||
Transcoding = contextKey("transcoding")
|
||||
ClientUniqueId = contextKey("clientUniqueId")
|
||||
ReverseProxyIp = contextKey("reverseProxyIp")
|
||||
InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins
|
||||
)
|
||||
|
||||
var allKeys = []contextKey{
|
||||
User,
|
||||
Username,
|
||||
Client,
|
||||
Version,
|
||||
Player,
|
||||
Transcoding,
|
||||
ClientUniqueId,
|
||||
ReverseProxyIp,
|
||||
InternalAuth,
|
||||
}
|
||||
|
||||
func WithUser(ctx context.Context, u model.User) context.Context {
|
||||
return context.WithValue(ctx, User, u)
|
||||
}
|
||||
|
||||
func WithUsername(ctx context.Context, username string) context.Context {
|
||||
return context.WithValue(ctx, Username, username)
|
||||
}
|
||||
|
||||
func WithClient(ctx context.Context, client string) context.Context {
|
||||
return context.WithValue(ctx, Client, client)
|
||||
}
|
||||
|
||||
func WithVersion(ctx context.Context, version string) context.Context {
|
||||
return context.WithValue(ctx, Version, version)
|
||||
}
|
||||
|
||||
func WithPlayer(ctx context.Context, player model.Player) context.Context {
|
||||
return context.WithValue(ctx, Player, player)
|
||||
}
|
||||
|
||||
func WithTranscoding(ctx context.Context, t model.Transcoding) context.Context {
|
||||
return context.WithValue(ctx, Transcoding, t)
|
||||
}
|
||||
|
||||
func WithClientUniqueId(ctx context.Context, clientUniqueId string) context.Context {
|
||||
return context.WithValue(ctx, ClientUniqueId, clientUniqueId)
|
||||
}
|
||||
|
||||
func WithReverseProxyIp(ctx context.Context, reverseProxyIp string) context.Context {
|
||||
return context.WithValue(ctx, ReverseProxyIp, reverseProxyIp)
|
||||
}
|
||||
|
||||
func WithInternalAuth(ctx context.Context, username string) context.Context {
|
||||
return context.WithValue(ctx, InternalAuth, username)
|
||||
}
|
||||
|
||||
func UserFrom(ctx context.Context) (model.User, bool) {
|
||||
v, ok := ctx.Value(User).(model.User)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func UsernameFrom(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(Username).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func ClientFrom(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(Client).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func VersionFrom(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(Version).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func PlayerFrom(ctx context.Context) (model.Player, bool) {
|
||||
v, ok := ctx.Value(Player).(model.Player)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func TranscodingFrom(ctx context.Context) (model.Transcoding, bool) {
|
||||
v, ok := ctx.Value(Transcoding).(model.Transcoding)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func ClientUniqueIdFrom(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(ClientUniqueId).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func ReverseProxyIpFrom(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(ReverseProxyIp).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func InternalAuthFrom(ctx context.Context) (string, bool) {
|
||||
if v := ctx.Value(InternalAuth); v != nil {
|
||||
if username, ok := v.(string); ok {
|
||||
return username, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func AddValues(ctx, requestCtx context.Context) context.Context {
|
||||
for _, key := range allKeys {
|
||||
if v := requestCtx.Value(key); v != nil {
|
||||
ctx = context.WithValue(ctx, key, v)
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
81
model/scanner.go
Normal file
81
model/scanner.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ScanTarget represents a specific folder within a library to be scanned.
|
||||
// NOTE: This struct is used as a map key, so it should only contain comparable types.
|
||||
type ScanTarget struct {
|
||||
LibraryID int
|
||||
FolderPath string // Relative path within the library, or "" for entire library
|
||||
}
|
||||
|
||||
func (st ScanTarget) String() string {
|
||||
return fmt.Sprintf("%d:%s", st.LibraryID, st.FolderPath)
|
||||
}
|
||||
|
||||
// ScannerStatus holds information about the current scan status
|
||||
type ScannerStatus struct {
|
||||
Scanning bool
|
||||
LastScan time.Time
|
||||
Count uint32
|
||||
FolderCount uint32
|
||||
LastError string
|
||||
ScanType string
|
||||
ElapsedTime time.Duration
|
||||
}
|
||||
|
||||
type Scanner interface {
|
||||
// ScanAll starts a scan of all libraries. This is a blocking operation.
|
||||
ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
|
||||
// ScanFolders scans specific library/folder pairs, recursing into subdirectories.
|
||||
// If targets is nil, it scans all libraries. This is a blocking operation.
|
||||
ScanFolders(ctx context.Context, fullScan bool, targets []ScanTarget) (warnings []string, err error)
|
||||
Status(context.Context) (*ScannerStatus, error)
|
||||
}
|
||||
|
||||
// ParseTargets parses scan targets strings into ScanTarget structs.
|
||||
// Example: []string{"1:Music/Rock", "2:Classical"}
|
||||
func ParseTargets(libFolders []string) ([]ScanTarget, error) {
|
||||
targets := make([]ScanTarget, 0, len(libFolders))
|
||||
|
||||
for _, part := range libFolders {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split by the first colon
|
||||
colonIdx := strings.Index(part, ":")
|
||||
if colonIdx == -1 {
|
||||
return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
|
||||
}
|
||||
|
||||
libIDStr := part[:colonIdx]
|
||||
folderPath := part[colonIdx+1:]
|
||||
|
||||
libID, err := strconv.Atoi(libIDStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid library ID %q: %w", libIDStr, err)
|
||||
}
|
||||
if libID <= 0 {
|
||||
return nil, fmt.Errorf("invalid library ID %q", libIDStr)
|
||||
}
|
||||
|
||||
targets = append(targets, ScanTarget{
|
||||
LibraryID: libID,
|
||||
FolderPath: folderPath,
|
||||
})
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return nil, fmt.Errorf("no valid targets found")
|
||||
}
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
89
model/scanner_test.go
Normal file
89
model/scanner_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseTargets", func() {
|
||||
It("parses multiple entries in slice", func() {
|
||||
targets, err := model.ParseTargets([]string{"1:Music/Rock", "1:Music/Jazz", "2:Classical"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(3))
|
||||
Expect(targets[0].LibraryID).To(Equal(1))
|
||||
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
|
||||
Expect(targets[1].LibraryID).To(Equal(1))
|
||||
Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
|
||||
Expect(targets[2].LibraryID).To(Equal(2))
|
||||
Expect(targets[2].FolderPath).To(Equal("Classical"))
|
||||
})
|
||||
|
||||
It("handles empty folder paths", func() {
|
||||
targets, err := model.ParseTargets([]string{"1:", "2:"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
Expect(targets[0].FolderPath).To(Equal(""))
|
||||
Expect(targets[1].FolderPath).To(Equal(""))
|
||||
})
|
||||
|
||||
It("trims whitespace from entries", func() {
|
||||
targets, err := model.ParseTargets([]string{" 1:Music/Rock", " 2:Classical "})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
Expect(targets[0].LibraryID).To(Equal(1))
|
||||
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
|
||||
Expect(targets[1].LibraryID).To(Equal(2))
|
||||
Expect(targets[1].FolderPath).To(Equal("Classical"))
|
||||
})
|
||||
|
||||
It("skips empty strings", func() {
|
||||
targets, err := model.ParseTargets([]string{"1:Music/Rock", "", "2:Classical"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("handles paths with colons", func() {
|
||||
targets, err := model.ParseTargets([]string{"1:C:/Music/Rock", "2:/path:with:colons"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
Expect(targets[0].FolderPath).To(Equal("C:/Music/Rock"))
|
||||
Expect(targets[1].FolderPath).To(Equal("/path:with:colons"))
|
||||
})
|
||||
|
||||
It("returns error for invalid format without colon", func() {
|
||||
_, err := model.ParseTargets([]string{"1Music/Rock"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid target format"))
|
||||
})
|
||||
|
||||
It("returns error for non-numeric library ID", func() {
|
||||
_, err := model.ParseTargets([]string{"abc:Music/Rock"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid library ID"))
|
||||
})
|
||||
|
||||
It("returns error for negative library ID", func() {
|
||||
_, err := model.ParseTargets([]string{"-1:Music/Rock"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid library ID"))
|
||||
})
|
||||
|
||||
It("returns error for zero library ID", func() {
|
||||
_, err := model.ParseTargets([]string{"0:Music/Rock"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid library ID"))
|
||||
})
|
||||
|
||||
It("returns error for empty input", func() {
|
||||
_, err := model.ParseTargets([]string{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no valid targets found"))
|
||||
})
|
||||
|
||||
It("returns error for all empty strings", func() {
|
||||
_, err := model.ParseTargets([]string{"", " ", ""})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no valid targets found"))
|
||||
})
|
||||
})
|
||||
13
model/scrobble.go
Normal file
13
model/scrobble.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Scrobble struct {
|
||||
MediaFileID string
|
||||
UserID string
|
||||
SubmissionTime time.Time
|
||||
}
|
||||
|
||||
type ScrobbleRepository interface {
|
||||
RecordScrobble(mediaFileID string, submissionTime time.Time) error
|
||||
}
|
||||
23
model/scrobble_buffer.go
Normal file
23
model/scrobble_buffer.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ScrobbleEntry struct {
|
||||
ID string
|
||||
Service string
|
||||
UserID string
|
||||
PlayTime time.Time
|
||||
EnqueueTime time.Time
|
||||
MediaFileID string
|
||||
MediaFile
|
||||
}
|
||||
|
||||
type ScrobbleEntries []ScrobbleEntry
|
||||
|
||||
type ScrobbleBufferRepository interface {
|
||||
UserIDs(service string) ([]string, error)
|
||||
Enqueue(service, userId, mediaFileId string, playTime time.Time) error
|
||||
Next(service string, userId string) (*ScrobbleEntry, error)
|
||||
Dequeue(entry *ScrobbleEntry) error
|
||||
Length() (int64, error)
|
||||
}
|
||||
5
model/searchable.go
Normal file
5
model/searchable.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package model
|
||||
|
||||
type SearchableRepository[T any] interface {
|
||||
Search(q string, offset, size int, options ...QueryOptions) (T, error)
|
||||
}
|
||||
62
model/share.go
Normal file
62
model/share.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
)
|
||||
|
||||
type Share struct {
|
||||
ID string `structs:"id" json:"id,omitempty"`
|
||||
UserID string `structs:"user_id" json:"userId,omitempty"`
|
||||
Username string `structs:"-" json:"username,omitempty"`
|
||||
Description string `structs:"description" json:"description,omitempty"`
|
||||
Downloadable bool `structs:"downloadable" json:"downloadable"`
|
||||
ExpiresAt *time.Time `structs:"expires_at" json:"expiresAt,omitempty"`
|
||||
LastVisitedAt *time.Time `structs:"last_visited_at" json:"lastVisitedAt,omitempty"`
|
||||
ResourceIDs string `structs:"resource_ids" json:"resourceIds,omitempty"`
|
||||
ResourceType string `structs:"resource_type" json:"resourceType,omitempty"`
|
||||
Contents string `structs:"contents" json:"contents,omitempty"`
|
||||
Format string `structs:"format" json:"format,omitempty"`
|
||||
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate,omitempty"`
|
||||
VisitCount int `structs:"visit_count" json:"visitCount,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
|
||||
Albums Albums `structs:"-" json:"albums,omitempty"`
|
||||
URL string `structs:"-" json:"-"`
|
||||
ImageURL string `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
func (s Share) CoverArtID() ArtworkID {
|
||||
ids := strings.SplitN(s.ResourceIDs, ",", 2)
|
||||
if len(ids) == 0 {
|
||||
return ArtworkID{}
|
||||
}
|
||||
switch s.ResourceType {
|
||||
case "album":
|
||||
return Album{ID: ids[0]}.CoverArtID()
|
||||
case "playlist":
|
||||
return Playlist{ID: ids[0]}.CoverArtID()
|
||||
case "artist":
|
||||
return Artist{ID: ids[0]}.CoverArtID()
|
||||
}
|
||||
rnd := random.Int64N(len(s.Tracks))
|
||||
return s.Tracks[rnd].CoverArtID()
|
||||
}
|
||||
|
||||
type Shares []Share
|
||||
|
||||
// ToM3U8 exports the share to the Extended M3U8 format.
|
||||
func (s Share) ToM3U8() string {
|
||||
return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false)
|
||||
}
|
||||
|
||||
type ShareRepository interface {
|
||||
Exists(id string) (bool, error)
|
||||
Get(id string) (*Share, error)
|
||||
GetAll(options ...QueryOptions) (Shares, error)
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
}
|
||||
257
model/tag.go
Normal file
257
model/tag.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
TagName TagName `json:"tagName,omitempty"`
|
||||
TagValue string `json:"tagValue,omitempty"`
|
||||
AlbumCount int `json:"albumCount,omitempty"`
|
||||
SongCount int `json:"songCount,omitempty"`
|
||||
}
|
||||
|
||||
type TagList []Tag
|
||||
|
||||
func (l TagList) GroupByFrequency() Tags {
|
||||
grouped := map[string]map[string]int{}
|
||||
values := map[string]string{}
|
||||
for _, t := range l {
|
||||
if m, ok := grouped[string(t.TagName)]; !ok {
|
||||
grouped[string(t.TagName)] = map[string]int{t.ID: 1}
|
||||
} else {
|
||||
m[t.ID]++
|
||||
}
|
||||
values[t.ID] = t.TagValue
|
||||
}
|
||||
|
||||
tags := Tags{}
|
||||
for name, counts := range grouped {
|
||||
idList := make([]string, 0, len(counts))
|
||||
for tid := range counts {
|
||||
idList = append(idList, tid)
|
||||
}
|
||||
slices.SortFunc(idList, func(a, b string) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(counts[b], counts[a]),
|
||||
cmp.Compare(values[a], values[b]),
|
||||
)
|
||||
})
|
||||
tags[TagName(name)] = slice.Map(idList, func(id string) string { return values[id] })
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func (t Tag) String() string {
|
||||
return fmt.Sprintf("%s=%s", t.TagName, t.TagValue)
|
||||
}
|
||||
|
||||
func NewTag(name TagName, value string) Tag {
|
||||
name = name.ToLower()
|
||||
hashID := tagID(name, value)
|
||||
return Tag{
|
||||
ID: hashID,
|
||||
TagName: name,
|
||||
TagValue: value,
|
||||
}
|
||||
}
|
||||
|
||||
func tagID(name TagName, value string) string {
|
||||
return id.NewTagID(string(name), value)
|
||||
}
|
||||
|
||||
type RawTags map[string][]string
|
||||
|
||||
type Tags map[TagName][]string
|
||||
|
||||
func (t Tags) Values(name TagName) []string {
|
||||
return t[name]
|
||||
}
|
||||
|
||||
func (t Tags) IDs() []string {
|
||||
var ids []string
|
||||
for name, tag := range t {
|
||||
name = name.ToLower()
|
||||
for _, v := range tag {
|
||||
ids = append(ids, tagID(name, v))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (t Tags) Flatten(name TagName) TagList {
|
||||
var tags TagList
|
||||
for _, v := range t[name] {
|
||||
tags = append(tags, NewTag(name, v))
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func (t Tags) FlattenAll() TagList {
|
||||
var tags TagList
|
||||
for name, values := range t {
|
||||
for _, v := range values {
|
||||
tags = append(tags, NewTag(name, v))
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func (t Tags) Sort() {
|
||||
for _, values := range t {
|
||||
slices.Sort(values)
|
||||
}
|
||||
}
|
||||
|
||||
func (t Tags) Hash() []byte {
|
||||
if len(t) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := t.IDs()
|
||||
slices.Sort(ids)
|
||||
sum := md5.New()
|
||||
sum.Write([]byte(strings.Join(ids, "|")))
|
||||
return sum.Sum(nil)
|
||||
}
|
||||
|
||||
func (t Tags) ToGenres() (string, Genres) {
|
||||
values := t.Values("genre")
|
||||
if len(values) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
genres := slice.Map(values, func(g string) Genre {
|
||||
t := NewTag("genre", g)
|
||||
return Genre{ID: t.ID, Name: g}
|
||||
})
|
||||
return genres[0].Name, genres
|
||||
}
|
||||
|
||||
// Merge merges the tags from another Tags object into this one, removing any duplicates
|
||||
func (t Tags) Merge(tags Tags) {
|
||||
for name, values := range tags {
|
||||
for _, v := range values {
|
||||
t.Add(name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t Tags) Add(name TagName, v string) {
|
||||
for _, existing := range t[name] {
|
||||
if existing == v {
|
||||
return
|
||||
}
|
||||
}
|
||||
t[name] = append(t[name], v)
|
||||
}
|
||||
|
||||
type TagRepository interface {
|
||||
Add(libraryID int, tags ...Tag) error
|
||||
UpdateCounts() error
|
||||
}
|
||||
|
||||
type TagName string
|
||||
|
||||
func (t TagName) ToLower() TagName {
|
||||
return TagName(strings.ToLower(string(t)))
|
||||
}
|
||||
|
||||
func (t TagName) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// Tag names, as defined in the mappings.yaml file
|
||||
const (
|
||||
TagAlbum TagName = "album"
|
||||
TagTitle TagName = "title"
|
||||
TagTrackNumber TagName = "track"
|
||||
TagDiscNumber TagName = "disc"
|
||||
TagTotalTracks TagName = "tracktotal"
|
||||
TagTotalDiscs TagName = "disctotal"
|
||||
TagDiscSubtitle TagName = "discsubtitle"
|
||||
TagSubtitle TagName = "subtitle"
|
||||
TagGenre TagName = "genre"
|
||||
TagMood TagName = "mood"
|
||||
TagComment TagName = "comment"
|
||||
TagAlbumSort TagName = "albumsort"
|
||||
TagAlbumVersion TagName = "albumversion"
|
||||
TagTitleSort TagName = "titlesort"
|
||||
TagCompilation TagName = "compilation"
|
||||
TagGrouping TagName = "grouping"
|
||||
TagLyrics TagName = "lyrics"
|
||||
TagRecordLabel TagName = "recordlabel"
|
||||
TagReleaseType TagName = "releasetype"
|
||||
TagReleaseCountry TagName = "releasecountry"
|
||||
TagMedia TagName = "media"
|
||||
TagCatalogNumber TagName = "catalognumber"
|
||||
TagISRC TagName = "isrc"
|
||||
TagBPM TagName = "bpm"
|
||||
TagExplicitStatus TagName = "explicitstatus"
|
||||
|
||||
// Dates and years
|
||||
|
||||
TagOriginalDate TagName = "originaldate"
|
||||
TagReleaseDate TagName = "releasedate"
|
||||
TagRecordingDate TagName = "recordingdate"
|
||||
|
||||
// Artists and roles
|
||||
|
||||
TagAlbumArtist TagName = "albumartist"
|
||||
TagAlbumArtists TagName = "albumartists"
|
||||
TagAlbumArtistSort TagName = "albumartistsort"
|
||||
TagAlbumArtistsSort TagName = "albumartistssort"
|
||||
TagTrackArtist TagName = "artist"
|
||||
TagTrackArtists TagName = "artists"
|
||||
TagTrackArtistSort TagName = "artistsort"
|
||||
TagTrackArtistsSort TagName = "artistssort"
|
||||
TagComposer TagName = "composer"
|
||||
TagComposerSort TagName = "composersort"
|
||||
TagLyricist TagName = "lyricist"
|
||||
TagLyricistSort TagName = "lyricistsort"
|
||||
TagDirector TagName = "director"
|
||||
TagProducer TagName = "producer"
|
||||
TagEngineer TagName = "engineer"
|
||||
TagMixer TagName = "mixer"
|
||||
TagRemixer TagName = "remixer"
|
||||
TagDJMixer TagName = "djmixer"
|
||||
TagConductor TagName = "conductor"
|
||||
TagArranger TagName = "arranger"
|
||||
TagPerformer TagName = "performer"
|
||||
|
||||
// ReplayGain
|
||||
|
||||
TagReplayGainAlbumGain TagName = "replaygain_album_gain"
|
||||
TagReplayGainAlbumPeak TagName = "replaygain_album_peak"
|
||||
TagReplayGainTrackGain TagName = "replaygain_track_gain"
|
||||
TagReplayGainTrackPeak TagName = "replaygain_track_peak"
|
||||
TagR128AlbumGain TagName = "r128_album_gain"
|
||||
TagR128TrackGain TagName = "r128_track_gain"
|
||||
|
||||
// MusicBrainz
|
||||
|
||||
TagMusicBrainzArtistID TagName = "musicbrainz_artistid"
|
||||
TagMusicBrainzRecordingID TagName = "musicbrainz_recordingid"
|
||||
TagMusicBrainzTrackID TagName = "musicbrainz_trackid"
|
||||
TagMusicBrainzAlbumArtistID TagName = "musicbrainz_albumartistid"
|
||||
TagMusicBrainzAlbumID TagName = "musicbrainz_albumid"
|
||||
TagMusicBrainzReleaseGroupID TagName = "musicbrainz_releasegroupid"
|
||||
|
||||
TagMusicBrainzComposerID TagName = "musicbrainz_composerid"
|
||||
TagMusicBrainzLyricistID TagName = "musicbrainz_lyricistid"
|
||||
TagMusicBrainzDirectorID TagName = "musicbrainz_directorid"
|
||||
TagMusicBrainzProducerID TagName = "musicbrainz_producerid"
|
||||
TagMusicBrainzEngineerID TagName = "musicbrainz_engineerid"
|
||||
TagMusicBrainzMixerID TagName = "musicbrainz_mixerid"
|
||||
TagMusicBrainzRemixerID TagName = "musicbrainz_remixerid"
|
||||
TagMusicBrainzDJMixerID TagName = "musicbrainz_djmixerid"
|
||||
TagMusicBrainzConductorID TagName = "musicbrainz_conductorid"
|
||||
TagMusicBrainzArrangerID TagName = "musicbrainz_arrangerid"
|
||||
TagMusicBrainzPerformerID TagName = "musicbrainz_performerid"
|
||||
)
|
||||
246
model/tag_mappings.go
Normal file
246
model/tag_mappings.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type mappingsConf struct {
|
||||
Main tagMappings `yaml:"main"`
|
||||
Additional tagMappings `yaml:"additional"`
|
||||
Roles TagConf `yaml:"roles"`
|
||||
Artists TagConf `yaml:"artists"`
|
||||
}
|
||||
|
||||
type tagMappings map[TagName]TagConf
|
||||
|
||||
type TagConf struct {
|
||||
Aliases []string `yaml:"aliases"`
|
||||
Type TagType `yaml:"type"`
|
||||
MaxLength int `yaml:"maxLength"`
|
||||
Split []string `yaml:"split"`
|
||||
Album bool `yaml:"album"`
|
||||
SplitRx *regexp.Regexp `yaml:"-"`
|
||||
}
|
||||
|
||||
// SplitTagValue splits a tag value by the split separators, but only if it has a single value.
|
||||
func (c TagConf) SplitTagValue(values []string) []string {
|
||||
// If there's not exactly one value or no separators, return early.
|
||||
if len(values) != 1 || c.SplitRx == nil {
|
||||
return values
|
||||
}
|
||||
tag := values[0]
|
||||
|
||||
// Replace all occurrences of any separator with the zero-width space.
|
||||
tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp)
|
||||
|
||||
// Split by the zero-width space and trim each substring.
|
||||
parts := strings.Split(tag, consts.Zwsp)
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.TrimSpace(part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
type TagType string
|
||||
|
||||
const (
|
||||
TagTypeString TagType = "string"
|
||||
TagTypeInteger TagType = "int"
|
||||
TagTypeFloat TagType = "float"
|
||||
TagTypeDate TagType = "date"
|
||||
TagTypeUUID TagType = "uuid"
|
||||
TagTypePair TagType = "pair"
|
||||
)
|
||||
|
||||
func TagMappings() map[TagName]TagConf {
|
||||
mappings, _ := parseMappings()
|
||||
return mappings
|
||||
}
|
||||
|
||||
func TagRolesConf() TagConf {
|
||||
_, cfg := parseMappings()
|
||||
return cfg.Roles
|
||||
}
|
||||
|
||||
func TagArtistsConf() TagConf {
|
||||
_, cfg := parseMappings()
|
||||
return cfg.Artists
|
||||
}
|
||||
|
||||
func TagMainMappings() map[TagName]TagConf {
|
||||
_, mappings := parseMappings()
|
||||
return mappings.Main
|
||||
}
|
||||
|
||||
var _mappings mappingsConf
|
||||
|
||||
var parseMappings = sync.OnceValues(func() (map[TagName]TagConf, mappingsConf) {
|
||||
_mappings.Artists.SplitRx = compileSplitRegex("artists", _mappings.Artists.Split)
|
||||
_mappings.Roles.SplitRx = compileSplitRegex("roles", _mappings.Roles.Split)
|
||||
|
||||
normalized := tagMappings{}
|
||||
collectTags(_mappings.Main, normalized)
|
||||
_mappings.Main = normalized
|
||||
|
||||
normalized = tagMappings{}
|
||||
collectTags(_mappings.Additional, normalized)
|
||||
_mappings.Additional = normalized
|
||||
|
||||
// Merge main and additional mappings, log an error if a tag is found in both
|
||||
for k, v := range _mappings.Main {
|
||||
if _, ok := _mappings.Additional[k]; ok {
|
||||
log.Error("Tag found in both main and additional mappings", "tag", k)
|
||||
}
|
||||
normalized[k] = v
|
||||
}
|
||||
return normalized, _mappings
|
||||
})
|
||||
|
||||
func collectTags(tagMappings, normalized map[TagName]TagConf) {
|
||||
for k, v := range tagMappings {
|
||||
var aliases []string
|
||||
for _, val := range v.Aliases {
|
||||
aliases = append(aliases, strings.ToLower(val))
|
||||
}
|
||||
if v.Split != nil {
|
||||
if v.Type != "" && v.Type != TagTypeString {
|
||||
log.Error("Tag splitting only available for string types", "tag", k, "split", v.Split,
|
||||
"type", string(v.Type))
|
||||
v.Split = nil
|
||||
} else {
|
||||
v.SplitRx = compileSplitRegex(k, v.Split)
|
||||
}
|
||||
}
|
||||
v.Aliases = aliases
|
||||
normalized[k.ToLower()] = v
|
||||
}
|
||||
}
|
||||
|
||||
func compileSplitRegex(tagName TagName, split []string) *regexp.Regexp {
|
||||
// Build a list of escaped, non-empty separators.
|
||||
var escaped []string
|
||||
for _, s := range split {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
escaped = append(escaped, regexp.QuoteMeta(s))
|
||||
}
|
||||
// If no valid separators remain, return the original value.
|
||||
if len(escaped) == 0 {
|
||||
if len(split) > 0 {
|
||||
log.Warn("No valid separators found in split list", "split", split, "tag", tagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create one regex that matches any of the separators (case-insensitive).
|
||||
pattern := "(?i)(" + strings.Join(escaped, "|") + ")"
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
log.Warn("Error compiling regexp for split list", "pattern", pattern, "tag", tagName, "split", split, err)
|
||||
return nil
|
||||
}
|
||||
return re
|
||||
}
|
||||
|
||||
func tagNames() []string {
|
||||
mappings := TagMappings()
|
||||
names := make([]string, 0, len(mappings))
|
||||
for k := range mappings {
|
||||
names = append(names, string(k))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func numericTagNames() []string {
|
||||
mappings := TagMappings()
|
||||
names := make([]string, 0)
|
||||
for k, cfg := range mappings {
|
||||
if cfg.Type == TagTypeInteger || cfg.Type == TagTypeFloat {
|
||||
names = append(names, string(k))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func loadTagMappings() {
|
||||
mappingsFile, err := resources.FS().Open("mappings.yaml")
|
||||
if err != nil {
|
||||
log.Error("Error opening mappings.yaml", err)
|
||||
}
|
||||
decoder := yaml.NewDecoder(mappingsFile)
|
||||
err = decoder.Decode(&_mappings)
|
||||
if err != nil {
|
||||
log.Error("Error decoding mappings.yaml", err)
|
||||
}
|
||||
if len(_mappings.Main) == 0 {
|
||||
log.Error("No tag mappings found in mappings.yaml, check the format")
|
||||
}
|
||||
|
||||
// Use Scanner.GenreSeparators if specified and Tags.genre is not defined
|
||||
if conf.Server.Scanner.GenreSeparators != "" && len(conf.Server.Tags["genre"].Aliases) == 0 {
|
||||
genreConf := _mappings.Main[TagName("genre")]
|
||||
genreConf.Split = strings.Split(conf.Server.Scanner.GenreSeparators, "")
|
||||
genreConf.SplitRx = compileSplitRegex("genre", genreConf.Split)
|
||||
_mappings.Main[TagName("genre")] = genreConf
|
||||
log.Debug("Loading deprecated list of genre separators", "separators", genreConf.Split)
|
||||
}
|
||||
|
||||
// Overwrite the default mappings with the ones from the config
|
||||
for tag, cfg := range conf.Server.Tags {
|
||||
if cfg.Ignore {
|
||||
delete(_mappings.Main, TagName(tag))
|
||||
delete(_mappings.Additional, TagName(tag))
|
||||
continue
|
||||
}
|
||||
oldValue, ok := _mappings.Main[TagName(tag)]
|
||||
if !ok {
|
||||
oldValue = _mappings.Additional[TagName(tag)]
|
||||
}
|
||||
aliases := cfg.Aliases
|
||||
if len(aliases) == 0 {
|
||||
aliases = oldValue.Aliases
|
||||
}
|
||||
split := cfg.Split
|
||||
if split == nil {
|
||||
split = oldValue.Split
|
||||
}
|
||||
c := TagConf{
|
||||
Aliases: aliases,
|
||||
Split: split,
|
||||
Type: cmp.Or(TagType(cfg.Type), oldValue.Type),
|
||||
MaxLength: cmp.Or(cfg.MaxLength, oldValue.MaxLength),
|
||||
Album: cmp.Or(cfg.Album, oldValue.Album),
|
||||
}
|
||||
c.SplitRx = compileSplitRegex(TagName(tag), c.Split)
|
||||
if _, ok := _mappings.Main[TagName(tag)]; ok {
|
||||
_mappings.Main[TagName(tag)] = c
|
||||
} else {
|
||||
_mappings.Additional[TagName(tag)] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
loadTagMappings()
|
||||
|
||||
// This is here to avoid cyclic imports. The criteria package needs to know all tag names, so they can be
|
||||
// used in smart playlists
|
||||
criteria.AddRoles(slices.Collect(maps.Keys(AllRoles)))
|
||||
criteria.AddTagNames(tagNames())
|
||||
criteria.AddNumericTags(numericTagNames())
|
||||
})
|
||||
}
|
||||
120
model/tag_test.go
Normal file
120
model/tag_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Tag", func() {
|
||||
Describe("NewTag", func() {
|
||||
It("should create a new tag", func() {
|
||||
tag := NewTag("genre", "Rock")
|
||||
tag2 := NewTag("Genre", "Rock")
|
||||
tag3 := NewTag("Genre", "rock")
|
||||
Expect(tag2.ID).To(Equal(tag.ID))
|
||||
Expect(tag3.ID).To(Equal(tag.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Tags", func() {
|
||||
var tags Tags
|
||||
BeforeEach(func() {
|
||||
tags = Tags{
|
||||
"genre": {"Rock", "Pop"},
|
||||
"artist": {"The Beatles"},
|
||||
}
|
||||
})
|
||||
It("should flatten tags by name", func() {
|
||||
flat := tags.Flatten("genre")
|
||||
Expect(flat).To(ConsistOf(
|
||||
NewTag("genre", "Rock"),
|
||||
NewTag("genre", "Pop"),
|
||||
))
|
||||
})
|
||||
It("should flatten tags", func() {
|
||||
flat := tags.FlattenAll()
|
||||
Expect(flat).To(ConsistOf(
|
||||
NewTag("genre", "Rock"),
|
||||
NewTag("genre", "Pop"),
|
||||
NewTag("artist", "The Beatles"),
|
||||
))
|
||||
})
|
||||
It("should get values by name", func() {
|
||||
Expect(tags.Values("genre")).To(ConsistOf("Rock", "Pop"))
|
||||
Expect(tags.Values("artist")).To(ConsistOf("The Beatles"))
|
||||
})
|
||||
|
||||
Describe("Hash", func() {
|
||||
It("should always return the same value for the same tags ", func() {
|
||||
tags1 := Tags{
|
||||
"genre": {"Rock", "Pop"},
|
||||
}
|
||||
tags2 := Tags{
|
||||
"Genre": {"pop", "rock"},
|
||||
}
|
||||
Expect(tags1.Hash()).To(Equal(tags2.Hash()))
|
||||
})
|
||||
It("should return different values for different tags", func() {
|
||||
tags1 := Tags{
|
||||
"genre": {"Rock", "Pop"},
|
||||
}
|
||||
tags2 := Tags{
|
||||
"artist": {"The Beatles"},
|
||||
}
|
||||
Expect(tags1.Hash()).ToNot(Equal(tags2.Hash()))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TagList", func() {
|
||||
Describe("GroupByFrequency", func() {
|
||||
It("should return an empty Tags map for an empty TagList", func() {
|
||||
tagList := TagList{}
|
||||
|
||||
groupedTags := tagList.GroupByFrequency()
|
||||
|
||||
Expect(groupedTags).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should handle tags with different frequencies correctly", func() {
|
||||
tagList := TagList{
|
||||
NewTag("genre", "Jazz"),
|
||||
NewTag("genre", "Rock"),
|
||||
NewTag("genre", "Pop"),
|
||||
NewTag("genre", "Rock"),
|
||||
NewTag("artist", "The Rolling Stones"),
|
||||
NewTag("artist", "The Beatles"),
|
||||
NewTag("artist", "The Beatles"),
|
||||
}
|
||||
|
||||
groupedTags := tagList.GroupByFrequency()
|
||||
|
||||
Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Rock", "Jazz", "Pop"}))
|
||||
Expect(groupedTags).To(HaveKeyWithValue(TagName("artist"), []string{"The Beatles", "The Rolling Stones"}))
|
||||
})
|
||||
|
||||
It("should sort tags by name when frequency is the same", func() {
|
||||
tagList := TagList{
|
||||
NewTag("genre", "Jazz"),
|
||||
NewTag("genre", "Rock"),
|
||||
NewTag("genre", "Alternative"),
|
||||
NewTag("genre", "Pop"),
|
||||
}
|
||||
|
||||
groupedTags := tagList.GroupByFrequency()
|
||||
|
||||
Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Alternative", "Jazz", "Pop", "Rock"}))
|
||||
})
|
||||
It("should normalize casing", func() {
|
||||
tagList := TagList{
|
||||
NewTag("genre", "Synthwave"),
|
||||
NewTag("genre", "synthwave"),
|
||||
}
|
||||
|
||||
groupedTags := tagList.GroupByFrequency()
|
||||
|
||||
Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"synthwave"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
18
model/transcoding.go
Normal file
18
model/transcoding.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
type Transcoding struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
TargetFormat string `structs:"target_format" json:"targetFormat"`
|
||||
Command string `structs:"command" json:"command"`
|
||||
DefaultBitRate int `structs:"default_bit_rate" json:"defaultBitRate"`
|
||||
}
|
||||
|
||||
type Transcodings []Transcoding
|
||||
|
||||
type TranscodingRepository interface {
|
||||
Get(id string) (*Transcoding, error)
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Put(*Transcoding) error
|
||||
FindByFormat(format string) (*Transcoding, error)
|
||||
}
|
||||
61
model/user.go
Normal file
61
model/user.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
UserName string `structs:"user_name" json:"userName"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Email string `structs:"email" json:"email"`
|
||||
IsAdmin bool `structs:"is_admin" json:"isAdmin"`
|
||||
LastLoginAt *time.Time `structs:"last_login_at" json:"lastLoginAt"`
|
||||
LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
|
||||
// Library associations (many-to-many relationship)
|
||||
Libraries Libraries `structs:"-" json:"libraries,omitempty"`
|
||||
|
||||
// This is only available on the backend, and it is never sent over the wire
|
||||
Password string `structs:"-" json:"-"`
|
||||
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
|
||||
// It is received from the UI with the name "password"
|
||||
NewPassword string `structs:"password,omitempty" json:"password,omitempty"`
|
||||
// If changing the password, this is also required
|
||||
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
|
||||
}
|
||||
|
||||
func (u User) HasLibraryAccess(libraryID int) bool {
|
||||
if u.IsAdmin {
|
||||
return true // Admin users have access to all libraries
|
||||
}
|
||||
for _, lib := range u.Libraries {
|
||||
if lib.ID == libraryID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Users []User
|
||||
|
||||
type UserRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Delete(id string) error
|
||||
Get(id string) (*User, error)
|
||||
Put(*User) error
|
||||
UpdateLastLoginAt(id string) error
|
||||
UpdateLastAccessAt(id string) error
|
||||
FindFirstAdmin() (*User, error)
|
||||
// FindByUsername must be case-insensitive
|
||||
FindByUsername(username string) (*User, error)
|
||||
// FindByUsernameWithPassword is the same as above, but also returns the decrypted password
|
||||
FindByUsernameWithPassword(username string) (*User, error)
|
||||
|
||||
// Library association methods
|
||||
GetUserLibraries(userID string) (Libraries, error)
|
||||
SetUserLibraries(userID string, libraryIDs []int) error
|
||||
}
|
||||
8
model/user_props.go
Normal file
8
model/user_props.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package model
|
||||
|
||||
type UserPropsRepository interface {
|
||||
Put(userId, key string, value string) error
|
||||
Get(userId, key string) (string, error)
|
||||
Delete(userId, key string) error
|
||||
DefaultGet(userId, key string, defaultValue string) (string, error)
|
||||
}
|
||||
83
model/user_test.go
Normal file
83
model/user_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("User", func() {
|
||||
var user model.User
|
||||
var libraries model.Libraries
|
||||
|
||||
BeforeEach(func() {
|
||||
libraries = model.Libraries{
|
||||
{ID: 1, Name: "Rock Library", Path: "/music/rock"},
|
||||
{ID: 2, Name: "Jazz Library", Path: "/music/jazz"},
|
||||
{ID: 3, Name: "Classical Library", Path: "/music/classical"},
|
||||
}
|
||||
|
||||
user = model.User{
|
||||
ID: "user1",
|
||||
UserName: "testuser",
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
IsAdmin: false,
|
||||
Libraries: libraries,
|
||||
}
|
||||
})
|
||||
|
||||
Describe("HasLibraryAccess", func() {
|
||||
Context("when user is admin", func() {
|
||||
BeforeEach(func() {
|
||||
user.IsAdmin = true
|
||||
})
|
||||
|
||||
It("returns true for any library ID", func() {
|
||||
Expect(user.HasLibraryAccess(1)).To(BeTrue())
|
||||
Expect(user.HasLibraryAccess(99)).To(BeTrue())
|
||||
Expect(user.HasLibraryAccess(-1)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true even when user has no libraries assigned", func() {
|
||||
user.Libraries = nil
|
||||
Expect(user.HasLibraryAccess(1)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when user is not admin", func() {
|
||||
BeforeEach(func() {
|
||||
user.IsAdmin = false
|
||||
})
|
||||
|
||||
It("returns true for libraries the user has access to", func() {
|
||||
Expect(user.HasLibraryAccess(1)).To(BeTrue())
|
||||
Expect(user.HasLibraryAccess(2)).To(BeTrue())
|
||||
Expect(user.HasLibraryAccess(3)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for libraries the user does not have access to", func() {
|
||||
Expect(user.HasLibraryAccess(4)).To(BeFalse())
|
||||
Expect(user.HasLibraryAccess(99)).To(BeFalse())
|
||||
Expect(user.HasLibraryAccess(-1)).To(BeFalse())
|
||||
Expect(user.HasLibraryAccess(0)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when user has no libraries assigned", func() {
|
||||
user.Libraries = nil
|
||||
Expect(user.HasLibraryAccess(1)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("handles duplicate library IDs correctly", func() {
|
||||
user.Libraries = model.Libraries{
|
||||
{ID: 1, Name: "Library 1", Path: "/music1"},
|
||||
{ID: 1, Name: "Library 1 Duplicate", Path: "/music1-dup"},
|
||||
{ID: 2, Name: "Library 2", Path: "/music2"},
|
||||
}
|
||||
Expect(user.HasLibraryAccess(1)).To(BeTrue())
|
||||
Expect(user.HasLibraryAccess(2)).To(BeTrue())
|
||||
Expect(user.HasLibraryAccess(3)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user