mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-30 15:07:56 +01:00
Most albums embed the same picture in every track, so clients requesting per-track cover art (mf- ids) download and cache the same image once per song. This change hashes the embedded picture at scan time (XXH3 of the bytes, the same digest the artwork pipeline uses) and stores it on media_file.embed_art_hash. ToAlbum stores the hash of the track it already picks as the album's embedded cover (EmbedArtPath) on album.embed_art_hash. A new MediaFile.HasOwnCoverArt replaces the scattered "HasCoverArt && EnableMediaFileCoverArt" checks: a track whose hash equals its album's now returns the disc/album artwork id from CoverArtID, so the Subsonic, Jellyfin and native responses point every matching song at one cached image. Serving a stale mf- URL follows the same rule, so what is advertised and what is served agree. Tracks with a different embedded picture (compilations, per-track art) keep their own ids. Rows scanned before this change have an empty hash and keep the previous behavior until a full scan; no config option is added. The album hash is hydrated onto tracks with one batched PK lookup per page, only when the page has hashed tracks. BestImageIndex is exported from core/artwork so the scanner hashes the same picture the resolver serves.
582 lines
21 KiB
Go
582 lines
21 KiB
Go
package model
|
|
|
|
import (
|
|
"cmp"
|
|
"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/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 is the parent album's artwork state, hydrated alongside the track's own so a
|
|
// song's Jellyfin album-art tag can be pixel-versioned without a second query.
|
|
AlbumImage ItemImage `structs:"-" json:"-" hash:"ignore"`
|
|
// AlbumEmbedArtHash is the album's embedded-cover hash, hydrated so HasOwnCoverArt can compare.
|
|
AlbumEmbedArtHash string `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"` // 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" hash:"ignore"`
|
|
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
|
EmbedArtHash string `structs:"embed_art_hash" json:"-"` // XXH3 of the embedded picture bytes
|
|
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"` // 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 && len(mf.Tags[TagSubtitle]) > 0 {
|
|
return appendSuffix(mf.Title, mf.Tags[TagSubtitle][0])
|
|
}
|
|
return mf.Title
|
|
}
|
|
|
|
func (mf MediaFile) FullAlbumName() string {
|
|
if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 {
|
|
return appendSuffix(mf.Album, mf.Tags[TagAlbumVersion][0])
|
|
}
|
|
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)
|
|
}
|
|
|
|
// HasOwnCoverArt reports whether the track serves its own embedded art. A picture identical to the
|
|
// album's embedded cover defers to the album id instead, so clients cache one image per album.
|
|
func (mf MediaFile) HasOwnCoverArt() bool {
|
|
if !mf.HasCoverArt || !conf.Server.EnableMediaFileCoverArt {
|
|
return false
|
|
}
|
|
return mf.EmbedArtHash == "" || mf.EmbedArtHash != mf.AlbumEmbedArtHash
|
|
}
|
|
|
|
func (mf MediaFile) CoverArtID() ArtworkID {
|
|
if mf.HasOwnCoverArt() {
|
|
return artworkIDFromMediaFile(mf)
|
|
}
|
|
return mf.DiscCoverArtID()
|
|
}
|
|
|
|
// DiscCoverArtID returns the disc artwork ID when the media file has a disc number,
|
|
// otherwise it returns the album artwork ID.
|
|
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()
|
|
}
|
|
|
|
// AlbumCoverArtID uses AlbumImage, not the track's own ItemImage: an album id must carry the
|
|
// album's content hash even when the track resolved art of its own.
|
|
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
|
|
}
|
|
|
|
// HasEmbeddedLyrics reports whether the lyrics column holds any lyrics. It is never "" post-scan;
|
|
// no-lyrics is normalized to the "[]" sentinel, so string emptiness alone is meaningless.
|
|
func (mf MediaFile) HasEmbeddedLyrics() bool {
|
|
return mf.Lyrics != "" && mf.Lyrics != "[]"
|
|
}
|
|
|
|
// String is mainly used for debugging
|
|
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
|
|
}
|
|
|
|
// 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 := 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))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// AudioCodec returns the audio codec for this file.
|
|
// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
|
|
func (mf MediaFile) AudioCodec() string {
|
|
// If we have a stored codec from scanning, normalize and return it
|
|
if mf.Codec != "" {
|
|
return strings.ToLower(mf.Codec)
|
|
}
|
|
// Fallback: infer from Suffix + BitDepth
|
|
return mf.inferCodecFromSuffix()
|
|
}
|
|
|
|
// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
|
|
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":
|
|
// AAC if BitDepth==0, ALAC if BitDepth>0
|
|
if gg.V(mf.BitDepth) > 0 {
|
|
return "alac"
|
|
}
|
|
return "aac"
|
|
case "m4b", "m4p", "m4r":
|
|
return "aac"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
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))
|
|
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
|
|
var embedArt *MediaFile
|
|
for i := range mfs {
|
|
m := &mfs[i]
|
|
// 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)
|
|
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)
|
|
|
|
embedArt = firstArtTrack(embedArt, 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
|
|
}
|
|
|
|
if embedArt != nil {
|
|
a.EmbedArtPath = embedArt.Path
|
|
a.EmbedArtHash = embedArt.EmbedArtHash
|
|
}
|
|
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
|
|
}
|
|
|
|
// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if
|
|
// none. It counts by dereferenced value so a genuine 0.0 is a real candidate
|
|
// (slice.MostFrequent skips the zero value and compares pointers by identity).
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// firstArtTrack picks the media file whose embedded picture serves as the album cover, preferring
|
|
// lower disc numbers and then path order for consistency.
|
|
func firstArtTrack(current, m *MediaFile) *MediaFile {
|
|
if !m.HasCoverArt {
|
|
return current
|
|
}
|
|
if current == nil || current.DiscNumber == 0 || (m.DiscNumber < current.DiscNumber && m.DiscNumber > 0) {
|
|
return m
|
|
}
|
|
if m.DiscNumber == current.DiscNumber && m.Path < current.Path {
|
|
return m
|
|
}
|
|
return current
|
|
}
|
|
|
|
// 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)
|
|
CountBySuffix(options ...QueryOptions) (map[string]int64, error)
|
|
Exists(id string) (bool, error)
|
|
Put(m *MediaFile) error
|
|
UpdateProbeData(id string, data string) error
|
|
Get(id string) (*MediaFile, error)
|
|
GetWithParticipants(id string) (*MediaFile, error)
|
|
GetAll(options ...QueryOptions) (MediaFiles, error)
|
|
// GetRandom returns up to options.Max media files in random order, applying the same
|
|
// filters as GetAll. Sort/Order are ignored.
|
|
GetRandom(options ...QueryOptions) (MediaFiles, error)
|
|
GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
|
|
// MatchesCriteria reports whether the media file matches the criteria's rule
|
|
// expression, using the logged user's annotations. Limit and offset are ignored.
|
|
MatchesCriteria(id string, c criteria.Criteria) (bool, error)
|
|
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
|
|
// GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given
|
|
// folders or their direct children.
|
|
GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error)
|
|
// GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't
|
|
// pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors.
|
|
GetCursorWithArtwork(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]
|
|
}
|