playlist phantom track matching added, updated search queries for efficiency
This commit is contained in:
+81
-9
@@ -19,7 +19,6 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidM3U = errors.New("invalid M3U file: missing #EXTM3U header")
|
||||
errEmptyM3UFile = errors.New("M3U file is empty")
|
||||
errPlaylistDirNil = errors.New("playlists directory path is empty")
|
||||
)
|
||||
@@ -132,15 +131,15 @@ func parseM3U8(filePath string) (parsedPlaylist, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check header.
|
||||
// Check header. If the first non-empty line is not
|
||||
// #EXTM3U, treat the file as a simple M3U (just
|
||||
// path lines) and fall through to process normally.
|
||||
if !headerSeen {
|
||||
if line == m3uHeader {
|
||||
headerSeen = true
|
||||
headerSeen = true
|
||||
|
||||
if line == m3uHeader {
|
||||
continue
|
||||
}
|
||||
|
||||
return parsedPlaylist{}, errInvalidM3U
|
||||
}
|
||||
|
||||
// Playlist name directive.
|
||||
@@ -300,11 +299,16 @@ func findPlaylistFile(
|
||||
)
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return "", nil
|
||||
// Filter matches to ensure the extracted ID matches the
|
||||
// target. The glob pattern "1-*.m3u8" also matches
|
||||
// "10-foo.m3u8", "11-bar.m3u8", etc.
|
||||
for _, m := range matches {
|
||||
if extractPlaylistID(m) == id {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
return matches[0], nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// removeOldPlaylistFile removes an old playlist file for the given
|
||||
@@ -409,6 +413,74 @@ func extractPlaylistID(filePath string) int64 {
|
||||
return id
|
||||
}
|
||||
|
||||
// removeM3UEntries removes entries from a slice whose resolved
|
||||
// absolute paths appear in the target set.
|
||||
func removeM3UEntries(
|
||||
entries []m3uEntry,
|
||||
targetAbsPaths map[string]struct{},
|
||||
libraryRoot string,
|
||||
) []m3uEntry {
|
||||
result := make([]m3uEntry, 0, len(entries))
|
||||
|
||||
for _, e := range entries {
|
||||
absPath := toAbsolutePath(
|
||||
e.RelativePath, libraryRoot,
|
||||
)
|
||||
if _, remove := targetAbsPaths[absPath]; remove {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, e)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// replaceM3UEntryPaths replaces the relative paths of entries
|
||||
// whose resolved absolute paths match keys in the replacements
|
||||
// map. Values are new relative paths.
|
||||
func replaceM3UEntryPaths(
|
||||
entries []m3uEntry,
|
||||
replacements map[string]string,
|
||||
libraryRoot string,
|
||||
) []m3uEntry {
|
||||
result := make([]m3uEntry, len(entries))
|
||||
|
||||
for i, e := range entries {
|
||||
result[i] = e
|
||||
|
||||
absPath := toAbsolutePath(
|
||||
e.RelativePath, libraryRoot,
|
||||
)
|
||||
|
||||
if newRel, ok := replacements[absPath]; ok {
|
||||
result[i].RelativePath = newRel
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// findM3UEntry finds the M3U entry whose resolved absolute path
|
||||
// matches the given target path. Returns the entry and its index,
|
||||
// or -1 if not found.
|
||||
func findM3UEntry(
|
||||
entries []m3uEntry,
|
||||
targetAbsPath string,
|
||||
libraryRoot string,
|
||||
) (m3uEntry, int) {
|
||||
for i, e := range entries {
|
||||
absPath := toAbsolutePath(
|
||||
e.RelativePath, libraryRoot,
|
||||
)
|
||||
if absPath == targetAbsPath {
|
||||
return e, i
|
||||
}
|
||||
}
|
||||
|
||||
return m3uEntry{}, -1
|
||||
}
|
||||
|
||||
// displayTitle builds an EXTINF display title from artist and title.
|
||||
func displayTitle(artist, title string) string {
|
||||
artist = strings.TrimSpace(artist)
|
||||
|
||||
@@ -201,25 +201,24 @@ func TestWriteM3U8EmptyDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseM3U8InvalidFile(t *testing.T) {
|
||||
func TestParseM3U8EmptyFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
badFile := filepath.Join(dir, "bad.m3u8")
|
||||
emptyFile := filepath.Join(dir, "empty.m3u8")
|
||||
|
||||
// Write a file without the M3U header.
|
||||
err := os.WriteFile(
|
||||
badFile,
|
||||
[]byte("just some text\n"),
|
||||
emptyFile,
|
||||
[]byte(""),
|
||||
0o644,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("could not write test file: %v", err)
|
||||
}
|
||||
|
||||
_, err = parseM3U8(badFile)
|
||||
_, err = parseM3U8(emptyFile)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid M3U file")
|
||||
t.Fatal("expected error for empty M3U file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,3 +591,272 @@ func TestParseExtInf(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPlaylistFileOverlappingIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create playlists with IDs 1 and 10 — the glob
|
||||
// pattern "1-*.m3u8" must not match "10-longer.m3u8".
|
||||
if err := writeM3U8(dir, 1, "Short", nil); err != nil {
|
||||
t.Fatalf("writeM3U8(1) error = %v", err)
|
||||
}
|
||||
|
||||
if err := writeM3U8(dir, 10, "Longer", nil); err != nil {
|
||||
t.Fatalf("writeM3U8(10) error = %v", err)
|
||||
}
|
||||
|
||||
found, err := findPlaylistFile(dir, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("findPlaylistFile(1) error = %v", err)
|
||||
}
|
||||
|
||||
if got := extractPlaylistID(found); got != 1 {
|
||||
t.Errorf(
|
||||
"findPlaylistFile(1) returned ID %d, want 1",
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
found, err = findPlaylistFile(dir, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("findPlaylistFile(10) error = %v", err)
|
||||
}
|
||||
|
||||
if got := extractPlaylistID(found); got != 10 {
|
||||
t.Errorf(
|
||||
"findPlaylistFile(10) returned ID %d, want 10",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseM3U8SimpleFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
simpleFile := filepath.Join(dir, "simple.m3u")
|
||||
|
||||
// Write a simple M3U with no #EXTM3U header — just paths.
|
||||
content := "Artist/Album/01 - Song.flac\nOther/Track.mp3\n"
|
||||
|
||||
if err := os.WriteFile(
|
||||
simpleFile, []byte(content), 0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("could not write test file: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := parseM3U8(simpleFile)
|
||||
if err != nil {
|
||||
t.Fatalf("parseM3U8() error = %v", err)
|
||||
}
|
||||
|
||||
if len(parsed.Entries) != 2 {
|
||||
t.Fatalf(
|
||||
"parsed %d entries, want 2",
|
||||
len(parsed.Entries),
|
||||
)
|
||||
}
|
||||
|
||||
if parsed.Entries[0].RelativePath !=
|
||||
"Artist/Album/01 - Song.flac" {
|
||||
t.Errorf(
|
||||
"entry[0].RelativePath = %q, want %q",
|
||||
parsed.Entries[0].RelativePath,
|
||||
"Artist/Album/01 - Song.flac",
|
||||
)
|
||||
}
|
||||
|
||||
if parsed.Entries[1].RelativePath !=
|
||||
"Other/Track.mp3" {
|
||||
t.Errorf(
|
||||
"entry[1].RelativePath = %q, want %q",
|
||||
parsed.Entries[1].RelativePath,
|
||||
"Other/Track.mp3",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseM3U8SimpleFormatWithComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
simpleFile := filepath.Join(dir, "commented.m3u")
|
||||
|
||||
// Simple M3U with comment lines (no #EXTM3U header).
|
||||
content := "# Generated by SomeApp\n" +
|
||||
"Artist/Song.flac\n" +
|
||||
"# Another comment\n" +
|
||||
"Other/Track.mp3\n"
|
||||
|
||||
if err := os.WriteFile(
|
||||
simpleFile, []byte(content), 0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("could not write test file: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := parseM3U8(simpleFile)
|
||||
if err != nil {
|
||||
t.Fatalf("parseM3U8() error = %v", err)
|
||||
}
|
||||
|
||||
if len(parsed.Entries) != 2 {
|
||||
t.Fatalf(
|
||||
"parsed %d entries, want 2",
|
||||
len(parsed.Entries),
|
||||
)
|
||||
}
|
||||
|
||||
if parsed.Entries[0].RelativePath !=
|
||||
"Artist/Song.flac" {
|
||||
t.Errorf(
|
||||
"entry[0].RelativePath = %q, want %q",
|
||||
parsed.Entries[0].RelativePath,
|
||||
"Artist/Song.flac",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveM3UEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries := []m3uEntry{
|
||||
{RelativePath: "Artist/Song1.flac"},
|
||||
{RelativePath: "Artist/Song2.flac"},
|
||||
{RelativePath: "Artist/Song3.flac"},
|
||||
}
|
||||
|
||||
targets := map[string]struct{}{
|
||||
"/music/Artist/Song2.flac": {},
|
||||
}
|
||||
|
||||
result := removeM3UEntries(entries, targets, "/music")
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].RelativePath != "Artist/Song1.flac" {
|
||||
t.Errorf(
|
||||
"entry[0] = %q, want %q",
|
||||
result[0].RelativePath,
|
||||
"Artist/Song1.flac",
|
||||
)
|
||||
}
|
||||
|
||||
if result[1].RelativePath != "Artist/Song3.flac" {
|
||||
t.Errorf(
|
||||
"entry[1] = %q, want %q",
|
||||
result[1].RelativePath,
|
||||
"Artist/Song3.flac",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveM3UEntriesAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries := []m3uEntry{
|
||||
{RelativePath: "Song.flac"},
|
||||
}
|
||||
|
||||
targets := map[string]struct{}{
|
||||
"/music/Song.flac": {},
|
||||
}
|
||||
|
||||
result := removeM3UEntries(entries, targets, "/music")
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 entries, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceM3UEntryPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries := []m3uEntry{
|
||||
{
|
||||
RelativePath: "old/path/song.flac",
|
||||
DurationSec: 180,
|
||||
DisplayTitle: "Song",
|
||||
},
|
||||
{
|
||||
RelativePath: "other/track.mp3",
|
||||
DurationSec: 240,
|
||||
DisplayTitle: "Track",
|
||||
},
|
||||
}
|
||||
|
||||
replacements := map[string]string{
|
||||
"/music/old/path/song.flac": "new/path/song.flac",
|
||||
}
|
||||
|
||||
result := replaceM3UEntryPaths(
|
||||
entries, replacements, "/music",
|
||||
)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].RelativePath != "new/path/song.flac" {
|
||||
t.Errorf(
|
||||
"entry[0].RelativePath = %q, want %q",
|
||||
result[0].RelativePath,
|
||||
"new/path/song.flac",
|
||||
)
|
||||
}
|
||||
|
||||
// Duration and title should be preserved.
|
||||
if result[0].DurationSec != 180 {
|
||||
t.Errorf(
|
||||
"entry[0].DurationSec = %d, want 180",
|
||||
result[0].DurationSec,
|
||||
)
|
||||
}
|
||||
|
||||
// Unchanged entry should remain the same.
|
||||
if result[1].RelativePath != "other/track.mp3" {
|
||||
t.Errorf(
|
||||
"entry[1].RelativePath = %q, want %q",
|
||||
result[1].RelativePath,
|
||||
"other/track.mp3",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindM3UEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries := []m3uEntry{
|
||||
{RelativePath: "Artist/Song1.flac"},
|
||||
{RelativePath: "Artist/Song2.flac"},
|
||||
{RelativePath: "Artist/Song3.flac"},
|
||||
}
|
||||
|
||||
entry, idx := findM3UEntry(
|
||||
entries, "/music/Artist/Song2.flac", "/music",
|
||||
)
|
||||
|
||||
if idx != 1 {
|
||||
t.Errorf("expected index 1, got %d", idx)
|
||||
}
|
||||
|
||||
if entry.RelativePath != "Artist/Song2.flac" {
|
||||
t.Errorf(
|
||||
"entry.RelativePath = %q, want %q",
|
||||
entry.RelativePath,
|
||||
"Artist/Song2.flac",
|
||||
)
|
||||
}
|
||||
|
||||
// Not found.
|
||||
_, idx = findM3UEntry(
|
||||
entries, "/music/Artist/Missing.flac", "/music",
|
||||
)
|
||||
|
||||
if idx != -1 {
|
||||
t.Errorf("expected index -1, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
// Package playlist provides playlist management functionality.
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Scoring weights for candidate matching.
|
||||
const (
|
||||
weightFilename = 0.50
|
||||
weightTitle = 0.30
|
||||
weightDuration = 0.10
|
||||
weightPathDirs = 0.10
|
||||
autoMatchMinimum = 0.85
|
||||
)
|
||||
|
||||
// maxCandidates is the default limit for search results.
|
||||
const maxCandidates = 20
|
||||
|
||||
// maxLibrarySearchResults is the limit for manual library search.
|
||||
const maxLibrarySearchResults = 50
|
||||
|
||||
// durationToleranceClose is the duration difference in seconds
|
||||
// considered a near-exact match.
|
||||
const durationToleranceClose = 1
|
||||
|
||||
// durationToleranceMedium is the medium tolerance threshold.
|
||||
const durationToleranceMedium = 5
|
||||
|
||||
// durationToleranceFar is the maximum tolerance before scoring
|
||||
// drops to zero.
|
||||
const durationToleranceFar = 15
|
||||
|
||||
// separatorPattern splits file paths and names on common
|
||||
// separators: slashes, hyphens, underscores, spaces, dots.
|
||||
var separatorPattern = regexp.MustCompile(
|
||||
`[/\\\-_. ]+`,
|
||||
)
|
||||
|
||||
// trackNumberPattern matches leading track numbers like
|
||||
// "01", "1", "01.", "01 -", etc.
|
||||
var trackNumberPattern = regexp.MustCompile(
|
||||
`^\d{1,3}[.\-\s]*$`,
|
||||
)
|
||||
|
||||
// phantomProfile pre-computes all derived data for a phantom
|
||||
// track so that scoring multiple candidates avoids redundant
|
||||
// string processing.
|
||||
type phantomProfile struct {
|
||||
baseLower string // lowercase basename
|
||||
baseStem string // basename without extension
|
||||
baseWords []string // significant words from stem
|
||||
dirWords []string // significant words from dir path
|
||||
displayLow string // lowercase display title
|
||||
parsedArt string // parsed artist from display title
|
||||
parsedTitle string // parsed title from display title
|
||||
titleWords []string // significant words from display title
|
||||
durationSec int // phantom duration in seconds
|
||||
}
|
||||
|
||||
// newPhantomProfile builds a phantomProfile from raw phantom
|
||||
// data, performing all string splits and normalisation once.
|
||||
func newPhantomProfile(
|
||||
phantomPath string,
|
||||
displayTitle string,
|
||||
durationSec int,
|
||||
) phantomProfile {
|
||||
baseLower := strings.ToLower(
|
||||
filepath.Base(phantomPath),
|
||||
)
|
||||
baseStem := stripExtension(baseLower)
|
||||
displayLow := strings.ToLower(
|
||||
strings.TrimSpace(displayTitle),
|
||||
)
|
||||
parsedArt, parsedTitle := parseDisplayTitle(displayLow)
|
||||
|
||||
return phantomProfile{
|
||||
baseLower: baseLower,
|
||||
baseStem: baseStem,
|
||||
baseWords: significantWords(baseStem),
|
||||
dirWords: pathDirWords(phantomPath),
|
||||
displayLow: displayLow,
|
||||
parsedArt: parsedArt,
|
||||
parsedTitle: parsedTitle,
|
||||
titleWords: significantWords(displayLow),
|
||||
durationSec: durationSec,
|
||||
}
|
||||
}
|
||||
|
||||
// scoreCandidate computes a match confidence (0.0-1.0) between
|
||||
// a phantom track and a candidate library track.
|
||||
func scoreCandidate(
|
||||
pp phantomProfile,
|
||||
candidatePath string,
|
||||
candidateTitle string,
|
||||
candidateArtist string,
|
||||
candidateDurationMs int64,
|
||||
) float64 {
|
||||
fnScore := scoreFilename(pp, candidatePath)
|
||||
titleScore := scoreTitleArtist(
|
||||
pp, candidateTitle, candidateArtist,
|
||||
)
|
||||
durScore := scoreDuration(
|
||||
pp.durationSec, candidateDurationMs,
|
||||
)
|
||||
dirScore := scorePathDirs(pp, candidatePath)
|
||||
|
||||
// If duration is unknown, redistribute its weight to
|
||||
// filename.
|
||||
fnWeight := weightFilename
|
||||
durWeight := weightDuration
|
||||
|
||||
if pp.durationSec == 0 {
|
||||
fnWeight += durWeight
|
||||
durWeight = 0
|
||||
}
|
||||
|
||||
return fnScore*fnWeight +
|
||||
titleScore*weightTitle +
|
||||
durScore*durWeight +
|
||||
dirScore*weightPathDirs
|
||||
}
|
||||
|
||||
// scoreFilename compares the basenames of two file paths.
|
||||
func scoreFilename(
|
||||
pp phantomProfile, candidatePath string,
|
||||
) float64 {
|
||||
cBase := strings.ToLower(
|
||||
filepath.Base(candidatePath),
|
||||
)
|
||||
|
||||
// Exact basename match.
|
||||
if pp.baseLower == cBase {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Match ignoring extension.
|
||||
cStem := stripExtension(cBase)
|
||||
|
||||
if pp.baseStem == cStem {
|
||||
return 0.8
|
||||
}
|
||||
|
||||
// Check if all significant words from phantom stem appear
|
||||
// in candidate stem.
|
||||
cWords := significantWords(cStem)
|
||||
|
||||
if len(pp.baseWords) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return keywordOverlap(pp.baseWords, cWords)
|
||||
}
|
||||
|
||||
// scoreTitleArtist compares the phantom's EXTINF display title
|
||||
// against the candidate's DB title and artist fields.
|
||||
func scoreTitleArtist(
|
||||
pp phantomProfile,
|
||||
candidateTitle, candidateArtist string,
|
||||
) float64 {
|
||||
if pp.displayLow == "" {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
candidateTitle = strings.ToLower(
|
||||
strings.TrimSpace(candidateTitle),
|
||||
)
|
||||
candidateArtist = strings.ToLower(
|
||||
strings.TrimSpace(candidateArtist),
|
||||
)
|
||||
|
||||
// Exact title match.
|
||||
if pp.parsedTitle != "" &&
|
||||
pp.parsedTitle == candidateTitle {
|
||||
if pp.parsedArt != "" &&
|
||||
pp.parsedArt == candidateArtist {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
return 0.8
|
||||
}
|
||||
|
||||
// Keyword overlap between display title and combined
|
||||
// candidate metadata.
|
||||
combined := candidateTitle + " " + candidateArtist
|
||||
cWords := significantWords(combined)
|
||||
|
||||
if len(pp.titleWords) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return keywordOverlap(pp.titleWords, cWords)
|
||||
}
|
||||
|
||||
// scoreDuration computes a score based on duration proximity.
|
||||
func scoreDuration(
|
||||
phantomSec int, candidateMs int64,
|
||||
) float64 {
|
||||
if phantomSec == 0 || candidateMs == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
diff := math.Abs(
|
||||
float64(phantomSec) - float64(candidateMs)/1000.0,
|
||||
)
|
||||
|
||||
switch {
|
||||
case diff <= float64(durationToleranceClose):
|
||||
return 1.0
|
||||
case diff <= float64(durationToleranceMedium):
|
||||
return 0.8
|
||||
case diff <= float64(durationToleranceFar):
|
||||
return 0.5
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
|
||||
// scorePathDirs compares the directory components of two paths.
|
||||
func scorePathDirs(
|
||||
pp phantomProfile, candidatePath string,
|
||||
) float64 {
|
||||
if len(pp.dirWords) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
cDirs := pathDirWords(candidatePath)
|
||||
|
||||
return keywordOverlap(pp.dirWords, cDirs)
|
||||
}
|
||||
|
||||
// parseDisplayTitle splits an EXTINF display title on " - " into
|
||||
// (artist, title). If no separator is found, returns ("", full).
|
||||
func parseDisplayTitle(dt string) (artist, title string) {
|
||||
idx := strings.Index(dt, " - ")
|
||||
if idx < 0 {
|
||||
return "", dt
|
||||
}
|
||||
|
||||
return strings.TrimSpace(dt[:idx]),
|
||||
strings.TrimSpace(dt[idx+3:])
|
||||
}
|
||||
|
||||
// extractKeywords extracts meaningful search keywords from a file
|
||||
// path by splitting on separators, removing track numbers, common
|
||||
// noise words, and the file extension.
|
||||
func extractKeywords(filePath string) []string {
|
||||
// Remove extension.
|
||||
stem := stripExtension(filePath)
|
||||
|
||||
// Split on separators.
|
||||
parts := separatorPattern.Split(stem, -1)
|
||||
|
||||
var keywords []string
|
||||
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip pure track numbers.
|
||||
if trackNumberPattern.MatchString(p) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip very short tokens.
|
||||
if len(p) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
keywords = append(keywords, strings.ToLower(p))
|
||||
}
|
||||
|
||||
return dedupStrings(keywords)
|
||||
}
|
||||
|
||||
// significantWords extracts meaningful lowercase words from a
|
||||
// string, filtering out noise.
|
||||
func significantWords(s string) []string {
|
||||
parts := separatorPattern.Split(s, -1)
|
||||
|
||||
var words []string
|
||||
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip pure track numbers.
|
||||
if trackNumberPattern.MatchString(p) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip single characters.
|
||||
if countRunes(p) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
words = append(words, strings.ToLower(p))
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
|
||||
// pathDirWords extracts lowercase words from the directory
|
||||
// portion of a path (excluding the filename).
|
||||
func pathDirWords(filePath string) []string {
|
||||
dir := filepath.Dir(filePath)
|
||||
if dir == "." || dir == "/" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return significantWords(dir)
|
||||
}
|
||||
|
||||
// keywordOverlap calculates the proportion of source words that
|
||||
// appear in target words (Jaccard-like, asymmetric).
|
||||
func keywordOverlap(source, target []string) float64 {
|
||||
if len(source) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
targetSet := make(map[string]struct{}, len(target))
|
||||
|
||||
for _, w := range target {
|
||||
targetSet[w] = struct{}{}
|
||||
}
|
||||
|
||||
var matches int
|
||||
|
||||
for _, w := range source {
|
||||
if _, ok := targetSet[w]; ok {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
|
||||
return float64(matches) / float64(len(source))
|
||||
}
|
||||
|
||||
// stripExtension removes the file extension from a path or
|
||||
// filename.
|
||||
func stripExtension(s string) string {
|
||||
ext := filepath.Ext(s)
|
||||
if ext == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
return s[:len(s)-len(ext)]
|
||||
}
|
||||
|
||||
// dedupStrings removes duplicate strings, preserving order.
|
||||
func dedupStrings(ss []string) []string {
|
||||
seen := make(map[string]struct{}, len(ss))
|
||||
|
||||
var result []string
|
||||
|
||||
for _, s := range ss {
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[s] = struct{}{}
|
||||
|
||||
result = append(result, s)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// countRunes returns the number of runes in a string.
|
||||
func countRunes(s string) int {
|
||||
return utf8.RuneCountInString(s)
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScoreCandidateExactFilename(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pp := newPhantomProfile(
|
||||
"/old/path/Artist/Album/01 - Song.flac",
|
||||
"Artist - Song",
|
||||
243,
|
||||
)
|
||||
|
||||
score := scoreCandidate(
|
||||
pp,
|
||||
"/new/path/Artist/Album/01 - Song.flac",
|
||||
"Song",
|
||||
"Artist",
|
||||
243000,
|
||||
)
|
||||
|
||||
if score < 0.9 {
|
||||
t.Errorf("expected score >= 0.9, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreCandidateNoMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pp := newPhantomProfile(
|
||||
"/music/Artist/Album/01 - Song.flac",
|
||||
"Artist - Song",
|
||||
243,
|
||||
)
|
||||
|
||||
score := scoreCandidate(
|
||||
pp,
|
||||
"/music/Completely/Different/track.mp3",
|
||||
"Other Title",
|
||||
"Other Artist",
|
||||
180000,
|
||||
)
|
||||
|
||||
if score > 0.3 {
|
||||
t.Errorf("expected score <= 0.3, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreCandidateSameFilenameNewDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Common case: file moved to a different directory.
|
||||
pp := newPhantomProfile(
|
||||
"/music/Old Dir/Artist/01 - Song.flac",
|
||||
"Artist - Song",
|
||||
243,
|
||||
)
|
||||
|
||||
score := scoreCandidate(
|
||||
pp,
|
||||
"/music/New Dir/Artist/01 - Song.flac",
|
||||
"Song",
|
||||
"Artist",
|
||||
243000,
|
||||
)
|
||||
|
||||
if score < 0.8 {
|
||||
t.Errorf(
|
||||
"expected score >= 0.8 for same filename, got %f",
|
||||
score,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreCandidateDurationOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Very close duration, but different filenames.
|
||||
score := scoreDuration(243, 243500)
|
||||
if score < 0.8 {
|
||||
t.Errorf(
|
||||
"expected duration score >= 0.8 for ~0.5s diff, got %f",
|
||||
score,
|
||||
)
|
||||
}
|
||||
|
||||
// Exact match.
|
||||
score = scoreDuration(180, 180000)
|
||||
if score != 1.0 {
|
||||
t.Errorf(
|
||||
"expected 1.0 for exact match, got %f",
|
||||
score,
|
||||
)
|
||||
}
|
||||
|
||||
// Far apart.
|
||||
score = scoreDuration(100, 200000)
|
||||
if score != 0.0 {
|
||||
t.Errorf(
|
||||
"expected 0.0 for 100s diff, got %f",
|
||||
score,
|
||||
)
|
||||
}
|
||||
|
||||
// Unknown duration.
|
||||
score = scoreDuration(0, 180000)
|
||||
if score != 0.0 {
|
||||
t.Errorf(
|
||||
"expected 0.0 for unknown, got %f",
|
||||
score,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreFilename(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
phantom string
|
||||
cand string
|
||||
minScore float64
|
||||
maxScore float64
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
phantom: "/a/b/song.flac",
|
||||
cand: "/c/d/song.flac",
|
||||
minScore: 1.0,
|
||||
maxScore: 1.0,
|
||||
},
|
||||
{
|
||||
name: "same stem different ext",
|
||||
phantom: "/a/song.flac",
|
||||
cand: "/b/song.mp3",
|
||||
minScore: 0.7,
|
||||
maxScore: 0.9,
|
||||
},
|
||||
{
|
||||
name: "completely different",
|
||||
phantom: "/a/song.flac",
|
||||
cand: "/b/other.mp3",
|
||||
minScore: 0.0,
|
||||
maxScore: 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pp := newPhantomProfile(tt.phantom, "", 0)
|
||||
score := scoreFilename(pp, tt.cand)
|
||||
|
||||
if score < tt.minScore || score > tt.maxScore {
|
||||
t.Errorf(
|
||||
"scoreFilename(%q, %q) = %f, want [%f, %f]",
|
||||
tt.phantom, tt.cand,
|
||||
score, tt.minScore, tt.maxScore,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreTitleArtist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
display string
|
||||
title string
|
||||
artist string
|
||||
minScore float64
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
display: "Pink Floyd - Comfortably Numb",
|
||||
title: "Comfortably Numb",
|
||||
artist: "Pink Floyd",
|
||||
minScore: 0.9,
|
||||
},
|
||||
{
|
||||
name: "title only match",
|
||||
display: "Comfortably Numb",
|
||||
title: "Comfortably Numb",
|
||||
artist: "Pink Floyd",
|
||||
minScore: 0.7,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
display: "Something Else",
|
||||
title: "Completely Different",
|
||||
artist: "Other Artist",
|
||||
minScore: 0.0,
|
||||
},
|
||||
{
|
||||
name: "empty display title",
|
||||
display: "",
|
||||
title: "Any Title",
|
||||
artist: "Any Artist",
|
||||
minScore: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pp := newPhantomProfile(
|
||||
"/dummy/path.flac", tt.display, 0,
|
||||
)
|
||||
score := scoreTitleArtist(
|
||||
pp, tt.title, tt.artist,
|
||||
)
|
||||
|
||||
if score < tt.minScore {
|
||||
t.Errorf(
|
||||
"scoreTitleArtist(%q, %q, %q) = %f, want >= %f",
|
||||
tt.display, tt.title, tt.artist,
|
||||
score, tt.minScore,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKeywords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "typical music path",
|
||||
path: "/music/Pink Floyd/The Wall/03 - Another Brick in the Wall.flac",
|
||||
expected: []string{
|
||||
"music", "pink", "floyd", "the",
|
||||
"wall", "another", "brick", "in",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "simple filename",
|
||||
path: "song.mp3",
|
||||
expected: []string{"song"},
|
||||
},
|
||||
{
|
||||
name: "track number stripped",
|
||||
path: "01 - Song Title.flac",
|
||||
expected: []string{"song", "title"},
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
path: "",
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := extractKeywords(tt.path)
|
||||
if !stringSliceEqual(result, tt.expected) {
|
||||
t.Errorf(
|
||||
"extractKeywords(%q) = %v, want %v",
|
||||
tt.path, result, tt.expected,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDisplayTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
artist string
|
||||
title string
|
||||
}{
|
||||
{
|
||||
input: "Artist - Title",
|
||||
artist: "Artist",
|
||||
title: "Title",
|
||||
},
|
||||
{
|
||||
input: "Just a Title",
|
||||
artist: "",
|
||||
title: "Just a Title",
|
||||
},
|
||||
{
|
||||
input: "",
|
||||
artist: "",
|
||||
title: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
artist, title := parseDisplayTitle(tt.input)
|
||||
if artist != tt.artist || title != tt.title {
|
||||
t.Errorf(
|
||||
"parseDisplayTitle(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.input, artist, title,
|
||||
tt.artist, tt.title,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeywordOverlap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Full overlap.
|
||||
score := keywordOverlap(
|
||||
[]string{"a", "b", "c"},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
)
|
||||
|
||||
if score != 1.0 {
|
||||
t.Errorf("expected 1.0, got %f", score)
|
||||
}
|
||||
|
||||
// Partial overlap.
|
||||
score = keywordOverlap(
|
||||
[]string{"a", "b", "c"},
|
||||
[]string{"a", "d", "e"},
|
||||
)
|
||||
|
||||
expected := 1.0 / 3.0
|
||||
if math.Abs(score-expected) > 0.01 {
|
||||
t.Errorf("expected ~%f, got %f", expected, score)
|
||||
}
|
||||
|
||||
// No overlap.
|
||||
score = keywordOverlap(
|
||||
[]string{"a", "b"},
|
||||
[]string{"c", "d"},
|
||||
)
|
||||
|
||||
if score != 0.0 {
|
||||
t.Errorf("expected 0.0, got %f", score)
|
||||
}
|
||||
|
||||
// Empty source.
|
||||
score = keywordOverlap(nil, []string{"a"})
|
||||
if score != 0.0 {
|
||||
t.Errorf("expected 0.0 for empty source, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortCandidatesByScore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
candidates := []CandidateTrack{
|
||||
{FilePath: "a", Score: 0.3},
|
||||
{FilePath: "b", Score: 0.9},
|
||||
{FilePath: "c", Score: 0.6},
|
||||
}
|
||||
|
||||
sortCandidatesByScore(candidates)
|
||||
|
||||
if candidates[0].FilePath != "b" {
|
||||
t.Errorf(
|
||||
"expected first candidate to be 'b', got %q",
|
||||
candidates[0].FilePath,
|
||||
)
|
||||
}
|
||||
|
||||
if candidates[1].FilePath != "c" {
|
||||
t.Errorf(
|
||||
"expected second candidate to be 'c', got %q",
|
||||
candidates[1].FilePath,
|
||||
)
|
||||
}
|
||||
|
||||
if candidates[2].FilePath != "a" {
|
||||
t.Errorf(
|
||||
"expected third candidate to be 'a', got %q",
|
||||
candidates[2].FilePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// stringSliceEqual compares two string slices.
|
||||
func stringSliceEqual(a, b []string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -67,6 +68,32 @@ type WithTracks struct {
|
||||
Tracks []Track `json:"Tracks"`
|
||||
}
|
||||
|
||||
// CandidateTrack represents a potential library match for a
|
||||
// phantom track.
|
||||
type CandidateTrack struct {
|
||||
FilePath string `json:"FilePath"`
|
||||
Title string `json:"Title"`
|
||||
Artist string `json:"Artist"`
|
||||
Album string `json:"Album"`
|
||||
Duration string `json:"Duration"`
|
||||
Score float64 `json:"Score"`
|
||||
}
|
||||
|
||||
// PhantomMatch represents a high-confidence pairing of a phantom
|
||||
// track to a library track.
|
||||
type PhantomMatch struct {
|
||||
PhantomPath string `json:"PhantomPath"`
|
||||
PhantomTitle string `json:"PhantomTitle"`
|
||||
Candidate CandidateTrack `json:"Candidate"`
|
||||
}
|
||||
|
||||
// PhantomSearchResult contains auto-matched pairs and remaining
|
||||
// unmatched phantom paths for a batch search operation.
|
||||
type PhantomSearchResult struct {
|
||||
AutoMatched []PhantomMatch `json:"AutoMatched"`
|
||||
Unmatched []string `json:"Unmatched"`
|
||||
}
|
||||
|
||||
// Service manages playlist operations.
|
||||
type Service struct {
|
||||
ctx context.Context
|
||||
@@ -679,9 +706,10 @@ func (s *Service) ImportPlaylist(
|
||||
var (
|
||||
resolved int
|
||||
unresolved int
|
||||
position int
|
||||
)
|
||||
|
||||
for i, entry := range parsed.Entries {
|
||||
for _, entry := range parsed.Entries {
|
||||
absPath := toAbsolutePath(
|
||||
entry.RelativePath, libraryRoot,
|
||||
)
|
||||
@@ -701,7 +729,7 @@ func (s *Service) ImportPlaylist(
|
||||
sqlcgen.AddPlaylistTrackParams{
|
||||
PlaylistID: created.ID,
|
||||
AudioFileID: audioFile.ID,
|
||||
Position: int64(i),
|
||||
Position: int64(position),
|
||||
},
|
||||
)
|
||||
if addErr != nil {
|
||||
@@ -715,6 +743,7 @@ func (s *Service) ImportPlaylist(
|
||||
continue
|
||||
}
|
||||
|
||||
position++
|
||||
resolved++
|
||||
}
|
||||
|
||||
@@ -834,7 +863,9 @@ func (s *Service) restoreSinglePlaylist(
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
for i, entry := range parsed.Entries {
|
||||
var position int
|
||||
|
||||
for _, entry := range parsed.Entries {
|
||||
absPath := toAbsolutePath(
|
||||
entry.RelativePath, libraryRoot,
|
||||
)
|
||||
@@ -853,7 +884,7 @@ func (s *Service) restoreSinglePlaylist(
|
||||
sqlcgen.AddPlaylistTrackParams{
|
||||
PlaylistID: playlistID,
|
||||
AudioFileID: audioFile.ID,
|
||||
Position: int64(i),
|
||||
Position: int64(position),
|
||||
},
|
||||
)
|
||||
if addErr != nil {
|
||||
@@ -867,6 +898,7 @@ func (s *Service) restoreSinglePlaylist(
|
||||
continue
|
||||
}
|
||||
|
||||
position++
|
||||
restored++
|
||||
}
|
||||
|
||||
@@ -1196,3 +1228,538 @@ func (s *Service) migrateExistingPlaylists() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Phantom track resolution
|
||||
// =================================================================
|
||||
|
||||
// FindPhantomMatches searches the library for matches for the
|
||||
// given phantom file paths. High-confidence matches are returned
|
||||
// as auto-matched pairs; the rest remain in the unmatched list.
|
||||
func (s *Service) FindPhantomMatches(
|
||||
playlistID int64,
|
||||
phantomPaths []string,
|
||||
) (PhantomSearchResult, error) {
|
||||
if len(phantomPaths) == 0 {
|
||||
return PhantomSearchResult{}, nil
|
||||
}
|
||||
|
||||
dir, err := s.playlistsDir()
|
||||
if err != nil {
|
||||
return PhantomSearchResult{}, fmt.Errorf(
|
||||
"could not get playlists dir: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
libraryRoot := s.getLibraryRoot()
|
||||
|
||||
// Load M3U8 entries for display title / duration data.
|
||||
m3uPath, err := findPlaylistFile(dir, playlistID)
|
||||
if err != nil {
|
||||
return PhantomSearchResult{}, fmt.Errorf(
|
||||
"could not find playlist file: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
var entries []m3uEntry
|
||||
|
||||
if m3uPath != "" {
|
||||
parsed, parseErr := parseM3U8(m3uPath)
|
||||
if parseErr == nil {
|
||||
entries = parsed.Entries
|
||||
}
|
||||
}
|
||||
|
||||
// Build a lookup from absolute path to M3U entry.
|
||||
entryByPath := make(map[string]m3uEntry, len(entries))
|
||||
|
||||
for _, e := range entries {
|
||||
absPath := toAbsolutePath(
|
||||
e.RelativePath, libraryRoot,
|
||||
)
|
||||
entryByPath[absPath] = e
|
||||
}
|
||||
|
||||
// Track which candidates have been claimed by auto-match
|
||||
// so we don't assign the same candidate to two phantoms.
|
||||
claimed := make(map[string]struct{})
|
||||
|
||||
var result PhantomSearchResult
|
||||
|
||||
for _, phantomPath := range phantomPaths {
|
||||
entry := entryByPath[phantomPath]
|
||||
candidates := s.searchCandidates(
|
||||
phantomPath, entry,
|
||||
)
|
||||
|
||||
matched := false
|
||||
|
||||
for _, c := range candidates {
|
||||
if _, taken := claimed[c.FilePath]; taken {
|
||||
continue
|
||||
}
|
||||
|
||||
if c.Score >= autoMatchMinimum {
|
||||
result.AutoMatched = append(
|
||||
result.AutoMatched,
|
||||
PhantomMatch{
|
||||
PhantomPath: phantomPath,
|
||||
PhantomTitle: entry.DisplayTitle,
|
||||
Candidate: c,
|
||||
},
|
||||
)
|
||||
|
||||
claimed[c.FilePath] = struct{}{}
|
||||
matched = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
result.Unmatched = append(
|
||||
result.Unmatched, phantomPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPhantomCandidates returns scored candidate matches for a
|
||||
// single phantom track.
|
||||
func (s *Service) GetPhantomCandidates(
|
||||
playlistID int64,
|
||||
phantomPath string,
|
||||
) ([]CandidateTrack, error) {
|
||||
dir, err := s.playlistsDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not get playlists dir: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
libraryRoot := s.getLibraryRoot()
|
||||
|
||||
// Find the M3U entry for this phantom.
|
||||
m3uPath, err := findPlaylistFile(dir, playlistID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not find playlist file: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
var entry m3uEntry
|
||||
|
||||
if m3uPath != "" {
|
||||
parsed, parseErr := parseM3U8(m3uPath)
|
||||
if parseErr == nil {
|
||||
entry, _ = findM3UEntry(
|
||||
parsed.Entries, phantomPath, libraryRoot,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return s.searchCandidates(
|
||||
phantomPath, entry,
|
||||
), nil
|
||||
}
|
||||
|
||||
// SearchLibrary searches the entire library by a free-text query
|
||||
// for manual phantom resolution.
|
||||
func (s *Service) SearchLibrary(
|
||||
query string,
|
||||
) ([]CandidateTrack, error) {
|
||||
trimmed := strings.TrimSpace(query)
|
||||
if trimmed == "" {
|
||||
return []CandidateTrack{}, nil
|
||||
}
|
||||
|
||||
rows, err := s.db.SearchFTS(
|
||||
trimmed, maxLibrarySearchResults,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"library search failed: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
candidates := make([]CandidateTrack, 0, len(rows))
|
||||
|
||||
for _, row := range rows {
|
||||
candidates = append(candidates, CandidateTrack{
|
||||
FilePath: row.FilePath,
|
||||
Title: row.Title,
|
||||
Artist: row.Artist,
|
||||
Album: row.Album,
|
||||
Duration: strconv.FormatInt(
|
||||
row.LengthMilliseconds, 10,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// ResolvePhantomTracks replaces phantom entries in a playlist
|
||||
// with real library tracks. The matches map keys are phantom
|
||||
// absolute paths and values are resolved absolute paths.
|
||||
func (s *Service) ResolvePhantomTracks(
|
||||
playlistID int64,
|
||||
matches map[string]string,
|
||||
) error {
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir, err := s.playlistsDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not get playlists dir: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
libraryRoot := s.getLibraryRoot()
|
||||
|
||||
m3uPath, err := findPlaylistFile(dir, playlistID)
|
||||
if err != nil || m3uPath == "" {
|
||||
return fmt.Errorf(
|
||||
"could not find M3U8 file for playlist %d: %w",
|
||||
playlistID, err,
|
||||
)
|
||||
}
|
||||
|
||||
parsed, err := parseM3U8(m3uPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not parse M3U8: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Get next available DB position.
|
||||
nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition(
|
||||
s.db.Ctx, playlistID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not get next position: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Build M3U path replacements and insert DB rows.
|
||||
pathReplacements := make(
|
||||
map[string]string, len(matches),
|
||||
)
|
||||
|
||||
var resolved int
|
||||
|
||||
for phantomAbs, resolvedAbs := range matches {
|
||||
audioFile, lookupErr := s.db.Queries.GetAudioFileByPath(
|
||||
s.db.Ctx, resolvedAbs,
|
||||
)
|
||||
if lookupErr != nil {
|
||||
s.logger.Warn(
|
||||
"Resolved path not found in library",
|
||||
"phantomPath", phantomAbs,
|
||||
"resolvedPath", resolvedAbs,
|
||||
"err", lookupErr,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
_, addErr := s.db.Queries.AddPlaylistTrack(
|
||||
s.db.Ctx,
|
||||
sqlcgen.AddPlaylistTrackParams{
|
||||
PlaylistID: playlistID,
|
||||
AudioFileID: audioFile.ID,
|
||||
Position: nextPos + int64(resolved),
|
||||
},
|
||||
)
|
||||
if addErr != nil {
|
||||
s.logger.Warn(
|
||||
"Could not add resolved track",
|
||||
"playlistId", playlistID,
|
||||
"path", resolvedAbs,
|
||||
"err", addErr,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
newRel := toRelativePath(resolvedAbs, libraryRoot)
|
||||
pathReplacements[phantomAbs] = newRel
|
||||
resolved++
|
||||
}
|
||||
|
||||
// Rewrite the M3U8 with updated paths.
|
||||
if resolved > 0 {
|
||||
updated := replaceM3UEntryPaths(
|
||||
parsed.Entries, pathReplacements, libraryRoot,
|
||||
)
|
||||
|
||||
playlist, nameErr := s.db.Queries.GetPlaylist(
|
||||
s.db.Ctx, playlistID,
|
||||
)
|
||||
if nameErr != nil {
|
||||
return fmt.Errorf(
|
||||
"could not get playlist name: %w", nameErr,
|
||||
)
|
||||
}
|
||||
|
||||
if writeErr := writeM3U8(
|
||||
dir, playlistID, playlist.Name, updated,
|
||||
); writeErr != nil {
|
||||
return fmt.Errorf(
|
||||
"could not rewrite M3U8: %w", writeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
"Phantom tracks resolved",
|
||||
"playlistId", playlistID,
|
||||
"resolved", resolved,
|
||||
"requested", len(matches),
|
||||
)
|
||||
|
||||
s.emitEvent(events.PlaylistTracksChanged, playlistID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePhantomTracks removes phantom entries from a playlist's
|
||||
// M3U8 file. Since phantom tracks have no DB rows, only the
|
||||
// M3U8 file is modified.
|
||||
func (s *Service) RemovePhantomTracks(
|
||||
playlistID int64,
|
||||
phantomPaths []string,
|
||||
) error {
|
||||
if len(phantomPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir, err := s.playlistsDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not get playlists dir: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
libraryRoot := s.getLibraryRoot()
|
||||
|
||||
m3uPath, err := findPlaylistFile(dir, playlistID)
|
||||
if err != nil || m3uPath == "" {
|
||||
return fmt.Errorf(
|
||||
"could not find M3U8 file for playlist %d: %w",
|
||||
playlistID, err,
|
||||
)
|
||||
}
|
||||
|
||||
parsed, err := parseM3U8(m3uPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not parse M3U8: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
targetSet := make(
|
||||
map[string]struct{}, len(phantomPaths),
|
||||
)
|
||||
|
||||
for _, p := range phantomPaths {
|
||||
targetSet[p] = struct{}{}
|
||||
}
|
||||
|
||||
updated := removeM3UEntries(
|
||||
parsed.Entries, targetSet, libraryRoot,
|
||||
)
|
||||
|
||||
playlist, err := s.db.Queries.GetPlaylist(
|
||||
s.db.Ctx, playlistID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not get playlist name: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := writeM3U8(
|
||||
dir, playlistID, playlist.Name, updated,
|
||||
); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not rewrite M3U8: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
"Phantom tracks removed",
|
||||
"playlistId", playlistID,
|
||||
"removed", len(phantomPaths),
|
||||
)
|
||||
|
||||
s.emitEvent(events.PlaylistTracksChanged, playlistID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchCandidates finds and scores candidate library tracks
|
||||
// for a single phantom track.
|
||||
func (s *Service) searchCandidates(
|
||||
phantomPath string,
|
||||
entry m3uEntry,
|
||||
) []CandidateTrack {
|
||||
basename := filepath.Base(phantomPath)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
var combined []database.SearchRow
|
||||
|
||||
// 1. Exact basename match via indexed column.
|
||||
bnRows, err := s.db.Queries.SearchAudioFilesByBasename(
|
||||
s.db.Ctx,
|
||||
sqlcgen.SearchAudioFilesByBasenameParams{
|
||||
Basename: basename,
|
||||
Limit: int64(maxCandidates),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn(
|
||||
"Basename search failed",
|
||||
"basename", basename,
|
||||
"err", err,
|
||||
)
|
||||
}
|
||||
|
||||
for _, r := range bnRows {
|
||||
if _, ok := seen[r.FilePath]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[r.FilePath] = struct{}{}
|
||||
|
||||
combined = append(combined, database.SearchRow{
|
||||
FilePath: r.FilePath,
|
||||
LengthMilliseconds: r.LengthMilliseconds,
|
||||
Title: r.Title,
|
||||
Artist: r.Artist,
|
||||
Album: r.Album,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. FTS5 filename-token search for fuzzy basename
|
||||
// matches (e.g. different extension).
|
||||
ftsFileRows, err := s.db.SearchFTSByFilename(
|
||||
basename, maxCandidates,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn(
|
||||
"FTS filename search failed",
|
||||
"basename", basename,
|
||||
"err", err,
|
||||
)
|
||||
}
|
||||
|
||||
for _, r := range ftsFileRows {
|
||||
if _, ok := seen[r.FilePath]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[r.FilePath] = struct{}{}
|
||||
|
||||
combined = append(combined, r)
|
||||
}
|
||||
|
||||
// 3. FTS5 keyword search from path + display title.
|
||||
keywords := extractKeywords(phantomPath)
|
||||
|
||||
if entry.DisplayTitle != "" {
|
||||
titleKeywords := extractKeywords(
|
||||
entry.DisplayTitle,
|
||||
)
|
||||
keywords = append(keywords, titleKeywords...)
|
||||
keywords = dedupStrings(keywords)
|
||||
}
|
||||
|
||||
if len(keywords) > 0 {
|
||||
kwQuery := strings.Join(keywords, " ")
|
||||
|
||||
kwRows, kwErr := s.db.SearchFTS(
|
||||
kwQuery, maxCandidates,
|
||||
)
|
||||
if kwErr != nil {
|
||||
s.logger.Warn(
|
||||
"FTS keyword search failed",
|
||||
"keywords", keywords,
|
||||
"err", kwErr,
|
||||
)
|
||||
}
|
||||
|
||||
for _, r := range kwRows {
|
||||
if _, ok := seen[r.FilePath]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[r.FilePath] = struct{}{}
|
||||
|
||||
combined = append(combined, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Score each candidate.
|
||||
pp := newPhantomProfile(
|
||||
phantomPath, entry.DisplayTitle,
|
||||
entry.DurationSec,
|
||||
)
|
||||
|
||||
candidates := make(
|
||||
[]CandidateTrack, 0, len(combined),
|
||||
)
|
||||
|
||||
for _, row := range combined {
|
||||
score := scoreCandidate(
|
||||
pp,
|
||||
row.FilePath,
|
||||
row.Title,
|
||||
row.Artist,
|
||||
row.LengthMilliseconds,
|
||||
)
|
||||
|
||||
candidates = append(candidates, CandidateTrack{
|
||||
FilePath: row.FilePath,
|
||||
Title: row.Title,
|
||||
Artist: row.Artist,
|
||||
Album: row.Album,
|
||||
Duration: strconv.FormatInt(
|
||||
row.LengthMilliseconds, 10,
|
||||
),
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by score descending.
|
||||
sortCandidatesByScore(candidates)
|
||||
|
||||
if len(candidates) > maxCandidates {
|
||||
candidates = candidates[:maxCandidates]
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
// sortCandidatesByScore sorts candidates by score descending.
|
||||
func sortCandidatesByScore(candidates []CandidateTrack) {
|
||||
slices.SortFunc(
|
||||
candidates,
|
||||
func(a, b CandidateTrack) int {
|
||||
if a.Score > b.Score {
|
||||
return -1
|
||||
}
|
||||
|
||||
if a.Score < b.Score {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user