From ea32c0baa68c317489c745f6601ccc2fbccc664c Mon Sep 17 00:00:00 2001 From: joren Date: Fri, 3 Apr 2026 22:50:31 +0200 Subject: [PATCH] add monitor sync jobs and duplicate-safe qobuz updates --- .env.example | 3 + README.md | 53 +++++- cmd/qtransfer/main.go | 348 +++++++++++++++++++++++++++++++++-- go.mod | 2 + go.sum | 2 + internal/config/config.go | 87 ++++++--- internal/jobconfig/config.go | 76 ++++++++ internal/qobuz/client.go | 22 +++ internal/session/session.go | 18 +- 9 files changed, 557 insertions(+), 54 deletions(-) create mode 100644 go.sum create mode 100644 internal/jobconfig/config.go diff --git a/.env.example b/.env.example index b667519..81856db 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ SPOTIFY_SCOPES=playlist-read-private,playlist-read-collaborative,user-library-re QTRANSFER_SPOTIFY_MANUAL_CODE=true QTRANSFER_SESSION_FILE=~/.config/qtransfer/session.json QTRANSFER_REMEMBER_CREDS=true +QTRANSFER_CONFIG= QOBUZ_USERNAME= QOBUZ_PASSWORD= @@ -21,3 +22,5 @@ QTRANSFER_MONITOR=false QTRANSFER_MONITOR_ONCE=false QTRANSFER_MONITOR_TRANSFER=false QTRANSFER_MONITOR_INTERVAL=5m +QTRANSFER_SYNC_MODE=append +QTRANSFER_TARGET_PLAYLIST_ID=0 diff --git a/README.md b/README.md index a27f8aa..0f81478 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Spotify -> Qobuz playlist transfer tool in Go. - Session cache for Spotify/Qobuz credentials and tokens (so you do not need to re-enter each run). - Fetches all Spotify playlists and liked songs. - Playlist URL mode (`--playlist-url`) for direct targeted transfers. -- Monitor mode to detect playlist updates (`--monitor`) with optional auto-transfer (`--monitor-transfer`). +- Monitor mode to detect playlist updates (`--monitor`) with optional sync-to-Qobuz (`--monitor-transfer`) and sync modes (`--sync-mode`). - Interactive selection prompt (or non-interactive flags). - Creates Qobuz playlists and fills them with best-effort track matches. - Transfers liked songs into a dedicated playlist (not favorites). @@ -58,13 +58,16 @@ Credentials/tokens are cached in `~/.config/qtransfer/session.json` by default. - `--liked`: include liked songs as a generated Qobuz playlist - `--playlist "Name"` (repeatable): transfer specific playlists by exact name - `--playlist-url "..."` (repeatable): transfer specific Spotify playlists by URL/URI/ID +- `--config sync.toml`: TOML config with global options and per-playlist monitor sync jobs - `--spotify-manual-code=true|false`: paste callback URL/code manually or use local callback server - `--remember-creds=true|false`: persist/reuse tokens and credentials in session file - `--session-file path`: custom session file path (default `~/.config/qtransfer/session.json`) - `--monitor`: monitor selected playlists for updates - `--monitor-interval 5m`: monitor polling interval - `--monitor-once`: run one monitor check and exit -- `--monitor-transfer`: in monitor mode, transfer only changed playlists +- `--monitor-transfer`: in monitor mode, sync changed playlists to Qobuz +- `--sync-mode append|mirror`: append only, or mirror source by recreating target playlist +- `--target-playlist-id 123456`: bind single monitored source playlist to an existing Qobuz playlist ID - `--qobuz-self-test`: run Qobuz login/verify/search checks and exit (skips Spotify) - `--qobuz-self-test-write`: when self-test is enabled, also create a test playlist and add one track - `--qobuz-self-test-query "..."`: search query used during self-test @@ -102,6 +105,49 @@ Monitor selected playlists for changes: --monitor --monitor-interval 2m ``` +Monitor with append-only sync (never removes from Qobuz): + +```bash +./qtransfer \ + --playlist-url "https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd" \ + --monitor --monitor-transfer --sync-mode append --monitor-interval 2m +``` + +Monitor with mirror sync (recreates playlist on change): + +```bash +./qtransfer \ + --playlist-url "https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd" \ + --monitor --monitor-transfer --sync-mode mirror --monitor-interval 2m +``` + +Config-file mode: + +```bash +./qtransfer --config sync.toml +``` + +Example `sync.toml`: + +```toml +[global] +monitor = true +monitor_transfer = true +monitor_interval = "10m" +sync_mode = "append" +include_liked = true + +[[playlist]] +url = "https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd" +sync_mode = "append" +target_playlist_id = 61646089 + +[[playlist]] +url = "spotify:playlist:37i9dQZF1DWY4xHQp97fN6" +sync_mode = "mirror" +enabled = true +``` + Login command: ```bash @@ -122,10 +168,13 @@ Logout command (removes cached session): - `QTRANSFER_SPOTIFY_MANUAL_CODE` (optional, defaults to true) - `QTRANSFER_SESSION_FILE` (optional) - `QTRANSFER_REMEMBER_CREDS` (optional, defaults to true) +- `QTRANSFER_CONFIG` (optional) - `QTRANSFER_MONITOR` (optional) - `QTRANSFER_MONITOR_ONCE` (optional) - `QTRANSFER_MONITOR_TRANSFER` (optional) - `QTRANSFER_MONITOR_INTERVAL` (optional) +- `QTRANSFER_SYNC_MODE` (optional: append|mirror) +- `QTRANSFER_TARGET_PLAYLIST_ID` (optional, monitor mode only) - `QTRANSFER_QOBUZ_SELF_TEST` (optional) - `QTRANSFER_QOBUZ_SELF_TEST_WRITE` (optional) - `QTRANSFER_QOBUZ_SELF_TEST_QUERY` (optional) diff --git a/cmd/qtransfer/main.go b/cmd/qtransfer/main.go index 99df653..203bc9b 100644 --- a/cmd/qtransfer/main.go +++ b/cmd/qtransfer/main.go @@ -5,15 +5,18 @@ import ( "context" "crypto/sha1" "encoding/hex" + "errors" "fmt" "os" "os/signal" "sort" "strings" + "sync" "syscall" "time" "qtransfer/internal/config" + "qtransfer/internal/jobconfig" "qtransfer/internal/match" "qtransfer/internal/model" "qtransfer/internal/qobuz" @@ -30,6 +33,11 @@ type sourceSelection struct { IncludeLiked bool } +type monitorPlan struct { + SyncMode string + TargetPlaylistID int64 +} + func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -54,6 +62,9 @@ func run() error { sess.Spotify = session.SpotifyState{} sess.Qobuz = session.QobuzState{} } + if sess.Playlists == nil { + sess.Playlists = map[string]session.PlaylistSyncRef{} + } applySessionDefaults(&cfg, &sess) if cfg.Command == "login" { @@ -69,6 +80,26 @@ func run() error { return err } + fileCfg := jobconfig.File{} + if strings.TrimSpace(cfg.ConfigFile) != "" { + loaded, err := jobconfig.Load(cfg.ConfigFile) + if err != nil { + return err + } + fileCfg = loaded + if err := applyFileGlobals(&cfg, fileCfg.Global); err != nil { + return err + } + } + + planURLs, monitorPlans, err := buildPlaylistPlans(cfg, fileCfg.Playlists) + if err != nil { + return err + } + if len(planURLs) > 0 { + cfg.PlaylistURLs = planURLs + } + spToken, err := authenticateSpotify(ctx, cfg, &sess) if err != nil { return fmt.Errorf("spotify auth failed: %w", err) @@ -86,7 +117,7 @@ func run() error { } if cfg.Monitor { - if err := runMonitorMode(ctx, cfg, sp, &sess, selection); err != nil { + if err := runMonitorMode(ctx, cfg, sp, &sess, selection, monitorPlans); err != nil { return err } return persistSession(cfg, sess) @@ -264,17 +295,24 @@ func prompt(scanner *bufio.Scanner, label, defaultValue string) string { return v } -func runMonitorMode(ctx context.Context, cfg config.Config, sp *spotify.Client, sess *session.Data, selection sourceSelection) error { +func runMonitorMode(ctx context.Context, cfg config.Config, sp *spotify.Client, sess *session.Data, selection sourceSelection, plans map[string]monitorPlan) error { if len(selection.Playlists) == 0 && !selection.IncludeLiked { return fmt.Errorf("monitor mode requires at least one playlist or --liked") } + if sess.Playlists == nil { + sess.Playlists = map[string]session.PlaylistSyncRef{} + } fmt.Println("Starting monitor mode...") fmt.Printf("Watching %d playlist(s)", len(selection.Playlists)) if selection.IncludeLiked { fmt.Printf(" + liked songs") } - fmt.Printf(" | interval=%s\n", cfg.MonitorInterval) + fmt.Printf(" | interval=%s", cfg.MonitorInterval) + if cfg.MonitorTransfer { + fmt.Printf(" | sync-mode=%s", cfg.SyncMode) + } + fmt.Println() playlistIDs := make([]string, 0, len(selection.Playlists)) for _, p := range selection.Playlists { @@ -290,7 +328,7 @@ func runMonitorMode(ctx context.Context, cfg config.Config, sp *spotify.Client, return err } matcher = match.NewMatcher(qb) - fmt.Println("Monitor transfer mode enabled: changed playlists will be transferred.") + fmt.Println("Monitor transfer mode enabled: changed playlists will be synced to Qobuz.") } prev := cloneMap(sess.Monitor) @@ -335,25 +373,24 @@ func runMonitorMode(ctx context.Context, cfg config.Config, sp *spotify.Client, fmt.Printf("[%s] Updated: %s\n", time.Now().Format("15:04:05"), strings.Join(names, ", ")) if cfg.MonitorTransfer && qb != nil && matcher != nil { - transferCfg := transfer.Config{ - DryRun: cfg.DryRun, - PublicPlaylists: cfg.PublicPlaylists, - Concurrency: cfg.Concurrency, - LikedName: cfg.LikedPlaylist, - Progress: func(msg string) { - fmt.Printf("\r%-140s", msg) - }, + for _, pl := range changedPlaylists { + key := "playlist:" + pl.SourceID + plan := plans[pl.SourceID] + if err := syncChangedPlaylist(ctx, cfg, qb, matcher, key, pl, plan, sess); err != nil { + fmt.Printf("Sync error for %s: %v\n", pl.Name, err) + curr[key] = prev[key] + } } - - likedToTransfer := []model.Track{} if likedChanged { - likedToTransfer = currentLiked - } - if _, err := transfer.Run(ctx, transferCfg, qb, matcher, changedPlaylists, likedToTransfer, likedChanged); err != nil { - return fmt.Errorf("monitor transfer failed: %w", err) + plan := monitorPlan{SyncMode: cfg.SyncMode} + likedPlaylist := model.Playlist{Name: cfg.LikedPlaylist, Tracks: currentLiked} + if err := syncChangedPlaylist(ctx, cfg, qb, matcher, "liked", likedPlaylist, plan, sess); err != nil { + fmt.Printf("Sync error for liked songs: %v\n", err) + curr["liked"] = prev["liked"] + } } fmt.Print("\r") - fmt.Println("Monitor transfer cycle complete. ") + fmt.Println("Monitor sync cycle complete. ") } prev = curr @@ -525,6 +562,114 @@ func resolvePlaylistIDs(inputs []string) ([]string, error) { return out, nil } +func applyFileGlobals(cfg *config.Config, g jobconfig.GlobalConfig) error { + if g.Monitor != nil { + cfg.Monitor = *g.Monitor + } + if g.MonitorOnce != nil { + cfg.MonitorOnce = *g.MonitorOnce + } + if g.MonitorTransfer != nil { + cfg.MonitorTransfer = *g.MonitorTransfer + } + if strings.TrimSpace(g.MonitorInterval) != "" { + d, err := time.ParseDuration(strings.TrimSpace(g.MonitorInterval)) + if err != nil { + return fmt.Errorf("invalid global monitor_interval %q: %w", g.MonitorInterval, err) + } + cfg.MonitorInterval = d + } + if strings.TrimSpace(g.SyncMode) != "" { + cfg.SyncMode = strings.ToLower(strings.TrimSpace(g.SyncMode)) + } + if g.IncludeLiked != nil { + cfg.IncludeLiked = *g.IncludeLiked + } + if g.DryRun != nil { + cfg.DryRun = *g.DryRun + } + if g.PublicPlaylists != nil { + cfg.PublicPlaylists = *g.PublicPlaylists + } + if g.Concurrency != nil { + cfg.Concurrency = *g.Concurrency + } + if strings.TrimSpace(g.ReportPath) != "" { + cfg.ReportPath = strings.TrimSpace(g.ReportPath) + } + + return cfg.Validate() +} + +func buildPlaylistPlans(cfg config.Config, fileEntries []jobconfig.PlaylistEntry) ([]string, map[string]monitorPlan, error) { + urls := make([]string, 0, len(cfg.PlaylistURLs)+len(fileEntries)) + for _, u := range cfg.PlaylistURLs { + u = strings.TrimSpace(u) + if u != "" { + urls = append(urls, u) + } + } + + plans := map[string]monitorPlan{} + + for _, raw := range urls { + id, err := spotify.ParsePlaylistID(raw) + if err != nil { + return nil, nil, fmt.Errorf("invalid playlist-url %q: %w", raw, err) + } + plans[id] = monitorPlan{SyncMode: cfg.SyncMode, TargetPlaylistID: cfg.TargetPlaylistID} + } + + for i, entry := range fileEntries { + if !entry.IsEnabled() { + continue + } + raw := strings.TrimSpace(entry.URL) + id, err := spotify.ParsePlaylistID(raw) + if err != nil { + return nil, nil, fmt.Errorf("invalid config playlist entry %d url %q: %w", i+1, entry.URL, err) + } + mode := strings.ToLower(strings.TrimSpace(entry.SyncMode)) + if mode == "" { + mode = cfg.SyncMode + } + if mode == "" { + mode = "append" + } + if entry.TargetPlaylistID > 0 && mode == "mirror" { + return nil, nil, fmt.Errorf("playlist entry %d cannot use mirror mode with target_playlist_id", i+1) + } + plans[id] = monitorPlan{SyncMode: mode, TargetPlaylistID: entry.TargetPlaylistID} + urls = append(urls, raw) + } + + if cfg.TargetPlaylistID > 0 { + uniqueIDs := map[string]struct{}{} + for id := range plans { + uniqueIDs[id] = struct{}{} + } + if len(uniqueIDs) != 1 { + return nil, nil, fmt.Errorf("--target-playlist-id can only be used with exactly one source playlist URL") + } + } + + seen := map[string]struct{}{} + deduped := make([]string, 0, len(urls)) + for _, u := range urls { + u = strings.TrimSpace(u) + if u == "" { + continue + } + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + deduped = append(deduped, u) + } + + return deduped, plans, nil +} + func buildFingerprintMap(playlists []model.Playlist, liked []model.Track, includeLiked bool) map[string]string { m := make(map[string]string, len(playlists)+1) for _, p := range playlists { @@ -623,6 +768,9 @@ func persistSession(cfg config.Config, sess session.Data) error { if sess.Monitor == nil { sess.Monitor = map[string]string{} } + if sess.Playlists == nil { + sess.Playlists = map[string]session.PlaylistSyncRef{} + } return session.Save(cfg.SessionPath, sess) } @@ -634,6 +782,168 @@ func cloneMap(in map[string]string) map[string]string { return out } +func syncChangedPlaylist(ctx context.Context, cfg config.Config, qb *qobuz.Client, matcher *match.Matcher, key string, pl model.Playlist, plan monitorPlan, sess *session.Data) error { + mode := strings.ToLower(strings.TrimSpace(plan.SyncMode)) + if mode == "" { + mode = cfg.SyncMode + } + if mode == "" { + mode = "append" + } + if plan.TargetPlaylistID > 0 && mode == "mirror" { + return fmt.Errorf("mirror mode is not supported with explicit target playlist id (%d)", plan.TargetPlaylistID) + } + + prev := sess.Playlists[key] + matchedIDs, unmatched := matchPlaylistTracks(ctx, matcher, pl.Tracks, cfg.Concurrency, func(done, total int) { + fmt.Printf("\r%-140s", fmt.Sprintf("Matching %s (%d/%d)", pl.Name, done, total)) + }) + matchedIDs = uniqueIDs(matchedIDs) + fmt.Print("\r") + + fingerprint := playlistFingerprint(pl) + if key == "liked" { + fingerprint = trackListFingerprint(pl.Tracks) + } + + if cfg.DryRun { + fmt.Printf("Dry-run sync %s: matched=%d/%d unmatched=%d\n", pl.Name, len(matchedIDs), len(pl.Tracks), len(unmatched)) + sess.Playlists[key] = session.PlaylistSyncRef{ + SourceName: pl.Name, + QobuzPlaylistID: prev.QobuzPlaylistID, + Fingerprint: fingerprint, + } + return nil + } + + targetID := prev.QobuzPlaylistID + if plan.TargetPlaylistID > 0 { + targetID = plan.TargetPlaylistID + } + if mode == "mirror" && targetID > 0 { + if err := qb.DeletePlaylist(ctx, targetID); err != nil { + fmt.Printf("Warning: failed deleting old playlist %d for mirror sync: %v\n", targetID, err) + } + targetID = 0 + } + + if targetID == 0 { + createdID, err := qb.CreatePlaylist(ctx, pl.Name, sanitizeDescription(pl.Description), cfg.PublicPlaylists) + if err != nil { + return fmt.Errorf("create qobuz playlist: %w", err) + } + targetID = createdID + } + + if len(matchedIDs) > 0 { + if err := qb.AddTracksToPlaylist(ctx, targetID, matchedIDs); err != nil { + if errors.Is(err, qobuz.ErrDuplicateTracks) { + fmt.Printf("No new tracks added for %s: matched tracks already exist in Qobuz playlist %d.\n", pl.Name, targetID) + } else { + return fmt.Errorf("add tracks to qobuz playlist %d: %w", targetID, err) + } + } + } + + sess.Playlists[key] = session.PlaylistSyncRef{ + SourceName: pl.Name, + QobuzPlaylistID: targetID, + Fingerprint: fingerprint, + } + + fmt.Printf("Synced %s (%s): qobuz=%d matched=%d/%d unmatched=%d\n", pl.Name, mode, targetID, len(matchedIDs), len(pl.Tracks), len(unmatched)) + return nil +} + +func matchPlaylistTracks(ctx context.Context, matcher *match.Matcher, 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 && (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 printSummary(rep model.TransferReport, reportPath string, elapsed time.Duration, dryRun bool) { mode := "TRANSFER" if dryRun { diff --git a/go.mod b/go.mod index 51c2c2c..5b539bd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module qtransfer go 1.22 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/internal/config/config.go b/internal/config/config.go index 5e1c9fe..060a39e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,34 +13,37 @@ import ( ) type Config struct { - Command string - SpotifyClientID string - SpotifyRedirect string - SpotifyScopes []string - SpotifyManual bool - SessionPath string - RememberCreds bool - QobuzUsername string - QobuzPassword string - QobuzAppID string - QobuzAppSecret string - QobuzSelfTest bool - QobuzTestWrite bool - QobuzTestQuery string - Monitor bool - MonitorOnce bool - MonitorTransfer bool - MonitorInterval time.Duration - LikedPlaylist string - DryRun bool - ReportPath string - Concurrency int - PlaylistNames []string - PlaylistURLs []string - AllPlaylists bool - IncludeLiked bool - NonInteractive bool - PublicPlaylists bool + Command string + ConfigFile string + SpotifyClientID string + SpotifyRedirect string + SpotifyScopes []string + SpotifyManual bool + SessionPath string + RememberCreds bool + QobuzUsername string + QobuzPassword string + QobuzAppID string + QobuzAppSecret string + QobuzSelfTest bool + QobuzTestWrite bool + QobuzTestQuery string + Monitor bool + MonitorOnce bool + MonitorTransfer bool + MonitorInterval time.Duration + SyncMode string + TargetPlaylistID int64 + LikedPlaylist string + DryRun bool + ReportPath string + Concurrency int + PlaylistNames []string + PlaylistURLs []string + AllPlaylists bool + IncludeLiked bool + NonInteractive bool + PublicPlaylists bool } type multiFlag []string @@ -80,6 +83,7 @@ func Load() (Config, error) { defaultAppSecret := envOr("QOBUZ_APP_SECRET", "e79f8b9be485692b0e5f9dd895826368") defaultConcurrency := envIntOr("QTRANSFER_CONCURRENCY", 4) + flag.StringVar(&cfg.ConfigFile, "config", envOr("QTRANSFER_CONFIG", ""), "Path to TOML config file with playlist sync jobs") flag.StringVar(&cfg.SpotifyClientID, "spotify-client-id", envOr("SPOTIFY_CLIENT_ID", ""), "Spotify app client ID") flag.StringVar(&cfg.SpotifyRedirect, "spotify-redirect-uri", envOr("SPOTIFY_REDIRECT_URI", "http://127.0.0.1:8888/callback"), "Spotify OAuth redirect URI") scopes := flag.String("spotify-scopes", envOr("SPOTIFY_SCOPES", defaultScopes), "Comma-separated Spotify OAuth scopes") @@ -96,8 +100,10 @@ func Load() (Config, error) { flag.StringVar(&cfg.QobuzTestQuery, "qobuz-self-test-query", envOr("QTRANSFER_QOBUZ_SELF_TEST_QUERY", "Daft Punk One More Time"), "Search query used for --qobuz-self-test") flag.BoolVar(&cfg.Monitor, "monitor", envBoolOr("QTRANSFER_MONITOR", false), "Monitor selected playlists for updates") flag.BoolVar(&cfg.MonitorOnce, "monitor-once", envBoolOr("QTRANSFER_MONITOR_ONCE", false), "Run a single monitor check then exit") - flag.BoolVar(&cfg.MonitorTransfer, "monitor-transfer", envBoolOr("QTRANSFER_MONITOR_TRANSFER", false), "When monitoring, transfer playlists that changed") + flag.BoolVar(&cfg.MonitorTransfer, "monitor-transfer", envBoolOr("QTRANSFER_MONITOR_TRANSFER", false), "When monitoring, sync changed playlists to Qobuz") flag.DurationVar(&cfg.MonitorInterval, "monitor-interval", envDurationOr("QTRANSFER_MONITOR_INTERVAL", 5*time.Minute), "Monitor polling interval (e.g. 2m, 30s)") + flag.StringVar(&cfg.SyncMode, "sync-mode", strings.ToLower(envOr("QTRANSFER_SYNC_MODE", "append")), "Sync mode for monitor-transfer: append or mirror") + flag.Int64Var(&cfg.TargetPlaylistID, "target-playlist-id", envInt64Or("QTRANSFER_TARGET_PLAYLIST_ID", 0), "Bind source playlist (single URL only) to existing Qobuz playlist ID") flag.BoolVar(&cfg.DryRun, "dry-run", envBoolOr("QTRANSFER_DRY_RUN", false), "Resolve matches only, do not create or mutate Qobuz playlists") flag.StringVar(&cfg.ReportPath, "report", envOr("QTRANSFER_REPORT", "transfer-report.json"), "Report output path") @@ -119,6 +125,8 @@ func Load() (Config, error) { cfg.PlaylistNames = playlists cfg.PlaylistURLs = playlistURLs cfg.SpotifyScopes = splitComma(*scopes) + cfg.SyncMode = strings.ToLower(strings.TrimSpace(cfg.SyncMode)) + cfg.SyncMode = strings.ToLower(strings.TrimSpace(cfg.SyncMode)) if err := cfg.Validate(); err != nil { return Config{}, err @@ -144,6 +152,15 @@ func (c Config) Validate() error { if c.MonitorInterval < 2*time.Second { return fmt.Errorf("monitor interval must be at least 2s") } + if c.SyncMode != "append" && c.SyncMode != "mirror" { + return fmt.Errorf("invalid sync mode %q (expected append or mirror)", c.SyncMode) + } + if c.TargetPlaylistID < 0 { + return fmt.Errorf("target-playlist-id must be >= 0") + } + if c.TargetPlaylistID > 0 && !c.Monitor { + return fmt.Errorf("target-playlist-id is currently supported with --monitor mode") + } if strings.TrimSpace(c.SessionPath) == "" { return fmt.Errorf("session file path cannot be empty") } @@ -192,6 +209,18 @@ func envIntOr(key string, fallback int) int { return n } +func envInt64Or(key string, fallback int64) int64 { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fallback + } + return n +} + func envBoolOr(key string, fallback bool) bool { v := strings.TrimSpace(os.Getenv(key)) if v == "" { diff --git a/internal/jobconfig/config.go b/internal/jobconfig/config.go new file mode 100644 index 0000000..2b67152 --- /dev/null +++ b/internal/jobconfig/config.go @@ -0,0 +1,76 @@ +package jobconfig + +import ( + "fmt" + "os" + "strings" + + "github.com/BurntSushi/toml" +) + +type File struct { + Global GlobalConfig `toml:"global"` + Playlists []PlaylistEntry `toml:"playlist"` +} + +type GlobalConfig struct { + Monitor *bool `toml:"monitor"` + MonitorOnce *bool `toml:"monitor_once"` + MonitorTransfer *bool `toml:"monitor_transfer"` + MonitorInterval string `toml:"monitor_interval"` + SyncMode string `toml:"sync_mode"` + IncludeLiked *bool `toml:"include_liked"` + DryRun *bool `toml:"dry_run"` + PublicPlaylists *bool `toml:"public_playlists"` + Concurrency *int `toml:"concurrency"` + ReportPath string `toml:"report"` +} + +type PlaylistEntry struct { + URL string `toml:"url"` + SyncMode string `toml:"sync_mode"` + TargetPlaylistID int64 `toml:"target_playlist_id"` + Enabled *bool `toml:"enabled"` +} + +func Load(path string) (File, error) { + var cfg File + if strings.TrimSpace(path) == "" { + return cfg, fmt.Errorf("empty config path") + } + + b, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + + if err := toml.Unmarshal(b, &cfg); err != nil { + return cfg, fmt.Errorf("parse toml config: %w", err) + } + + for i, p := range cfg.Playlists { + if strings.TrimSpace(p.URL) == "" { + return cfg, fmt.Errorf("playlist entry %d missing url", i+1) + } + mode := strings.ToLower(strings.TrimSpace(p.SyncMode)) + if mode != "" && mode != "append" && mode != "mirror" { + return cfg, fmt.Errorf("playlist entry %d has invalid sync_mode %q", i+1, p.SyncMode) + } + if p.TargetPlaylistID < 0 { + return cfg, fmt.Errorf("playlist entry %d has invalid target_playlist_id %d", i+1, p.TargetPlaylistID) + } + } + + if mode := strings.ToLower(strings.TrimSpace(cfg.Global.SyncMode)); mode != "" && mode != "append" && mode != "mirror" { + return cfg, fmt.Errorf("invalid global sync_mode %q", cfg.Global.SyncMode) + } + + return cfg, nil +} + +func (p PlaylistEntry) IsEnabled() bool { + if p.Enabled == nil { + return true + } + return *p.Enabled +} diff --git a/internal/qobuz/client.go b/internal/qobuz/client.go index 2dc042c..4647d43 100644 --- a/internal/qobuz/client.go +++ b/internal/qobuz/client.go @@ -5,6 +5,7 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -39,6 +40,8 @@ type Track struct { Album string } +var ErrDuplicateTracks = errors.New("qobuz duplicate tracks") + func NewClient(appID, appSecret string) *Client { return &Client{ httpClient: &http.Client{Timeout: 30 * time.Second}, @@ -197,6 +200,7 @@ func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID int64, trac if len(trackIDs) == 0 { return nil } + hadDuplicate := false chunks := chunk(trackIDs, 100) for _, ch := range chunks { ids := make([]string, 0, len(ch)) @@ -210,9 +214,16 @@ func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID int64, trac var out map[string]any if err := c.postFormSigned(ctx, "/playlist/addTracks", form, &out); err != nil { + if isDuplicateConflictErr(err) { + hadDuplicate = true + continue + } return err } } + if hadDuplicate { + return ErrDuplicateTracks + } return nil } @@ -366,6 +377,17 @@ func isSigError(err error) bool { return strings.Contains(msg, "signature") || strings.Contains(msg, "request_sig") } +func isDuplicateConflictErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "duplicate track") { + return false + } + return strings.Contains(msg, "(409)") || strings.Contains(msg, `"code":409`) || strings.Contains(msg, "code: 409") +} + func md5Hex(s string) string { h := md5.Sum([]byte(s)) return hex.EncodeToString(h[:]) diff --git a/internal/session/session.go b/internal/session/session.go index 0af1bad..fc41a1a 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -10,10 +10,11 @@ import ( ) type Data struct { - Spotify SpotifyState `json:"spotify"` - Qobuz QobuzState `json:"qobuz"` - Monitor map[string]string `json:"monitor"` - Meta map[string]string `json:"meta,omitempty"` + Spotify SpotifyState `json:"spotify"` + Qobuz QobuzState `json:"qobuz"` + Monitor map[string]string `json:"monitor"` + Playlists map[string]PlaylistSyncRef `json:"playlists"` + Meta map[string]string `json:"meta,omitempty"` } type SpotifyState struct { @@ -30,6 +31,12 @@ type QobuzState struct { AccessToken string `json:"access_token,omitempty"` } +type PlaylistSyncRef struct { + SourceName string `json:"source_name,omitempty"` + QobuzPlaylistID int64 `json:"qobuz_playlist_id,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + func DefaultPath() string { home, err := os.UserHomeDir() if err != nil || strings.TrimSpace(home) == "" { @@ -72,6 +79,9 @@ func Load(path string) (Data, error) { if d.Monitor == nil { d.Monitor = map[string]string{} } + if d.Playlists == nil { + d.Playlists = map[string]PlaylistSyncRef{} + } return d, nil }