release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr is off, and with it the resolver had stopped merging: it only merged when Lidarr had said which copies it mapped. Minstrel is now the only program writing the library, so the rule is about the copies: - A song held twice on one album: the extra copies merge into the one whose tags fit best (then lossless, larger, older). - Across releases or under another title: when every copy but one is a video rip (held back, or named like one and not marked fine), the rips merge into the one that is not. - Clean copies on different releases are linked as one song, as before. - Anything else waits for the operator. Gone with Lidarr: release switches and their settle window, the unmapped-file read, the rip search, the merge guard and its 409/503, lidarr.Client's release methods, and duplicate_group_members.lidarr_state (migration 0078). The duplicates page drops the per-copy Lidarr badge and the blocked-merge notice; past release changes stay in its history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
221 lines
7.1 KiB
Go
221 lines
7.1 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
)
|
|
|
|
// songLinkFixture is the single and the album it came from: one song on two
|
|
// releases, in one pending acoustic group, and a user who liked the single.
|
|
type songLinkFixture struct {
|
|
single, albumCut dbq.Track
|
|
groupID pgtype.UUID
|
|
user dbq.User
|
|
}
|
|
|
|
func newSongLinkFixture(t *testing.T, pool *pgxpool.Pool) songLinkFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
exec := func(sql string, args ...any) {
|
|
t.Helper()
|
|
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
|
t.Fatalf("exec %q: %v", sql, err)
|
|
}
|
|
}
|
|
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Link Artist", SortName: "Link Artist"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
track := func(album, path string) dbq.Track {
|
|
t.Helper()
|
|
a, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: album, SortTitle: album, ArtistID: artist.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
|
Title: "Feel Good Inc.", AlbumID: a.ID, ArtistID: artist.ID,
|
|
DurationMs: 1000, FilePath: path, FileSize: 100, FileFormat: "mp3",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tr
|
|
}
|
|
f := songLinkFixture{
|
|
single: track("Feel Good Inc. (single)", "/music/Link Artist/Feel Good Inc/01 Feel Good Inc.mp3"),
|
|
albumCut: track("Demon Days", "/music/Link Artist/Demon Days/06 Feel Good Inc.mp3"),
|
|
}
|
|
f.user, err = q.CreateUser(ctx, dbq.CreateUserParams{
|
|
Username: dbtest.TestUserPrefix + "song-link", PasswordHash: "x", ApiTokenHash: "song-link-token",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('song-link', 'acoustic') RETURNING id`,
|
|
).Scan(&f.groupID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exec(`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.single.ID, f.albumCut.ID)
|
|
exec(`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, f.user.ID, f.single.ID)
|
|
return f
|
|
}
|
|
|
|
// A cross-release group becomes one song: its copies share a song key, the
|
|
// like on the single reaches the album cut (and is logged for sync), the Liked
|
|
// list shows the song once, a like or unlike on either copy reaches both, and
|
|
// "not the same song" undoes the link.
|
|
func TestCrossReleaseCopiesCountAsOneSong_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
f := newSongLinkFixture(t, pool)
|
|
|
|
res, err := ResolveDuplicates(ctx, pool, nil, "", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Classes[ClassCrossRelease] != 1 || res.SongsLinked != 1 {
|
|
t.Fatalf("classes %v, linked %d; want one cross-release group linked", res.Classes, res.SongsLinked)
|
|
}
|
|
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
|
|
if keys[0] != keys[1] {
|
|
t.Fatalf("song keys differ after linking: %v", keys)
|
|
}
|
|
|
|
liked, err := q.ListLikedTrackIDs(ctx, f.user.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(liked) != 2 {
|
|
t.Errorf("liked track ids = %d, want both copies", len(liked))
|
|
}
|
|
var logged int
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT count(*) FROM library_changes WHERE entity_type = 'like_track' AND op = 'upsert'`,
|
|
).Scan(&logged); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if logged != 1 {
|
|
t.Errorf("logged %d shared likes, want 1 (the album cut)", logged)
|
|
}
|
|
if n, err := q.CountLikedTracks(ctx, f.user.ID); err != nil || n != 1 {
|
|
t.Errorf("CountLikedTracks = %d (%v), want 1 song", n, err)
|
|
}
|
|
rows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{UserID: f.user.ID, Limit: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Errorf("Liked list has %d rows, want the song once", len(rows))
|
|
}
|
|
|
|
// A second pass changes nothing.
|
|
if res, err := ResolveDuplicates(ctx, pool, nil, "", true); err != nil || res.SongsLinked != 0 {
|
|
t.Errorf("second pass linked %d (%v), want 0", res.SongsLinked, err)
|
|
}
|
|
|
|
unliked, err := q.UnlikeTrack(ctx, dbq.UnlikeTrackParams{UserID: f.user.ID, TrackID: f.albumCut.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(unliked) != 2 {
|
|
t.Errorf("unlike removed %d likes, want both copies", len(unliked))
|
|
}
|
|
added, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(added) != 2 {
|
|
t.Errorf("like added %d, want both copies", len(added))
|
|
}
|
|
if again, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID}); err != nil || len(again) != 0 {
|
|
t.Errorf("repeat like added %d (%v), want 0", len(again), err)
|
|
}
|
|
|
|
if err := q.UnlinkDuplicateGroupSongs(ctx, f.groupID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys = songKeys(t, q, f.single.ID, f.albumCut.ID)
|
|
if keys[0] == keys[1] {
|
|
t.Error("song keys still shared after unlinking")
|
|
}
|
|
}
|
|
|
|
// Without the operator's permission the resolver links nothing.
|
|
func TestCrossReleaseLinkNeedsAutoResolve_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
f := newSongLinkFixture(t, pool)
|
|
if _, err := ResolveDuplicates(ctx, pool, nil, "", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
|
|
if keys[0] == keys[1] {
|
|
t.Error("linked with auto-resolve off")
|
|
}
|
|
}
|
|
|
|
// A merge keeps the removed copy's link to the song on another release,
|
|
// whichever copy's id names the song. That copy has no song_id of its own,
|
|
// and before the fix its link was lost whenever the random ids made it the
|
|
// key (the flaky failure in run 8891).
|
|
func TestMergeInheritsSongLink_Integration(t *testing.T) {
|
|
for _, removeAlbumCut := range []bool{true, false} {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
f := newSongLinkFixture(t, pool)
|
|
if _, err := q.LinkTracksAsSong(ctx, []pgtype.UUID{f.single.ID, f.albumCut.ID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loser, kept := f.albumCut, f.single
|
|
if !removeAlbumCut {
|
|
loser, kept = f.single, f.albumCut
|
|
}
|
|
// A third, unlinked copy of the removed track stands in for a survivor.
|
|
other, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
|
Title: loser.Title, AlbumID: loser.AlbumID, ArtistID: loser.ArtistID,
|
|
DurationMs: 1000, FilePath: loser.FilePath + ".copy.mp3", FileSize: 90, FileFormat: "mp3",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := q.MergeInheritSongLink(ctx, dbq.MergeInheritSongLinkParams{SurvivorID: other.ID, LoserID: loser.ID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := q.DeleteTrack(ctx, loser.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys := songKeys(t, q, other.ID, kept.ID)
|
|
if keys[0] != keys[1] {
|
|
t.Errorf("removing the %s: survivor did not join the song", map[bool]string{true: "album cut", false: "single"}[removeAlbumCut])
|
|
}
|
|
}
|
|
}
|
|
|
|
func songKeys(t *testing.T, q *dbq.Queries, ids ...pgtype.UUID) []pgtype.UUID {
|
|
t.Helper()
|
|
rows, err := q.ListTrackSongKeys(context.Background(), ids)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
byID := map[pgtype.UUID]pgtype.UUID{}
|
|
for _, r := range rows {
|
|
byID[r.ID] = r.SongKey
|
|
}
|
|
out := make([]pgtype.UUID, len(ids))
|
|
for i, id := range ids {
|
|
out[i] = byID[id]
|
|
}
|
|
return out
|
|
}
|