Flag slave-only songs whose album or artist the master has
Scans now mark slave-only songs as missing from the master when the master has their album folder or at least their artist folder. A new Missing from master filter in Review lists them, split by album and artist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
206b2ab5db
commit
4bae6742a4
9 files changed
+281
-15
No files matched your search
@@ -0,0 +1,91 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A slave-only song is "missing from the master" when the master has its
|
||||
// album folder (the song was probably never added there) or at least its
|
||||
// artist folder (the album is missing). Folders are compared with disc
|
||||
// folders ignored and release labels such as "Deluxe Edition" stripped, so
|
||||
// "Robot Face (Deluxe)" in the slave finds "Robot Face" in the master.
|
||||
|
||||
// Missing levels.
|
||||
const (
|
||||
MissingAlbum = "album"
|
||||
MissingArtist = "artist"
|
||||
)
|
||||
|
||||
// folderNorm is a folder name reduced to letters and digits, release labels removed.
|
||||
func folderNorm(name string) string {
|
||||
if w := words(stripRelease(name)); len(w) > 0 {
|
||||
return strings.Join(w, "")
|
||||
}
|
||||
return strings.ToLower(name)
|
||||
}
|
||||
|
||||
// folderKeys returns the normalized artist key and album key of a song's
|
||||
// folders; the album key is empty for a song directly in the artist folder.
|
||||
func folderKeys(rel string) (artist, album string) {
|
||||
var parts []string
|
||||
for _, p := range strings.Split(filepath.Dir(rel), string(filepath.Separator)) {
|
||||
if p != "." && !discFolder.MatchString(p) {
|
||||
parts = append(parts, folderNorm(p))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
album = strings.Join(parts, "/")
|
||||
}
|
||||
return parts[0], album
|
||||
}
|
||||
|
||||
// masterFolders indexes the master's artist and album folders by key, to
|
||||
// their real spelling.
|
||||
type masterFolders struct {
|
||||
artists map[string]string
|
||||
albums map[string]string
|
||||
}
|
||||
|
||||
func buildMasterFolders(rels []string) masterFolders {
|
||||
mf := masterFolders{artists: map[string]string{}, albums: map[string]string{}}
|
||||
for _, rel := range rels {
|
||||
artist, album := folderKeys(rel)
|
||||
if artist == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := mf.artists[artist]; !ok {
|
||||
mf.artists[artist] = firstComponent(rel)
|
||||
}
|
||||
if album != "" {
|
||||
if _, ok := mf.albums[album]; !ok {
|
||||
d := filepath.Dir(rel)
|
||||
for d != "." && discFolder.MatchString(filepath.Base(d)) {
|
||||
d = filepath.Dir(d)
|
||||
}
|
||||
mf.albums[album] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
return mf
|
||||
}
|
||||
|
||||
// missing reports whether the master has a slave-only song's album or
|
||||
// artist, with a reason naming the master's folder.
|
||||
func (mf masterFolders) missing(relSlave string) (level, reason string) {
|
||||
artist, album := folderKeys(relSlave)
|
||||
if artist == "" {
|
||||
return "", ""
|
||||
}
|
||||
if d, ok := mf.albums[album]; ok && album != "" {
|
||||
return MissingAlbum, fmt.Sprintf("the master has the album %q but not this song", d)
|
||||
}
|
||||
if d, ok := mf.artists[artist]; ok {
|
||||
return MissingArtist, fmt.Sprintf("the master has the artist %q but not this album", d)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
)
|
||||
|
||||
func TestMissingFromMaster(t *testing.T) {
|
||||
ev := setup(t, false)
|
||||
m, s := ev.master, ev.slave
|
||||
ev.write(m, "Caravan Palace/Robot Face/01 Lone Digger.flac", "a")
|
||||
ev.write(m, "Caravan Palace/Chronologic/Disc 1/01 Miracle.flac", "b")
|
||||
ev.write(s, "Caravan Palace/Robot Face/01 Lone Digger.flac", "a")
|
||||
ev.write(s, "Caravan Palace/Robot Face/05 Waterguns.mp3", "song") // album in master
|
||||
ev.write(s, "Caravan Palace/robot face (Deluxe Edition)/12 Bonus.mp3", "song") // same album, other spelling
|
||||
ev.write(s, "Caravan Palace/Chronologic/Disc 2/03 Plume.mp3", "song") // disc folders ignored
|
||||
ev.write(s, "Caravan Palace/Panic/01 Dragons.mp3", "song") // only the artist
|
||||
ev.write(s, "Caravan Palace/Loose.mp3", "song") // only the artist
|
||||
ev.write(s, "Unknown Artist/Album/Song.mp3", "song") // neither
|
||||
ev.scan()
|
||||
|
||||
want := map[string]string{
|
||||
"Caravan Palace/Robot Face/05 Waterguns.mp3": "album",
|
||||
"Caravan Palace/robot face (Deluxe Edition)/12 Bonus.mp3": "album",
|
||||
"Caravan Palace/Chronologic/Disc 2/03 Plume.mp3": "album",
|
||||
"Caravan Palace/Panic/01 Dragons.mp3": "artist",
|
||||
"Caravan Palace/Loose.mp3": "artist",
|
||||
"Unknown Artist/Album/Song.mp3": "",
|
||||
"Caravan Palace/Robot Face/01 Lone Digger.flac": "",
|
||||
}
|
||||
for rel, level := range want {
|
||||
mt, err := ev.st.MatchBySlave(ctx, ev.pair.ID, rel)
|
||||
if err != nil {
|
||||
t.Fatal(rel, err)
|
||||
}
|
||||
if mt.Missing != level {
|
||||
t.Errorf("%s: missing %q, want %q", rel, mt.Missing, level)
|
||||
}
|
||||
}
|
||||
mt, _ := ev.st.MatchBySlave(ctx, ev.pair.ID, "Caravan Palace/Chronologic/Disc 2/03 Plume.mp3")
|
||||
if !strings.Contains(mt.Reason, `"Caravan Palace/Chronologic"`) {
|
||||
t.Errorf("reason should name the master's album folder: %q", mt.Reason)
|
||||
}
|
||||
|
||||
sum, err := ev.st.Summary(ctx, ev.pair.ID)
|
||||
if err != nil || sum.Missing["album"] != 3 || sum.Missing["artist"] != 2 {
|
||||
t.Fatalf("summary %+v %v", sum.Missing, err)
|
||||
}
|
||||
if _, n, _ := ev.st.Matches(ctx, ev.pair.ID, store.MatchFilter{Status: "missing"}); n != 5 {
|
||||
t.Fatalf("missing filter: %d", n)
|
||||
}
|
||||
if _, n, _ := ev.st.Matches(ctx, ev.pair.ID, store.MatchFilter{Status: "missing_album"}); n != 3 {
|
||||
t.Fatalf("missing_album filter: %d", n)
|
||||
}
|
||||
|
||||
// Once the master gets the song, it is no longer missing.
|
||||
ev.write(m, "Caravan Palace/Robot Face/05 Waterguns.mp3", "song")
|
||||
ev.scan()
|
||||
if mt, _ := ev.st.MatchBySlave(ctx, ev.pair.ID, "Caravan Palace/Robot Face/05 Waterguns.mp3"); mt.Missing != "" || mt.Status == store.StatusUnmatched {
|
||||
t.Fatalf("now in master: %+v", mt)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,8 @@ type masterIndex struct {
|
||||
byDir map[string][]string // lower(dir) -> rels
|
||||
byTitle map[string][]string // lower(top folder)|normTitle -> rels
|
||||
|
||||
folders masterFolders // artist and album folders, for songs missing from the master
|
||||
|
||||
approx *approxIndex // nil unless the pair enables approximate matching
|
||||
threshold float64
|
||||
verify VerifyFunc // confirms library-wide approximate matches; nil disables that scope
|
||||
@@ -113,6 +115,7 @@ type masterIndex struct {
|
||||
func buildIndex(files map[string]fsops.Info, p store.Pair) *masterIndex {
|
||||
ix := &masterIndex{files: files, lower: map[string]string{}, byDir: map[string][]string{}, byTitle: map[string][]string{}}
|
||||
rels := sortedKeys(files)
|
||||
ix.folders = buildMasterFolders(rels)
|
||||
if p.ApproxMatching {
|
||||
ix.approx = buildApproxIndex(rels)
|
||||
ix.threshold = float64(p.ApproxThreshold) / 100
|
||||
@@ -327,6 +330,9 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
|
||||
pm = &p
|
||||
}
|
||||
e.classify(ctx, l, &m, masterFiles[m.RelMaster], si, pm)
|
||||
if m.Status == store.StatusUnmatched {
|
||||
m.Missing, m.Reason = ix.folders.missing(rel)
|
||||
}
|
||||
if err := e.st.UpsertMatch(ctx, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -483,6 +489,9 @@ func (e *Engine) refresh(ctx context.Context, l *libs, relSlave string, hint ...
|
||||
m.Kind = store.KindNone
|
||||
}
|
||||
e.classify(ctx, l, &m, mi, si, pm)
|
||||
if m.Status == store.StatusUnmatched && pm != nil && pm.Status == store.StatusUnmatched {
|
||||
m.Missing, m.Reason = pm.Missing, pm.Reason // the next scan re-checks the master's folders
|
||||
}
|
||||
_ = e.st.UpsertMatch(ctx, &m)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user