439 lines
10 KiB
Go
439 lines
10 KiB
Go
package recovery
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"navimigrate/internal/model"
|
|
"navimigrate/internal/qobuz"
|
|
)
|
|
|
|
type Searcher interface {
|
|
SearchTracks(ctx context.Context, query string, limit int) ([]qobuz.Track, error)
|
|
}
|
|
|
|
type TrackMatcher interface {
|
|
MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack
|
|
}
|
|
|
|
type PlaylistAdder interface {
|
|
AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error
|
|
}
|
|
|
|
type ProgressFunc func(message string)
|
|
|
|
type BuildOptions struct {
|
|
DownloadMissing bool
|
|
Downloader QobuzDL
|
|
Progress ProgressFunc
|
|
}
|
|
|
|
type ReaddOptions struct {
|
|
Force bool
|
|
Progress ProgressFunc
|
|
}
|
|
|
|
type ReaddSummary struct {
|
|
Playlists int
|
|
Candidates int
|
|
Matched int
|
|
Added int
|
|
Errors int
|
|
}
|
|
|
|
func BuildManifestFromReport(ctx context.Context, rep model.TransferReport, searcher Searcher, opts BuildOptions) (Manifest, error) {
|
|
manifest := Manifest{
|
|
Version: ManifestVersion,
|
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
|
DownloadDir: opts.Downloader.OutputDir,
|
|
QobuzDLPath: opts.Downloader.Path,
|
|
Playlists: make([]PlaylistManifest, 0, len(rep.Results)),
|
|
}
|
|
|
|
type albumJob struct {
|
|
ID string
|
|
}
|
|
albums := map[string]albumJob{}
|
|
totalUnmatched := 0
|
|
for _, pl := range rep.Results {
|
|
totalUnmatched += len(pl.Unmatched)
|
|
}
|
|
processed := 0
|
|
|
|
for _, pl := range rep.Results {
|
|
if len(pl.Unmatched) == 0 {
|
|
continue
|
|
}
|
|
playlistEntry := PlaylistManifest{
|
|
Name: pl.Name,
|
|
TargetID: pl.TargetID,
|
|
Tracks: make([]TrackManifest, 0, len(pl.Unmatched)),
|
|
}
|
|
|
|
for _, um := range pl.Unmatched {
|
|
processed++
|
|
notify(opts.Progress, fmt.Sprintf("Qobuz lookup %d/%d: %s - %s", processed, totalUnmatched, short(pl.Name, 36), short(um.Source.Title, 44)))
|
|
entry := TrackManifest{Source: um.Source}
|
|
cand, ok, err := findQobuzCandidate(ctx, searcher, um.Source)
|
|
if err != nil {
|
|
entry.LookupError = err.Error()
|
|
} else if ok {
|
|
entry.QobuzQuery = cand.Query
|
|
entry.QobuzScore = cand.Score
|
|
entry.QobuzTrackID = cand.TrackID
|
|
entry.QobuzAlbumID = cand.AlbumID
|
|
entry.QobuzAlbumTitle = cand.AlbumTitle
|
|
entry.QobuzAlbumArtist = cand.AlbumArtist
|
|
if strings.TrimSpace(cand.AlbumID) != "" {
|
|
albums[cand.AlbumID] = albumJob{ID: cand.AlbumID}
|
|
}
|
|
}
|
|
playlistEntry.Tracks = append(playlistEntry.Tracks, entry)
|
|
}
|
|
manifest.Playlists = append(manifest.Playlists, playlistEntry)
|
|
}
|
|
|
|
if opts.DownloadMissing {
|
|
albumIDs := make([]string, 0, len(albums))
|
|
for id := range albums {
|
|
albumIDs = append(albumIDs, id)
|
|
}
|
|
sort.Strings(albumIDs)
|
|
|
|
downloadState := map[string]error{}
|
|
for i, albumID := range albumIDs {
|
|
notify(opts.Progress, fmt.Sprintf("Qobuz album download %d/%d: %s", i+1, len(albumIDs), albumID))
|
|
err := opts.Downloader.DownloadAlbum(ctx, albumID)
|
|
downloadState[albumID] = err
|
|
}
|
|
|
|
for pIdx := range manifest.Playlists {
|
|
for tIdx := range manifest.Playlists[pIdx].Tracks {
|
|
entry := &manifest.Playlists[pIdx].Tracks[tIdx]
|
|
if strings.TrimSpace(entry.QobuzAlbumID) == "" {
|
|
continue
|
|
}
|
|
entry.DownloadAttempted = true
|
|
if err := downloadState[entry.QobuzAlbumID]; err != nil {
|
|
entry.Downloaded = false
|
|
entry.DownloadError = err.Error()
|
|
} else {
|
|
entry.Downloaded = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
notify(opts.Progress, "Qobuz missing-track processing complete")
|
|
return manifest, nil
|
|
}
|
|
|
|
func ReaddDownloadedToPlaylists(ctx context.Context, manifest *Manifest, matcher TrackMatcher, adder PlaylistAdder, opts ReaddOptions) (ReaddSummary, error) {
|
|
summary := ReaddSummary{}
|
|
for pIdx := range manifest.Playlists {
|
|
pl := &manifest.Playlists[pIdx]
|
|
if strings.TrimSpace(pl.TargetID) == "" {
|
|
continue
|
|
}
|
|
summary.Playlists++
|
|
|
|
toAdd := make([]string, 0)
|
|
entryIdxs := make([]int, 0)
|
|
for tIdx := range pl.Tracks {
|
|
entry := &pl.Tracks[tIdx]
|
|
if !opts.Force && entry.Added {
|
|
continue
|
|
}
|
|
if !entry.Downloaded {
|
|
continue
|
|
}
|
|
|
|
summary.Candidates++
|
|
entry.RematchAttempted = true
|
|
res := matcher.MatchTrack(ctx, entry.Source)
|
|
if !res.Matched || strings.TrimSpace(res.TargetID) == "" {
|
|
entry.Rematched = false
|
|
entry.RematchReason = res.Reason
|
|
if entry.RematchReason == "" {
|
|
entry.RematchReason = "not found in Navidrome after rescan"
|
|
}
|
|
continue
|
|
}
|
|
|
|
entry.Rematched = true
|
|
entry.RematchReason = ""
|
|
entry.RematchTrackID = res.TargetID
|
|
toAdd = append(toAdd, res.TargetID)
|
|
entryIdxs = append(entryIdxs, tIdx)
|
|
summary.Matched++
|
|
}
|
|
|
|
if len(toAdd) == 0 {
|
|
continue
|
|
}
|
|
toAdd = uniqueIDs(toAdd)
|
|
notify(opts.Progress, fmt.Sprintf("Re-add to playlist %s: %d track(s)", pl.Name, len(toAdd)))
|
|
err := adder.AddTracksToPlaylist(ctx, pl.TargetID, toAdd)
|
|
if err != nil {
|
|
summary.Errors++
|
|
for _, idx := range entryIdxs {
|
|
entry := &pl.Tracks[idx]
|
|
entry.AddAttempted = true
|
|
entry.Added = false
|
|
entry.AddError = err.Error()
|
|
}
|
|
continue
|
|
}
|
|
|
|
for _, idx := range entryIdxs {
|
|
entry := &pl.Tracks[idx]
|
|
entry.AddAttempted = true
|
|
entry.Added = true
|
|
entry.AddError = ""
|
|
summary.Added++
|
|
}
|
|
}
|
|
|
|
manifest.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
notify(opts.Progress, "Re-add from downloaded manifest complete")
|
|
return summary, nil
|
|
}
|
|
|
|
type qobuzCandidate struct {
|
|
TrackID string
|
|
AlbumID string
|
|
AlbumTitle string
|
|
AlbumArtist string
|
|
Score float64
|
|
Query string
|
|
}
|
|
|
|
func findQobuzCandidate(ctx context.Context, searcher Searcher, src model.Track) (qobuzCandidate, bool, error) {
|
|
queries := buildQueries(src)
|
|
if len(queries) == 0 {
|
|
return qobuzCandidate{}, false, nil
|
|
}
|
|
|
|
best := qobuzCandidate{Score: -999}
|
|
seenTrack := map[string]struct{}{}
|
|
firstErr := error(nil)
|
|
errCount := 0
|
|
for _, q := range queries {
|
|
res, err := searcher.SearchTracks(ctx, q, 20)
|
|
if err != nil {
|
|
errCount++
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
for _, tr := range res {
|
|
if strings.TrimSpace(tr.ID) == "" {
|
|
continue
|
|
}
|
|
if _, ok := seenTrack[tr.ID]; ok {
|
|
continue
|
|
}
|
|
seenTrack[tr.ID] = struct{}{}
|
|
sc := score(src, tr)
|
|
if sc > best.Score {
|
|
best = qobuzCandidate{
|
|
TrackID: tr.ID,
|
|
AlbumID: tr.AlbumID,
|
|
AlbumTitle: tr.Album,
|
|
AlbumArtist: tr.AlbumArtist,
|
|
Score: sc,
|
|
Query: q,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if errCount == len(queries) && firstErr != nil {
|
|
return qobuzCandidate{}, false, fmt.Errorf("qobuz search failed for all queries: %w", firstErr)
|
|
}
|
|
|
|
if strings.TrimSpace(best.TrackID) == "" || strings.TrimSpace(best.AlbumID) == "" {
|
|
return qobuzCandidate{}, false, nil
|
|
}
|
|
if best.Score < 45 {
|
|
return qobuzCandidate{}, false, nil
|
|
}
|
|
return best, true, nil
|
|
}
|
|
|
|
func buildQueries(src model.Track) []string {
|
|
title := strings.TrimSpace(src.Title)
|
|
if title == "" {
|
|
return nil
|
|
}
|
|
artist := ""
|
|
if len(src.Artists) > 0 {
|
|
artist = strings.TrimSpace(src.Artists[0])
|
|
}
|
|
queries := make([]string, 0, 4)
|
|
if strings.TrimSpace(src.ISRC) != "" {
|
|
queries = append(queries, strings.ToUpper(strings.TrimSpace(src.ISRC)))
|
|
}
|
|
queries = append(queries, strings.TrimSpace(title+" "+artist))
|
|
queries = append(queries, title)
|
|
if strings.TrimSpace(src.Album) != "" {
|
|
queries = append(queries, strings.TrimSpace(title+" "+src.Album+" "+artist))
|
|
}
|
|
|
|
uniq := map[string]struct{}{}
|
|
out := make([]string, 0, len(queries))
|
|
for _, q := range queries {
|
|
q = strings.TrimSpace(q)
|
|
if q == "" {
|
|
continue
|
|
}
|
|
if _, ok := uniq[q]; ok {
|
|
continue
|
|
}
|
|
uniq[q] = struct{}{}
|
|
out = append(out, q)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func score(src model.Track, dst qobuz.Track) float64 {
|
|
s := 0.0
|
|
if strings.TrimSpace(src.ISRC) != "" && strings.EqualFold(strings.TrimSpace(src.ISRC), strings.TrimSpace(dst.ISRC)) {
|
|
s += 60
|
|
}
|
|
s += 25 * similarity(normalize(src.Title), normalize(dst.Title))
|
|
if len(src.Artists) > 0 {
|
|
s += 20 * similarity(normalize(src.Artists[0]), normalize(dst.Artist))
|
|
}
|
|
if src.DurationMS > 0 && dst.Duration > 0 {
|
|
delta := math.Abs(float64(src.DurationMS/1000 - dst.Duration))
|
|
switch {
|
|
case delta <= 2:
|
|
s += 10
|
|
case delta <= 5:
|
|
s += 7
|
|
case delta <= 10:
|
|
s += 4
|
|
case delta > 25:
|
|
s -= 6
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func normalize(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
repl := strings.NewReplacer("&", " and ", "'", "", "-", " ")
|
|
s = repl.Replace(s)
|
|
parts := strings.Fields(s)
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func similarity(a, b string) float64 {
|
|
if a == "" || b == "" {
|
|
return 0
|
|
}
|
|
if a == b {
|
|
return 1
|
|
}
|
|
ta := tokenSet(a)
|
|
tb := tokenSet(b)
|
|
inter := 0
|
|
for t := range ta {
|
|
if _, ok := tb[t]; ok {
|
|
inter++
|
|
}
|
|
}
|
|
if inter == 0 {
|
|
return 0
|
|
}
|
|
jaccard := float64(inter) / float64(len(ta)+len(tb)-inter)
|
|
lev := levenshteinRatio(a, b)
|
|
return (jaccard * 0.6) + (lev * 0.4)
|
|
}
|
|
|
|
func tokenSet(s string) map[string]struct{} {
|
|
set := map[string]struct{}{}
|
|
for _, p := range strings.Fields(s) {
|
|
set[p] = struct{}{}
|
|
}
|
|
return set
|
|
}
|
|
|
|
func levenshteinRatio(a, b string) float64 {
|
|
ar := []rune(a)
|
|
br := []rune(b)
|
|
if len(ar) == 0 || len(br) == 0 {
|
|
return 0
|
|
}
|
|
d := levenshtein(ar, br)
|
|
maxLen := len(ar)
|
|
if len(br) > maxLen {
|
|
maxLen = len(br)
|
|
}
|
|
return 1 - float64(d)/float64(maxLen)
|
|
}
|
|
|
|
func levenshtein(a, b []rune) int {
|
|
dp := make([]int, len(b)+1)
|
|
for j := 0; j <= len(b); j++ {
|
|
dp[j] = j
|
|
}
|
|
for i := 1; i <= len(a); i++ {
|
|
prev := dp[0]
|
|
dp[0] = i
|
|
for j := 1; j <= len(b); j++ {
|
|
tmp := dp[j]
|
|
cost := 0
|
|
if a[i-1] != b[j-1] {
|
|
cost = 1
|
|
}
|
|
dp[j] = min3(dp[j]+1, dp[j-1]+1, prev+cost)
|
|
prev = tmp
|
|
}
|
|
}
|
|
return dp[len(b)]
|
|
}
|
|
|
|
func min3(a, b, c int) int {
|
|
arr := []int{a, b, c}
|
|
sort.Ints(arr)
|
|
return arr[0]
|
|
}
|
|
|
|
func uniqueIDs(ids []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func notify(fn ProgressFunc, msg string) {
|
|
if fn != nil {
|
|
fn(msg)
|
|
}
|
|
}
|
|
|
|
func short(s string, max int) string {
|
|
s = strings.TrimSpace(s)
|
|
if max <= 3 || len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max-3] + "..."
|
|
}
|