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_SPOTIFY_MANUAL_CODE=true
QTRANSFER_SESSION_FILE=~/.config/qtransfer/session.json QTRANSFER_SESSION_FILE=~/.config/qtransfer/session.json
QTRANSFER_REMEMBER_CREDS=true QTRANSFER_REMEMBER_CREDS=true
QTRANSFER_CONFIG=
QOBUZ_USERNAME= QOBUZ_USERNAME=
QOBUZ_PASSWORD= QOBUZ_PASSWORD=
@@ -21,3 +22,5 @@ QTRANSFER_MONITOR=false
QTRANSFER_MONITOR_ONCE=false QTRANSFER_MONITOR_ONCE=false
QTRANSFER_MONITOR_TRANSFER=false QTRANSFER_MONITOR_TRANSFER=false
QTRANSFER_MONITOR_INTERVAL=5m 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). - 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. - Fetches all Spotify playlists and liked songs.
- Playlist URL mode (`--playlist-url`) for direct targeted transfers. - 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). - Interactive selection prompt (or non-interactive flags).
- Creates Qobuz playlists and fills them with best-effort track matches. - Creates Qobuz playlists and fills them with best-effort track matches.
- Transfers liked songs into a dedicated playlist (not favorites). - 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 - `--liked`: include liked songs as a generated Qobuz playlist
- `--playlist "Name"` (repeatable): transfer specific playlists by exact name - `--playlist "Name"` (repeatable): transfer specific playlists by exact name
- `--playlist-url "..."` (repeatable): transfer specific Spotify playlists by URL/URI/ID - `--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 - `--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 - `--remember-creds=true|false`: persist/reuse tokens and credentials in session file
- `--session-file path`: custom session file path (default `~/.config/qtransfer/session.json`) - `--session-file path`: custom session file path (default `~/.config/qtransfer/session.json`)
- `--monitor`: monitor selected playlists for updates - `--monitor`: monitor selected playlists for updates
- `--monitor-interval 5m`: monitor polling interval - `--monitor-interval 5m`: monitor polling interval
- `--monitor-once`: run one monitor check and exit - `--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`: 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-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 - `--qobuz-self-test-query "..."`: search query used during self-test
@@ -102,6 +105,49 @@ Monitor selected playlists for changes:
--monitor --monitor-interval 2m --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: Login command:
```bash ```bash
@@ -122,10 +168,13 @@ Logout command (removes cached session):
- `QTRANSFER_SPOTIFY_MANUAL_CODE` (optional, defaults to true) - `QTRANSFER_SPOTIFY_MANUAL_CODE` (optional, defaults to true)
- `QTRANSFER_SESSION_FILE` (optional) - `QTRANSFER_SESSION_FILE` (optional)
- `QTRANSFER_REMEMBER_CREDS` (optional, defaults to true) - `QTRANSFER_REMEMBER_CREDS` (optional, defaults to true)
- `QTRANSFER_CONFIG` (optional)
- `QTRANSFER_MONITOR` (optional) - `QTRANSFER_MONITOR` (optional)
- `QTRANSFER_MONITOR_ONCE` (optional) - `QTRANSFER_MONITOR_ONCE` (optional)
- `QTRANSFER_MONITOR_TRANSFER` (optional) - `QTRANSFER_MONITOR_TRANSFER` (optional)
- `QTRANSFER_MONITOR_INTERVAL` (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` (optional)
- `QTRANSFER_QOBUZ_SELF_TEST_WRITE` (optional) - `QTRANSFER_QOBUZ_SELF_TEST_WRITE` (optional)
- `QTRANSFER_QOBUZ_SELF_TEST_QUERY` (optional) - `QTRANSFER_QOBUZ_SELF_TEST_QUERY` (optional)

View File

@@ -5,15 +5,18 @@ import (
"context" "context"
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"sort" "sort"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
"qtransfer/internal/config" "qtransfer/internal/config"
"qtransfer/internal/jobconfig"
"qtransfer/internal/match" "qtransfer/internal/match"
"qtransfer/internal/model" "qtransfer/internal/model"
"qtransfer/internal/qobuz" "qtransfer/internal/qobuz"
@@ -30,6 +33,11 @@ type sourceSelection struct {
IncludeLiked bool IncludeLiked bool
} }
type monitorPlan struct {
SyncMode string
TargetPlaylistID int64
}
func main() { func main() {
if err := run(); err != nil { if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -54,6 +62,9 @@ func run() error {
sess.Spotify = session.SpotifyState{} sess.Spotify = session.SpotifyState{}
sess.Qobuz = session.QobuzState{} sess.Qobuz = session.QobuzState{}
} }
if sess.Playlists == nil {
sess.Playlists = map[string]session.PlaylistSyncRef{}
}
applySessionDefaults(&cfg, &sess) applySessionDefaults(&cfg, &sess)
if cfg.Command == "login" { if cfg.Command == "login" {
@@ -69,6 +80,26 @@ func run() error {
return err 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) spToken, err := authenticateSpotify(ctx, cfg, &sess)
if err != nil { if err != nil {
return fmt.Errorf("spotify auth failed: %w", err) return fmt.Errorf("spotify auth failed: %w", err)
@@ -86,7 +117,7 @@ func run() error {
} }
if cfg.Monitor { 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 err
} }
return persistSession(cfg, sess) return persistSession(cfg, sess)
@@ -264,17 +295,24 @@ func prompt(scanner *bufio.Scanner, label, defaultValue string) string {
return v 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 { if len(selection.Playlists) == 0 && !selection.IncludeLiked {
return fmt.Errorf("monitor mode requires at least one playlist or --liked") 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.Println("Starting monitor mode...")
fmt.Printf("Watching %d playlist(s)", len(selection.Playlists)) fmt.Printf("Watching %d playlist(s)", len(selection.Playlists))
if selection.IncludeLiked { if selection.IncludeLiked {
fmt.Printf(" + liked songs") 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)) playlistIDs := make([]string, 0, len(selection.Playlists))
for _, p := range selection.Playlists { for _, p := range selection.Playlists {
@@ -290,7 +328,7 @@ func runMonitorMode(ctx context.Context, cfg config.Config, sp *spotify.Client,
return err return err
} }
matcher = match.NewMatcher(qb) 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) 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, ", ")) fmt.Printf("[%s] Updated: %s\n", time.Now().Format("15:04:05"), strings.Join(names, ", "))
if cfg.MonitorTransfer && qb != nil && matcher != nil { if cfg.MonitorTransfer && qb != nil && matcher != nil {
transferCfg := transfer.Config{ for _, pl := range changedPlaylists {
DryRun: cfg.DryRun, key := "playlist:" + pl.SourceID
PublicPlaylists: cfg.PublicPlaylists, plan := plans[pl.SourceID]
Concurrency: cfg.Concurrency, if err := syncChangedPlaylist(ctx, cfg, qb, matcher, key, pl, plan, sess); err != nil {
LikedName: cfg.LikedPlaylist, fmt.Printf("Sync error for %s: %v\n", pl.Name, err)
Progress: func(msg string) { curr[key] = prev[key]
fmt.Printf("\r%-140s", msg) }
},
} }
likedToTransfer := []model.Track{}
if likedChanged { if likedChanged {
likedToTransfer = currentLiked plan := monitorPlan{SyncMode: cfg.SyncMode}
} likedPlaylist := model.Playlist{Name: cfg.LikedPlaylist, Tracks: currentLiked}
if _, err := transfer.Run(ctx, transferCfg, qb, matcher, changedPlaylists, likedToTransfer, likedChanged); err != nil { if err := syncChangedPlaylist(ctx, cfg, qb, matcher, "liked", likedPlaylist, plan, sess); err != nil {
return fmt.Errorf("monitor transfer failed: %w", err) fmt.Printf("Sync error for liked songs: %v\n", err)
curr["liked"] = prev["liked"]
}
} }
fmt.Print("\r") fmt.Print("\r")
fmt.Println("Monitor transfer cycle complete. ") fmt.Println("Monitor sync cycle complete. ")
} }
prev = curr prev = curr
@@ -525,6 +562,114 @@ func resolvePlaylistIDs(inputs []string) ([]string, error) {
return out, nil 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 { func buildFingerprintMap(playlists []model.Playlist, liked []model.Track, includeLiked bool) map[string]string {
m := make(map[string]string, len(playlists)+1) m := make(map[string]string, len(playlists)+1)
for _, p := range playlists { for _, p := range playlists {
@@ -623,6 +768,9 @@ func persistSession(cfg config.Config, sess session.Data) error {
if sess.Monitor == nil { if sess.Monitor == nil {
sess.Monitor = map[string]string{} sess.Monitor = map[string]string{}
} }
if sess.Playlists == nil {
sess.Playlists = map[string]session.PlaylistSyncRef{}
}
return session.Save(cfg.SessionPath, sess) return session.Save(cfg.SessionPath, sess)
} }
@@ -634,6 +782,168 @@ func cloneMap(in map[string]string) map[string]string {
return out 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) { func printSummary(rep model.TransferReport, reportPath string, elapsed time.Duration, dryRun bool) {
mode := "TRANSFER" mode := "TRANSFER"
if dryRun { if dryRun {

2
go.mod
View File

@@ -1,3 +1,5 @@
module qtransfer module qtransfer
go 1.22 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 { type Config struct {
Command string Command string
SpotifyClientID string ConfigFile string
SpotifyRedirect string SpotifyClientID string
SpotifyScopes []string SpotifyRedirect string
SpotifyManual bool SpotifyScopes []string
SessionPath string SpotifyManual bool
RememberCreds bool SessionPath string
QobuzUsername string RememberCreds bool
QobuzPassword string QobuzUsername string
QobuzAppID string QobuzPassword string
QobuzAppSecret string QobuzAppID string
QobuzSelfTest bool QobuzAppSecret string
QobuzTestWrite bool QobuzSelfTest bool
QobuzTestQuery string QobuzTestWrite bool
Monitor bool QobuzTestQuery string
MonitorOnce bool Monitor bool
MonitorTransfer bool MonitorOnce bool
MonitorInterval time.Duration MonitorTransfer bool
LikedPlaylist string MonitorInterval time.Duration
DryRun bool SyncMode string
ReportPath string TargetPlaylistID int64
Concurrency int LikedPlaylist string
PlaylistNames []string DryRun bool
PlaylistURLs []string ReportPath string
AllPlaylists bool Concurrency int
IncludeLiked bool PlaylistNames []string
NonInteractive bool PlaylistURLs []string
PublicPlaylists bool AllPlaylists bool
IncludeLiked bool
NonInteractive bool
PublicPlaylists bool
} }
type multiFlag []string type multiFlag []string
@@ -80,6 +83,7 @@ func Load() (Config, error) {
defaultAppSecret := envOr("QOBUZ_APP_SECRET", "e79f8b9be485692b0e5f9dd895826368") defaultAppSecret := envOr("QOBUZ_APP_SECRET", "e79f8b9be485692b0e5f9dd895826368")
defaultConcurrency := envIntOr("QTRANSFER_CONCURRENCY", 4) 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.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") 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") 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.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.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.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.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.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") 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.PlaylistNames = playlists
cfg.PlaylistURLs = playlistURLs cfg.PlaylistURLs = playlistURLs
cfg.SpotifyScopes = splitComma(*scopes) 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 { if err := cfg.Validate(); err != nil {
return Config{}, err return Config{}, err
@@ -144,6 +152,15 @@ func (c Config) Validate() error {
if c.MonitorInterval < 2*time.Second { if c.MonitorInterval < 2*time.Second {
return fmt.Errorf("monitor interval must be at least 2s") 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) == "" { if strings.TrimSpace(c.SessionPath) == "" {
return fmt.Errorf("session file path cannot be empty") return fmt.Errorf("session file path cannot be empty")
} }
@@ -192,6 +209,18 @@ func envIntOr(key string, fallback int) int {
return n 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 { func envBoolOr(key string, fallback bool) bool {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { 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" "crypto/md5"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -39,6 +40,8 @@ type Track struct {
Album string Album string
} }
var ErrDuplicateTracks = errors.New("qobuz duplicate tracks")
func NewClient(appID, appSecret string) *Client { func NewClient(appID, appSecret string) *Client {
return &Client{ return &Client{
httpClient: &http.Client{Timeout: 30 * time.Second}, 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 { if len(trackIDs) == 0 {
return nil return nil
} }
hadDuplicate := false
chunks := chunk(trackIDs, 100) chunks := chunk(trackIDs, 100)
for _, ch := range chunks { for _, ch := range chunks {
ids := make([]string, 0, len(ch)) 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 var out map[string]any
if err := c.postFormSigned(ctx, "/playlist/addTracks", form, &out); err != nil { if err := c.postFormSigned(ctx, "/playlist/addTracks", form, &out); err != nil {
if isDuplicateConflictErr(err) {
hadDuplicate = true
continue
}
return err return err
} }
} }
if hadDuplicate {
return ErrDuplicateTracks
}
return nil return nil
} }
@@ -366,6 +377,17 @@ func isSigError(err error) bool {
return strings.Contains(msg, "signature") || strings.Contains(msg, "request_sig") 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 { func md5Hex(s string) string {
h := md5.Sum([]byte(s)) h := md5.Sum([]byte(s))
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])

View File

@@ -10,10 +10,11 @@ import (
) )
type Data struct { type Data struct {
Spotify SpotifyState `json:"spotify"` Spotify SpotifyState `json:"spotify"`
Qobuz QobuzState `json:"qobuz"` Qobuz QobuzState `json:"qobuz"`
Monitor map[string]string `json:"monitor"` Monitor map[string]string `json:"monitor"`
Meta map[string]string `json:"meta,omitempty"` Playlists map[string]PlaylistSyncRef `json:"playlists"`
Meta map[string]string `json:"meta,omitempty"`
} }
type SpotifyState struct { type SpotifyState struct {
@@ -30,6 +31,12 @@ type QobuzState struct {
AccessToken string `json:"access_token,omitempty"` 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 { func DefaultPath() string {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" { if err != nil || strings.TrimSpace(home) == "" {
@@ -72,6 +79,9 @@ func Load(path string) (Data, error) {
if d.Monitor == nil { if d.Monitor == nil {
d.Monitor = map[string]string{} d.Monitor = map[string]string{}
} }
if d.Playlists == nil {
d.Playlists = map[string]PlaylistSyncRef{}
}
return d, nil return d, nil
} }