release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The operator's library had 536 groups waiting for review, 2026-10-19. A sample
showed most were one recording under two titles on two releases.
- The headline and the Duplicates count leave out cross-release groups, which
the resolver keeps and links: the headline read 3,479 when 536 needed review.
- Any group spanning albums is cross_release and is linked as one song,
whatever its titles say. Nothing is deleted.
- One album, two versions named as such ("Lovesick (Instrumental)") and not
byte-identical: a new class, distinct, dismissed by the resolver.
- One album, one recording under different song titles (mismatch): the
album's cached MusicBrainz track list decides. When exactly one slot's song
is as long as the file and every other slot's song is clearly not, the other
copies are removed and their tracks marked missing, so the reacquisition
sweeper wants the album back. Otherwise the group stays for review.
- The duplicates_found notice moves from the sweep to the resolver: it names
only groups a pass first left for review.
- Resolved automatically lists dismissals and wrong-slot removals beside
merges.
Migration 0094 adds 'distinct' to the class CHECK (rule 36).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
6.9 KiB
Go
208 lines
6.9 KiB
Go
package library
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||
)
|
||
|
||
// notifyingAdmin wires a real notifier for the test and returns an admin whose
|
||
// inbox the test reads.
|
||
func notifyingAdmin(t *testing.T, pool *pgxpool.Pool) dbq.User {
|
||
t.Helper()
|
||
admin, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||
Username: dbtest.TestUserPrefix + "libnotify", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("admin: %v", err)
|
||
}
|
||
SetNotifier(notifications.New(pool, nil, nil))
|
||
t.Cleanup(func() { SetNotifier(nil) })
|
||
return admin
|
||
}
|
||
|
||
type notice struct {
|
||
kind string
|
||
count int64
|
||
body string
|
||
items []string
|
||
}
|
||
|
||
func unreadNotices(t *testing.T, pool *pgxpool.Pool, user dbq.User) []notice {
|
||
t.Helper()
|
||
rows, err := dbq.New(pool).ListNotifications(context.Background(), dbq.ListNotificationsParams{
|
||
UserID: user.ID, PageLimit: 50,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list notifications: %v", err)
|
||
}
|
||
var out []notice
|
||
for _, r := range rows {
|
||
if r.ReadAt.Valid {
|
||
continue
|
||
}
|
||
var p notifications.Payload
|
||
if err := json.Unmarshal(r.Payload, &p); err != nil {
|
||
t.Fatalf("payload: %v", err)
|
||
}
|
||
out = append(out, notice{kind: r.Kind, count: p.Count, body: p.Detail, items: p.Items})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func TestNotifyScanFinished_FailuresCoalesceAndShutdownIsSilent(t *testing.T) {
|
||
pool := newPool(t)
|
||
admin := notifyingAdmin(t, pool)
|
||
ctx := context.Background()
|
||
|
||
notifyScanFinished(ctx, "")
|
||
if got := unreadNotices(t, pool, admin); len(got) != 0 {
|
||
t.Fatalf("a clean scan notified: %+v", got)
|
||
}
|
||
|
||
cancelled, cancel := context.WithCancel(ctx)
|
||
cancel()
|
||
notifyScanFinished(cancelled, "library: context canceled")
|
||
if got := unreadNotices(t, pool, admin); len(got) != 0 {
|
||
t.Fatalf("a scan cut short by shutdown notified: %+v", got)
|
||
}
|
||
|
||
notifyScanFinished(ctx, "library: root /music missing")
|
||
notifyScanFinished(ctx, "library: root /music still missing")
|
||
got := unreadNotices(t, pool, admin)
|
||
if len(got) != 1 || got[0].kind != string(notifications.KindScanFailed) || got[0].count != 2 ||
|
||
got[0].body != "library: root /music still missing" {
|
||
t.Fatalf("notices = %+v, want one scan_failed counting 2 with the latest error", got)
|
||
}
|
||
}
|
||
|
||
// M542: reconcile only records what it marked. The scan tells admins after
|
||
// it has adopted moves and folded replacements, so a file it marked and then
|
||
// healed is never reported as lost.
|
||
func TestReconcileMissing_RecordsTheMarksWithoutNotifying(t *testing.T) {
|
||
pool := newPool(t)
|
||
admin := notifyingAdmin(t, pool)
|
||
s := testScanner(t, populatedRoot(t))
|
||
|
||
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
|
||
seen := map[string]struct{}{}
|
||
for i := 0; i < 10; i++ {
|
||
p := fmt.Sprintf("/music/%02d.mp3", i)
|
||
rows = append(rows, row(byte(i), p, false))
|
||
if i >= 2 {
|
||
seen[p] = struct{}{}
|
||
}
|
||
}
|
||
var stats Stats
|
||
if err := s.reconcileMissing(context.Background(), &fakeReconciler{rows: rows}, seen, &stats); err != nil {
|
||
t.Fatalf("reconcile: %v", err)
|
||
}
|
||
if len(stats.marked) != 2 {
|
||
t.Errorf("marked %d tracks; want the 2 the walk did not see", len(stats.marked))
|
||
}
|
||
if got := unreadNotices(t, pool, admin); len(got) != 0 {
|
||
t.Fatalf("reconcile notified before the scan could heal anything: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestNotifyStillMissing_NamesOnlyTheTracksStillGone(t *testing.T) {
|
||
pool := newPool(t)
|
||
admin := notifyingAdmin(t, pool)
|
||
ctx := context.Background()
|
||
q := dbq.New(pool)
|
||
dir := t.TempDir()
|
||
first, album, artist := seedTrack(t, pool, filepath.Join(dir, "a.mp3"))
|
||
second, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||
Title: "Another Track", AlbumID: album.ID, ArtistID: artist.ID,
|
||
DurationMs: 1000, FilePath: filepath.Join(dir, "b.mp3"), FileSize: 100, FileFormat: "mp3",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// The scan marked both; the second came back (a move it adopted).
|
||
marked := []pgtype.UUID{first.ID, second.ID}
|
||
if _, err := q.MarkTracksMissing(ctx, []pgtype.UUID{first.ID}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
s := New(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil)
|
||
s.notifyStillMissing(ctx, marked)
|
||
got := unreadNotices(t, pool, admin)
|
||
if len(got) != 1 || got[0].kind != string(notifications.KindTracksMissing) || got[0].count != 1 ||
|
||
len(got[0].items) != 1 || got[0].items[0] != "Delete Test Artist – Delete Test Track" {
|
||
t.Fatalf("notices = %+v, want one tracks_missing naming the one track still gone", got)
|
||
}
|
||
|
||
// Nothing still gone: nothing to say.
|
||
if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s.notifyStillMissing(ctx, []pgtype.UUID{second.ID})
|
||
if got := unreadNotices(t, pool, admin); len(got) != 0 {
|
||
t.Fatalf("a healed track was reported: %+v", got)
|
||
}
|
||
}
|
||
|
||
// Admins hear about a duplicate group once, when a resolver pass first leaves
|
||
// it for them. A group the resolver settles or links is never news, and a
|
||
// group already seen by an earlier pass is not news again.
|
||
func TestResolveDuplicates_TellsAdminsOnlyOfNewGroupsLeftForThem(t *testing.T) {
|
||
pool := newPool(t)
|
||
admin := notifyingAdmin(t, pool)
|
||
ctx := context.Background()
|
||
q := dbq.New(pool)
|
||
dir := t.TempDir()
|
||
first, album, artist := seedTrack(t, pool, filepath.Join(dir, "a.mp3"))
|
||
if _, err := pool.Exec(ctx, `UPDATE tracks SET title = 'Alpha' WHERE id = $1`, first.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
second, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||
Title: "Beta", AlbumID: album.ID, ArtistID: artist.ID,
|
||
DurationMs: 1000, FilePath: filepath.Join(dir, "b.mp3"), FileSize: 100, FileFormat: "mp3",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// One album, one recording under two song titles, no track list to decide.
|
||
var groupID pgtype.UUID
|
||
if err := pool.QueryRow(ctx,
|
||
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('notify-review', 'exact') RETURNING id`,
|
||
).Scan(&groupID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := pool.Exec(ctx, `INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`,
|
||
groupID, first.ID, second.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, err := ResolveDuplicates(ctx, pool, nil, "", true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := unreadNotices(t, pool, admin)
|
||
if len(got) != 1 || got[0].kind != string(notifications.KindDuplicatesFound) || got[0].count != 1 ||
|
||
len(got[0].items) != 1 || got[0].items[0] == "" {
|
||
t.Fatalf("after the first pass notices = %+v, want one duplicates_found naming the group", got)
|
||
}
|
||
|
||
if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := ResolveDuplicates(ctx, pool, nil, "", true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := unreadNotices(t, pool, admin); len(got) != 0 {
|
||
t.Fatalf("a group an earlier pass saw was told again: %+v", got)
|
||
}
|
||
}
|