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

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