Drop Artist/Album/Title depth assumptions from layout checks
Matching has always been by relative path, so any folder layout works as long as both libraries use the same one. Remove the depth and loose-root-file warnings, which flagged valid layouts, and reword the UI and README to say "same folder layout on both sides" with Artist/Album/Title.ext only as an example. The "elsewhere" fallback is now described as same top-level folder, different subfolder. Add a test covering flat and deeply nested layouts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f49274360b
commit
75e0516434
9 files changed
+52
-33
No files matched your search
@@ -9,13 +9,17 @@ It ships as a single Go binary with an embedded React web UI and SQLite state, p
|
|||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
Files are matched by their **path relative to each library root**. Any folder layout works (`Artist/Album/Title.ext`,
|
||||||
|
`Genre/Artist/Title.ext`, flat, …) as long as both libraries use the same one, so that a song sits at the same relative
|
||||||
|
path on both sides. The fallbacks below catch small differences, and a scan warns when the two layouts don't line up.
|
||||||
|
|
||||||
| Situation | What playlink does |
|
| Situation | What playlink does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Same `Artist/Album/Title.ext` path, **byte-identical** | Status `identical`. Link with one click, in bulk, or automatically after each scan. |
|
| Same relative path, **byte-identical** | Status `identical`. Link with one click, in bulk, or automatically after each scan. |
|
||||||
| Same path, **different bytes** (re-encode, different tags) | Status `different`. Manual review with a codec/bitrate/bit-depth/duration comparison. |
|
| Same path, **different bytes** (re-encode, different tags) | Status `different`. Manual review with a codec/bitrate/bit-depth/duration comparison. |
|
||||||
| Same title, **different format** (`Song.mp3` vs `Song.flac`) | Status `similar` (other format). Manual review. |
|
| Same title, **different format** (`Song.mp3` vs `Song.flac`) | Status `similar` (other format). Manual review. |
|
||||||
| Same title after ignoring track numbers/punctuation (`01 - Song!.flac` vs `Song.mp3`) | Status `similar` (similar title). Manual review. |
|
| Same title after ignoring track numbers/punctuation (`01 - Song!.flac` vs `Song.mp3`) | Status `similar` (similar title). Manual review. |
|
||||||
| Same artist and title, but in a **different album folder** | Status `similar` (other album folder). Manual review, plus a layout warning. |
|
| Same top-level folder and title, but a **different subfolder** | Status `similar` (same top folder, other subfolder). Manual review. |
|
||||||
| Only exists in the slave library | Status `unmatched`. Never touched. |
|
| Only exists in the slave library | Status `unmatched`. Never touched. |
|
||||||
| Already the same inode | Status `linked`. |
|
| Already the same inode | Status `linked`. |
|
||||||
|
|
||||||
@@ -28,8 +32,8 @@ codec-weighted bitrate (Opus and AAC count for more per kbps than MP3). You then
|
|||||||
libraries share the better file.
|
libraries share the better file.
|
||||||
- **Ignore.** The pair is hidden from review until you un-ignore it.
|
- **Ignore.** The pair is hidden from review until you un-ignore it.
|
||||||
|
|
||||||
**Build mode** covers the "start fresh" workflow. Point the slave at an empty folder, browse the host library by artist and album, tick
|
**Build mode** covers the "start fresh" workflow. Point the slave at an empty folder, browse the host library, tick the
|
||||||
what you want, and playlink hardlinks it in at the same path. **Remove** deletes slave files, but only ones that are hardlinks of the
|
folders or songs you want, and playlink hardlinks them in at the same relative path. **Remove** deletes slave files, but only ones that are hardlinks of the
|
||||||
host file.
|
host file.
|
||||||
|
|
||||||
## Safety model
|
## Safety model
|
||||||
|
|||||||
@@ -293,9 +293,11 @@ func (e *Engine) NormalizePair(p *store.Pair) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// layoutWarnings does a cheap top-level comparison of the two libraries.
|
// layoutWarnings does a cheap top-level comparison of the two libraries.
|
||||||
|
// Any folder layout works; what matters is that both libraries use the same
|
||||||
|
// one, so matching files sit at the same relative path.
|
||||||
func layoutWarnings(l *libs) []string {
|
func layoutWarnings(l *libs) []string {
|
||||||
var warns []string
|
var warns []string
|
||||||
list := func(root string) (dirs map[string]bool, looseFiles int) {
|
list := func(root string) (dirs map[string]bool, files int) {
|
||||||
dirs = map[string]bool{}
|
dirs = map[string]bool{}
|
||||||
ents, _ := os.ReadDir(root)
|
ents, _ := os.ReadDir(root)
|
||||||
for _, en := range ents {
|
for _, en := range ents {
|
||||||
@@ -305,20 +307,17 @@ func layoutWarnings(l *libs) []string {
|
|||||||
if en.IsDir() {
|
if en.IsDir() {
|
||||||
dirs[strings.ToLower(en.Name())] = true
|
dirs[strings.ToLower(en.Name())] = true
|
||||||
} else if l.hasExt(en.Name()) {
|
} else if l.hasExt(en.Name()) {
|
||||||
looseFiles++
|
files++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hd, hl := list(l.host.Path)
|
hd, _ := list(l.host.Path)
|
||||||
sd, sl := list(l.slave.Path)
|
sd, sf := list(l.slave.Path)
|
||||||
if len(sd) == 0 && sl == 0 {
|
if len(sd) == 0 && sf == 0 {
|
||||||
warns = append(warns, "The slave library is empty. Use Build to pick songs from the host library to hardlink into it.")
|
warns = append(warns, "The slave library is empty. Use Build to pick songs from the host library to hardlink into it.")
|
||||||
return warns
|
return warns
|
||||||
}
|
}
|
||||||
if hl > 0 || sl > 0 {
|
|
||||||
warns = append(warns, fmt.Sprintf("Found audio files directly in a library root (host: %d, slave: %d). Both libraries should use Artist/Album/Title.ext.", hl, sl))
|
|
||||||
}
|
|
||||||
shared := 0
|
shared := 0
|
||||||
for d := range sd {
|
for d := range sd {
|
||||||
if hd[d] {
|
if hd[d] {
|
||||||
@@ -326,8 +325,8 @@ func layoutWarnings(l *libs) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(sd) > 0 && shared*2 < len(sd) {
|
if len(sd) > 0 && shared*2 < len(sd) {
|
||||||
warns = append(warns, fmt.Sprintf("Only %d of %d artist folders in the slave library also exist in the host library. "+
|
warns = append(warns, fmt.Sprintf("Only %d of %d top-level folders in the slave library also exist in the host library. "+
|
||||||
"The two libraries should share the same Artist/Album/Title.ext layout, or most files will not match.", shared, len(sd)))
|
"Both libraries should use the same folder layout, or most files will not match.", shared, len(sd)))
|
||||||
}
|
}
|
||||||
return warns
|
return warns
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -479,3 +480,27 @@ func TestNormTitle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnyLayoutMatchesWithoutWarnings(t *testing.T) {
|
||||||
|
ev := setup(t, false)
|
||||||
|
// Flat files, deeper Genre/Artist/Album/Disc nesting: all fine as long as both sides agree.
|
||||||
|
for _, rel := range []string{"loose.flac", "Rock/Band/Album/Disc 1/01 - Song.flac", "Band/Song.flac"} {
|
||||||
|
ev.write(ev.host, rel, "same:"+rel)
|
||||||
|
ev.write(ev.slave, rel, "same:"+rel)
|
||||||
|
}
|
||||||
|
ev.scan()
|
||||||
|
for _, rel := range []string{"loose.flac", "Rock/Band/Album/Disc 1/01 - Song.flac", "Band/Song.flac"} {
|
||||||
|
if m := ev.match(rel); m.Status != store.StatusIdentical || m.Kind != store.KindPath {
|
||||||
|
t.Fatalf("%s: want identical/path, got %s/%s", rel, m.Status, m.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, err := ev.st.Pair(ctx, ev.pair.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, w := range p.LastScanWarnings {
|
||||||
|
if strings.Contains(w, "layout") || strings.Contains(w, "folder") || strings.Contains(w, "depth") {
|
||||||
|
t.Fatalf("matching layouts must not produce layout warnings, got %q", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-14
@@ -20,7 +20,6 @@ import (
|
|||||||
type walkStats struct {
|
type walkStats struct {
|
||||||
files int
|
files int
|
||||||
symlinks int
|
symlinks int
|
||||||
offDepth int // files not at Artist/Album/Title depth
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// walk lists audio files beneath root. Hidden entries, playlink staging files,
|
// walk lists audio files beneath root. Hidden entries, playlink staging files,
|
||||||
@@ -67,9 +66,6 @@ func walk(ctx context.Context, l *libs, root fsops.Root, out map[string]fsops.In
|
|||||||
}
|
}
|
||||||
out[rel] = in
|
out[rel] = in
|
||||||
ws.files++
|
ws.files++
|
||||||
if strings.Count(rel, string(filepath.Separator)) != 2 {
|
|
||||||
ws.offDepth++
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return ws, err
|
return ws, err
|
||||||
@@ -107,7 +103,7 @@ type hostIndex struct {
|
|||||||
files map[string]fsops.Info
|
files map[string]fsops.Info
|
||||||
lower map[string]string // lower(rel) -> rel
|
lower map[string]string // lower(rel) -> rel
|
||||||
byDir map[string][]string // lower(dir) -> rels
|
byDir map[string][]string // lower(dir) -> rels
|
||||||
byTitle map[string][]string // lower(artist)|normTitle -> rels
|
byTitle map[string][]string // lower(top folder)|normTitle -> rels
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildIndex(files map[string]fsops.Info) *hostIndex {
|
func buildIndex(files map[string]fsops.Info) *hostIndex {
|
||||||
@@ -167,8 +163,8 @@ func (ix *hostIndex) find(relSlave string) (string, string) {
|
|||||||
if len(fuzzyHits) == 1 {
|
if len(fuzzyHits) == 1 {
|
||||||
return fuzzyHits[0], store.KindFuzzy
|
return fuzzyHits[0], store.KindFuzzy
|
||||||
}
|
}
|
||||||
if artist := firstComponent(relSlave); artist != "" && norm != "" {
|
if top := firstComponent(relSlave); top != "" && norm != "" {
|
||||||
if hits := ix.byTitle[strings.ToLower(artist)+"|"+norm]; len(hits) == 1 {
|
if hits := ix.byTitle[strings.ToLower(top)+"|"+norm]; len(hits) == 1 {
|
||||||
return hits[0], store.KindElsewhere
|
return hits[0], store.KindElsewhere
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,14 +306,8 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
|
|||||||
if ss.files > 0 {
|
if ss.files > 0 {
|
||||||
if n := kinds[store.KindElsewhere] + kinds[store.KindNone]; n*4 > ss.files {
|
if n := kinds[store.KindElsewhere] + kinds[store.KindNone]; n*4 > ss.files {
|
||||||
warns = append(warns, fmt.Sprintf("%d of %d slave files have no counterpart at the same path in the host library. "+
|
warns = append(warns, fmt.Sprintf("%d of %d slave files have no counterpart at the same path in the host library. "+
|
||||||
"Check that both libraries use the same Artist/Album/Title.ext layout.", n, ss.files))
|
"Check that both libraries use the same folder layout.", n, ss.files))
|
||||||
}
|
}
|
||||||
if ss.offDepth > 0 {
|
|
||||||
warns = append(warns, fmt.Sprintf("%d slave files are not at Artist/Album/Title.ext depth.", ss.offDepth))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hs.offDepth > 0 {
|
|
||||||
warns = append(warns, fmt.Sprintf("%d host files are not at Artist/Album/Title.ext depth.", hs.offDepth))
|
|
||||||
}
|
}
|
||||||
if hs.symlinks+ss.symlinks > 0 {
|
if hs.symlinks+ss.symlinks > 0 {
|
||||||
warns = append(warns, fmt.Sprintf("Ignored %d symlinks (playlink never follows or replaces symlinks).", hs.symlinks+ss.symlinks))
|
warns = append(warns, fmt.Sprintf("Ignored %d symlinks (playlink never follows or replaces symlinks).", hs.symlinks+ss.symlinks))
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ const (
|
|||||||
KindPathCase = "path_case" // relative path differs only by case
|
KindPathCase = "path_case" // relative path differs only by case
|
||||||
KindExtension = "extension" // same directory and title, different extension
|
KindExtension = "extension" // same directory and title, different extension
|
||||||
KindFuzzy = "fuzzy" // same directory, normalized title matches
|
KindFuzzy = "fuzzy" // same directory, normalized title matches
|
||||||
KindElsewhere = "elsewhere" // same artist, different album folder
|
KindElsewhere = "elsewhere" // same top-level folder and title, different subfolder
|
||||||
KindNone = "none"
|
KindNone = "none"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Vendored
Whitespace-only changes.
@@ -39,8 +39,8 @@ export function Build({ pair }: { pair: Pair }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Alert tone="blue">
|
<Alert tone="blue">
|
||||||
Build the slave library from the host. Browse the host library, pick artists, albums or songs, and playlink hardlinks them into the slave
|
Build the slave library from the host. Browse the host library, pick folders or songs, and playlink hardlinks them into the slave
|
||||||
library at the same <code className="mono">Artist/Album/Title.ext</code> path. No extra space is used. Existing slave files are never
|
library at the same relative path. No extra space is used. Existing slave files are never
|
||||||
overwritten, and <b>Remove</b> only deletes slave files that are hardlinks of the host file, so the host copy is always kept.
|
overwritten, and <b>Remove</b> only deletes slave files that are hardlinks of the host file, so the host copy is always kept.
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Alert tone="blue">
|
<Alert tone="blue">
|
||||||
Both libraries should use the same <code className="mono">Artist/Album/Title.ext</code> layout. Files are matched by relative path first.
|
Both libraries should use the same folder layout (for example <code className="mono">Artist/Album/Title.ext</code>), because files are matched by
|
||||||
|
their relative path first. Any layout works as long as it is the same on both sides.
|
||||||
Both must be on the same filesystem. On Unraid, mount one common parent folder into the container instead of two separate volumes.
|
Both must be on the same filesystem. On Unraid, mount one common parent folder into the container instead of two separate volumes.
|
||||||
</Alert>
|
</Alert>
|
||||||
<PathField
|
<PathField
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const kindLabel: Record<Match["kind"], string> = {
|
|||||||
path_case: "path differs in case",
|
path_case: "path differs in case",
|
||||||
extension: "other format",
|
extension: "other format",
|
||||||
fuzzy: "similar title",
|
fuzzy: "similar title",
|
||||||
elsewhere: "other album folder",
|
elsewhere: "same top folder, other subfolder",
|
||||||
none: "no match",
|
none: "no match",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user