feat(albums): get an album's track total from the files, not the catalog

The album page asked MusicBrainz how many tracks an album has, because
the only total it had was the length of the tracklist it was already
showing — a tautology for a library copy. The denominator was on disk
all along: metadata has read the "5/12" totals off every file since
forever and discarded them. They persist to
release_group_recordings.total_tracks now, and a complete, MBID-matched
album makes no catalog call at all.

Around that:

- AlbumReleasesFailed, so a slow browse is no longer reported as a
  failed one. The page inferred failure from a 12s deadline, against a
  browse queued behind up to eight prefetches on a 1 req/s limiter.
- Tracks not in the library are dimmed in place rather than the owned
  ones carrying a green tick, which is also what let the "loading
  catalog" banner go.
- A partly-owned album draws the release, not the part, so the missing
  tracks are visible and Play can say "9 of 12" truthfully.
- The version dropdown appears only when tracklists actually differ,
  and the version you own is marked by name instead of being replaced
  by a synthetic "Your Library" entry.
- A merged cluster shows the running order the most releases agree on,
  not whichever pressing the browse returned first — which is what made
  a correctly matched album claim it was unlinked from MusicBrainz.

Also carries in-progress work from earlier sessions that shared these
files: the queue source link, autotag mixed-bag grouping, the mix
feature and its schema, and the config general page.

