// 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. // Either side can be the one that does not know: an M3U8 written // without EXTINF lines carries no duration, and neither does a // library file whose length was never read. Scoring a candidate // out of 0.9 for the *library's* gap made an otherwise exact // filename match unable to reach the auto-match threshold. fnWeight := weightFilename durWeight := weightDuration if pp.durationSec == 0 || candidateDurationMs == 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) }