add monitor sync jobs and duplicate-safe qobuz updates

This commit is contained in:
joren
2026-04-03 22:50:31 +02:00
parent f7805ddfd8
commit ea32c0baa6
9 changed files with 557 additions and 54 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

2
go.mod
View File

@@ -1,3 +1,5 @@
module qtransfer
go 1.22
require github.com/BurntSushi/toml v1.6.0

2
go.sum Normal file
View File

@@ -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=

View File

@@ -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 == "" {

View File

@@ -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
}

View File

@@ -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[:])

View File

@@ -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
}