add monitor sync jobs and duplicate-safe qobuz updates
This commit is contained in:
@@ -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
|
||||
|
||||
53
README.md
53
README.md
@@ -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)
|
||||
|
||||
@@ -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
2
go.mod
@@ -1,3 +1,5 @@
|
||||
module qtransfer
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/BurntSushi/toml v1.6.0
|
||||
|
||||
2
go.sum
Normal file
2
go.sum
Normal 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=
|
||||
@@ -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