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
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"]
}
if _, err := transfer.Run(ctx, transferCfg, qb, matcher, changedPlaylists, likedToTransfer, likedChanged); err != nil {
return fmt.Errorf("monitor transfer failed: %w", err)
}
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

@@ -14,6 +14,7 @@ import (
type Config struct {
Command string
ConfigFile string
SpotifyClientID string
SpotifyRedirect string
SpotifyScopes []string
@@ -31,6 +32,8 @@ type Config struct {
MonitorOnce bool
MonitorTransfer bool
MonitorInterval time.Duration
SyncMode string
TargetPlaylistID int64
LikedPlaylist string
DryRun bool
ReportPath 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

@@ -13,6 +13,7 @@ type Data struct {
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"`
}
@@ -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
}