Files
yellowjacket/backend/download/pathmatch.go
T
yonluandClaude Sonnet 5 e190fd75b9
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s
feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-06 17:12:01 -04:00

315 lines
8.4 KiB
Go

package download
import (
"path"
"regexp"
"strconv"
"strings"
"yellowjacket/backend/autotag"
)
// Candidate files arrive as paths, not tags — a Soulseek result is
// `@@abc\Music\Pink Floyd - The Wall (1979) [FLAC]\1-01 In The Flesh.flac`
// and nothing more. Everything the ranker knows about whether a
// candidate is the right album comes from parsing that string, so the
// heuristics here carry real weight.
// audioExtensions maps a lowercase file extension to its format.
var audioExtensions = map[string]Format{
".flac": FormatFLAC,
".mp3": FormatMP3,
".ogg": FormatOGG,
".oga": FormatOGG,
".opus": FormatOpus,
".wav": FormatWAV,
".m4a": FormatAAC,
".aac": FormatAAC,
".alac": FormatALAC,
".wma": FormatWMA,
".ape": FormatUnknown,
".wv": FormatUnknown,
}
var (
// trackNumPattern matches a leading track number in the common
// shapes: "01 - Title", "1. Title", "1-01 Title" (disc-track),
// "[01] Title". The disc group is optional.
trackNumPattern = regexp.MustCompile(
`^\s*\[?(?:(\d{1,2})\s*[-_.]\s*)?(\d{1,3})\]?\s*[-_.)\]]?\s+`,
)
// bareTrackNumPattern matches a number with no separator at all
// ("01Title" is rare, but "01 Title" with a single space is not).
bareTrackNumPattern = regexp.MustCompile(`^\s*(\d{1,3})\s+`)
// bitratePattern finds a bitrate hint in a folder or file name:
// "[320]", "V0", "320kbps", "(V2)".
bitratePattern = regexp.MustCompile(
`(?i)\b(\d{2,4})\s*k(?:bps|b/s)?\b|\[(\d{2,4})\]`,
)
// vbrPattern finds LAME VBR preset names, which imply a bitrate
// band rather than a number.
vbrPattern = regexp.MustCompile(`(?i)\b(V[0-2])\b`)
// yearPattern finds a 4-digit year in parentheses or brackets.
yearPattern = regexp.MustCompile(`[(\[](19|20)\d{2}[)\]]`)
// junkSuffixPattern strips scene/rip tags from a folder name before
// comparing it to an album title.
junkSuffixPattern = regexp.MustCompile(
`(?i)[\[(]\s*(flac|mp3|web|cd|vinyl|24bit|16bit|lossless|` +
`v0|v2|320|256|192|128|kbps|reissue|remaster(ed)?|` +
`\d{2,3}\s*k(bps)?)\s*[^\])]*[\])]`,
)
// separatorPattern splits "Artist - Album" style folder names.
separatorPattern = regexp.MustCompile(`\s+[-–—]\s+`)
)
// FormatForPath returns the audio format implied by a path's extension,
// and whether the path is audio at all. Cue sheets, logs, playlists
// and cover images are not.
func FormatForPath(p string) (Format, bool) {
ext := strings.ToLower(path.Ext(strings.ReplaceAll(p, `\`, "/")))
f, ok := audioExtensions[ext]
return f, ok
}
// TrackHint is what a single candidate file's path reveals about the
// track it holds. Every field is best-effort and may be zero.
type TrackHint struct {
Disc int
Track int
// Title is the filename with the extension, track number and any
// leading artist credit removed.
Title string
// Folder is the immediate parent directory name, cleaned of scene
// tags — the best available proxy for the album title.
Folder string
}
// ParsePath extracts what it can from one candidate file path.
func ParsePath(p string) TrackHint {
// Soulseek paths are Windows-style; normalize before splitting.
norm := strings.ReplaceAll(p, `\`, "/")
base := path.Base(norm)
folder := path.Base(path.Dir(norm))
name := strings.TrimSuffix(base, path.Ext(base))
hint := TrackHint{Folder: cleanAlbumName(folder)}
if m := trackNumPattern.FindStringSubmatch(name); m != nil {
if m[1] != "" {
hint.Disc, _ = strconv.Atoi(m[1])
}
hint.Track, _ = strconv.Atoi(m[2])
name = name[len(m[0]):]
} else if m := bareTrackNumPattern.FindStringSubmatch(name); m != nil {
hint.Track, _ = strconv.Atoi(m[1])
name = name[len(m[0]):]
}
// "Artist - Title" inside the filename: drop the leading credit
// when what follows is substantial. Guessing wrong here costs a
// little title similarity; not doing it costs a lot, because most
// Soulseek folders name the artist in every file.
if parts := separatorPattern.Split(name, 2); len(parts) == 2 {
if len(strings.TrimSpace(parts[1])) >= 3 {
name = parts[1]
}
}
hint.Title = strings.TrimSpace(name)
return hint
}
// cleanAlbumName strips year markers and scene tags from a folder name
// so it can be compared against a release title.
func cleanAlbumName(folder string) string {
s := junkSuffixPattern.ReplaceAllString(folder, " ")
s = yearPattern.ReplaceAllString(s, " ")
// A folder is often "Artist - Album"; keep the right-hand side when
// there is one, since the album is what we compare against.
if parts := separatorPattern.Split(s, 2); len(parts) == 2 {
if len(strings.TrimSpace(parts[1])) >= 2 {
s = parts[1]
}
}
return strings.TrimSpace(strings.Join(strings.Fields(s), " "))
}
// BitrateForPath infers a bitrate in kbps from path text. Returns 0
// when nothing is stated. VBR presets map to their nominal average.
func BitrateForPath(p string) int {
if m := vbrPattern.FindStringSubmatch(p); m != nil {
switch strings.ToUpper(m[1]) {
case "V0":
return 245
case "V1":
return 225
case "V2":
return 190
}
}
if m := bitratePattern.FindStringSubmatch(p); m != nil {
raw := m[1]
if raw == "" {
raw = m[2]
}
if n, err := strconv.Atoi(raw); err == nil && n >= 32 && n <= 3000 {
return n
}
}
return 0
}
// AnnotateFiles fills in Format, IsAudio and Bitrate for a candidate's
// files. Providers call this so each adapter does not re-derive the
// same things from the same paths.
func AnnotateFiles(files []CandidateFile) []CandidateFile {
out := make([]CandidateFile, len(files))
for i, f := range files {
format, isAudio := FormatForPath(f.Path)
f.IsAudio = isAudio
if f.Format == FormatUnknown {
f.Format = format
}
if f.Bitrate == 0 {
f.Bitrate = BitrateForPath(f.Path)
}
out[i] = f
}
return out
}
// matchFiles aligns a candidate's audio files to the expected tracklist
// and returns the per-file assignment plus the mean title similarity of
// the aligned pairs.
//
// Alignment is greedy by score rather than optimal: candidate folders
// are small (a few dozen files at most) and the common cases — correct
// track numbers, or clean "NN Title" names — are unambiguous, so the
// extra machinery of Hungarian assignment buys nothing here.
func matchFiles(
files []CandidateFile,
expected []ExpectedTrack,
) ([]CandidateFile, float64) {
annotated := make([]CandidateFile, len(files))
copy(annotated, files)
if len(expected) == 0 {
return annotated, 0
}
hints := make([]TrackHint, len(annotated))
for i, f := range annotated {
hints[i] = ParsePath(f.Path)
}
takenExpected := make(map[int]bool, len(expected))
var (
total float64
matched int
)
// Pass 1: trust explicit track numbers when they are unique and in
// range. A folder that numbers its files correctly is the strong
// case, and title comparison only adds noise there.
for i := range annotated {
if !annotated[i].IsAudio || hints[i].Track == 0 {
continue
}
idx := indexForPosition(expected, hints[i].Disc, hints[i].Track)
if idx < 0 || takenExpected[idx] {
continue
}
takenExpected[idx] = true
annotated[i].MatchedTo = expected[idx].Position
total += autotag.TitleSimilarity(hints[i].Title, expected[idx].Title)
matched++
}
// Pass 2: title similarity for whatever is left.
for i := range annotated {
if !annotated[i].IsAudio || annotated[i].MatchedTo != 0 {
continue
}
bestIdx, bestSim := -1, 0.0
for j := range expected {
if takenExpected[j] {
continue
}
sim := autotag.TitleSimilarity(hints[i].Title, expected[j].Title)
if sim > bestSim {
bestIdx, bestSim = j, sim
}
}
// Below this the "match" is two unrelated strings sharing a few
// characters, and counting it drags the mean toward noise.
const minTitleSim = 0.55
if bestIdx < 0 || bestSim < minTitleSim {
continue
}
takenExpected[bestIdx] = true
annotated[i].MatchedTo = expected[bestIdx].Position
total += bestSim
matched++
}
if matched == 0 {
return annotated, 0
}
return annotated, total / float64(matched)
}
// indexForPosition finds the expected track at a disc/track position.
// A zero disc hint matches on track number alone, which is right for
// single-disc releases and the best guess for multi-disc folders that
// do not encode the disc.
func indexForPosition(expected []ExpectedTrack, disc, track int) int {
for i, e := range expected {
if e.Position != track {
continue
}
if disc != 0 && e.DiscNumber != 0 && e.DiscNumber != disc {
continue
}
return i
}
return -1
}