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_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
|
||||||
|
|||||||
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).
|
- 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)
|
||||||
|
|||||||
@@ -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 := 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.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
2
go.mod
@@ -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
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=
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Command string
|
Command string
|
||||||
|
ConfigFile string
|
||||||
SpotifyClientID string
|
SpotifyClientID string
|
||||||
SpotifyRedirect string
|
SpotifyRedirect string
|
||||||
SpotifyScopes []string
|
SpotifyScopes []string
|
||||||
@@ -31,6 +32,8 @@ type Config struct {
|
|||||||
MonitorOnce bool
|
MonitorOnce bool
|
||||||
MonitorTransfer bool
|
MonitorTransfer bool
|
||||||
MonitorInterval time.Duration
|
MonitorInterval time.Duration
|
||||||
|
SyncMode string
|
||||||
|
TargetPlaylistID int64
|
||||||
LikedPlaylist string
|
LikedPlaylist string
|
||||||
DryRun bool
|
DryRun bool
|
||||||
ReportPath string
|
ReportPath 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 == "" {
|
||||||
|
|||||||
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"
|
"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[:])
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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"`
|
||||||
|
Playlists map[string]PlaylistSyncRef `json:"playlists"`
|
||||||
Meta map[string]string `json:"meta,omitempty"`
|
Meta map[string]string `json:"meta,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user