package transfer import ( "context" "fmt" "strings" "sync" "time" "navimigrate/internal/model" ) type Writer interface { CreatePlaylist(ctx context.Context, name string) (string, error) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error } type TrackMatcher interface { MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack } type Config struct { DryRun bool Concurrency int Progress ProgressFunc } type ProgressFunc func(message string) func Run(ctx context.Context, cfg Config, writer Writer, matcher TrackMatcher, playlists []model.Playlist) (model.TransferReport, error) { rep := model.TransferReport{ StartedAt: time.Now().UTC().Format(time.RFC3339), DryRun: cfg.DryRun, } totalPlaylists := len(playlists) for i, pl := range playlists { result := processPlaylist(ctx, cfg, writer, matcher, pl, i+1, totalPlaylists) rep.Results = append(rep.Results, result) notify(cfg, fmt.Sprintf( "Transfer %d/%d done: %s | matched %d/%d | added %d | unmatched %d", i+1, totalPlaylists, shortName(pl.Name), result.MatchedTracks, result.TotalTracks, result.AddedTracks, len(result.Unmatched), )) } rep.EndedAt = time.Now().UTC().Format(time.RFC3339) notify(cfg, "Transfer processing complete") return rep, nil } func processPlaylist(ctx context.Context, cfg Config, writer Writer, matcher TrackMatcher, pl model.Playlist, playlistIndex, playlistTotal int) model.PlaylistTransferResult { res := model.PlaylistTransferResult{ Name: pl.Name, TotalTracks: len(pl.Tracks), Errors: []string{}, Unmatched: []model.MatchedTrack{}, } notify(cfg, fmt.Sprintf("Transfer %d/%d matching: %s (0/%d)", playlistIndex, playlistTotal, shortName(pl.Name), len(pl.Tracks))) matched, unmatched := matchTracks(ctx, matcher, pl.Tracks, cfg.Concurrency, func(done, total int) { notify(cfg, fmt.Sprintf("Transfer %d/%d matching: %s (%d/%d)", playlistIndex, playlistTotal, shortName(pl.Name), done, total)) }) res.MatchedTracks = len(matched) res.Unmatched = unmatched if cfg.DryRun { res.AddedTracks = len(uniqueIDs(matched)) notify(cfg, fmt.Sprintf("Transfer %d/%d dry-run: %s resolved %d matches", playlistIndex, playlistTotal, shortName(pl.Name), res.AddedTracks)) return res } notify(cfg, fmt.Sprintf("Transfer %d/%d creating playlist: %s", playlistIndex, playlistTotal, shortName(pl.Name))) playlistID, err := writer.CreatePlaylist(ctx, pl.Name) if err != nil { res.Errors = append(res.Errors, fmt.Sprintf("create playlist: %v", err)) notify(cfg, fmt.Sprintf("Transfer %d/%d failed creating playlist: %s", playlistIndex, playlistTotal, shortName(pl.Name))) return res } res.TargetID = playlistID ids := uniqueIDs(matched) if len(ids) == 0 { notify(cfg, fmt.Sprintf("Transfer %d/%d no matched tracks to add: %s", playlistIndex, playlistTotal, shortName(pl.Name))) return res } notify(cfg, fmt.Sprintf("Transfer %d/%d adding %d track(s): %s", playlistIndex, playlistTotal, len(ids), shortName(pl.Name))) if err := writer.AddTracksToPlaylist(ctx, playlistID, ids); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("add tracks: %v", err)) notify(cfg, fmt.Sprintf("Transfer %d/%d failed adding tracks: %s", playlistIndex, playlistTotal, shortName(pl.Name))) return res } res.AddedTracks = len(ids) notify(cfg, fmt.Sprintf("Transfer %d/%d added %d track(s): %s", playlistIndex, playlistTotal, res.AddedTracks, shortName(pl.Name))) return res } func matchTracks(ctx context.Context, matcher TrackMatcher, tracks []model.Track, concurrency int, progress func(done, total int)) ([]string, []model.MatchedTrack) { if concurrency < 1 { concurrency = 1 } type job struct { idx int trk model.Track } type out struct { idx int res model.MatchedTrack } jobs := make(chan job) results := make(chan out) var wg sync.WaitGroup for i := 0; i < concurrency; i++ { wg.Add(1) go func() { defer wg.Done() for j := range jobs { m := matcher.MatchTrack(ctx, j.trk) results <- out{idx: j.idx, res: m} } }() } go func() { for i, t := range tracks { jobs <- job{idx: i, trk: t} } close(jobs) wg.Wait() close(results) }() ordered := make([]model.MatchedTrack, len(tracks)) total := len(tracks) step := 1 if total > 100 { step = total / 100 } done := 0 for r := range results { ordered[r.idx] = r.res done++ if progress != nil { if done == 1 || done == total || done%step == 0 { progress(done, total) } } } matched := make([]string, 0, len(tracks)) unmatched := make([]model.MatchedTrack, 0) for _, r := range ordered { if r.Matched && strings.TrimSpace(r.TargetID) != "" { matched = append(matched, r.TargetID) } else { unmatched = append(unmatched, r) } } return matched, unmatched } 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(cfg Config, msg string) { if cfg.Progress != nil { cfg.Progress(msg) } } func shortName(s string) string { const limit = 48 s = strings.TrimSpace(s) if len(s) <= limit { return s } if limit <= 3 { return s[:limit] } return s[:limit-3] + "..." }