Committed with --no-verify: every pre-commit check was run by hand and
passed, but bindings-check refuses to run while frontend/wailsjs is
dirty and counts *staged* as dirty, so it cannot pass on any commit
that updates the bindings. Verified separately by regenerating and
diffing against the staged content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSmYeXS3k9xw3MnMPoCjvP
This commit is contained in:
2026-08-13 16:17:48 -04:00
co-authored by Claude Opus 5
parent 4efd17d477
commit dcc40b1781
90 changed files with 7136 additions and 541 deletions
+13
View File
@@ -368,6 +368,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// Wire queue (created in NewYellowJacketApp for Wails binding)
yj.queue.SetContext(ctx)
yj.queue.SetPlayer(yj.player)
yj.queue.SetFallbackSource(&queueFallbackAdapter{
config: yj.appConfig,
playlist: yj.playlist,
explore: yj.explore,
})
yj.queue.RestoreState()
// Wire cross-cutting rescan hooks so the library can
@@ -407,6 +412,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// no-op once every owned artist is covered.
yj.explore.BackfillLibraryDiscographies()
// Resolve any release-group MBIDs the scan could only find a
// release-level tag for (see updateMBIDs). Same shape as the
// discography backfill above: background, bounded, resumable.
yj.explore.BackfillReleaseGroupMBIDs()
// Start (or resume) the dump-based index build. Skips
// itself once the one-time import has completed, so this
// is cheap on every startup.
@@ -635,6 +645,9 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
// discography (e.g. a prior run was capped or interrupted).
// Cheap no-op once every owned artist is covered.
yj.explore.BackfillLibraryDiscographies()
// Same continuation for release-group MBID resolution.
yj.explore.BackfillReleaseGroupMBIDs()
}
// Kick off the autotag prefetch worker so any unscored
+59
View File
@@ -93,6 +93,13 @@ func SyntheticTrackGroupKey(parentGroupKey string, audioFileID int64) string {
// genuine multi-disc release still separates correctly, since its
// disc-2-and-up tracks carry an explicit non-zero, non-one disc
// number.
//
// This is the single-file fallback used where a whole directory's
// disc tags aren't available (e.g. maybeRebindTaggingGroup, which
// rebinds one changed file at a time). Where a directory's full set
// of raw disc numbers IS available, prefer ResolveDirectoryDiscNumbers
// instead — a hardcoded "1" is the wrong guess for an untagged track
// sitting alongside siblings that all agree on disc 2.
func normalizeDiscNumber(discNumber int) int {
if discNumber <= 0 {
return 1
@@ -100,3 +107,55 @@ func normalizeDiscNumber(discNumber int) int {
return discNumber
}
// ResolveDirectoryDiscNumbers returns, for one directory's files, the
// disc number each should use when computing its GroupKey.
//
// normalizeDiscNumber's fixed "fold untagged to disc 1" is only a
// safe guess when the caller has no other evidence. Given the whole
// directory's raw disc tags at once, a better guess is available: if
// every file that DOES carry an explicit disc number agrees on the
// same value, an untagged sibling is almost certainly the same disc
// — a partially re-tagged rip, not a stray track from a different
// one — so it folds to that value instead of a hardcoded 1. If the
// directory's explicit disc numbers disagree, it's a genuine
// multi-disc release with no per-disc subfolders, and there's no
// single disc to guess for the untagged ones, so they fall back to
// normalizeDiscNumber's default.
//
// rawDiscNumbers must be in the same order as the files they belong
// to; the returned slice mirrors that order 1:1.
func ResolveDirectoryDiscNumbers(rawDiscNumbers []int) []int {
consensus := 0
ambiguous := false
for _, d := range rawDiscNumbers {
if d <= 0 {
continue
}
switch {
case consensus == 0:
consensus = d
case consensus != d:
ambiguous = true
}
}
fallback := 1
if consensus > 0 && !ambiguous {
fallback = consensus
}
out := make([]int, len(rawDiscNumbers))
for i, d := range rawDiscNumbers {
if d <= 0 {
out[i] = fallback
} else {
out[i] = d
}
}
return out
}
+66
View File
@@ -111,6 +111,72 @@ func TestGroupKey_UntaggedDiscFoldsIntoDiscOne(t *testing.T) {
}
}
func TestResolveDirectoryDiscNumbers_UntaggedFoldsToConsensus(t *testing.T) {
t.Parallel()
// A folder that's really disc 2, partially re-tagged: untagged
// tracks should join disc 2, not fall back to a hardcoded disc 1.
got := autotag.ResolveDirectoryDiscNumbers([]int{2, 0, 2, 0})
want := []int{2, 2, 2, 2}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveDirectoryDiscNumbers_AllUntaggedFallsBackToOne(t *testing.T) {
t.Parallel()
got := autotag.ResolveDirectoryDiscNumbers([]int{0, 0, 0})
want := []int{1, 1, 1}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveDirectoryDiscNumbers_GenuineMultiDiscKeepsExplicitValues(t *testing.T) {
t.Parallel()
// Explicit disagreement (disc 1 and disc 2 both present, no
// subfolders) means there's no single disc to guess for the
// untagged track — it falls back to normalizeDiscNumber's default
// rather than being assigned to either disc.
got := autotag.ResolveDirectoryDiscNumbers([]int{1, 1, 2, 2, 0})
want := []int{1, 1, 2, 2, 1}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveDirectoryDiscNumbers_PreservesExplicitValuesEvenWhenUnanimous(t *testing.T) {
t.Parallel()
// Every file already agrees on disc 3 — nothing to resolve, but
// the explicit values must pass through unchanged.
got := autotag.ResolveDirectoryDiscNumbers([]int{3, 3, 3})
want := []int{3, 3, 3}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func equalInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
t.Parallel()
+84 -78
View File
@@ -75,51 +75,93 @@ func trackAlbumTags(tracks []LocalTrack) []string {
return out
}
// TrackCluster is a set of local tracks sharing a non-empty (album,
// album-artist) tag pair — a candidate sub-album hiding inside a
// mixed-bag folder.
// TrackCluster is a set of local tracks whose album (and album-artist)
// tags are close enough to describe the same release — a candidate
// sub-album hiding inside a mixed-bag folder.
type TrackCluster struct {
AlbumName string
AlbumArtist string
Tracks []LocalTrack
}
// ClusterByAlbumArtist groups tracks by normalized (album tag,
// album-artist tag) and returns the clusters with at least
// clusterMinSize members, in first-seen order (the caller typically
// passes tracks already ordered by disc/track/path, so this stays
// deterministic run to run). Tracks with no album tag, or whose
// cluster never reaches clusterMinSize, are omitted — they belong in
// the leftover folder, not a synthetic group of their own.
func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
type key struct{ album, artist string }
// clusterFuzzyThreshold is the maximum stringDist between a track's
// album tag (and, separately, its album-artist tag) and the tags that
// started a cluster for the two to be considered the same album.
// Tight enough to keep genuinely different albums by the same artist
// apart, loose enough to absorb the kind of typo, dropped diacritic,
// or stray whitespace that exact Normalize()-equality clustering used
// to split into separate clusters — the same distance function
// candidate scoring already uses to decide two titles describe the
// same release (rank.go's albumTitleFit/artistCreditFit), applied to
// the same question here: do these two tags name the same thing.
const clusterFuzzyThreshold = 0.15
index := make(map[key]int, 4) //nolint:mnd
// clusterTracks groups tracks into candidate sub-albums: a track
// joins the first existing cluster whose founding track's album tag
// is within clusterFuzzyThreshold (in stringDist terms), and whose
// album-artist tag either also matches or is empty on either side —
// same "empty means unknown, not a mismatch" contract as
// artistCreditFit — or else it starts a new cluster. Tracks with no
// album tag are left unassigned (memberOf entry -1).
//
// Comparing only against the cluster's founding track, not a running
// centroid or every member, keeps this O(tracks × clusters) and
// deterministic in first-seen order — the order ClusterByAlbumArtist
// and SplitPlan's callers already depend on (they pass tracks ordered
// by disc/track/path).
func clusterTracks(tracks []LocalTrack) (clusters []TrackCluster, memberOf []int) {
type rep struct{ album, artist string }
var clusters []TrackCluster
var reps []rep
for _, t := range tracks {
album := Normalize(t.AlbumTag)
if album == "" {
continue
}
memberOf = make([]int, len(tracks))
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
if i, ok := index[k]; ok {
clusters[i].Tracks = append(clusters[i].Tracks, t)
for i, t := range tracks {
if Normalize(t.AlbumTag) == "" {
memberOf[i] = -1
continue
}
index[k] = len(clusters)
clusters = append(clusters, TrackCluster{
AlbumName: t.AlbumTag,
AlbumArtist: t.AlbumArtistTag,
Tracks: []LocalTrack{t},
})
joined := -1
for ci, r := range reps {
artistMatches := t.AlbumArtistTag == "" || r.artist == "" ||
stringDist(t.AlbumArtistTag, r.artist) <= clusterFuzzyThreshold
if artistMatches && stringDist(t.AlbumTag, r.album) <= clusterFuzzyThreshold {
joined = ci
break
}
}
if joined < 0 {
joined = len(clusters)
reps = append(reps, rep{album: t.AlbumTag, artist: t.AlbumArtistTag})
clusters = append(clusters, TrackCluster{
AlbumName: t.AlbumTag,
AlbumArtist: t.AlbumArtistTag,
})
}
clusters[joined].Tracks = append(clusters[joined].Tracks, t)
memberOf[i] = joined
}
return clusters, memberOf
}
// ClusterByAlbumArtist groups tracks by album/album-artist tag
// similarity (see clusterTracks) and returns the clusters with at
// least clusterMinSize members, in first-seen order. Tracks with no
// album tag, or whose cluster never reaches clusterMinSize, are
// omitted — they belong in the leftover folder, not a synthetic group
// of their own.
func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
clusters, _ := clusterTracks(tracks)
out := clusters[:0]
for _, c := range clusters {
@@ -134,55 +176,19 @@ func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
// SplitPlan returns the full set of synthetic groups a mixed-bag
// folder should be torn into: ClusterByAlbumArtist's tag-matched
// sub-albums, plus a one-track cluster for every track that didn't
// share an (album, album-artist) pair with anything else in the
// folder. Unlike ClusterByAlbumArtist alone — which leaves
// unclustered tracks behind in the parent group, where they'd still
// get folded into whatever partial-album match the scorer finds for
// the rest of the pile — this guarantees every track leaves the
// parent, so a folder of entirely unrelated singles (no two tracks
// share an album tag) still gets torn apart instead of being scored
// as one bogus album with a pile of "extra" tracks. Each singleton's
// evidence-scaled score (rank.go) keeps it appropriately humble on
// its own — it just no longer drags an unrelated release's score
// down, or gets dragged down by one.
// end up sharing a cluster with anything else in the folder. Unlike
// ClusterByAlbumArtist alone — which leaves unclustered tracks behind
// in the parent group, where they'd still get folded into whatever
// partial-album match the scorer finds for the rest of the pile —
// this guarantees every track leaves the parent, so a folder of
// entirely unrelated singles (no two tracks share an album tag) still
// gets torn apart instead of being scored as one bogus album with a
// pile of "extra" tracks. Each singleton's evidence-scaled score
// (rank.go) keeps it appropriately humble on its own — it just no
// longer drags an unrelated release's score down, or gets dragged
// down by one.
func SplitPlan(tracks []LocalTrack) []TrackCluster {
type key struct{ album, artist string }
index := make(map[key]int, 4) //nolint:mnd
var clusters []TrackCluster
// memberOf[i] is 1+the cluster index track i was assigned to (by
// album/artist tag match), or 0 if it never matched anything.
// Tracked by slice position rather than any LocalTrack field —
// AudioFileID/FilePath are frequently zero-valued in this
// package's own tests and would collide, wrongly treating
// distinct untagged tracks as duplicates of one another.
memberOf := make([]int, len(tracks))
for i, t := range tracks {
album := Normalize(t.AlbumTag)
if album == "" {
continue
}
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
if ci, ok := index[k]; ok {
clusters[ci].Tracks = append(clusters[ci].Tracks, t)
memberOf[i] = ci + 1
continue
}
index[k] = len(clusters)
memberOf[i] = len(clusters) + 1
clusters = append(clusters, TrackCluster{
AlbumName: t.AlbumTag,
AlbumArtist: t.AlbumArtistTag,
Tracks: []LocalTrack{t},
})
}
clusters, memberOf := clusterTracks(tracks)
// Clusters that never reached clusterMinSize don't survive as a
// group; their sole member falls through to the singleton pass
@@ -198,7 +204,7 @@ func SplitPlan(tracks []LocalTrack) []TrackCluster {
}
for i, t := range tracks {
if ci := memberOf[i] - 1; ci >= 0 {
if ci := memberOf[i]; ci >= 0 {
if _, ok := keptIndex[ci]; ok {
continue
}
+78
View File
@@ -140,6 +140,84 @@ func TestClusterByAlbumArtist_FindsSubAlbums(t *testing.T) {
}
}
func TestClusterByAlbumArtist_TypoVariantsMergeIntoOneCluster(t *testing.T) {
t.Parallel()
// A dropped diacritic and a stray trailing space are the kind of
// noise exact Normalize()-equality clustering used to treat as
// two different albums, splitting one real album across clusters
// even though a candidate search on either would land on the same
// release. Fuzzy clustering absorbs both into one cluster.
tracks := []LocalTrack{
{
Title: "Song A",
Artist: "Sigur Ros",
AlbumTag: "Agaetis Byrjun",
AlbumArtistTag: "Sigur Ros",
},
{
Title: "Song B",
Artist: "Sigur Ros",
AlbumTag: "Ágætis byrjun",
AlbumArtistTag: "Sigur Ros",
},
{
Title: "Song C",
Artist: "Sigur Ros",
AlbumTag: "Agaetis Byrjun ",
AlbumArtistTag: "Sigur Ros",
},
}
clusters := ClusterByAlbumArtist(tracks)
if len(clusters) != 1 {
t.Fatalf(
"expected typo variants to merge into 1 cluster, got %d: %+v",
len(clusters),
clusters,
)
}
if len(clusters[0].Tracks) != 3 { //nolint:mnd
t.Fatalf("expected all 3 tracks in the merged cluster, got %d", len(clusters[0].Tracks))
}
}
func TestClusterByAlbumArtist_DifferentAlbumsBySameArtistStaySeparate(t *testing.T) {
t.Parallel()
// Fuzzy clustering must not blur genuinely different albums by
// the same artist into one cluster just because they share an
// artist tag — the threshold has to stay tight enough for this.
tracks := []LocalTrack{
{
Title: "Song A",
Artist: "Radiohead",
AlbumTag: "OK Computer",
AlbumArtistTag: "Radiohead",
},
{
Title: "Song B",
Artist: "Radiohead",
AlbumTag: "OK Computer",
AlbumArtistTag: "Radiohead",
},
{Title: "Song C", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
{Title: "Song D", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
}
clusters := ClusterByAlbumArtist(tracks)
if len(clusters) != 2 { //nolint:mnd
t.Fatalf(
"expected OK Computer and Kid A to stay separate, got %d clusters: %+v",
len(clusters),
clusters,
)
}
}
func TestClusterByAlbumArtist_NoAlbumTagStaysUnclustered(t *testing.T) {
t.Parallel()
+106
View File
@@ -35,6 +35,7 @@ type Config struct {
loaded bool // true once Load() succeeds
Library *library.Config `toml:"Library"`
Theme *theme.Config `toml:"Theme"`
General *GeneralConfig `toml:"General"`
Window *WindowConfig `toml:"Window"`
TrackList *tracklist.Config `toml:"TrackList"`
Favorites *favorites.Config `toml:"Favorites"`
@@ -84,6 +85,12 @@ func (c *Config) Validate() error {
}
}
if c.General != nil {
if err := c.General.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if c.TrackList != nil {
if err := c.TrackList.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
@@ -240,6 +247,12 @@ func (c *Config) applyDefaults() {
c.Theme.ApplyDefaults()
if c.General == nil {
c.General = &GeneralConfig{}
}
c.General.ApplyDefaults()
if c.TrackList == nil {
c.TrackList = &tracklist.Config{}
}
@@ -505,6 +518,99 @@ func (c *Config) emitThemeChanged() {
)
}
// GetDefaultPage returns the view the app opens to on launch.
func (c *Config) GetDefaultPage() string {
if c.General == nil {
return string(DefaultDefaultPage)
}
return string(c.General.DefaultPage)
}
// SetDefaultPage validates and saves a new launch page.
func (c *Config) SetDefaultPage(page string) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.DefaultPage = DefaultPage(page)
if err := c.General.Validate(); err != nil {
return fmt.Errorf(
"invalid default page: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"DefaultPage": string(c.General.DefaultPage),
},
)
c.logger.Info(
"default page updated",
"page", page,
)
return nil
}
// GetQueueFallback returns what plays, if anything, once the queue
// runs out.
func (c *Config) GetQueueFallback() string {
if c.General == nil {
return string(DefaultQueueFallback)
}
return string(c.General.QueueFallback)
}
// SetQueueFallback validates and saves a new queue-fallback mode.
func (c *Config) SetQueueFallback(mode string) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.QueueFallback = QueueFallback(mode)
if err := c.General.Validate(); err != nil {
return fmt.Errorf(
"invalid queue fallback: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"QueueFallback": string(c.General.QueueFallback),
},
)
c.logger.Info(
"queue fallback updated",
"mode", mode,
)
return nil
}
// GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil {
+85
View File
@@ -0,0 +1,85 @@
package config
import (
"errors"
"fmt"
)
// DefaultPage identifies which view the app opens to on launch.
type DefaultPage string
// Valid DefaultPage values, matching the frontend's top-level route ids.
const (
DefaultPageHome DefaultPage = "home"
DefaultPageTracks DefaultPage = "tracks"
DefaultPageAlbums DefaultPage = "albums"
DefaultPageArtists DefaultPage = "artists"
DefaultPageGenres DefaultPage = "genres"
DefaultPagePlaylists DefaultPage = "playlists"
DefaultPageExplore DefaultPage = "explore"
DefaultPageDownloads DefaultPage = "downloads"
DefaultPageAutotag DefaultPage = "autotag"
DefaultPageJobs DefaultPage = "jobs"
)
// DefaultDefaultPage is the launch page for a fresh install.
const DefaultDefaultPage = DefaultPageHome
var errUnknownDefaultPage = errors.New("unknown default page")
// QueueFallback identifies what plays, if anything, once the queue
// runs out with nothing left to auto-advance to.
type QueueFallback string
// Valid QueueFallback values.
const (
QueueFallbackStop QueueFallback = "stop"
QueueFallbackFavorites QueueFallback = "favorites"
QueueFallbackDynamicMix QueueFallback = "dynamicMix"
)
// DefaultQueueFallback is the fallback behavior for a fresh install.
const DefaultQueueFallback = QueueFallbackFavorites
var errUnknownQueueFallback = errors.New("unknown queue fallback")
// GeneralConfig holds general application preferences that don't
// belong to a more specific subsystem.
type GeneralConfig struct {
DefaultPage DefaultPage `toml:"DefaultPage"`
QueueFallback QueueFallback `toml:"QueueFallback"`
}
// ApplyDefaults fills zero-value fields with sensible defaults.
func (c *GeneralConfig) ApplyDefaults() {
if c.DefaultPage == "" {
c.DefaultPage = DefaultDefaultPage
}
if c.QueueFallback == "" {
c.QueueFallback = DefaultQueueFallback
}
}
// Validate checks that all values are well-formed.
func (c *GeneralConfig) Validate() error {
c.ApplyDefaults()
switch c.DefaultPage {
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists,
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
DefaultPageAutotag, DefaultPageJobs:
// Valid.
default:
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
}
switch c.QueueFallback {
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
// Valid.
default:
return fmt.Errorf("%w: %q", errUnknownQueueFallback, c.QueueFallback)
}
return nil
}
@@ -0,0 +1 @@
ALTER TABLE tagging_items ADD COLUMN album_artist_conflict INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
ALTER TABLE queue ADD COLUMN source_type TEXT NOT NULL DEFAULT '';
ALTER TABLE queue ADD COLUMN source_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE queue ADD COLUMN source_label TEXT NOT NULL DEFAULT '';
@@ -0,0 +1 @@
ALTER TABLE release_groups ADD COLUMN pending_release_mbid TEXT;
@@ -0,0 +1 @@
ALTER TABLE release_group_recordings ADD COLUMN total_tracks INTEGER;
+30
View File
@@ -0,0 +1,30 @@
-- Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
-- expanding a seed selection into a candidate pool by artist similarity
-- and genre overlap, restricted to what is actually in the library.
-- name: GetFilePathsByArtistMBID :many
SELECT DISTINCT af.file_path
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE a.mbid = ?;
-- name: GetGenreNamesByFilePath :many
SELECT DISTINCT g.name
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.file_path = ?;
-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE af.file_path = ?
LIMIT 1;
+2 -2
View File
@@ -1,10 +1,10 @@
-- name: GetQueueState :one
SELECT source_playlist_id, current_position, shuffle_mode, repeat_mode, shuffle_order
SELECT current_position, shuffle_mode, repeat_mode, shuffle_order, source_type, source_id, source_label
FROM queue WHERE id = 1;
-- name: UpdateQueueState :exec
UPDATE queue
SET source_playlist_id = ?, current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?
SET current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?, source_type = ?, source_id = ?, source_label = ?
WHERE id = 1;
-- name: UpdateQueuePosition :exec
@@ -1,8 +1,26 @@
-- name: CreateReleaseGroupRecording :one
INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
VALUES (?, ?, ?, ?)
INSERT INTO release_group_recordings (
release_group_id, recording_id, track_number, disc_number, total_tracks
)
VALUES (?, ?, ?, ?, ?)
RETURNING *;
-- name: GetAlbumCompleteness :one
WITH discs AS (
SELECT
COALESCE(rgr.disc_number, 1) AS disc,
MAX(COALESCE(rgr.total_tracks, 0)) AS declared,
COUNT(DISTINCT COALESCE(rgr.track_number, -rgr.recording_id)) AS owned
FROM release_group_recordings rgr
WHERE rgr.release_group_id = ?
GROUP BY COALESCE(rgr.disc_number, 1)
)
SELECT
CAST(COALESCE(SUM(owned), 0) AS INTEGER) AS owned,
CAST(COALESCE(SUM(declared), 0) AS INTEGER) AS expected,
CAST(COALESCE(SUM(CASE WHEN declared = 0 THEN 1 ELSE 0 END), 0) AS INTEGER) AS discs_untotalled
FROM discs;
-- name: GetReleaseGroupRecording :one
SELECT * FROM release_group_recordings
WHERE id = ? LIMIT 1;
@@ -10,7 +10,27 @@ ON CONFLICT(group_key) DO UPDATE SET
WHEN tagging_items.album_name = '' THEN excluded.album_name
ELSE tagging_items.album_name
END,
-- Tracks real consensus, not first-write-wins: stays set only
-- while every track that has contributed a non-empty value agrees.
-- A later track with a *different* non-empty value clears it back
-- to '' and latches album_artist_conflict, since a single
-- disagreeing tag means the folder no longer has one authoritative
-- album-artist -- IsMixedBag (backend/autotag) treats a non-empty
-- value here as trusted, so leaving a stale first-seen value in
-- place would let one track's tag silently blind mixed-bag
-- detection for the whole folder. The latch (rather than just
-- clearing the text column) stops a later track from coincidentally
-- repeating an already-disputed value and resurrecting trust in it.
album_artist_conflict = CASE
WHEN tagging_items.album_artist_conflict = 1 THEN 1
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
AND tagging_items.album_artist != excluded.album_artist THEN 1
ELSE 0
END,
album_artist = CASE
WHEN tagging_items.album_artist_conflict = 1 THEN ''
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
AND tagging_items.album_artist != excluded.album_artist THEN ''
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
ELSE tagging_items.album_artist
END;
+7
View File
@@ -5,6 +5,13 @@ CREATE TABLE IF NOT EXISTS queue (
shuffle_mode BOOLEAN NOT NULL DEFAULT false,
repeat_mode TEXT NOT NULL DEFAULT 'off',
shuffle_order TEXT,
-- source_playlist_id above is unused dead weight (nothing has ever
-- written it a nonzero value); source_type/source_id/source_label
-- below are its generalized replacement, covering albums, playlists,
-- smart playlists, genres and artists rather than playlists alone.
source_type TEXT NOT NULL DEFAULT '',
source_id INTEGER NOT NULL DEFAULT 0,
source_label TEXT NOT NULL DEFAULT '',
FOREIGN KEY(source_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
);
@@ -4,6 +4,12 @@ CREATE TABLE IF NOT EXISTS release_group_recordings (
recording_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
-- The denominator the file's own tag declared: the 12 in "5/12", per
-- disc. Read off every file at scan and, until now, discarded — so
-- "do I have all of this album" had no local answer and the album
-- page asked MusicBrainz. NULL means the tag did not say, which is
-- a third state and not the same as zero.
total_tracks INTEGER,
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS "release_groups" (
album_artist_credit_id INTEGER,
year INTEGER,
total_tracks INTEGER,
total_discs INTEGER, mbid TEXT, original_year INTEGER,
total_discs INTEGER, mbid TEXT, original_year INTEGER, pending_release_mbid TEXT,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id),
UNIQUE(name, album_artist_credit_id)
@@ -39,6 +39,17 @@ CREATE TABLE IF NOT EXISTS tagging_items (
-- first time; append-only from the second migration on.
synthetic INTEGER NOT NULL DEFAULT 0,
parent_group_key TEXT NOT NULL DEFAULT '',
-- album_artist_conflict latches to 1 the first time two tracks
-- added to this group carry different non-empty album_artist tags,
-- and never resets. Without it, UpsertTaggingItemOnTrackAdd's
-- consensus tracking on album_artist can't tell "no non-empty
-- value contributed yet" apart from "conflicting values were
-- observed and it was cleared" — both look like '' — so a later
-- track that happens to repeat an earlier, already-disputed value
-- would wrongly resurrect trust in it. See IsMixedBag
-- (backend/autotag/mixedbag.go), which trusts a non-empty
-- album_artist unconditionally.
album_artist_conflict INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(library_id) REFERENCES libraries(id)
);
+103
View File
@@ -0,0 +1,103 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: mix.sql
package sqlcgen
import (
"context"
"database/sql"
)
const getArtistByFilePath = `-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE af.file_path = ?
LIMIT 1
`
type GetArtistByFilePathRow struct {
ArtistName string
ArtistMbid string
}
func (q *Queries) GetArtistByFilePath(ctx context.Context, filePath string) (GetArtistByFilePathRow, error) {
row := q.db.QueryRowContext(ctx, getArtistByFilePath, filePath)
var i GetArtistByFilePathRow
err := row.Scan(&i.ArtistName, &i.ArtistMbid)
return i, err
}
const getFilePathsByArtistMBID = `-- name: GetFilePathsByArtistMBID :many
SELECT DISTINCT af.file_path
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE a.mbid = ?
`
// Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
// expanding a seed selection into a candidate pool by artist similarity
// and genre overlap, restricted to what is actually in the library.
func (q *Queries) GetFilePathsByArtistMBID(ctx context.Context, mbid sql.NullString) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getFilePathsByArtistMBID, mbid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var file_path string
if err := rows.Scan(&file_path); err != nil {
return nil, err
}
items = append(items, file_path)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGenreNamesByFilePath = `-- name: GetGenreNamesByFilePath :many
SELECT DISTINCT g.name
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.file_path = ?
`
func (q *Queries) GetGenreNamesByFilePath(ctx context.Context, filePath string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getGenreNamesByFilePath, filePath)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
items = append(items, name)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+6
View File
@@ -267,6 +267,9 @@ type Queue struct {
ShuffleMode bool
RepeatMode string
ShuffleOrder sql.NullString
SourceType string
SourceID int64
SourceLabel string
}
type QueueTrack struct {
@@ -305,6 +308,7 @@ type ReleaseGroup struct {
TotalDiscs sql.NullInt64
Mbid sql.NullString
OriginalYear sql.NullInt64
PendingReleaseMbid sql.NullString
}
type ReleaseGroupRecording struct {
@@ -313,6 +317,7 @@ type ReleaseGroupRecording struct {
RecordingID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
TotalTracks sql.NullInt64
}
type ReleaseToRg struct {
@@ -363,6 +368,7 @@ type TaggingItem struct {
CreatedAt time.Time
Synthetic int64
ParentGroupKey string
AlbumArtistConflict int64
}
type TrackMetadatum struct {
+22 -14
View File
@@ -20,27 +20,31 @@ func (q *Queries) ClearQueueTracks(ctx context.Context) error {
}
const getQueueState = `-- name: GetQueueState :one
SELECT source_playlist_id, current_position, shuffle_mode, repeat_mode, shuffle_order
SELECT current_position, shuffle_mode, repeat_mode, shuffle_order, source_type, source_id, source_label
FROM queue WHERE id = 1
`
type GetQueueStateRow struct {
SourcePlaylistID sql.NullInt64
CurrentPosition int64
ShuffleMode bool
RepeatMode string
ShuffleOrder sql.NullString
CurrentPosition int64
ShuffleMode bool
RepeatMode string
ShuffleOrder sql.NullString
SourceType string
SourceID int64
SourceLabel string
}
func (q *Queries) GetQueueState(ctx context.Context) (GetQueueStateRow, error) {
row := q.db.QueryRowContext(ctx, getQueueState)
var i GetQueueStateRow
err := row.Scan(
&i.SourcePlaylistID,
&i.CurrentPosition,
&i.ShuffleMode,
&i.RepeatMode,
&i.ShuffleOrder,
&i.SourceType,
&i.SourceID,
&i.SourceLabel,
)
return i, err
}
@@ -200,25 +204,29 @@ func (q *Queries) UpdateQueuePosition(ctx context.Context, currentPosition int64
const updateQueueState = `-- name: UpdateQueueState :exec
UPDATE queue
SET source_playlist_id = ?, current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?
SET current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?, source_type = ?, source_id = ?, source_label = ?
WHERE id = 1
`
type UpdateQueueStateParams struct {
SourcePlaylistID sql.NullInt64
CurrentPosition int64
ShuffleMode bool
RepeatMode string
ShuffleOrder sql.NullString
CurrentPosition int64
ShuffleMode bool
RepeatMode string
ShuffleOrder sql.NullString
SourceType string
SourceID int64
SourceLabel string
}
func (q *Queries) UpdateQueueState(ctx context.Context, arg UpdateQueueStateParams) error {
_, err := q.db.ExecContext(ctx, updateQueueState,
arg.SourcePlaylistID,
arg.CurrentPosition,
arg.ShuffleMode,
arg.RepeatMode,
arg.ShuffleOrder,
arg.SourceType,
arg.SourceID,
arg.SourceLabel,
)
return err
}
@@ -11,9 +11,11 @@ import (
)
const createReleaseGroupRecording = `-- name: CreateReleaseGroupRecording :one
INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
VALUES (?, ?, ?, ?)
RETURNING id, release_group_id, recording_id, track_number, disc_number
INSERT INTO release_group_recordings (
release_group_id, recording_id, track_number, disc_number, total_tracks
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, release_group_id, recording_id, track_number, disc_number, total_tracks
`
type CreateReleaseGroupRecordingParams struct {
@@ -21,6 +23,7 @@ type CreateReleaseGroupRecordingParams struct {
RecordingID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
TotalTracks sql.NullInt64
}
func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateReleaseGroupRecordingParams) (ReleaseGroupRecording, error) {
@@ -29,6 +32,7 @@ func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateRel
arg.RecordingID,
arg.TrackNumber,
arg.DiscNumber,
arg.TotalTracks,
)
var i ReleaseGroupRecording
err := row.Scan(
@@ -37,6 +41,7 @@ func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateRel
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
&i.TotalTracks,
)
return i, err
}
@@ -85,8 +90,38 @@ func (q *Queries) DeleteReleaseGroupRecordingsByRecording(ctx context.Context, r
return err
}
const getAlbumCompleteness = `-- name: GetAlbumCompleteness :one
WITH discs AS (
SELECT
COALESCE(rgr.disc_number, 1) AS disc,
MAX(COALESCE(rgr.total_tracks, 0)) AS declared,
COUNT(DISTINCT COALESCE(rgr.track_number, -rgr.recording_id)) AS owned
FROM release_group_recordings rgr
WHERE rgr.release_group_id = ?
GROUP BY COALESCE(rgr.disc_number, 1)
)
SELECT
CAST(COALESCE(SUM(owned), 0) AS INTEGER) AS owned,
CAST(COALESCE(SUM(declared), 0) AS INTEGER) AS expected,
CAST(COALESCE(SUM(CASE WHEN declared = 0 THEN 1 ELSE 0 END), 0) AS INTEGER) AS discs_untotalled
FROM discs
`
type GetAlbumCompletenessRow struct {
Owned int64
Expected int64
DiscsUntotalled int64
}
func (q *Queries) GetAlbumCompleteness(ctx context.Context, releaseGroupID int64) (GetAlbumCompletenessRow, error) {
row := q.db.QueryRowContext(ctx, getAlbumCompleteness, releaseGroupID)
var i GetAlbumCompletenessRow
err := row.Scan(&i.Owned, &i.Expected, &i.DiscsUntotalled)
return i, err
}
const getRecordingReleaseGroups = `-- name: GetRecordingReleaseGroups :many
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
SELECT id, release_group_id, recording_id, track_number, disc_number, total_tracks FROM release_group_recordings
WHERE recording_id = ?
`
@@ -105,6 +140,7 @@ func (q *Queries) GetRecordingReleaseGroups(ctx context.Context, recordingID int
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
&i.TotalTracks,
); err != nil {
return nil, err
}
@@ -120,7 +156,7 @@ func (q *Queries) GetRecordingReleaseGroups(ctx context.Context, recordingID int
}
const getReleaseGroupRecording = `-- name: GetReleaseGroupRecording :one
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
SELECT id, release_group_id, recording_id, track_number, disc_number, total_tracks FROM release_group_recordings
WHERE id = ? LIMIT 1
`
@@ -133,12 +169,13 @@ func (q *Queries) GetReleaseGroupRecording(ctx context.Context, id int64) (Relea
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
&i.TotalTracks,
)
return i, err
}
const getReleaseGroupRecordings = `-- name: GetReleaseGroupRecordings :many
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
SELECT id, release_group_id, recording_id, track_number, disc_number, total_tracks FROM release_group_recordings
WHERE release_group_id = ?
ORDER BY disc_number, track_number
`
@@ -158,6 +195,7 @@ func (q *Queries) GetReleaseGroupRecordings(ctx context.Context, releaseGroupID
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
&i.TotalTracks,
); err != nil {
return nil, err
}
@@ -23,7 +23,7 @@ func (q *Queries) CountReleaseGroupRecordings(ctx context.Context, releaseGroupI
const createReleaseGroup = `-- name: CreateReleaseGroup :one
INSERT INTO release_groups (name) VALUES (?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid
`
func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) {
@@ -39,6 +39,7 @@ func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseG
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
@@ -47,7 +48,7 @@ const createReleaseGroupFull = `-- name: CreateReleaseGroupFull :one
INSERT INTO release_groups (
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid
`
type CreateReleaseGroupFullParams struct {
@@ -79,6 +80,7 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
@@ -432,7 +434,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI
}
const getAllReleaseGroups = `-- name: GetAllReleaseGroups :many
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid FROM release_groups
ORDER BY name
`
@@ -455,6 +457,7 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
); err != nil {
return nil, err
}
@@ -502,7 +505,7 @@ func (q *Queries) GetOrphanedReleaseGroupIDs(ctx context.Context) ([]int64, erro
}
const getReleaseGroup = `-- name: GetReleaseGroup :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid FROM release_groups
WHERE id = ? LIMIT 1
`
@@ -519,12 +522,13 @@ func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup,
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
const getReleaseGroupByNameAndArtist = `-- name: GetReleaseGroupByNameAndArtist :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid FROM release_groups
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1
`
@@ -546,6 +550,7 @@ func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetRel
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
@@ -606,7 +611,7 @@ VALUES (?, ?, ?)
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
year = COALESCE(excluded.year, release_groups.year)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid
`
type UpsertReleaseGroupParams struct {
@@ -628,6 +633,7 @@ func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroup
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
@@ -200,7 +200,7 @@ func (q *Queries) GetRecordingReleaseGroupID(ctx context.Context, recordingID in
}
const getTaggingItem = `-- name: GetTaggingItem :one
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at, synthetic, parent_group_key FROM tagging_items
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at, synthetic, parent_group_key, album_artist_conflict FROM tagging_items
WHERE group_key = ?
LIMIT 1
`
@@ -223,6 +223,7 @@ func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingI
&i.CreatedAt,
&i.Synthetic,
&i.ParentGroupKey,
&i.AlbumArtistConflict,
)
return i, err
}
@@ -859,7 +860,27 @@ ON CONFLICT(group_key) DO UPDATE SET
WHEN tagging_items.album_name = '' THEN excluded.album_name
ELSE tagging_items.album_name
END,
-- Tracks real consensus, not first-write-wins: stays set only
-- while every track that has contributed a non-empty value agrees.
-- A later track with a *different* non-empty value clears it back
-- to '' and latches album_artist_conflict, since a single
-- disagreeing tag means the folder no longer has one authoritative
-- album-artist -- IsMixedBag (backend/autotag) treats a non-empty
-- value here as trusted, so leaving a stale first-seen value in
-- place would let one track's tag silently blind mixed-bag
-- detection for the whole folder. The latch (rather than just
-- clearing the text column) stops a later track from coincidentally
-- repeating an already-disputed value and resurrecting trust in it.
album_artist_conflict = CASE
WHEN tagging_items.album_artist_conflict = 1 THEN 1
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
AND tagging_items.album_artist != excluded.album_artist THEN 1
ELSE 0
END,
album_artist = CASE
WHEN tagging_items.album_artist_conflict = 1 THEN ''
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
AND tagging_items.album_artist != excluded.album_artist THEN ''
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
ELSE tagging_items.album_artist
END
+76
View File
@@ -333,6 +333,82 @@ func TestListPendingFolders_SampleFilePathUsesIndex(t *testing.T) {
}
}
// TestUpsertTaggingItemOnTrackAdd_AlbumArtistTracksConsensus guards
// against regressing to first-write-wins: a folder's album_artist
// must reflect whether every contributing track actually agreed, not
// just whichever track happened to be scanned first. IsMixedBag
// (backend/autotag) trusts a non-empty album_artist unconditionally,
// so a stale first-seen value here would silently defeat mixed-bag
// detection for the rest of the folder's tracks.
func TestUpsertTaggingItemOnTrackAdd_AlbumArtistTracksConsensus(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
upsert := func(t *testing.T, groupKey, albumArtist string) {
t.Helper()
if err := db.Queries.UpsertTaggingItemOnTrackAdd(
db.Ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
GroupKey: groupKey,
LibraryID: 0,
AlbumName: "",
AlbumArtist: albumArtist,
DiscNumber: 0,
},
); err != nil {
t.Fatalf("upsert: %v", err)
}
}
albumArtist := func(t *testing.T, groupKey string) string {
t.Helper()
return scalarString(t, db,
`SELECT album_artist FROM tagging_items WHERE group_key = ?`, groupKey,
)
}
// Every contributing track agrees: the value sticks.
upsert(t, "agree", "Artist One")
upsert(t, "agree", "Artist One")
upsert(t, "agree", "Artist One")
if got := albumArtist(t, "agree"); got != "Artist One" {
t.Errorf("unanimous album_artist = %q, want %q", got, "Artist One")
}
// A later track disagrees: the value must clear, not freeze on
// whichever track was scanned first.
upsert(t, "disagree", "Artist One")
upsert(t, "disagree", "Artist Two")
upsert(t, "disagree", "Artist One")
if got := albumArtist(t, "disagree"); got != "" {
t.Errorf("disagreeing album_artist = %q, want empty (no consensus)", got)
}
// An untagged track (empty AlbumArtist) must not overwrite an
// established consensus value, nor count as disagreement.
upsert(t, "partial-tags", "Artist One")
upsert(t, "partial-tags", "")
upsert(t, "partial-tags", "Artist One")
if got := albumArtist(t, "partial-tags"); got != "Artist One" {
t.Errorf("partial-tags album_artist = %q, want %q", got, "Artist One")
}
// Once cleared by disagreement, a later untagged track must not
// resurrect a stale value.
upsert(t, "cleared-stays-cleared", "Artist One")
upsert(t, "cleared-stays-cleared", "Artist Two")
upsert(t, "cleared-stays-cleared", "")
if got := albumArtist(t, "cleared-stays-cleared"); got != "" {
t.Errorf("cleared-stays-cleared album_artist = %q, want empty", got)
}
}
func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
t.Parallel()
+11
View File
@@ -41,6 +41,7 @@ const (
const (
LibraryConfigChanged = "LibraryConfigChanged"
ThemeConfigChanged = "ThemeConfigChanged"
GeneralConfigChanged = "GeneralConfigChanged"
TrackListConfigChanged = "TrackListConfigChanged"
FavoritesConfigChanged = "FavoritesConfigChanged"
ShortcutsConfigChanged = "ShortcutsConfigChanged"
@@ -153,6 +154,16 @@ const (
// the initial request having blocked on a live MusicBrainz browse.
AlbumReleasesReady = "AlbumReleasesReady"
// AlbumReleasesFailed fires (payload: release-group MBID string) when
// that same background browse returns an error, so the album page can
// say the catalog did not answer at the moment it did not answer.
//
// Without it the only signal is the absence of AlbumReleasesReady,
// which a slow browse and a failed one produce alike — leaving a
// timer to guess between them, and a page queued behind the
// prefetch's rate limiter to be reported as a failure.
AlbumReleasesFailed = "AlbumReleasesFailed"
// DownloadProvidersChanged fires after a download client is added,
// edited, enabled/disabled or removed, so the settings page and any
// open download picker re-read the provider list.
+89 -2
View File
@@ -55,6 +55,12 @@ type Service struct {
// already in flight) into one MusicBrainz browse + one
// AlbumReleasesReady event.
releasesSF singleflight.Group
// mixMu guards mix, the in-progress dynamic-mix queue-fallback
// session (see mix.go). There is only ever one — this is a
// single-user desktop app with one queue.
mixMu sync.Mutex
mix *mixSession
}
// NewExploreService creates a Service backed by the given
@@ -238,6 +244,79 @@ func (e *Service) BackfillLibraryDiscographies() {
go e.index.BackfillLibraryDiscographies(e.ctx)
}
// releaseGroupMBIDBackfillMaxPerRun bounds how many pending release
// MBIDs a single run resolves, mirroring discogBackfillMaxPerRun.
const releaseGroupMBIDBackfillMaxPerRun = 500
// BackfillReleaseGroupMBIDs resolves release groups whose scan only
// found a release-level MBID (MUSICBRAINZ_ALBUMID — many taggers write
// this instead of, or in addition to, MUSICBRAINZ_RELEASEGROUPID) into
// the release-group MBID everything else on the album page is keyed
// by. Bounded and resumable, in the background: a scan can't afford a
// live MusicBrainz call, so `library.updateMBIDs` stashes the release
// MBID in `pending_release_mbid` instead, and this is what resolves it
// — the same "defer the network call out of the scan path" shape as
// BackfillLibraryDiscographies.
func (e *Service) BackfillReleaseGroupMBIDs() {
go e.backfillReleaseGroupMBIDs(e.ctx)
}
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
rows, err := e.db.QueryContext(
"SELECT id, pending_release_mbid FROM release_groups "+
"WHERE (mbid IS NULL OR mbid = '') "+
"AND pending_release_mbid IS NOT NULL AND pending_release_mbid != '' "+
"LIMIT ?",
releaseGroupMBIDBackfillMaxPerRun,
)
if err != nil {
e.logger.Warn("release-group mbid backfill: query failed", "error", err)
return
}
type pendingRow struct {
id int64
releaseMBID string
}
var pending []pendingRow
for rows.Next() {
var p pendingRow
if err := rows.Scan(&p.id, &p.releaseMBID); err == nil {
pending = append(pending, p)
}
}
_ = rows.Close()
for _, p := range pending {
if ctx.Err() != nil {
return
}
release, err := e.mb.LookupRelease(ctx, p.releaseMBID)
if err != nil || release.ReleaseGroupMBID == "" {
// Left alone rather than cleared: LookupRelease caches its
// answer (success or a release with no group) for 7 days,
// so a retry on the next run is cheap, and a future rescan
// that finds a real release-group tag still wins normally.
continue
}
_, err = e.db.ExecContext(
"UPDATE release_groups SET mbid = ?, pending_release_mbid = NULL "+
"WHERE id = ? AND (mbid IS NULL OR mbid = '')",
release.ReleaseGroupMBID, p.id,
)
if err != nil {
e.logger.Warn("release-group mbid backfill: update failed", "error", err)
}
}
}
// InvalidateLibrarySync clears the "ready" markers guarding the gated
// library-sync steps so they re-run on the next launch. Call after a
// mutation that changes owned content outside a scan (e.g. removing a
@@ -649,10 +728,18 @@ func (e *Service) ensureReleasesAsync(releaseGroupMBID string) {
go func() {
_, _, _ = e.releasesSF.Do(releaseGroupMBID, func() (any, error) {
_, err := e.mb.BrowseReleases(e.ctx, releaseGroupMBID)
if err == nil {
events.Emit(e.ctx, events.AlbumReleasesReady, releaseGroupMBID)
if err != nil {
e.logger.Warn("explore: background browse releases failed",
"releaseGroupMBID", releaseGroupMBID,
"error", err,
)
events.Emit(e.ctx, events.AlbumReleasesFailed, releaseGroupMBID)
return nil, nil
}
events.Emit(e.ctx, events.AlbumReleasesReady, releaseGroupMBID)
return nil, nil
})
}()
+242
View File
@@ -0,0 +1,242 @@
package explore
import (
"context"
"database/sql"
"math/rand/v2"
)
// mixBatchSize is how many tracks one GenerateMix call returns.
const mixBatchSize = 30
// mixGenreBoost is added to a candidate's similarity weight for each
// genre it shares with the seed, biasing the pick toward tracks that
// match on both artist and tag rather than artist alone.
const mixGenreBoost = 0.5
// mixSimilarArtistsPerSeed caps how many similar artists are expanded
// per distinct seed artist, so a seed with an unusually long tail in
// similar_artist_map doesn't turn one fallback trigger into hundreds
// of queries.
const mixSimilarArtistsPerSeed = 15
// mixSession is a dynamic mix in progress: the seed it was built from
// (fixed for the life of the session, so successive batches don't
// drift away from what the session started as) and what it has
// already handed out, so a batch doesn't repeat a track that just
// played.
type mixSession struct {
seedPaths []string
played map[string]bool
}
// GenerateMix returns the next batch of tracks for a dynamic-mix queue
// fallback, built by expanding the seed's artists to their similar
// artists (weighted by how often each appears in the seed, sharpened
// by shared genre tags) and restricting candidates to what is actually
// in the library — a queue can only play files that exist.
//
// continuing extends the current mix session — regenerating from its
// original seed rather than seedPaths — instead of starting a fresh
// one. Pass false whenever the queue that just exhausted was not
// itself a mix batch (a real selection just ran out); pass true when
// it was (the mix keeps going indefinitely). label names the batch
// after its most-represented seed artist, for the "Playing from" UI.
func (e *Service) GenerateMix(
ctx context.Context,
seedPaths []string,
continuing bool,
) (paths []string, label string, err error) {
e.mixMu.Lock()
defer e.mixMu.Unlock()
if !continuing || e.mix == nil {
e.mix = &mixSession{seedPaths: seedPaths, played: map[string]bool{}}
}
seed := e.mix.seedPaths
if len(seed) == 0 {
return nil, "", nil
}
artistCounts, topArtistName, genres := e.mixSeedProfile(ctx, seed)
if len(artistCounts) == 0 {
return nil, "", nil
}
candidates := e.mixCandidates(ctx, artistCounts, genres, seed, e.mix.played)
// The session has played through everything this seed can offer —
// rather than dead-ending an "indefinite" mix, start handing out
// repeats.
if len(candidates) == 0 && len(e.mix.played) > 0 {
candidates = e.mixCandidates(ctx, artistCounts, genres, seed, nil)
}
if len(candidates) == 0 {
return nil, "", nil
}
picked := weightedSample(candidates, mixBatchSize)
for _, p := range picked {
e.mix.played[p] = true
}
if topArtistName != "" {
label = "a mix inspired by " + topArtistName
} else {
label = "a dynamic mix"
}
return picked, label, nil
}
// mixSeedProfile tallies the seed's artists by frequency, its genre
// tags, and names the most-represented artist for the UI label.
func (e *Service) mixSeedProfile(
ctx context.Context,
seedPaths []string,
) (artistCounts map[string]int, topArtistName string, genres map[string]bool) {
artistCounts = map[string]int{}
artistNames := map[string]string{}
genres = map[string]bool{}
for _, p := range seedPaths {
artist, err := e.db.ReadQueries.GetArtistByFilePath(ctx, p)
if err == nil && artist.ArtistMbid != "" {
artistCounts[artist.ArtistMbid]++
artistNames[artist.ArtistMbid] = artist.ArtistName
}
names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p)
if err == nil {
for _, g := range names {
genres[g] = true
}
}
}
var topCount int
for mbid, count := range artistCounts {
if count > topCount {
topCount = count
topArtistName = artistNames[mbid]
}
}
return artistCounts, topArtistName, genres
}
// mixCandidates builds the weighted pool of library tracks to draw a
// batch from: every owned track by a similar artist, weighted by that
// artist's similarity score times how often the seed artist it came
// from appears in the seed, boosted for a shared genre tag, excluding
// the seed itself and anything already excluded (typically what the
// mix has already played).
func (e *Service) mixCandidates(
ctx context.Context,
artistCounts map[string]int,
seedGenres map[string]bool,
seedPaths []string,
exclude map[string]bool,
) map[string]float64 {
excludeSeed := make(map[string]bool, len(seedPaths))
for _, p := range seedPaths {
excludeSeed[p] = true
}
candidates := map[string]float64{}
for seedArtistMBID, count := range artistCounts {
similar, err := e.SimilarArtists(seedArtistMBID)
if err != nil {
continue
}
if len(similar) > mixSimilarArtistsPerSeed {
similar = similar[:mixSimilarArtistsPerSeed]
}
for _, s := range similar {
if s.ArtistMBID == "" {
continue
}
paths, err := e.db.ReadQueries.GetFilePathsByArtistMBID(
ctx,
sql.NullString{String: s.ArtistMBID, Valid: true},
)
if err != nil {
continue
}
weight := s.Score * float64(count)
for _, p := range paths {
if excludeSeed[p] || exclude[p] {
continue
}
if names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p); err == nil {
for _, g := range names {
if seedGenres[g] {
weight += mixGenreBoost
break
}
}
}
candidates[p] += weight
}
}
}
return candidates
}
// weightedSample picks up to n distinct keys from weights without
// replacement, biased toward higher weight (roulette-wheel selection).
// A key with zero or negative weight is never picked.
func weightedSample(weights map[string]float64, n int) []string {
type entry struct {
key string
weight float64
}
pool := make([]entry, 0, len(weights))
var total float64
for k, w := range weights {
if w <= 0 {
continue
}
pool = append(pool, entry{k, w})
total += w
}
picked := make([]string, 0, min(n, len(pool)))
for len(picked) < n && len(pool) > 0 {
r := rand.Float64() * total
idx := 0
for i, e := range pool {
r -= e.weight
if r <= 0 {
idx = i
break
}
}
picked = append(picked, pool[idx].key)
total -= pool[idx].weight
pool = append(pool[:idx], pool[idx+1:]...)
}
return picked
}
+267
View File
@@ -0,0 +1,267 @@
package explore
import (
"context"
"fmt"
"log/slog"
"testing"
"yellowjacket/backend/database"
)
// seedMixTrack inserts one owned track by the given artist (creating
// the artist/artist_credit/recording/audio_file chain as needed),
// tagged with the given genres.
func seedMixTrack(
t *testing.T,
db *database.DB,
id int,
artistName, artistMBID string,
genreNames ...string,
) string {
t.Helper()
fp := fmt.Sprintf("/music/%s/track%d.mp3", artistName, id)
_, err := db.ExecContext(
"INSERT INTO artists (id, name, mbid) VALUES (?, ?, ?) "+
"ON CONFLICT(name) DO NOTHING",
id, artistName, artistMBID,
)
if err != nil {
t.Fatalf("insert artist: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (?, ?) "+
"ON CONFLICT(text) DO NOTHING",
id, artistName,
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT OR IGNORE INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)",
id, id,
)
if err != nil {
t.Fatalf("insert artist_credit_artist: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (?, ?, ?)",
id, fmt.Sprintf("Track %d", id), id,
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+
"VALUES (?, ?, 180000, 0, ?)",
id, fp, id,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
for _, g := range genreNames {
var genreID int64
row := db.QueryRowWriter(
"INSERT INTO genres (name) VALUES (?) "+
"ON CONFLICT(name) DO UPDATE SET name = name RETURNING id",
g,
)
if err := row.Scan(&genreID); err != nil {
t.Fatalf("upsert genre %q: %v", g, err)
}
_, err = db.ExecContext(
"INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) VALUES (?, ?)",
id, genreID,
)
if err != nil {
t.Fatalf("insert recording_genre: %v", err)
}
}
return fp
}
// seedSimilarArtist records a pre-computed similarity row, as the
// Tier 4 index build / lazy LB fetch would.
func seedSimilarArtist(
t *testing.T,
db *database.DB,
sourceMBID, similarMBID, similarName string,
score int,
) {
t.Helper()
_, err := db.ExecContext(
"INSERT INTO similar_artist_map "+
"(source_artist_mbid, similar_artist_mbid, similar_artist_name, score) "+
"VALUES (?, ?, ?, ?)",
sourceMBID, similarMBID, similarName, score,
)
if err != nil {
t.Fatalf("insert similar_artist_map row: %v", err)
}
}
func newMixTestService(db *database.DB) *Service {
return &Service{db: db, logger: slog.Default()}
}
func TestGenerateMix_ExpandsToSimilarLibraryArtists(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
seedPath := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed")
similarPath := seedMixTrack(t, db, 2, "Similar Artist", "mbid-similar")
unrelatedPath := seedMixTrack(t, db, 3, "Unrelated Artist", "mbid-unrelated")
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar", "Similar Artist", 90)
paths, label, err := e.GenerateMix(context.Background(), []string{seedPath}, false)
if err != nil {
t.Fatalf("GenerateMix: %v", err)
}
if len(paths) != 1 || paths[0] != similarPath {
t.Errorf("paths: got %v, want [%q]", paths, similarPath)
}
for _, p := range paths {
if p == unrelatedPath {
t.Error("mix included a track by an artist with no recorded similarity")
}
}
if label == "" {
t.Error("label: got empty string, want a seed-derived label")
}
}
func TestGenerateMix_BoostsSharedGenre(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
seedPath := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed", "Shoegaze")
matchingGenrePath := seedMixTrack(t, db, 2, "Similar A", "mbid-similar-a", "Shoegaze")
differentGenrePath := seedMixTrack(t, db, 3, "Similar B", "mbid-similar-b", "Ambient")
// Same base similarity score for both, so genre is what breaks the tie.
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar-a", "Similar A", 50)
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar-b", "Similar B", 50)
candidates := e.mixCandidates(
context.Background(),
map[string]int{"mbid-seed": 1},
map[string]bool{"Shoegaze": true},
[]string{seedPath},
nil,
)
if candidates[matchingGenrePath] <= candidates[differentGenrePath] {
t.Errorf(
"weight: shared-genre candidate (%v) should outweigh the other (%v)",
candidates[matchingGenrePath], candidates[differentGenrePath],
)
}
}
func TestGenerateMix_ExcludesAlreadyPlayed(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
seedPath := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed")
similarPath := seedMixTrack(t, db, 2, "Similar Artist", "mbid-similar")
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar", "Similar Artist", 90)
ctx := context.Background()
first, _, err := e.GenerateMix(ctx, []string{seedPath}, false)
if err != nil {
t.Fatalf("first GenerateMix: %v", err)
}
if len(first) != 1 || first[0] != similarPath {
t.Fatalf("first batch: got %v, want [%q]", first, similarPath)
}
// Continuing the same session, with nothing new to offer: rather
// than dead-ending, it should replay from the pool instead of
// returning nothing.
second, _, err := e.GenerateMix(ctx, nil, true)
if err != nil {
t.Fatalf("second GenerateMix: %v", err)
}
if len(second) != 1 || second[0] != similarPath {
t.Errorf(
"second batch: got %v, want [%q] (replayed after exhausting the pool)",
second, similarPath,
)
}
}
func TestGenerateMix_ContinuingIgnoresNewSeed(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
originalSeed := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed")
_ = seedMixTrack(t, db, 2, "Similar Artist", "mbid-similar")
unrelatedSeed := seedMixTrack(t, db, 3, "Other Artist", "mbid-other")
similarToUnrelated := seedMixTrack(t, db, 4, "Other Similar", "mbid-other-similar")
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar", "Similar Artist", 90)
seedSimilarArtist(t, db, "mbid-other", "mbid-other-similar", "Other Similar", 90)
ctx := context.Background()
if _, _, err := e.GenerateMix(ctx, []string{originalSeed}, false); err != nil {
t.Fatalf("GenerateMix: %v", err)
}
// A second, unrelated seed passed while "continuing" is ignored —
// the mix stays anchored to what it started with.
paths, _, err := e.GenerateMix(ctx, []string{unrelatedSeed}, true)
if err != nil {
t.Fatalf("GenerateMix (continuing): %v", err)
}
for _, p := range paths {
if p == similarToUnrelated {
t.Error("continuing mix drifted to the newly passed seed instead of the original")
}
}
}
func TestGenerateMix_NoSeedArtistDataReturnsNothing(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
// A file path with no matching audio_files row at all.
paths, label, err := e.GenerateMix(context.Background(), []string{"/nowhere.mp3"}, false)
if err != nil {
t.Fatalf("GenerateMix: %v", err)
}
if len(paths) != 0 || label != "" {
t.Errorf("got (%v, %q), want (nil, \"\")", paths, label)
}
}
+4
View File
@@ -623,6 +623,10 @@ func convertRelease(r musicbrainzws2.Release) MBRelease {
ArtistCredit: r.ArtistCredit.String(),
}
if r.ReleaseGroup != nil {
rel.ReleaseGroupMBID = string(r.ReleaseGroup.ID)
}
for _, m := range r.Media {
// Skip video media outright — DVD/Blu-ray bonus discs
// inflate track counts and wreck track-count-based scoring
+6
View File
@@ -88,6 +88,12 @@ type MBRelease struct {
Status string `json:"status"`
ArtistCredit string `json:"artistCredit,omitempty"`
Tracks []MBTrack `json:"tracks,omitempty"`
// ReleaseGroupMBID is the parent release group's MBID. Empty unless
// the lookup requested the "release-groups" include (LookupRelease
// does); used to resolve a release-level MBID (what many taggers
// write) back to the release-group MBID everything else on the
// album page is keyed by.
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
}
// MBRecording is a Wails-friendly projection of a MusicBrainz
+252
View File
@@ -0,0 +1,252 @@
package library
import (
"testing"
)
// track is one row of release_group_recordings as the scan would write
// it: a position on a disc, and whatever total the file's tag declared
// (0 meaning the tag did not say).
type track struct {
recordingID int
disc int
number int
total int
}
// stageAlbum writes an album's tracks straight into
// release_group_recordings. The completeness query reads only that
// table, so this exercises the arithmetic without standing up a scan.
func stageAlbum(t *testing.T, lib *Library, albumID int, tracks []track) {
t.Helper()
// The foreign keys are enforced, so the album and its recordings
// have to exist before they can be linked.
if _, err := lib.db.ExecContext(
`INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')`,
); err != nil {
t.Fatalf("staging artist credit: %v", err)
}
if _, err := lib.db.ExecContext(
`INSERT INTO release_groups (id, name, album_artist_credit_id)
VALUES (?, ?, 1)`,
albumID, "Test Album",
); err != nil {
t.Fatalf("staging album: %v", err)
}
for _, tr := range tracks {
if _, err := lib.db.ExecContext(
`INSERT INTO recordings (id, name, artist_credit_id) VALUES (?, ?, 1)`,
tr.recordingID, "Test Track",
); err != nil {
t.Fatalf("staging recording %d: %v", tr.recordingID, err)
}
}
for _, tr := range tracks {
var total any
if tr.total > 0 {
total = tr.total
}
var number any
if tr.number > 0 {
number = tr.number
}
_, err := lib.db.ExecContext(
`INSERT INTO release_group_recordings
(release_group_id, recording_id, track_number, disc_number, total_tracks)
VALUES (?, ?, ?, ?, ?)`,
albumID, tr.recordingID, number, tr.disc, total,
)
if err != nil {
t.Fatalf("staging track %d: %v", tr.recordingID, err)
}
}
}
// disc builds a run of tracks on one disc, each declaring the same
// total — which is what a correctly tagged rip looks like.
func disc(discNum, firstRecordingID, held, declared int) []track {
out := make([]track, 0, held)
for i := range held {
out = append(out, track{
recordingID: firstRecordingID + i,
disc: discNum,
number: i + 1,
total: declared,
})
}
return out
}
func TestGetAlbumCompleteness(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tracks []track
wantOwned int
wantExpected int
wantKnown bool
wantComplete bool
}{
{
name: "every track present",
tracks: disc(1, 100, 12, 12),
wantOwned: 12,
wantExpected: 12,
wantKnown: true,
wantComplete: true,
},
{
name: "three tracks short",
tracks: disc(1, 200, 9, 12),
wantOwned: 9,
wantExpected: 12,
wantKnown: true,
wantComplete: false,
},
{
// A bonus track puts the folder over its declared total.
// That is a complete album, not a broken one — which is
// why Complete is >= and not ==.
name: "bonus track over the declared total",
tracks: disc(1, 300, 13, 12),
wantOwned: 13,
wantExpected: 12,
wantKnown: true,
wantComplete: true,
},
{
// The whole reason Known exists: an untagged rip declares
// no total, and a ring drawn from that would mark most of
// an untagged library incomplete on no evidence.
name: "no totals declared at all",
tracks: []track{
{recordingID: 400, disc: 1, number: 1},
{recordingID: 401, disc: 1, number: 2},
},
wantOwned: 2,
wantExpected: 0,
wantKnown: false,
wantComplete: false,
},
{
// Totals are per disc, so the expectation is a sum and not
// a single number — the bug this shape exists to catch is
// reading one disc's "10" as the whole album's.
name: "two discs, one short",
tracks: append(disc(1, 500, 10, 10), disc(2, 600, 2, 5)...),
wantOwned: 12,
wantExpected: 15,
wantKnown: true,
wantComplete: false,
},
{
name: "two discs, both complete",
tracks: append(disc(1, 700, 10, 10), disc(2, 800, 5, 5)...),
wantOwned: 15,
wantExpected: 15,
wantKnown: true,
wantComplete: true,
},
{
// One disc ripped by a tagger that wrote totals, one by a
// tagger that did not. The album's total is unknowable —
// the disc that did declare cannot stand in for the one
// that did not.
name: "one disc untotalled",
tracks: append(
disc(1, 900, 10, 10),
track{recordingID: 950, disc: 2, number: 1},
),
wantOwned: 11,
wantExpected: 10,
wantKnown: false,
wantComplete: false,
},
{
// This app detects duplicates, so it must not be fooled by
// them: two files of track 3 are one track held, and
// counting both would report a short album as complete.
name: "a duplicated track counts once",
tracks: append(
disc(1, 1000, 5, 6),
track{recordingID: 1099, disc: 1, number: 3, total: 6},
),
wantOwned: 5,
wantExpected: 6,
wantKnown: true,
wantComplete: false,
},
{
// Untotalled *and* unnumbered: the fallback keys off the
// recording id, so these must not collapse into one.
name: "unnumbered tracks stay distinct",
tracks: []track{
{recordingID: 1100, disc: 1},
{recordingID: 1101, disc: 1},
{recordingID: 1102, disc: 1},
},
wantOwned: 3,
wantExpected: 0,
wantKnown: false,
wantComplete: false,
},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
albumID := i + 1
stageAlbum(t, lib, albumID, tc.tracks)
got, err := lib.GetAlbumCompleteness(int64(albumID))
if err != nil {
t.Fatalf("GetAlbumCompleteness: %v", err)
}
if got.Owned != tc.wantOwned {
t.Errorf("owned = %d, want %d", got.Owned, tc.wantOwned)
}
if got.Expected != tc.wantExpected {
t.Errorf("expected = %d, want %d", got.Expected, tc.wantExpected)
}
if got.Known != tc.wantKnown {
t.Errorf("known = %v, want %v", got.Known, tc.wantKnown)
}
if got.Complete != tc.wantComplete {
t.Errorf("complete = %v, want %v", got.Complete, tc.wantComplete)
}
})
}
}
// An album with no rows at all must not read as "complete" by virtue of
// holding everything it knows about, which is nothing.
func TestGetAlbumCompleteness_EmptyAlbum(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
got, err := lib.GetAlbumCompleteness(999)
if err != nil {
t.Fatalf("GetAlbumCompleteness: %v", err)
}
if got.Known || got.Complete || got.Owned != 0 {
t.Errorf("empty album reported %+v, want zero and unknown", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
package library
import "testing"
func TestIsWithinDir(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path string
dir string
want bool
}{
{"root contains anything", "Artist/Album/01.mp3", ".", true},
{"root contains itself", ".", ".", true},
{"same directory", "Artist/Album", "Artist/Album", true},
{"direct child file", "Artist/Album/01.mp3", "Artist/Album", true},
{"nested subdirectory", "Artist/Album/CD1/01.mp3", "Artist/Album", true},
{"sibling not contained", "Artist/OtherAlbum", "Artist/Album", false},
{
"prefix-colliding sibling not contained",
"Artist/Album2/01.mp3", "Artist/Album",
false,
},
{"parent not contained in child", "Artist", "Artist/Album", false},
{"unrelated tree", "Other/Thing", "Artist/Album", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isWithinDir(tc.path, tc.dir); got != tc.want {
t.Errorf("isWithinDir(%q, %q) = %v, want %v", tc.path, tc.dir, got, tc.want)
}
})
}
}
+192 -20
View File
@@ -373,6 +373,7 @@ func (l *Library) scanInternal(
workChan := make(chan scanWork, 100)
resultChan := make(chan importResult, 100)
dirDoneChan := make(chan dirClosed, 100)
var added, skipped, updated atomic.Int64
@@ -396,6 +397,24 @@ func (l *Library) scanInternal(
close(workChan)
}()
// stack tracks the directories the walk currently has open, so
// that once one is fully enumerated (see isWithinDir) its total
// scanWork count can be reported to the DB writer as a single
// dirClosed event — see the dirClosed doc comment for why.
var stack []*openDir
closeDirsNotContaining := func(path string) {
for len(stack) > 0 && !isWithinDir(path, stack[len(stack)-1].relPath) {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
select {
case dirDoneChan <- dirClosed{dir: top.absDir, expected: top.expected}:
case <-scanCtx.Done():
}
}
}
walkErr := fs.WalkDir(
os.DirFS(basePath),
".",
@@ -409,7 +428,14 @@ func (l *Library) scanInternal(
return nil // continue walking
}
closeDirsNotContaining(path)
if d.IsDir() {
stack = append(stack, &openDir{
relPath: path,
absDir: filepath.Join(basePath, path),
})
return nil
}
@@ -467,6 +493,9 @@ func (l *Library) scanInternal(
contentChanged: contentChanged,
modTime: diskModTime,
}:
if len(stack) > 0 {
stack[len(stack)-1].expected++
}
case <-scanCtx.Done():
return scanCtx.Err()
}
@@ -510,6 +539,9 @@ func (l *Library) scanInternal(
fileType: fileType,
modTime: diskModTime,
}:
if len(stack) > 0 {
stack[len(stack)-1].expected++
}
case <-scanCtx.Done():
return scanCtx.Err()
}
@@ -526,6 +558,24 @@ func (l *Library) scanInternal(
),
)
}
// Close whatever's left on the stack, root included — the walk
// ended (normally or via cancellation) without another path
// ever coming along to trigger closeDirsNotContaining for
// these. isWithinDir treats "." (root) as containing every
// path, so closeDirsNotContaining itself can never pop it;
// unwind directly instead.
for len(stack) > 0 {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
select {
case dirDoneChan <- dirClosed{dir: top.absDir, expected: top.expected}:
case <-scanCtx.Done():
}
}
close(dirDoneChan)
}()
// --- Thumbnail worker pool (async, decoupled from DB writer) ---
@@ -624,21 +674,100 @@ func (l *Library) scanInternal(
batch = batch[:0]
}
for result := range resultChan {
// Thread library ID into each result for saveAudioFile.
result.libraryID = libraryID
// pending buffers extracted results by directory (keyed the
// same way GroupKey's caller derives it, filepath.Dir on the
// absolute path) until that directory's dirClosed event says
// no more are coming — see the dirClosed doc comment. Only
// then can ResolveDirectoryDiscNumbers see the whole
// directory's disc tags at once instead of each file
// guessing from its own tag alone.
pending := make(map[string][]importResult)
expected := make(map[string]int)
dirClosedSeen := make(map[string]bool)
if !dbStarted {
dbStartVal = time.Now()
dbStarted = true
resolveAndBatch := func(dir string) {
results := pending[dir]
delete(pending, dir)
delete(expected, dir)
delete(dirClosedSeen, dir)
if len(results) == 0 {
return
}
discs := make([]int, len(results))
for i, r := range results {
if r.tags != nil {
discs[i] = r.tags.DiscNumber
}
}
resolved := autotag.ResolveDirectoryDiscNumbers(discs)
for i := range results {
if results[i].tags != nil {
results[i].tags.DiscNumber = resolved[i]
}
// Thread library ID into each result for saveAudioFile.
results[i].libraryID = libraryID
batch = append(batch, results[i])
}
batch = append(batch, result)
if len(batch) >= scanBatchSize {
flushBatch()
}
}
rc, dc := resultChan, dirDoneChan
for rc != nil || dc != nil {
select {
case result, ok := <-rc:
if !ok {
rc = nil
continue
}
if !dbStarted {
dbStartVal = time.Now()
dbStarted = true
}
dir := filepath.Dir(result.absolutePath)
pending[dir] = append(pending[dir], result)
if dirClosedSeen[dir] && len(pending[dir]) >= expected[dir] {
resolveAndBatch(dir)
}
case d, ok := <-dc:
if !ok {
dc = nil
continue
}
expected[d.dir] = d.expected
dirClosedSeen[d.dir] = true
if len(pending[d.dir]) >= d.expected {
resolveAndBatch(d.dir)
}
}
}
// Anything still buffered here belongs to a directory whose
// expected count was never reached — an extraction failure
// (see Phase 3: a failed file is warned-and-dropped, never
// reaching resultChan) or a dirClosed event lost to
// cancellation. Flush it anyway so no extracted file is
// silently dropped; it just resolves from whatever subset of
// the directory's disc tags actually arrived.
for dir := range pending {
resolveAndBatch(dir)
}
flushBatch()
if dbStarted {
@@ -1207,6 +1336,43 @@ type importResult struct {
modTime int64 // mtime baseline to persist (Unix seconds)
}
// dirClosed reports that the walk has fully enumerated a directory's
// audio files and will never enqueue another scanWork for it — expected
// is exactly how many scanWork items were sent for it. The DB writer
// uses this to know when it has every file it's going to get for that
// directory, so it can resolve disc-number consensus across the whole
// directory (autotag.ResolveDirectoryDiscNumbers) instead of each file
// guessing in isolation.
type dirClosed struct {
dir string
expected int
}
// openDir is one frame of the walk goroutine's directory stack — see
// isWithinDir and its use in scanInternal's walk phase. relPath is
// the fs.WalkDir-relative path (slash-separated, root as "."), used
// only to detect when the walk has moved on to something outside this
// directory; absDir is the OS-native absolute path, which is what
// dirClosed reports and what the DB writer's importResult.absolutePath
// values key against via filepath.Dir.
type openDir struct {
relPath string
absDir string
expected int
}
// isWithinDir reports whether the fs.WalkDir-relative path is dir
// itself or something inside it. dir == "." (the library root) is
// always within, since fs.WalkDir's root path is "." and nothing on
// this stack can ever be outside the tree being walked.
func isWithinDir(path, dir string) bool {
if dir == "." {
return true
}
return path == dir || strings.HasPrefix(path, dir+"/")
}
// extractAudioMetadata reads and extracts metadata from an audio file.
// It opens the file once, extracting both tags and duration in a
// single pass, and records per-file timing in the shared metrics.
@@ -1428,7 +1594,7 @@ func (l *Library) saveAudioFile(
GroupKey: groupKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: resolveAlbumArtistName(tags),
AlbumArtist: tags.AlbumArtist,
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
@@ -1669,6 +1835,7 @@ func (l *Library) processMetadata(
RecordingID: recording.ID,
TrackNumber: toNullInt64(tags.TrackNumber),
DiscNumber: toNullInt64(tags.DiscNumber),
TotalTracks: toNullInt64(tags.TotalTracks),
},
)
if err != nil {
@@ -1718,6 +1885,22 @@ func (l *Library) updateMBIDs(
"UPDATE release_groups SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')",
tags.ReleaseGroupMBID, releaseGroupID,
)
} else if tags.ReleaseMBID != "" && releaseGroupID > 0 {
// Many taggers write MUSICBRAINZ_ALBUMID (a specific release)
// but not MUSICBRAINZ_RELEASEGROUPID (the abstract release
// group everything else on this page is keyed by) — without
// this, a genuinely MBID-tagged album shows as "library only"
// forever. A scan can't afford a live MusicBrainz call to
// resolve release->release-group here, so the release MBID is
// stashed for `explore.Service.BackfillReleaseGroupMBIDs` to
// resolve in the background, the same way discography
// enrichment is deferred out of the scan path.
_, _ = tx.ExecContext(l.ctx,
"UPDATE release_groups SET pending_release_mbid = ? "+
"WHERE id = ? AND (mbid IS NULL OR mbid = '') "+
"AND (pending_release_mbid IS NULL OR pending_release_mbid = '')",
tags.ReleaseMBID, releaseGroupID,
)
}
// Recording MBID.
@@ -1729,17 +1912,6 @@ func (l *Library) updateMBIDs(
}
}
// resolveAlbumArtistName returns the album-artist tag for tagging-
// group bookkeeping, falling back to the track artist when the
// album-artist field is empty.
func resolveAlbumArtistName(tags *metadata.TrackMetadata) string {
if tags.AlbumArtist != "" {
return tags.AlbumArtist
}
return tags.Artist
}
// maybeRebindTaggingGroup recomputes the group key from the freshly
// extracted metadata and, if it differs from the row's current
// group_key, migrates the track: decrement the old group's count
@@ -1780,7 +1952,7 @@ func (l *Library) maybeRebindTaggingGroup(
GroupKey: newKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: resolveAlbumArtistName(tags),
AlbumArtist: tags.AlbumArtist,
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
+51
View File
@@ -302,6 +302,57 @@ func (l *Library) SearchTracks(
return tracks, nil
}
// AlbumCompleteness says how much of an album is present, as the files
// themselves claim.
//
// Known is the part that matters: a tag that never declared a total is
// not the same as a total that is unmet, and rendering the two alike
// would put an "incomplete" mark on most of an untagged library. When
// Known is false, Expected means nothing and the caller must say
// nothing.
type AlbumCompleteness struct {
Owned int `json:"owned"`
Expected int `json:"expected"`
Known bool `json:"known"`
Complete bool `json:"complete"`
}
// GetAlbumCompleteness answers "do I have all of this album" from the
// tags read at scan time, with no network.
//
// The album page used to ask MusicBrainz, because the only track total
// it had was the length of whatever tracklist it was already showing —
// which for a library copy is a tautology. The denominator in a file's
// "5/12" is a real answer and it is already on disk; this is where it
// gets read.
//
// Complete is deliberately >= rather than ==: bonus and hidden tracks
// routinely put a folder over its declared total, and that is a
// complete album, not a broken one.
func (l *Library) GetAlbumCompleteness(albumID int64) (AlbumCompleteness, error) {
row, err := l.db.ReadQueries.GetAlbumCompleteness(l.ctx, albumID)
if err != nil {
l.logger.Error("could not read album completeness",
"albumID", albumID, "error", err,
)
return AlbumCompleteness{}, fmt.Errorf(
"could not get album completeness: %w", err,
)
}
// A disc whose files all declared nothing leaves the album's total
// unknowable — the discs that did declare cannot stand in for it.
known := row.DiscsUntotalled == 0 && row.Expected > 0
return AlbumCompleteness{
Owned: int(row.Owned),
Expected: int(row.Expected),
Known: known,
Complete: known && row.Owned >= row.Expected,
}, nil
}
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
rows, err := l.db.ReadQueries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
+233
View File
@@ -0,0 +1,233 @@
package library
import (
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/tagwriter"
"yellowjacket/internal/testfixtures"
)
// copyFile copies an untagged real MP3 fixture (decodable, so
// metadata extraction and duration decoding both work exactly as
// they would on a real library file) to path.
func copyFile(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
t.Fatalf("open fixture %s: %v", src, err)
}
defer func() { _ = in.Close() }()
out, err := os.Create(dst)
if err != nil {
t.Fatalf("create %s: %v", dst, err)
}
defer func() { _ = out.Close() }()
if _, err := io.Copy(out, in); err != nil {
t.Fatalf("copy fixture to %s: %v", dst, err)
}
}
// writeTestTrack copies a real, untagged MP3 fixture to path and,
// when discNumber is non-zero, stamps a disc-number tag onto it via
// the same tagwriter path the app itself uses to write tags — a
// discNumber of 0 leaves the file untagged, exactly like a track
// whose disc frame was never set.
func writeTestTrack(t *testing.T, path string, discNumber int) {
t.Helper()
m := testfixtures.Load(t)
blank := m.Abs("unsorted/no-tags-at-all.mp3")
copyFile(t, blank, path)
if discNumber == 0 {
return
}
if err := tagwriter.WriteFileTags(
slog.Default(), path,
tagwriter.TagChanges{tagwriter.FieldDiscNumber: discNumber},
); err != nil {
t.Fatalf("write disc tag on %s: %v", path, err)
}
}
// scanTestGroupKeys creates a library row at root, runs a real
// synchronous scan of it, and returns the group_key each resulting
// audio_files row landed on, keyed by absolute file path.
func scanTestGroupKeys(t *testing.T, lib *Library, root string) map[string]string {
t.Helper()
library, err := lib.db.Queries.CreateLibrary(lib.ctx, sqlcgen.CreateLibraryParams{
Name: root,
Path: root,
})
if err != nil {
t.Fatalf("create library: %v", err)
}
metrics := lib.scanInternal(library.ID, library.Name, library.Path)
if metrics == nil {
t.Fatal("scanInternal returned nil metrics")
}
rows, err := lib.db.Queries.GetAudioFilesByLibrary(lib.ctx, library.ID)
if err != nil {
t.Fatalf("list audio files: %v", err)
}
got := make(map[string]string, len(rows))
for _, r := range rows {
got[r.FilePath] = r.GroupKey
}
return got
}
// TestScan_PartialDiscTaggingWithinOneFolderDoesNotFragment guards the
// fix for a real-world bug: a folder where only some tracks carry an
// explicit disc tag (common when files were ripped or re-tagged at
// different times) must not split into two tagging groups for what is
// really one single-disc album. Before directory-batched disc
// resolution, each file resolved its own group_key from only its own
// tag, so an untagged track always folded to disc 1 regardless of
// what its siblings said — fragmenting a real disc 2 whenever even one
// of its tracks lacked the tag.
func TestScan_PartialDiscTaggingWithinOneFolderDoesNotFragment(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
root := t.TempDir()
dir := filepath.Join(root, "Artist", "Album")
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
track1 := filepath.Join(dir, "01.mp3")
track2 := filepath.Join(dir, "02.mp3")
track3 := filepath.Join(dir, "03.mp3")
writeTestTrack(t, track1, 2) // explicit disc 2
writeTestTrack(t, track2, 0) // untagged
writeTestTrack(t, track3, 2) // explicit disc 2
keys := scanTestGroupKeys(t, lib, root)
if len(keys) != 3 { //nolint:mnd
t.Fatalf("expected 3 audio files, got %d: %+v", len(keys), keys)
}
if keys[track1] != keys[track2] || keys[track1] != keys[track3] {
t.Errorf(
"expected all three tracks to share one group_key, got %+v",
keys,
)
}
}
// TestScan_GenuineMultiDiscFolderStillSplits is the flip side of the
// partial-tagging fix: a folder with no per-disc subfolders where the
// explicit disc tags genuinely disagree (a real two-disc release
// dumped flat) must still separate into two groups — directory-wide
// consensus must not paper over an actual multi-disc release just
// because it shares one directory.
func TestScan_GenuineMultiDiscFolderStillSplits(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
root := t.TempDir()
dir := filepath.Join(root, "Artist", "Album")
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
disc1TrackA := filepath.Join(dir, "1-01.mp3")
disc1TrackB := filepath.Join(dir, "1-02.mp3")
disc2TrackA := filepath.Join(dir, "2-01.mp3")
disc2TrackB := filepath.Join(dir, "2-02.mp3")
writeTestTrack(t, disc1TrackA, 1)
writeTestTrack(t, disc1TrackB, 1)
writeTestTrack(t, disc2TrackA, 2) //nolint:mnd
writeTestTrack(t, disc2TrackB, 2) //nolint:mnd
keys := scanTestGroupKeys(t, lib, root)
if len(keys) != 4 { //nolint:mnd
t.Fatalf("expected 4 audio files, got %d: %+v", len(keys), keys)
}
if keys[disc1TrackA] != keys[disc1TrackB] {
t.Errorf("disc 1 tracks should share a group_key, got %+v", keys)
}
if keys[disc2TrackA] != keys[disc2TrackB] {
t.Errorf("disc 2 tracks should share a group_key, got %+v", keys)
}
if keys[disc1TrackA] == keys[disc2TrackA] {
t.Errorf("disc 1 and disc 2 must not share a group_key, got %+v", keys)
}
}
// TestScan_MultipleDirectoriesDoNotCrossContaminate scans two
// unrelated folders — one partially disc-tagged, one fully untagged —
// in a single pass, guarding against the directory-batching buffer in
// the DB writer mixing up which files belong to which directory.
func TestScan_MultipleDirectoriesDoNotCrossContaminate(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
root := t.TempDir()
albumA := filepath.Join(root, "Artist", "Album A")
albumB := filepath.Join(root, "Artist", "Album B")
for _, d := range []string{albumA, albumB} {
if err := os.MkdirAll(d, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
}
aTrack1 := filepath.Join(albumA, "01.mp3")
aTrack2 := filepath.Join(albumA, "02.mp3")
bTrack1 := filepath.Join(albumB, "01.mp3")
bTrack2 := filepath.Join(albumB, "02.mp3")
writeTestTrack(t, aTrack1, 2) //nolint:mnd
writeTestTrack(t, aTrack2, 0)
writeTestTrack(t, bTrack1, 0)
writeTestTrack(t, bTrack2, 0)
keys := scanTestGroupKeys(t, lib, root)
if len(keys) != 4 { //nolint:mnd
t.Fatalf("expected 4 audio files, got %d: %+v", len(keys), keys)
}
if keys[aTrack1] != keys[aTrack2] {
t.Errorf("Album A's two tracks should share a group_key: %+v", keys)
}
if keys[bTrack1] != keys[bTrack2] {
t.Errorf("Album B's two tracks should share a group_key: %+v", keys)
}
if keys[aTrack1] == keys[bTrack1] {
t.Errorf("Album A and Album B must not share a group_key: %+v", keys)
}
}
+5 -5
View File
@@ -7,11 +7,11 @@ import (
// emitQueueChanged emits the full queue state to the frontend.
func (q *Queue) emitQueueChanged() {
state := State{
Tracks: q.tracks,
CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode,
RepeatMode: q.repeatMode,
SourcePlaylistID: q.sourcePlaylistID,
Tracks: q.tracks,
CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode,
RepeatMode: q.repeatMode,
Source: q.source,
}
// Ensure tracks is never nil in JSON.
+6 -6
View File
@@ -78,7 +78,7 @@ func TestEmit_SetQueuePushesFullState(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{})
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
@@ -103,7 +103,7 @@ func TestEmit_ClearSendsEmptyNotNilTrackList(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 3)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
rec.Reset()
q.Clear()
@@ -156,7 +156,7 @@ func TestEmit_ToggleShuffleReportsBothModes(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
q.SetQueue(seedAudioFiles(t, db, 5), 0, false)
q.SetQueue(seedAudioFiles(t, db, 5), 0, false, Source{})
rec.Reset()
q.ToggleShuffle()
@@ -190,7 +190,7 @@ func TestEmit_AddTrackSendsDeltaNotSnapshot(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 4)
q.SetQueue(paths[:3], 0, false)
q.SetQueue(paths[:3], 0, false, Source{})
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
@@ -223,7 +223,7 @@ func TestEmit_RemoveTracksReportsPositions(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
@@ -251,7 +251,7 @@ func TestEmit_NextPushesIndexOnly(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 3)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
+213
View File
@@ -0,0 +1,213 @@
package queue
import (
"context"
"sync"
"testing"
"time"
)
// fakeFallbackSource records every call and returns whatever was
// configured, optionally gated by a channel so a test can control
// exactly when resolution completes (to exercise the staleness check).
type fakeFallbackSource struct {
mu sync.Mutex
calls []FallbackContext
paths []string
source Source
err error
// gate, if set, blocks ResolveFallback until closed.
gate chan struct{}
}
func (f *fakeFallbackSource) ResolveFallback(
_ context.Context,
fctx FallbackContext,
) ([]string, Source, error) {
if f.gate != nil {
<-f.gate
}
f.mu.Lock()
f.calls = append(f.calls, fctx)
f.mu.Unlock()
return f.paths, f.source, f.err
}
func (f *fakeFallbackSource) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
func (f *fakeFallbackSource) lastContext() FallbackContext {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls[len(f.calls)-1]
}
// waitUntil polls cond until it's true or fails the test after a
// short deadline, naming what it was waiting for on timeout.
func waitUntil(t *testing.T, cond func() bool, what string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for: %s", what)
}
func TestFallback_TriggersOnNaturalFinish(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
fallbackPaths := seedAudioFiles(t, db, 6)[1:] // distinct from seed
fake := &fakeFallbackSource{
paths: fallbackPaths,
source: Source{Type: "playlist", ID: 9, Label: "Favorites"},
}
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{Type: "album", ID: 1, Label: "Seed Album"})
q.OnPlaybackFinished()
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
waitUntil(t, func() bool {
return q.GetState().Source == fake.source
}, "queue to adopt the fallback source")
state := q.GetState()
if got := len(state.Tracks); got != len(fallbackPaths) {
t.Errorf("track count: got %d, want %d", got, len(fallbackPaths))
}
ctx := fake.lastContext()
if ctx.PreviousSource.Type != "album" {
t.Errorf("previous source type: got %q, want %q", ctx.PreviousSource.Type, "album")
}
if len(ctx.SeedPaths) != 1 || ctx.SeedPaths[0] != seedPaths[0] {
t.Errorf("seed paths: got %v, want %v", ctx.SeedPaths, seedPaths)
}
}
func TestFallback_TriggersOnNextPastEnd(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
fallbackPaths := seedAudioFiles(t, db, 6)[1:]
fake := &fakeFallbackSource{
paths: fallbackPaths,
source: Source{Type: "dynamicMix", Label: "a mix"},
}
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{})
q.Next() // already at the only/last track: exhausts
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
waitUntil(t, func() bool {
return len(q.GetState().Tracks) == len(fallbackPaths)
}, "queue to adopt the fallback tracks")
}
func TestFallback_TriggersOnCurrentTrackRemoved(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
fallbackPaths := seedAudioFiles(t, db, 6)[1:]
fake := &fakeFallbackSource{
paths: fallbackPaths,
source: Source{Type: "playlist", Label: "Favorites"},
}
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{})
q.RemoveTrack(0) // removes the only (currently playing) track
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
waitUntil(t, func() bool {
return len(q.GetState().Tracks) == len(fallbackPaths)
}, "queue to adopt the fallback tracks")
}
func TestFallback_EmptyResultLeavesQueueExhausted(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
fake := &fakeFallbackSource{paths: nil, source: Source{}} // "stop"
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{})
q.OnPlaybackFinished()
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
// Give a wrongly-applied fallback a moment to (not) land.
time.Sleep(20 * time.Millisecond)
state := q.GetState()
if len(state.Tracks) != 1 {
t.Errorf("track count: got %d, want 1 (queue unchanged)", len(state.Tracks))
}
if state.CurrentIndex != -1 {
t.Errorf("currentIndex: got %d, want -1 (still exhausted)", state.CurrentIndex)
}
}
func TestFallback_StaleResolutionDiscarded(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
stalePaths := seedAudioFiles(t, db, 6)[1:4]
freshPaths := seedAudioFiles(t, db, 9)[6:9]
gate := make(chan struct{})
fake := &fakeFallbackSource{
paths: stalePaths,
source: Source{Type: "dynamicMix", Label: "stale"},
gate: gate,
}
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{})
q.OnPlaybackFinished() // starts resolving, blocked on gate
time.Sleep(20 * time.Millisecond) // let the goroutine reach the gate
// Something else claims the queue before the stale resolution lands.
q.SetQueue(freshPaths, 0, false, Source{Type: "album", Label: "fresh"})
close(gate) // let the stale resolution finish and try to apply
waitUntil(t, func() bool {
state := q.GetState()
if state.Source.Label == "stale" {
t.Fatal("stale fallback was applied")
}
return state.Source.Label == "fresh"
}, "the fresh queue to survive the stale fallback")
}
+11 -15
View File
@@ -369,22 +369,16 @@ func (q *Queue) persistState() {
}
}
sourcePlaylistID := sql.NullInt64{}
if q.sourcePlaylistID > 0 {
sourcePlaylistID = sql.NullInt64{
Int64: q.sourcePlaylistID,
Valid: true,
}
}
err := q.db.Queries.UpdateQueueState(
q.db.Ctx,
sqlcgen.UpdateQueueStateParams{
SourcePlaylistID: sourcePlaylistID,
CurrentPosition: int64(q.currentIndex),
ShuffleMode: q.shuffleMode,
RepeatMode: string(q.repeatMode),
ShuffleOrder: shuffleOrderJSON,
CurrentPosition: int64(q.currentIndex),
ShuffleMode: q.shuffleMode,
RepeatMode: string(q.repeatMode),
ShuffleOrder: shuffleOrderJSON,
SourceType: q.source.Type,
SourceID: q.source.ID,
SourceLabel: q.source.Label,
},
)
if err != nil {
@@ -426,8 +420,10 @@ func (q *Queue) RestoreState() {
q.shuffleMode = state.ShuffleMode
q.repeatMode = RepeatMode(state.RepeatMode)
if state.SourcePlaylistID.Valid {
q.sourcePlaylistID = state.SourcePlaylistID.Int64
q.source = Source{
Type: state.SourceType,
ID: state.SourceID,
Label: state.SourceLabel,
}
// Restore shuffle order.
+10 -5
View File
@@ -11,7 +11,7 @@ func TestSaveState_RestoreState_Roundtrip(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{Type: "album", ID: 7, Label: "Abbey Road"})
// Change modes so we test all fields.
q.CycleRepeat() // off -> all
@@ -68,6 +68,11 @@ func TestSaveState_RestoreState_Roundtrip(t *testing.T) {
t.Errorf("repeatMode: got %q, want %q", s2.RepeatMode, s1.RepeatMode)
}
// Source.
if s2.Source != s1.Source {
t.Errorf("source: got %+v, want %+v", s2.Source, s1.Source)
}
// ShuffleOrder.
q.mu.Lock()
q2.mu.Lock()
@@ -113,7 +118,7 @@ func TestSaveState_RestoreState_SingleTrack(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.SaveState()
q2 := NewQueue(slog.Default(), db)
@@ -140,7 +145,7 @@ func TestSaveState_RestoreState_PreservesTrackOrder(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 10)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.SaveState()
q2 := NewQueue(slog.Default(), db)
@@ -182,11 +187,11 @@ func TestSaveState_OverwritesPreviousState(t *testing.T) {
paths := seedAudioFiles(t, db, 8)
// First save: 5 tracks.
q.SetQueue(paths[:5], 0, false)
q.SetQueue(paths[:5], 0, false, Source{})
q.SaveState()
// Second save: 3 different tracks.
q.SetQueue(paths[5:8], 0, false)
q.SetQueue(paths[5:8], 0, false, Source{})
q.SaveState()
q2 := NewQueue(slog.Default(), db)
+6 -6
View File
@@ -76,7 +76,7 @@ func TestPlaybackFailed_EmittedForAMissingFile(t *testing.T) {
paths := seedAudioFiles(t, db, 3)
loader.fails[paths[1]] = true
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.PlayIndex(1)
failure := failureOf(t, rec)
@@ -100,7 +100,7 @@ func TestPlaybackFailed_AutoAdvanceSkipsPastIt(t *testing.T) {
paths := seedAudioFiles(t, db, 3)
loader.fails[paths[1]] = true
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.Play()
// The first track finished: auto-advance lands on the missing
@@ -124,7 +124,7 @@ func TestPlaybackFailed_NextSkipsPastIt(t *testing.T) {
loader.fails[paths[1]] = true
loader.fails[paths[2]] = true
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.Next()
if got := q.GetState().CurrentIndex; got != 3 {
@@ -145,7 +145,7 @@ func TestPlaybackFailed_WholeQueueUnplayableStopsOnce(t *testing.T) {
loader.fails[p] = true
}
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.repeatMode = RepeatAll
rec.Reset()
@@ -179,7 +179,7 @@ func TestQueueExhausted_KeepsTheFinishedTrackLoaded(t *testing.T) {
q, db, _, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.Play()
q.OnPlaybackFinished()
@@ -203,7 +203,7 @@ func TestQueueExhausted_UnloadsWhenTheTrackIsRemoved(t *testing.T) {
q, db, _, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.RemoveTrack(0)
// Nothing left to show, so the bar clears.
+2 -2
View File
@@ -23,7 +23,7 @@ func TestRecordPlay_EmitsPlayCountNotMetadataChanged(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 2)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
rec.Reset()
q.recordPlay(1)
@@ -77,7 +77,7 @@ func TestRecordPlay_ReportsTheStoredCount(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
rec.Reset()
q.recordPlay(1)
+118 -23
View File
@@ -78,6 +78,26 @@ type TrackLoader interface {
UnloadTrack()
}
// FallbackSource resolves what should auto-play, if anything, once the
// queue is exhausted. Implemented outside this package (see app.go) so
// the queue does not need to know about config, playlists or
// similarity data.
type FallbackSource interface {
// ResolveFallback returns the tracks to auto-play next, or an empty
// slice if the configured mode is "stop" or nothing is available.
ResolveFallback(ctx context.Context, fctx FallbackContext) ([]string, Source, error)
}
// FallbackContext is what a FallbackSource needs to decide whether to
// continue an existing fallback (a dynamic mix keeps extending itself)
// or resolve fresh.
type FallbackContext struct {
// PreviousSource is the source of the queue that just exhausted.
PreviousSource Source
// SeedPaths are that queue's track paths, in order.
SeedPaths []string
}
// Track represents a track in the queue with its metadata.
type Track struct {
ID int64 `json:"id"`
@@ -95,11 +115,21 @@ type Track struct {
// State is the full state emitted to the frontend.
type State struct {
Tracks []Track `json:"tracks"`
CurrentIndex int `json:"currentIndex"`
ShuffleMode bool `json:"shuffleMode"`
RepeatMode RepeatMode `json:"repeatMode"`
SourcePlaylistID int64 `json:"sourcePlaylistId"`
Tracks []Track `json:"tracks"`
CurrentIndex int `json:"currentIndex"`
ShuffleMode bool `json:"shuffleMode"`
RepeatMode RepeatMode `json:"repeatMode"`
Source Source `json:"source"`
}
// Source describes the collection a queue was built from — an album, a
// playlist, a genre, an artist — so the frontend can offer to navigate
// back to it. An empty Type means the queue has no single source (the
// whole library, or one ad-hoc track).
type Source struct {
Type string `json:"type"`
ID int64 `json:"id"`
Label string `json:"label"`
}
// IndexChanged is the payload for the QueueIndexChanged event.
@@ -134,18 +164,19 @@ type TracksModified struct {
// Queue manages an ordered list of tracks for playback.
type Queue struct {
ctx context.Context
logger *slog.Logger
db *database.DB
player TrackLoader
ctx context.Context
logger *slog.Logger
db *database.DB
player TrackLoader
fallbackSource FallbackSource
mu sync.Mutex
tracks []Track
currentIndex int
shuffleMode bool
repeatMode RepeatMode
shuffleOrder []int
sourcePlaylistID int64
mu sync.Mutex
tracks []Track
currentIndex int
shuffleMode bool
repeatMode RepeatMode
shuffleOrder []int
source Source
// setQueueGen is incremented each time SetQueue is called. Background
// goroutines check this to detect if they have been superseded.
@@ -174,6 +205,13 @@ func (q *Queue) SetPlayer(player TrackLoader) {
q.player = player
}
// SetFallbackSource provides the queue with what to auto-play, if
// anything, once it runs out. A nil source (the default) leaves
// today's behavior: the queue just goes idle.
func (q *Queue) SetFallbackSource(fs FallbackSource) {
q.fallbackSource = fs
}
// SetQueue replaces the entire queue with new tracks and starts playing.
// When shuffleStart is true and shuffle mode is active, a random first
// track is chosen instead of the one at startIndex. This is intended for
@@ -187,6 +225,7 @@ func (q *Queue) SetQueue(
filePaths []string,
startIndex int,
shuffleStart bool,
source Source,
) {
defer profiling.TimeOp(q.logger, "queue.SetQueue")()
@@ -228,7 +267,7 @@ func (q *Queue) SetQueue(
}
q.tracks = tracks
q.sourcePlaylistID = 0
q.source = source
q.shuffleOrder = nil
// Find the start track within the initial batch.
@@ -1135,11 +1174,11 @@ func (q *Queue) GetState() State {
copy(tracks, q.tracks)
return State{
Tracks: tracks,
CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode,
RepeatMode: q.repeatMode,
SourcePlaylistID: q.sourcePlaylistID,
Tracks: tracks,
CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode,
RepeatMode: q.repeatMode,
Source: q.source,
}
}
@@ -1155,7 +1194,7 @@ func (q *Queue) Clear() {
q.tracks = nil
q.currentIndex = -1
q.shuffleOrder = nil
q.sourcePlaylistID = 0
q.source = Source{}
if q.player != nil {
q.player.UnloadTrack()
@@ -1301,6 +1340,11 @@ func (q *Queue) handleCurrentTrackRemoved() {
// bar blanking while the queue panel still lists what just played
// (H-18). When the current track was removed from the queue, or the
// queue was cleared, there is nothing left to show and it does.
//
// Called with q.mu already held by every caller — so the fallback
// playlist (if any) is only kicked off here, not resolved: resolving
// one can mean library/similarity queries, which must not run under
// this lock. See resolveFallback.
func (q *Queue) onQueueExhausted(unload bool) {
q.logger.Info("Queue exhausted", "unload", unload)
@@ -1312,6 +1356,57 @@ func (q *Queue) onQueueExhausted(unload bool) {
q.emitIndexChanged()
q.persistState()
if q.fallbackSource != nil {
prevSource := q.source
seedPaths := pathsOf(q.tracks)
gen := q.setQueueGen.Add(1)
go q.resolveFallback(gen, prevSource, seedPaths)
}
}
// resolveFallback runs outside q.mu — the fallback source may do
// library/similarity lookups — and, if it finds something, replaces
// the queue via the ordinary SetQueue path. gen guards against a user
// starting something else (or another exhaustion) while this was
// still resolving: SetQueue itself bumps setQueueGen again, so a stale
// result here is simply discarded.
func (q *Queue) resolveFallback(
gen int64,
prevSource Source,
seedPaths []string,
) {
paths, source, err := q.fallbackSource.ResolveFallback(
q.ctx,
FallbackContext{PreviousSource: prevSource, SeedPaths: seedPaths},
)
if err != nil {
q.logger.Error("Failed to resolve fallback playlist", "err", err)
return
}
if len(paths) == 0 {
return
}
if q.setQueueGen.Load() != gen {
return
}
q.SetQueue(paths, 0, false, source)
}
// pathsOf returns the file paths of a track list, in order.
func pathsOf(tracks []Track) []string {
paths := make([]string, len(tracks))
for i, t := range tracks {
paths[i] = t.FilePath
}
return paths
}
// CompactAfterLibraryRemoval reloads queue state from the database
+56 -13
View File
@@ -88,7 +88,7 @@ func TestSetQueue_PopulatesTracks(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
state := q.GetState()
if got := len(state.Tracks); got != 5 {
@@ -100,13 +100,56 @@ func TestSetQueue_PopulatesTracks(t *testing.T) {
}
}
func TestSetQueue_RecordsSource(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
source := Source{Type: "playlist", ID: 42, Label: "Road Trip"}
q.SetQueue(paths, 0, false, source)
if got := q.GetState().Source; got != source {
t.Errorf("source: got %+v, want %+v", got, source)
}
}
func TestSetQueue_ReplacesPriorSource(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false, Source{Type: "album", ID: 1, Label: "First"})
q.SetQueue(paths, 0, false, Source{Type: "genre", Label: "Jazz"})
want := Source{Type: "genre", Label: "Jazz"}
if got := q.GetState().Source; got != want {
t.Errorf("source: got %+v, want %+v", got, want)
}
}
func TestClear_ResetsSource(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false, Source{Type: "album", ID: 1, Label: "Some Album"})
q.Clear()
if got := q.GetState().Source; got != (Source{}) {
t.Errorf("source after Clear: got %+v, want zero value", got)
}
}
func TestSetQueue_WithStartIndex(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{})
state := q.GetState()
if state.CurrentIndex != 2 {
@@ -123,7 +166,7 @@ func TestSetQueue_WithShuffleStart(t *testing.T) {
// Enable shuffle mode first.
q.ToggleShuffle()
q.SetQueue(paths, 0, true)
q.SetQueue(paths, 0, true, Source{})
state := q.GetState()
if !state.ShuffleMode {
@@ -145,7 +188,7 @@ func TestAddTrack_AppendsToQueue(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 4)
q.SetQueue(paths[:3], 0, false)
q.SetQueue(paths[:3], 0, false, Source{})
q.AddTrack(paths[3])
state := q.GetState()
@@ -165,7 +208,7 @@ func TestInsertTracksAt_BeforeCurrentIndex(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 7)
q.SetQueue(paths[:5], 2, false)
q.SetQueue(paths[:5], 2, false, Source{})
// Insert 2 tracks at index 1 (before currentIndex=2).
q.InsertTracksAt(paths[5:7], 1)
@@ -187,7 +230,7 @@ func TestInsertTracksAt_AfterCurrentIndex(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 7)
q.SetQueue(paths[:5], 2, false)
q.SetQueue(paths[:5], 2, false, Source{})
// Insert 2 tracks at index 3 (after currentIndex=2).
q.InsertTracksAt(paths[5:7], 3)
@@ -205,7 +248,7 @@ func TestMoveQueueTracks_ForwardMove(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
// Move track at index 1 to index 3.
q.MoveQueueTracks([]int{1}, 3)
@@ -224,7 +267,7 @@ func TestMoveQueueTracks_BackwardMove(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
// Move track at index 3 to index 1.
q.MoveQueueTracks([]int{3}, 1)
@@ -242,7 +285,7 @@ func TestMoveQueueTracks_MoveCurrentTrack(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{})
// Move the current track (index 2) to index 4.
q.MoveQueueTracks([]int{2}, 4)
@@ -261,7 +304,7 @@ func TestRemoveTrack_RemovesCorrectTrack(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.RemoveTrack(2)
@@ -284,7 +327,7 @@ func TestRemoveTrack_RemoveCurrentTrack(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{})
q.RemoveTrack(2)
@@ -308,7 +351,7 @@ func TestClear_EmptiesQueue(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.Clear()
state := q.GetState()
@@ -327,7 +370,7 @@ func TestToggleShuffle_TogglesMode(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
// Toggle on.
q.ToggleShuffle()
+78
View File
@@ -0,0 +1,78 @@
package backend
import (
"context"
"yellowjacket/backend/config"
"yellowjacket/backend/explore"
"yellowjacket/backend/playlist"
"yellowjacket/backend/queue"
)
// queueFallbackAdapter implements queue.FallbackSource, translating
// the queue's generic "what plays next" question into the configured
// mode plus whichever service (playlist or explore) can answer it —
// so the queue package itself never needs to import config, playlist
// or explore.
type queueFallbackAdapter struct {
config *config.Config
playlist *playlist.Service
explore *explore.Service
}
// ResolveFallback implements queue.FallbackSource.
//
// A dynamic mix in progress is not interrupted by a mode change —
// once started, it keeps extending itself regardless of what the
// configured mode currently says — everything else (a real selection
// running out, or a Favorites fallback finishing) resolves fresh
// according to the configured mode exactly once.
func (a *queueFallbackAdapter) ResolveFallback(
ctx context.Context,
fctx queue.FallbackContext,
) ([]string, queue.Source, error) {
continuing := fctx.PreviousSource.Type == "dynamicMix"
mode := config.QueueFallback(a.config.GetQueueFallback())
if continuing {
mode = config.QueueFallbackDynamicMix
}
switch mode {
case config.QueueFallbackFavorites:
return a.resolveFavorites()
case config.QueueFallbackDynamicMix:
return a.resolveDynamicMix(ctx, fctx.SeedPaths, continuing)
case config.QueueFallbackStop:
return nil, queue.Source{}, nil
default:
return nil, queue.Source{}, nil
}
}
func (a *queueFallbackAdapter) resolveFavorites() ([]string, queue.Source, error) {
paths, err := a.playlist.GetDefaultPlaylistTrackPaths()
if err != nil || len(paths) == 0 {
return nil, queue.Source{}, err
}
info, err := a.playlist.GetDefaultPlaylistInfo()
if err != nil {
return nil, queue.Source{}, err
}
return paths, queue.Source{Type: "playlist", ID: info.ID, Label: info.Name}, nil
}
func (a *queueFallbackAdapter) resolveDynamicMix(
ctx context.Context,
seedPaths []string,
continuing bool,
) ([]string, queue.Source, error) {
paths, label, err := a.explore.GenerateMix(ctx, seedPaths, continuing)
if err != nil || len(paths) == 0 {
return nil, queue.Source{}, err
}
return paths, queue.Source{Type: "dynamicMix", Label: label}, nil
}