Files
QTransfer/internal/transfer/transfer.go
joren c131bf6ab1 ye
2026-04-04 20:38:08 +02:00

233 lines
6.0 KiB
Go

package transfer
import (
"context"
"fmt"
"strings"
"sync"
"time"
"qtransfer/internal/model"
)
type QobuzWriter interface {
CreatePlaylist(ctx context.Context, name, description string, isPublic bool) (int64, error)
AddTracksToPlaylist(ctx context.Context, playlistID int64, trackIDs []int64) error
}
type TrackMatcher interface {
MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack
}
type Config struct {
DryRun bool
PublicPlaylists bool
Concurrency int
LikedName string
TargetPlaylistID int64 // when set, add tracks to this existing playlist instead of creating a new one
Progress ProgressFunc
}
type ProgressFunc func(message string)
func Run(ctx context.Context, cfg Config, writer QobuzWriter, matcher TrackMatcher, playlists []model.Playlist, likedSongs []model.Track, includeLiked bool) (model.TransferReport, error) {
rep := model.TransferReport{
StartedAt: time.Now().UTC().Format(time.RFC3339),
DryRun: cfg.DryRun,
}
all := make([]model.Playlist, 0, len(playlists)+1)
all = append(all, playlists...)
if includeLiked {
all = append(all, model.Playlist{
Name: cfg.LikedName,
Tracks: likedSongs,
})
}
totalPlaylists := len(all)
for i, pl := range all {
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 QobuzWriter, 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
}
var playlistID int64
if cfg.TargetPlaylistID > 0 {
playlistID = cfg.TargetPlaylistID
res.TargetID = playlistID
} else {
notify(cfg, fmt.Sprintf("Transfer %d/%d creating playlist: %s", playlistIndex, playlistTotal, shortName(pl.Name)))
created, err := writer.CreatePlaylist(ctx, pl.Name, sanitizeDescription(pl.Description), cfg.PublicPlaylists)
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
}
playlistID = created
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)) ([]int64, []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([]int64, 0, len(tracks))
unmatched := make([]model.MatchedTrack, 0)
for _, r := range ordered {
if r.Matched && r.QobuzID > 0 {
matched = append(matched, r.QobuzID)
} else {
unmatched = append(unmatched, r)
}
}
return matched, unmatched
}
func uniqueIDs(ids []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func sanitizeDescription(s string) string {
s = strings.TrimSpace(s)
if len(s) <= 1000 {
return s
}
return s[:1000]
}
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] + "..."
}