package model
import (
"cmp"
"context"
"encoding/json"
"fmt"
"iter"
"mime"
"path/filepath"
"slices"
"strings"
"time"
"github.com/deluan/rest"
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/slice"
"github.com/zeebo/xxh3"
)
type MediaFile struct {
Annotations `structs:"-" hash:"ignore"`
Bookmarkable `structs:"-" hash:"ignore"`
ItemImage `structs:"-" hash:"ignore"`
AlbumImage ItemImage `structs:"-" json:"-" 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"`
Artist string `structs:"artist" json:"artist"`
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"`
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
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,omitempty"`
Channels int `structs:"channels" json:"channels"`
Codec string `structs:"codec" json:"codec"`
ProbeData string `structs:"probe_data" json:"-" hash:"ignore"`
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"`
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"`
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"`
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
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"`
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"`
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"`
Participants Participants `structs:"participants" json:"participants" hash:"ignore"`
Missing bool `structs:"missing" json:"missing" hash:"ignore"`
BirthTime time.Time `structs:"birth_time" json:"birthTime" hash:"ignore"`
CreatedAt time.Time `structs:"created_at" json:"createdAt" hash:"ignore"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt" hash:"ignore"`
}
func (mf MediaFile) FullTitle() string {
if s := mf.Tags.First(TagSubtitle); conf.Server.Subsonic.AppendSubtitle && s != "" {
return appendSuffix(mf.Title, s)
}
return mf.Title
}
func (mf MediaFile) FullAlbumName() string {
if v := mf.Tags.First(TagAlbumVersion); conf.Server.Subsonic.AppendAlbumVersion && v != "" {
return appendSuffix(mf.Album, v)
}
return mf.Album
}
var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'}
func appendSuffix(base, suffix string) string {
suffix = strings.TrimSpace(suffix)
if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] {
return base + " " + suffix
}
return base + " (" + suffix + ")"
}
func (mf MediaFile) ContentType() string {
return mime.TypeByExtension("." + mf.Suffix)
}
func (mf MediaFile) CoverArtID() ArtworkID {
if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt {
return artworkIDFromMediaFile(mf)
}
return mf.DiscCoverArtID()
}
func (mf MediaFile) DiscCoverArtID() ArtworkID {
if mf.DiscNumber > 0 {
return ArtworkID{Kind: KindDiscArtwork, ID: DiscArtworkID(mf.AlbumID, mf.DiscNumber), Hash: mf.ImageHash}
}
return mf.AlbumCoverArtID()
}
func (mf MediaFile) AlbumCoverArtID() ArtworkID {
return artworkIDFromAlbum(Album{ID: mf.AlbumID, ItemImage: mf.AlbumImage})
}
func (mf MediaFile) StructuredLyrics() (LyricList, error) {
var lyrics LyricList
err := json.Unmarshal([]byte(mf.Lyrics), &lyrics)
if err != nil {
return nil, err
}
return lyrics, nil
}
func (mf MediaFile) HasEmbeddedLyrics() bool {
return mf.Lyrics != "" && mf.Lyrics != "[]"
}
func (mf MediaFile) String() string {
return mf.Path
}
type Work struct {
Name string
MbzWorkID string
}
type Movement struct {
Name string
Number int32
Count int32
}
func (mf MediaFile) Works() []Work {
names := mf.Tags.Values(TagWork)
if len(names) == 0 {
return nil
}
ids := mf.Tags.Values(TagMusicBrainzWorkID)
works := make([]Work, 0, len(names))
for i, name := range names {
w := Work{Name: name}
if i < len(ids) {
w.MbzWorkID = ids[i]
}
works = append(works, w)
}
return works
}
func (mf MediaFile) Movements() []Movement {
names := mf.Tags.Values(TagMovementName)
if len(names) == 0 {
return nil
}
numbers := mf.Tags.Values(TagMovementNumber)
counts := mf.Tags.Values(TagMovementTotal)
movements := make([]Movement, 0, len(names))
for i, name := range names {
m := Movement{Name: name}
if i < len(numbers) {
m.Number = number.ParseInt[int32](numbers[i])
}
if i < len(counts) {
m.Count = number.ParseInt[int32](counts[i])
}
movements = append(movements, m)
}
return movements
}
func (mf MediaFile) Hash() string {
opts := &hashstructure.HashOptions{
IgnoreZeroValue: true,
ZeroNil: true,
}
hash, _ := hashstructure.Hash(mf, opts)
sum := xxh3.New()
sum.Write(fmt.Appendf(nil, "%d", hash))
sum.Write(mf.Tags.Hash())
sum.Write(mf.Participants.Hash())
return fmt.Sprintf("%x", sum.Sum(nil))
}
func (mf MediaFile) Equals(other MediaFile) bool {
return mf.Hash() == other.Hash()
}
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)
}
func (mf MediaFile) AudioCodec() string {
if mf.Codec != "" {
return strings.ToLower(mf.Codec)
}
return mf.inferCodecFromSuffix()
}
func (mf MediaFile) inferCodecFromSuffix() string {
switch strings.ToLower(mf.Suffix) {
case "mp3", "mpga":
return "mp3"
case "mp2":
return "mp2"
case "ogg", "oga":
return "vorbis"
case "opus":
return "opus"
case "mpc":
return "mpc"
case "wma":
return "wma"
case "flac":
return "flac"
case "wav":
return "pcm"
case "aif", "aiff", "aifc":
return "pcm"
case "ape":
return "ape"
case "wv", "wvp":
return "wv"
case "tta":
return "tta"
case "tak":
return "tak"
case "shn":
return "shn"
case "dsf", "dff":
return "dsd"
case "m4a":
if gg.V(mf.BitDepth) > 0 {
return "alac"
}
return "aac"
case "m4b", "m4p", "m4r":
return "aac"
default:
return ""
}
}
type MediaFiles []MediaFile
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: ""}}
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))
rgAlbumGains := make([]*float64, 0, len(mfs))
rgAlbumPeaks := make([]*float64, 0, len(mfs))
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
a.Missing = true
embedArtPath := ""
embedArtDisc := 0
for _, m := range mfs {
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
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)
rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain)
rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak)
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)
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)
a.RGAlbumGain = mostFrequentPtr(rgAlbumGains)
a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks)
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 mostFrequentPtr(items []*float64) *float64 {
var counts map[float64]int
var best float64
var bestCount int
for _, it := range items {
if it == nil {
continue
}
if counts == nil {
counts = map[float64]int{}
}
counts[*it]++
if counts[*it] > bestCount {
bestCount = counts[*it]
best = *it
}
}
if bestCount == 0 {
return nil
}
return &best
}
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 t2.IsZero() {
return t1
}
if t1.After(t2) {
return t2
}
return t1
}
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
}
}
func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int) {
if !m.HasCoverArt {
return currentPath, currentDisc
}
if currentDisc == 0 || (m.DiscNumber < currentDisc && m.DiscNumber > 0) {
return m.Path, m.DiscNumber
}
if m.DiscNumber == currentDisc {
if m.Path < currentPath || currentPath == "" {
return m.Path, m.DiscNumber
}
}
return currentPath, currentDisc
}
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 {
rest.Repository[MediaFile]
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
CountBySuffix(ctx context.Context, options ...QueryOptions) (map[string]int64, error)
Exists(ctx context.Context, id string) (bool, error)
Put(ctx context.Context, m *MediaFile) error
UpdateProbeData(ctx context.Context, id string, data string) error
Get(ctx context.Context, id string) (*MediaFile, error)
GetWithParticipants(ctx context.Context, id string) (*MediaFile, error)
GetAll(ctx context.Context, options ...QueryOptions) (MediaFiles, error)
GetRandom(ctx context.Context, options ...QueryOptions) (MediaFiles, error)
GetAllByTags(ctx context.Context, tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
MatchesCriteria(ctx context.Context, id string, c criteria.Criteria) (bool, error)
GetCursor(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error)
GetAlbumIDsByFolder(ctx context.Context, lib Library, folderIDs ...string) ([]string, error)
GetCursorWithArtwork(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error)
Delete(ctx context.Context, id string) error
DeleteMissing(ctx context.Context, ids []string) error
DeleteAllMissing(ctx context.Context) (int64, error)
FindByPaths(ctx context.Context, paths []string) (MediaFiles, error)
ReassignReferences(ctx context.Context, prevID, newID string) error
MarkMissing(ctx context.Context, missing bool, mfs ...*MediaFile) error
MarkMissingByFolder(ctx context.Context, missing bool, folderIDs ...string) error
GetMissingAndMatching(ctx context.Context, libId int) (MediaFileCursor, error)
FindRecentFilesByMBZTrackID(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error)
FindRecentFilesByProperties(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error)
AnnotatedRepository
BookmarkableRepository
SearchableRepository[MediaFiles]
}