navidrome/model/mediafile.go
ts c6732e1fdf
feat(cli): add missing file list and remap subcommands (#5928)
* feat(cli): add missing file list and remap subcommands

Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com>

* fix: prevent remapping from dropping participants on target track

* fix: after remapping, refresh stats synchronously

* fix: only move album annotations if moving a track would empty the old album

* fix(persistence): keep the new item's annotation when reassigning onto an item the user already annotated

ReassignAnnotation was a plain UPDATE; the annotation table is unique on
(user_id, item_id, item_type), so when a user had annotated both items the
statement aborted and none of the rows moved. In the scanner that surfaced as
a warning; in the missing-file remap it rolled back the whole operation.
UPDATE OR IGNORE moves what it can and leaves the conflicting rows for GC.

* fix(core): keep the target track's history when remapping a missing file onto it

The remap discards the target's row, and GC then dropped its play counts,
stars, ratings, bookmarks and every playlist entry pointing at it. That is
harmless in the scanner, whose target was imported seconds earlier, but the
CLI lets the user pick any existing track. Move those references onto the
surviving id first; where a user already has a row for both, theirs on the
missing file wins.

* fix(persistence): stop FindByPaths dropping plain paths that contain a colon

Any colon was taken as the libraryID separator, and a non-numeric prefix
made the whole path vanish from the lookup. 'missing fix' then rejected the
very paths 'missing list' printed, and M3U imports silently skipped such
tracks. Only a numeric prefix qualifies a path now.

* perf(cli): stream 'missing list' instead of loading every missing file into memory

GetAll materialised the whole result set before a single row was written;
on a library with 97k missing files that peaked at 1.28 GB of RSS. Iterate
the repository cursor and write rows as they arrive.

* refactor(core): tidy the missing-file remap

Drop the log lines copied from deleteMissing that still said 'after deleting
missing files', the debug-on-success branches, and the what-comments; build
the affected album list without slice helpers.

* fix(cli): move path to the last column of 'missing list'

Path is the only variable-width field, so leading with it misaligns every
row that follows. Applies to both csv and json.

* fix(persistence): also try a numeric colon prefix as a plain path

'1999: A Different Life/01.mp3' parsed as library 1999 plus a truncated path
and matched nothing. The prefix is ambiguous, so search both ways.

Also buffer the json branch of 'missing list', which wrote a syscall per row.

* fix(persistence): move scrobbles and buffered scrobbles off a discarded media file

Both tables carry ON DELETE CASCADE on media_file_id, so 'missing fix'
deleting the target erased its play history and dropped scrobbles still
waiting on an external service. scrobble_buffer needs OR IGNORE for its
unique (user_id, service, media_file_id, play_time).

* fix(persistence): recompute the cached average rating after merging annotations

Merging the discarded row's annotations grows the rating population of the
surviving track, so media_file.average_rating no longer matched what the
annotation rows say. Only reachable since the remap started merging those
rows instead of deleting them.

* fix(persistence): recompute the cached average rating inside ReassignAnnotation

Moving annotation rows always changes the new item's rating population, so
the recompute belongs with the move rather than at each call site. Covers
the album reassign in the remap and the two scanner sites, and replaces the
explicit call ReassignReferences was making.

Album was the worse case: rate an album, move its files, and 'missing fix'
handed the rating to an album still caching an average of 0.

* fix(cli): let libraryID:path win over a file literally named like one

FindByPaths searches a numeric-prefixed reference both ways, so a top-level
file named '1:foo.mp3' can tie with library 1's 'foo.mp3'. The CLI then
rejected the reference as ambiguous while advising the exact syntax the
caller had used. Also disambiguates the same path in two libraries, which
is what the qualified form is for.

---------

Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-12 12:08:25 -04:00

580 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"`
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"`
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)
}
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)
}
// Otherwise fallback to disc (if available) or album cover
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
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)
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)
// 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)
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
}
}
// 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)
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)
// ReassignReferences moves annotations, bookmarks and playlist entries from prevID to newID,
// keeping newID's own row wherever a user has both.
ReassignReferences(prevID, newID string) 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]
}