test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m31s
release / Build signed APK (releases and dev) (push) Successful in 4m32s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
A new admin tab, Duplicates, beside Missing files: the proposals from the duplicate sweep, with a Sweep now trigger and a Not duplicates dismissal. Nothing on it merges or deletes; the merge is #3911. Each group shows: - whether it is identical audio or the same recording, with a match percentage from the weakest link between members - every copy's format, size, duration, path, and the likes and plays it carries (every user's; this is admin-only, and it is what decides which copy to keep) - the copy proposed to keep, and the rule that chose it The survivor rule is library.ProposeSurvivor, a pure function the merge will reuse: lossless over lossy, then the larger file, then the copy in the library longest, then lowest id. Bitrate is not in it because the scanner never fills tracks.bitrate, and for one recording at one duration a larger file is the higher bitrate. m4a is not counted as lossless: it may be AAC. The reason names the rule that separated first place from second, not every rule the winner passed. An empty report has three causes, and the page says which: still fingerprinting, the sweep has never run, or it ran and found nothing. The sweep's state and the backfill's progress come back with the groups for that reason. Groups left with fewer than two members since the sweep are not shown. GET /api/admin/library/duplicates, POST .../sweep (202, or 409 sweep_in_progress), POST .../{id}/dismiss (404 duplicate_group_not_pending when already resolved). Migration 0060 indexes play_events by track_id. Its only indexes led with user_id, so each copy's play count, and the merge's repointing of play history, would scan the whole table. Web only, like Missing files: Android has no library-health admin screens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
92 lines
3.3 KiB
Go
92 lines
3.3 KiB
Go
package library
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestProposeSurvivor(t *testing.T) {
|
|
older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
newer := older.Add(24 * time.Hour)
|
|
cases := []struct {
|
|
name string
|
|
cands []SurvivorCandidate
|
|
wantID string
|
|
wantReason string
|
|
}{
|
|
{
|
|
// Lossless wins even against a much larger lossy file, and even
|
|
// when the lossy copy has been in the library longer.
|
|
name: "lossless beats larger and older",
|
|
cands: []SurvivorCandidate{
|
|
{TrackID: "mp3", FileFormat: "mp3", FileSize: 90_000_000, AddedAt: older},
|
|
{TrackID: "flac", FileFormat: "FLAC", FileSize: 30_000_000, AddedAt: newer},
|
|
},
|
|
wantID: "flac", wantReason: "lossless (flac)",
|
|
},
|
|
{
|
|
// m4a may be AAC; it must not outrank an mp3 just for being m4a.
|
|
name: "m4a is not treated as lossless",
|
|
cands: []SurvivorCandidate{
|
|
{TrackID: "m4a", FileFormat: "m4a", FileSize: 5_000_000, AddedAt: older},
|
|
{TrackID: "mp3", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: newer},
|
|
},
|
|
wantID: "mp3", wantReason: "largest file",
|
|
},
|
|
{
|
|
name: "larger file wins among lossy copies",
|
|
cands: []SurvivorCandidate{
|
|
{TrackID: "128k", FileFormat: "mp3", FileSize: 3_400_000, AddedAt: older},
|
|
{TrackID: "320k", FileFormat: "mp3", FileSize: 8_600_000, AddedAt: newer},
|
|
},
|
|
wantID: "320k", wantReason: "largest file",
|
|
},
|
|
{
|
|
// The #3885 pair: identical audio, sizes equal but for the tags.
|
|
name: "the longest-standing copy wins when size ties",
|
|
cands: []SurvivorCandidate{
|
|
{TrackID: "www-02", FileFormat: "mp3", FileSize: 6_900_000, AddedAt: newer},
|
|
{TrackID: "www-01", FileFormat: "mp3", FileSize: 6_900_000, AddedAt: older},
|
|
},
|
|
wantID: "www-01", wantReason: "in the library longest",
|
|
},
|
|
{
|
|
name: "a full tie falls back to the lowest id, stably",
|
|
cands: []SurvivorCandidate{
|
|
{TrackID: "b", FileFormat: "mp3", FileSize: 1, AddedAt: older},
|
|
{TrackID: "a", FileFormat: "mp3", FileSize: 1, AddedAt: older},
|
|
},
|
|
wantID: "a", wantReason: "copies are otherwise identical",
|
|
},
|
|
{
|
|
name: "one copy",
|
|
cands: []SurvivorCandidate{{TrackID: "only", FileFormat: "mp3", FileSize: 1, AddedAt: older}},
|
|
wantID: "only", wantReason: "the only copy",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
id, reason := ProposeSurvivor(tc.cands)
|
|
if id != tc.wantID || reason != tc.wantReason {
|
|
t.Fatalf("ProposeSurvivor = (%q, %q), want (%q, %q)", id, reason, tc.wantID, tc.wantReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The reason must name the rule that decided. Across three copies that is the
|
|
// comparison between first and second place, not the first rule any pair
|
|
// differs on: here the lossy copy differs from the others by format, but the
|
|
// two FLACs are separated by size.
|
|
func TestProposeSurvivor_ReasonIsTheDecidingRule(t *testing.T) {
|
|
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
id, reason := ProposeSurvivor([]SurvivorCandidate{
|
|
{TrackID: "mp3", FileFormat: "mp3", FileSize: 99_000_000, AddedAt: at},
|
|
{TrackID: "flac-small", FileFormat: "flac", FileSize: 20_000_000, AddedAt: at},
|
|
{TrackID: "flac-big", FileFormat: "flac", FileSize: 40_000_000, AddedAt: at},
|
|
})
|
|
if id != "flac-big" || reason != "largest file" {
|
|
t.Fatalf("got (%q, %q), want (flac-big, largest file)", id, reason)
|
|
}
|
|
}
|