add monitor sync jobs and duplicate-safe qobuz updates
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
76
internal/jobconfig/config.go
Normal file
76
internal/jobconfig/config.go
Normal 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
|
||||
}
|
||||
@@ -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[:])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user