first commit
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_REDIRECT_URI=http://127.0.0.1:8888/callback
|
||||||
|
SPOTIFY_SCOPES=playlist-read-private,playlist-read-collaborative,user-library-read
|
||||||
|
QTRANSFER_SPOTIFY_MANUAL_CODE=true
|
||||||
|
QTRANSFER_SESSION_FILE=~/.config/qtransfer/session.json
|
||||||
|
QTRANSFER_REMEMBER_CREDS=true
|
||||||
|
|
||||||
|
QOBUZ_USERNAME=
|
||||||
|
QOBUZ_PASSWORD=
|
||||||
|
QOBUZ_APP_ID=312369995
|
||||||
|
QOBUZ_APP_SECRET=e79f8b9be485692b0e5f9dd895826368
|
||||||
|
|
||||||
|
QTRANSFER_DRY_RUN=false
|
||||||
|
QTRANSFER_CONCURRENCY=4
|
||||||
|
QTRANSFER_REPORT=transfer-report.json
|
||||||
|
QTRANSFER_LIKED_NAME=Spotify Liked Songs
|
||||||
|
QTRANSFER_QOBUZ_SELF_TEST=false
|
||||||
|
QTRANSFER_QOBUZ_SELF_TEST_WRITE=false
|
||||||
|
QTRANSFER_QOBUZ_SELF_TEST_QUERY=Daft Punk One More Time
|
||||||
|
QTRANSFER_MONITOR=false
|
||||||
|
QTRANSFER_MONITOR_ONCE=false
|
||||||
|
QTRANSFER_MONITOR_TRANSFER=false
|
||||||
|
QTRANSFER_MONITOR_INTERVAL=5m
|
||||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# QTransfer
|
||||||
|
|
||||||
|
Spotify -> Qobuz playlist transfer tool in Go.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Spotify OAuth login via Authorization Code + PKCE (client secret not required).
|
||||||
|
- Manual Spotify callback code entry (paste callback URL/code) enabled by default.
|
||||||
|
- 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`).
|
||||||
|
- 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).
|
||||||
|
- Writes a JSON transfer report with unmatched tracks and errors.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Go 1.22+
|
||||||
|
- Spotify app client ID with redirect URI configured (default: `http://127.0.0.1:8888/callback`)
|
||||||
|
- Qobuz account username/password
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./cmd/qtransfer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./qtransfer login
|
||||||
|
```
|
||||||
|
|
||||||
|
`qtransfer login` runs an interactive setup and stores Spotify/Qobuz credentials/tokens in the session file.
|
||||||
|
|
||||||
|
After login, run transfers without re-entering credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./qtransfer
|
||||||
|
```
|
||||||
|
|
||||||
|
For first-time non-interactive usage (without `login`), you can still pass flags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./qtransfer \
|
||||||
|
--spotify-client-id "<spotify-client-id>" \
|
||||||
|
--qobuz-username "<qobuz-user>" \
|
||||||
|
--qobuz-password "<qobuz-password>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials/tokens are cached in `~/.config/qtransfer/session.json` by default.
|
||||||
|
|
||||||
|
### Useful flags
|
||||||
|
|
||||||
|
- `--all`: transfer all Spotify playlists
|
||||||
|
- `--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
|
||||||
|
- `--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
|
||||||
|
- `--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
|
||||||
|
- `--non-interactive`: fail instead of prompting when no explicit selection is given
|
||||||
|
- `--dry-run`: resolve matches only, do not create playlists or add tracks
|
||||||
|
- `--report transfer-report.json`: report output path
|
||||||
|
- `--public-playlists`: create public Qobuz playlists
|
||||||
|
|
||||||
|
Quick auth check without waiting for Spotify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./qtransfer \
|
||||||
|
--qobuz-self-test \
|
||||||
|
--qobuz-username "<qobuz-user>" \
|
||||||
|
--qobuz-password "<qobuz-password>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Transfer from direct Spotify playlist URLs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./qtransfer \
|
||||||
|
--spotify-client-id "<spotify-client-id>" \
|
||||||
|
--qobuz-username "<qobuz-user>" \
|
||||||
|
--qobuz-password "<qobuz-password>" \
|
||||||
|
--playlist-url "https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd" \
|
||||||
|
--playlist-url "spotify:playlist:37i9dQZF1DWY4xHQp97fN6"
|
||||||
|
```
|
||||||
|
|
||||||
|
Monitor selected playlists for changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./qtransfer \
|
||||||
|
--spotify-client-id "<spotify-client-id>" \
|
||||||
|
--playlist-url "https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd" \
|
||||||
|
--monitor --monitor-interval 2m
|
||||||
|
```
|
||||||
|
|
||||||
|
Login command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./qtransfer login
|
||||||
|
```
|
||||||
|
|
||||||
|
Logout command (removes cached session):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./qtransfer logout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
- `SPOTIFY_CLIENT_ID`
|
||||||
|
- `SPOTIFY_REDIRECT_URI` (optional)
|
||||||
|
- `SPOTIFY_SCOPES` (optional)
|
||||||
|
- `QTRANSFER_SPOTIFY_MANUAL_CODE` (optional, defaults to true)
|
||||||
|
- `QTRANSFER_SESSION_FILE` (optional)
|
||||||
|
- `QTRANSFER_REMEMBER_CREDS` (optional, defaults to true)
|
||||||
|
- `QTRANSFER_MONITOR` (optional)
|
||||||
|
- `QTRANSFER_MONITOR_ONCE` (optional)
|
||||||
|
- `QTRANSFER_MONITOR_TRANSFER` (optional)
|
||||||
|
- `QTRANSFER_MONITOR_INTERVAL` (optional)
|
||||||
|
- `QTRANSFER_QOBUZ_SELF_TEST` (optional)
|
||||||
|
- `QTRANSFER_QOBUZ_SELF_TEST_WRITE` (optional)
|
||||||
|
- `QTRANSFER_QOBUZ_SELF_TEST_QUERY` (optional)
|
||||||
|
- `QOBUZ_USERNAME`
|
||||||
|
- `QOBUZ_PASSWORD`
|
||||||
|
- `QOBUZ_APP_ID` (optional, defaults to reverse-engineered app id)
|
||||||
|
- `QOBUZ_APP_SECRET` (optional, defaults to reverse-engineered app secret)
|
||||||
668
cmd/qtransfer/main.go
Normal file
668
cmd/qtransfer/main.go
Normal file
@@ -0,0 +1,668 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"qtransfer/internal/config"
|
||||||
|
"qtransfer/internal/match"
|
||||||
|
"qtransfer/internal/model"
|
||||||
|
"qtransfer/internal/qobuz"
|
||||||
|
"qtransfer/internal/report"
|
||||||
|
"qtransfer/internal/session"
|
||||||
|
"qtransfer/internal/spotify"
|
||||||
|
"qtransfer/internal/transfer"
|
||||||
|
"qtransfer/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceSelection struct {
|
||||||
|
Playlists []model.Playlist
|
||||||
|
LikedSongs []model.Track
|
||||||
|
IncludeLiked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
sess, err := session.Load(cfg.SessionPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !cfg.RememberCreds {
|
||||||
|
sess.Spotify = session.SpotifyState{}
|
||||||
|
sess.Qobuz = session.QobuzState{}
|
||||||
|
}
|
||||||
|
applySessionDefaults(&cfg, &sess)
|
||||||
|
|
||||||
|
if cfg.Command == "login" {
|
||||||
|
return runLoginCommand(ctx, &cfg, &sess)
|
||||||
|
}
|
||||||
|
if cfg.Command == "logout" {
|
||||||
|
return runLogoutCommand(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.QobuzSelfTest {
|
||||||
|
err := runQobuzSelfTest(ctx, cfg, &sess)
|
||||||
|
_ = persistSession(cfg, sess)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
spToken, err := authenticateSpotify(ctx, cfg, &sess)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("spotify auth failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := spotify.NewClient(spToken.AccessToken)
|
||||||
|
sp.SetProgress(func(msg string) {
|
||||||
|
fmt.Printf("\r%-130s", msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
selection, err := fetchSelection(ctx, cfg, sp)
|
||||||
|
fmt.Print("\r")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Monitor {
|
||||||
|
if err := runMonitorMode(ctx, cfg, sp, &sess, selection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return persistSession(cfg, sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Selected %d playlist(s)", len(selection.Playlists))
|
||||||
|
if selection.IncludeLiked {
|
||||||
|
fmt.Printf(" + liked songs (%d tracks)", len(selection.LikedSongs))
|
||||||
|
}
|
||||||
|
fmt.Println(".")
|
||||||
|
|
||||||
|
qb, err := authenticateQobuz(ctx, cfg, &sess)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher := match.NewMatcher(qb)
|
||||||
|
transferCfg := transfer.Config{
|
||||||
|
DryRun: cfg.DryRun,
|
||||||
|
PublicPlaylists: cfg.PublicPlaylists,
|
||||||
|
Concurrency: cfg.Concurrency,
|
||||||
|
LikedName: cfg.LikedPlaylist,
|
||||||
|
Progress: func(msg string) {
|
||||||
|
fmt.Printf("\r%-140s", msg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Starting transfer...")
|
||||||
|
start := time.Now()
|
||||||
|
rep, err := transfer.Run(ctx, transferCfg, qb, matcher, selection.Playlists, selection.LikedSongs, selection.IncludeLiked)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("transfer failed: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Print("\r")
|
||||||
|
fmt.Println("Transfer processing complete. ")
|
||||||
|
|
||||||
|
if err := report.Write(cfg.ReportPath, rep); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := persistSession(cfg, sess); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
printSummary(rep, cfg.ReportPath, time.Since(start), cfg.DryRun)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSelection(ctx context.Context, cfg config.Config, sp *spotify.Client) (sourceSelection, error) {
|
||||||
|
if len(cfg.PlaylistURLs) > 0 {
|
||||||
|
fmt.Println("Fetching Spotify playlist URL selections...")
|
||||||
|
ids, err := resolvePlaylistIDs(cfg.PlaylistURLs)
|
||||||
|
if err != nil {
|
||||||
|
return sourceSelection{}, err
|
||||||
|
}
|
||||||
|
playlists, err := sp.FetchPlaylistsByID(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return sourceSelection{}, fmt.Errorf("spotify fetch by URL failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
liked := []model.Track{}
|
||||||
|
if cfg.IncludeLiked {
|
||||||
|
liked, err = sp.FetchLikedSongs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return sourceSelection{}, fmt.Errorf("spotify fetch liked songs failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Fetched Spotify playlists and liked songs.")
|
||||||
|
return sourceSelection{Playlists: playlists, LikedSongs: liked, IncludeLiked: cfg.IncludeLiked}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Fetching Spotify playlists and liked songs...")
|
||||||
|
lib, err := sp.FetchLibrary(ctx, cfg.LikedPlaylist)
|
||||||
|
if err != nil {
|
||||||
|
return sourceSelection{}, fmt.Errorf("spotify fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedPlaylists, includeLiked, err := ui.ResolveSelection(lib, cfg.AllPlaylists, cfg.IncludeLiked, cfg.NonInteractive, cfg.PlaylistNames)
|
||||||
|
if err != nil {
|
||||||
|
return sourceSelection{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Fetched Spotify playlists and liked songs.")
|
||||||
|
return sourceSelection{Playlists: selectedPlaylists, LikedSongs: lib.LikedSongs, IncludeLiked: includeLiked}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLoginCommand(ctx context.Context, cfg *config.Config, sess *session.Data) error {
|
||||||
|
fmt.Println("QTransfer login setup")
|
||||||
|
fmt.Println("This stores tokens/credentials in your session file for future runs.")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
|
||||||
|
if strings.TrimSpace(cfg.SpotifyClientID) == "" {
|
||||||
|
cfg.SpotifyClientID = prompt(scanner, "Spotify client ID", sess.Spotify.ClientID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.SpotifyClientID) != "" {
|
||||||
|
fmt.Println("Starting Spotify authentication...")
|
||||||
|
tok, err := spotify.LoginWithPKCE(ctx, spotify.AuthConfig{
|
||||||
|
ClientID: cfg.SpotifyClientID,
|
||||||
|
RedirectURI: cfg.SpotifyRedirect,
|
||||||
|
Scopes: cfg.SpotifyScopes,
|
||||||
|
ManualCode: cfg.SpotifyManual,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("spotify login failed: %w", err)
|
||||||
|
}
|
||||||
|
updateSpotifySession(sess, tok, cfg.SpotifyClientID)
|
||||||
|
fmt.Println("- Spotify login: OK")
|
||||||
|
} else {
|
||||||
|
fmt.Println("- Spotify login: skipped (no client id provided)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(cfg.QobuzUsername) == "" {
|
||||||
|
cfg.QobuzUsername = prompt(scanner, "Qobuz username/email", sess.Qobuz.Username)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.QobuzPassword) == "" {
|
||||||
|
cfg.QobuzPassword = prompt(scanner, "Qobuz password", sess.Qobuz.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(cfg.QobuzUsername) != "" && strings.TrimSpace(cfg.QobuzPassword) != "" {
|
||||||
|
qb := qobuz.NewClient(cfg.QobuzAppID, cfg.QobuzAppSecret)
|
||||||
|
if err := qb.Login(ctx, cfg.QobuzUsername, cfg.QobuzPassword); err != nil {
|
||||||
|
return fmt.Errorf("qobuz login failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := qb.VerifyAuth(ctx); err != nil {
|
||||||
|
return fmt.Errorf("qobuz verify auth failed: %w", err)
|
||||||
|
}
|
||||||
|
sess.Qobuz.Username = cfg.QobuzUsername
|
||||||
|
sess.Qobuz.Password = cfg.QobuzPassword
|
||||||
|
sess.Qobuz.AccessToken = qb.Token()
|
||||||
|
fmt.Println("- Qobuz login: OK")
|
||||||
|
} else {
|
||||||
|
fmt.Println("- Qobuz login: skipped (username/password not provided)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := persistSession(*cfg, *sess); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Login complete. Session saved to %s\n", session.ResolvePath(cfg.SessionPath))
|
||||||
|
fmt.Println("You can now run `qtransfer` without passing username/password each time.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogoutCommand(cfg config.Config) error {
|
||||||
|
path := session.ResolvePath(cfg.SessionPath)
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
fmt.Printf("No session file found at %s\n", path)
|
||||||
|
fmt.Println("Already logged out.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("remove session file: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Removed session file %s\n", path)
|
||||||
|
fmt.Println("Logged out. Next run will require login again.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prompt(scanner *bufio.Scanner, label, defaultValue string) string {
|
||||||
|
defaultValue = strings.TrimSpace(defaultValue)
|
||||||
|
if defaultValue != "" {
|
||||||
|
fmt.Printf("%s [%s]: ", label, defaultValue)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s: ", label)
|
||||||
|
}
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
v := strings.TrimSpace(scanner.Text())
|
||||||
|
if v == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMonitorMode(ctx context.Context, cfg config.Config, sp *spotify.Client, sess *session.Data, selection sourceSelection) error {
|
||||||
|
if len(selection.Playlists) == 0 && !selection.IncludeLiked {
|
||||||
|
return fmt.Errorf("monitor mode requires at least one playlist or --liked")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
playlistIDs := make([]string, 0, len(selection.Playlists))
|
||||||
|
for _, p := range selection.Playlists {
|
||||||
|
playlistIDs = append(playlistIDs, p.SourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
qb := (*qobuz.Client)(nil)
|
||||||
|
matcher := (*match.Matcher)(nil)
|
||||||
|
if cfg.MonitorTransfer {
|
||||||
|
var err error
|
||||||
|
qb, err = authenticateQobuz(ctx, cfg, sess)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
matcher = match.NewMatcher(qb)
|
||||||
|
fmt.Println("Monitor transfer mode enabled: changed playlists will be transferred.")
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := cloneMap(sess.Monitor)
|
||||||
|
if len(prev) == 0 {
|
||||||
|
baseline := buildFingerprintMap(selection.Playlists, selection.LikedSongs, selection.IncludeLiked)
|
||||||
|
sess.Monitor = baseline
|
||||||
|
if err := persistSession(cfg, *sess); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Initialized monitor baseline in session file.")
|
||||||
|
prev = cloneMap(baseline)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCycle := func() error {
|
||||||
|
currentPlaylists, err := sp.FetchPlaylistsByID(ctx, playlistIDs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("monitor fetch playlists failed: %w", err)
|
||||||
|
}
|
||||||
|
currentLiked := []model.Track{}
|
||||||
|
if selection.IncludeLiked {
|
||||||
|
currentLiked, err = sp.FetchLikedSongs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("monitor fetch liked songs failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
curr := buildFingerprintMap(currentPlaylists, currentLiked, selection.IncludeLiked)
|
||||||
|
changedPlaylists, likedChanged := detectChanges(prev, curr, currentPlaylists, selection.IncludeLiked)
|
||||||
|
if len(changedPlaylists) == 0 && !likedChanged {
|
||||||
|
fmt.Printf("[%s] No updates detected.\n", time.Now().Format("15:04:05"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(changedPlaylists))
|
||||||
|
for _, p := range changedPlaylists {
|
||||||
|
names = append(names, p.Name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
if likedChanged {
|
||||||
|
names = append(names, cfg.LikedPlaylist+" (liked)")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
fmt.Print("\r")
|
||||||
|
fmt.Println("Monitor transfer cycle complete. ")
|
||||||
|
}
|
||||||
|
|
||||||
|
prev = curr
|
||||||
|
sess.Monitor = cloneMap(curr)
|
||||||
|
return persistSession(cfg, *sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runCycle(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.MonitorOnce {
|
||||||
|
fmt.Println("Monitor once completed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(cfg.MonitorInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
fmt.Println("Monitor stopped.")
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := runCycle(); err != nil {
|
||||||
|
fmt.Printf("Monitor cycle error: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticateSpotify(ctx context.Context, cfg config.Config, sess *session.Data) (spotify.Token, error) {
|
||||||
|
if cfg.RememberCreds && strings.TrimSpace(sess.Spotify.AccessToken) != "" && time.Now().Before(sess.Spotify.ExpiresAt.Add(-30*time.Second)) {
|
||||||
|
return spotify.Token{
|
||||||
|
AccessToken: sess.Spotify.AccessToken,
|
||||||
|
RefreshToken: sess.Spotify.RefreshToken,
|
||||||
|
Scope: sess.Spotify.Scope,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(cfg.SpotifyClientID) == "" {
|
||||||
|
return spotify.Token{}, fmt.Errorf("spotify client id required (set --spotify-client-id once or run `qtransfer login`)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RememberCreds && strings.TrimSpace(sess.Spotify.RefreshToken) != "" {
|
||||||
|
fmt.Println("Refreshing Spotify access token from session...")
|
||||||
|
tok, err := spotify.RefreshAccessToken(ctx, cfg.SpotifyClientID, sess.Spotify.RefreshToken)
|
||||||
|
if err == nil {
|
||||||
|
updateSpotifySession(sess, tok, cfg.SpotifyClientID)
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Spotify token refresh failed, falling back to login: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Starting Spotify authentication...")
|
||||||
|
tok, err := spotify.LoginWithPKCE(ctx, spotify.AuthConfig{
|
||||||
|
ClientID: cfg.SpotifyClientID,
|
||||||
|
RedirectURI: cfg.SpotifyRedirect,
|
||||||
|
Scopes: cfg.SpotifyScopes,
|
||||||
|
ManualCode: cfg.SpotifyManual,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return spotify.Token{}, err
|
||||||
|
}
|
||||||
|
if cfg.RememberCreds {
|
||||||
|
updateSpotifySession(sess, tok, cfg.SpotifyClientID)
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticateQobuz(ctx context.Context, cfg config.Config, sess *session.Data) (*qobuz.Client, error) {
|
||||||
|
qb := qobuz.NewClient(cfg.QobuzAppID, cfg.QobuzAppSecret)
|
||||||
|
|
||||||
|
if cfg.RememberCreds && strings.TrimSpace(sess.Qobuz.AccessToken) != "" {
|
||||||
|
qb.SetToken(sess.Qobuz.AccessToken)
|
||||||
|
if err := qb.VerifyAuth(ctx); err == nil {
|
||||||
|
return qb, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(cfg.QobuzUsername) == "" || strings.TrimSpace(cfg.QobuzPassword) == "" {
|
||||||
|
return nil, fmt.Errorf("qobuz username/password required (pass flags once or enable --remember-creds with existing session)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Authenticating with Qobuz...")
|
||||||
|
if err := qb.Login(ctx, cfg.QobuzUsername, cfg.QobuzPassword); err != nil {
|
||||||
|
return nil, fmt.Errorf("qobuz login failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := qb.VerifyAuth(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("qobuz auth verification failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RememberCreds {
|
||||||
|
sess.Qobuz.Username = cfg.QobuzUsername
|
||||||
|
sess.Qobuz.Password = cfg.QobuzPassword
|
||||||
|
sess.Qobuz.AccessToken = qb.Token()
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runQobuzSelfTest(ctx context.Context, cfg config.Config, sess *session.Data) error {
|
||||||
|
fmt.Println("Running Qobuz self-test...")
|
||||||
|
qb, err := authenticateQobuz(ctx, cfg, sess)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("- Login: OK")
|
||||||
|
|
||||||
|
if err := qb.VerifyAuth(ctx); err != nil {
|
||||||
|
return fmt.Errorf("qobuz verify auth failed: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("- user/get auth check: OK")
|
||||||
|
|
||||||
|
results, err := qb.SearchTracks(ctx, cfg.QobuzTestQuery, 5)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("qobuz search failed: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("- Search '%s': %d result(s)\n", cfg.QobuzTestQuery, len(results))
|
||||||
|
|
||||||
|
if cfg.QobuzTestWrite {
|
||||||
|
name := fmt.Sprintf("QTransfer SelfTest %d", time.Now().Unix())
|
||||||
|
playlistID, err := qb.CreatePlaylist(ctx, name, "temporary playlist created by qtransfer self-test", false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("qobuz create playlist failed: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("- Create playlist: OK (id=%d)\n", playlistID)
|
||||||
|
|
||||||
|
if len(results) > 0 {
|
||||||
|
if err := qb.AddTracksToPlaylist(ctx, playlistID, []int64{results[0].ID}); err != nil {
|
||||||
|
if delErr := qb.DeletePlaylist(context.Background(), playlistID); delErr != nil {
|
||||||
|
fmt.Printf("- Cleanup test playlist after add failure: failed (%v)\n", delErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("qobuz add tracks failed: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("- Add first search result to playlist: OK")
|
||||||
|
} else {
|
||||||
|
fmt.Println("- Add first search result: skipped (no search results)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := qb.DeletePlaylist(ctx, playlistID); err != nil {
|
||||||
|
fmt.Printf("- Cleanup test playlist: failed (%v)\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("- Cleanup test playlist: OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Qobuz self-test passed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePlaylistIDs(inputs []string) ([]string, error) {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(inputs))
|
||||||
|
for _, in := range inputs {
|
||||||
|
id, err := spotify.ParsePlaylistID(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid playlist-url %q: %w", in, err)
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("no playlist URLs provided")
|
||||||
|
}
|
||||||
|
return out, 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 {
|
||||||
|
if strings.TrimSpace(p.SourceID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m["playlist:"+p.SourceID] = playlistFingerprint(p)
|
||||||
|
}
|
||||||
|
if includeLiked {
|
||||||
|
m["liked"] = trackListFingerprint(liked)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectChanges(prev, curr map[string]string, playlists []model.Playlist, includeLiked bool) ([]model.Playlist, bool) {
|
||||||
|
changed := []model.Playlist{}
|
||||||
|
playlistByID := map[string]model.Playlist{}
|
||||||
|
for _, p := range playlists {
|
||||||
|
playlistByID[p.SourceID] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, now := range curr {
|
||||||
|
if prev[key] == now {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(key, "playlist:") {
|
||||||
|
id := strings.TrimPrefix(key, "playlist:")
|
||||||
|
if p, ok := playlistByID[id]; ok {
|
||||||
|
changed = append(changed, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
likedChanged := false
|
||||||
|
if includeLiked && prev["liked"] != curr["liked"] {
|
||||||
|
likedChanged = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed, likedChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
func playlistFingerprint(pl model.Playlist) string {
|
||||||
|
h := sha1.New()
|
||||||
|
h.Write([]byte(pl.SourceID))
|
||||||
|
h.Write([]byte("|"))
|
||||||
|
h.Write([]byte(pl.Name))
|
||||||
|
h.Write([]byte("|"))
|
||||||
|
h.Write([]byte(trackListFingerprint(pl.Tracks)))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func trackListFingerprint(tracks []model.Track) string {
|
||||||
|
h := sha1.New()
|
||||||
|
for _, t := range tracks {
|
||||||
|
id := strings.TrimSpace(t.SourceID)
|
||||||
|
if id == "" {
|
||||||
|
id = strings.ToLower(strings.TrimSpace(t.Title + "|" + strings.Join(t.Artists, ",") + "|" + t.Album))
|
||||||
|
}
|
||||||
|
h.Write([]byte(id))
|
||||||
|
h.Write([]byte("\n"))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySessionDefaults(cfg *config.Config, sess *session.Data) {
|
||||||
|
if strings.TrimSpace(cfg.SpotifyClientID) == "" {
|
||||||
|
cfg.SpotifyClientID = sess.Spotify.ClientID
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.QobuzUsername) == "" {
|
||||||
|
cfg.QobuzUsername = sess.Qobuz.Username
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.QobuzPassword) == "" {
|
||||||
|
cfg.QobuzPassword = sess.Qobuz.Password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSpotifySession(sess *session.Data, tok spotify.Token, clientID string) {
|
||||||
|
if strings.TrimSpace(clientID) != "" {
|
||||||
|
sess.Spotify.ClientID = strings.TrimSpace(clientID)
|
||||||
|
}
|
||||||
|
sess.Spotify.AccessToken = tok.AccessToken
|
||||||
|
if tok.RefreshToken != "" {
|
||||||
|
sess.Spotify.RefreshToken = tok.RefreshToken
|
||||||
|
}
|
||||||
|
sess.Spotify.Scope = tok.Scope
|
||||||
|
if tok.ExpiresIn > 0 {
|
||||||
|
sess.Spotify.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistSession(cfg config.Config, sess session.Data) error {
|
||||||
|
if !cfg.RememberCreds {
|
||||||
|
sess.Spotify = session.SpotifyState{}
|
||||||
|
sess.Qobuz = session.QobuzState{}
|
||||||
|
}
|
||||||
|
if sess.Monitor == nil {
|
||||||
|
sess.Monitor = map[string]string{}
|
||||||
|
}
|
||||||
|
return session.Save(cfg.SessionPath, sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMap(in map[string]string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSummary(rep model.TransferReport, reportPath string, elapsed time.Duration, dryRun bool) {
|
||||||
|
mode := "TRANSFER"
|
||||||
|
if dryRun {
|
||||||
|
mode = "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%s complete in %s\n", mode, elapsed.Round(time.Second))
|
||||||
|
totalMatched := 0
|
||||||
|
totalAdded := 0
|
||||||
|
totalUnmatched := 0
|
||||||
|
totalErrors := 0
|
||||||
|
|
||||||
|
for _, r := range rep.Results {
|
||||||
|
unmatched := len(r.Unmatched)
|
||||||
|
totalMatched += r.MatchedTracks
|
||||||
|
totalAdded += r.AddedTracks
|
||||||
|
totalUnmatched += unmatched
|
||||||
|
totalErrors += len(r.Errors)
|
||||||
|
|
||||||
|
targetInfo := ""
|
||||||
|
if r.TargetID > 0 {
|
||||||
|
targetInfo = fmt.Sprintf(" -> Qobuz %d", r.TargetID)
|
||||||
|
}
|
||||||
|
fmt.Printf("- %s%s: %d total, %d matched, %d added, %d unmatched\n", r.Name, targetInfo, r.TotalTracks, r.MatchedTracks, r.AddedTracks, unmatched)
|
||||||
|
if len(r.Errors) > 0 {
|
||||||
|
fmt.Printf(" errors: %s\n", strings.Join(r.Errors, " | "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nTotals: matched=%d added=%d unmatched=%d errors=%d\n", totalMatched, totalAdded, totalUnmatched, totalErrors)
|
||||||
|
fmt.Printf("Report written to %s\n", reportPath)
|
||||||
|
}
|
||||||
217
internal/config/config.go
Normal file
217
internal/config/config.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"qtransfer/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type multiFlag []string
|
||||||
|
|
||||||
|
func (m *multiFlag) String() string {
|
||||||
|
return strings.Join(*m, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *multiFlag) Set(v string) error {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return errors.New("playlist name cannot be empty")
|
||||||
|
}
|
||||||
|
*m = append(*m, v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
var playlists multiFlag
|
||||||
|
var playlistURLs multiFlag
|
||||||
|
command := "run"
|
||||||
|
parseArgs := os.Args[1:]
|
||||||
|
if len(parseArgs) > 0 {
|
||||||
|
first := strings.ToLower(strings.TrimSpace(parseArgs[0]))
|
||||||
|
if first == "login" || first == "logout" {
|
||||||
|
command = "login"
|
||||||
|
if first == "logout" {
|
||||||
|
command = "logout"
|
||||||
|
}
|
||||||
|
parseArgs = parseArgs[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultScopes := "playlist-read-private,playlist-read-collaborative,user-library-read"
|
||||||
|
defaultAppID := envOr("QOBUZ_APP_ID", "312369995")
|
||||||
|
defaultAppSecret := envOr("QOBUZ_APP_SECRET", "e79f8b9be485692b0e5f9dd895826368")
|
||||||
|
defaultConcurrency := envIntOr("QTRANSFER_CONCURRENCY", 4)
|
||||||
|
|
||||||
|
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")
|
||||||
|
flag.BoolVar(&cfg.SpotifyManual, "spotify-manual-code", envBoolOr("QTRANSFER_SPOTIFY_MANUAL_CODE", true), "Enter Spotify callback code/URL manually instead of running a local callback server")
|
||||||
|
flag.StringVar(&cfg.SessionPath, "session-file", envOr("QTRANSFER_SESSION_FILE", session.DefaultPath()), "Session file path for cached tokens/credentials")
|
||||||
|
flag.BoolVar(&cfg.RememberCreds, "remember-creds", envBoolOr("QTRANSFER_REMEMBER_CREDS", true), "Store/reuse credentials and tokens in session file")
|
||||||
|
|
||||||
|
flag.StringVar(&cfg.QobuzUsername, "qobuz-username", envOr("QOBUZ_USERNAME", ""), "Qobuz account username/email")
|
||||||
|
flag.StringVar(&cfg.QobuzPassword, "qobuz-password", envOr("QOBUZ_PASSWORD", ""), "Qobuz account password")
|
||||||
|
flag.StringVar(&cfg.QobuzAppID, "qobuz-app-id", defaultAppID, "Qobuz app ID")
|
||||||
|
flag.StringVar(&cfg.QobuzAppSecret, "qobuz-app-secret", defaultAppSecret, "Qobuz app secret")
|
||||||
|
flag.BoolVar(&cfg.QobuzSelfTest, "qobuz-self-test", envBoolOr("QTRANSFER_QOBUZ_SELF_TEST", false), "Run Qobuz login/verify/search checks and exit (skips Spotify)")
|
||||||
|
flag.BoolVar(&cfg.QobuzTestWrite, "qobuz-self-test-write", envBoolOr("QTRANSFER_QOBUZ_SELF_TEST_WRITE", false), "When --qobuz-self-test is set, also create a test playlist and add one track")
|
||||||
|
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.DurationVar(&cfg.MonitorInterval, "monitor-interval", envDurationOr("QTRANSFER_MONITOR_INTERVAL", 5*time.Minute), "Monitor polling interval (e.g. 2m, 30s)")
|
||||||
|
|
||||||
|
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.IntVar(&cfg.Concurrency, "concurrency", defaultConcurrency, "Concurrent track matching workers")
|
||||||
|
flag.StringVar(&cfg.LikedPlaylist, "liked-playlist-name", envOr("QTRANSFER_LIKED_NAME", "Spotify Liked Songs"), "Name of the generated liked-songs playlist on Qobuz")
|
||||||
|
|
||||||
|
flag.BoolVar(&cfg.AllPlaylists, "all", false, "Transfer all Spotify playlists")
|
||||||
|
flag.BoolVar(&cfg.IncludeLiked, "liked", false, "Include Spotify liked songs")
|
||||||
|
flag.BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive playlist selection prompts")
|
||||||
|
flag.BoolVar(&cfg.PublicPlaylists, "public-playlists", false, "Create public playlists on Qobuz (default private)")
|
||||||
|
flag.Var(&playlists, "playlist", "Playlist name to transfer (repeatable)")
|
||||||
|
flag.Var(&playlistURLs, "playlist-url", "Spotify playlist URL/URI/ID to transfer (repeatable)")
|
||||||
|
|
||||||
|
if err := flag.CommandLine.Parse(parseArgs); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
cfg.Command = command
|
||||||
|
|
||||||
|
cfg.PlaylistNames = playlists
|
||||||
|
cfg.PlaylistURLs = playlistURLs
|
||||||
|
cfg.SpotifyScopes = splitComma(*scopes)
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
if c.Command == "logout" {
|
||||||
|
if strings.TrimSpace(c.SessionPath) == "" {
|
||||||
|
return fmt.Errorf("session file path cannot be empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(c.QobuzAppID) == "" || strings.TrimSpace(c.QobuzAppSecret) == "" {
|
||||||
|
return fmt.Errorf("qobuz app id and secret are required")
|
||||||
|
}
|
||||||
|
if c.QobuzSelfTest && strings.TrimSpace(c.QobuzTestQuery) == "" {
|
||||||
|
return fmt.Errorf("qobuz self-test query cannot be empty")
|
||||||
|
}
|
||||||
|
if c.MonitorInterval < 2*time.Second {
|
||||||
|
return fmt.Errorf("monitor interval must be at least 2s")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.SessionPath) == "" {
|
||||||
|
return fmt.Errorf("session file path cannot be empty")
|
||||||
|
}
|
||||||
|
if !c.QobuzSelfTest && c.Command != "login" {
|
||||||
|
if strings.TrimSpace(c.SpotifyRedirect) == "" {
|
||||||
|
return fmt.Errorf("spotify redirect URI is required")
|
||||||
|
}
|
||||||
|
if len(c.SpotifyScopes) == 0 {
|
||||||
|
return fmt.Errorf("at least one Spotify scope is required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Concurrency < 1 {
|
||||||
|
return fmt.Errorf("concurrency must be >= 1")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitComma(s string) []string {
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
res := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
res = append(res, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func envIntOr(key string, fallback int) int {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func envBoolOr(key string, fallback bool) bool {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDurationOr(key string, fallback time.Duration) time.Duration {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
348
internal/match/matcher.go
Normal file
348
internal/match/matcher.go
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
package match
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"qtransfer/internal/model"
|
||||||
|
"qtransfer/internal/qobuz"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Searcher interface {
|
||||||
|
SearchTracks(ctx context.Context, query string, limit int) ([]qobuz.Track, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Matcher struct {
|
||||||
|
searcher Searcher
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
cache map[string][]qobuz.Track
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMatcher(searcher Searcher) *Matcher {
|
||||||
|
return &Matcher{
|
||||||
|
searcher: searcher,
|
||||||
|
cache: map[string][]qobuz.Track{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Matcher) MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack {
|
||||||
|
queries := m.buildQueries(src)
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return model.MatchedTrack{Source: src, Matched: false, Reason: "no usable metadata"}
|
||||||
|
}
|
||||||
|
|
||||||
|
type scored struct {
|
||||||
|
track qobuz.Track
|
||||||
|
score float64
|
||||||
|
query string
|
||||||
|
}
|
||||||
|
|
||||||
|
best := scored{score: -999}
|
||||||
|
seen := map[int64]struct{}{}
|
||||||
|
for _, q := range queries {
|
||||||
|
candidates, err := m.searchCached(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, ok := seen[c.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c.ID] = struct{}{}
|
||||||
|
score := scoreCandidate(src, c)
|
||||||
|
if score > best.score {
|
||||||
|
best = scored{track: c, score: score, query: q}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best.track.ID == 0 {
|
||||||
|
return model.MatchedTrack{Source: src, Matched: false, Reason: "no candidates"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best.score >= 45 {
|
||||||
|
return model.MatchedTrack{
|
||||||
|
Source: src,
|
||||||
|
QobuzID: best.track.ID,
|
||||||
|
Score: best.score,
|
||||||
|
Query: best.query,
|
||||||
|
Matched: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := fmt.Sprintf("best score %.1f below threshold", best.score)
|
||||||
|
return model.MatchedTrack{
|
||||||
|
Source: src,
|
||||||
|
QobuzID: best.track.ID,
|
||||||
|
Score: best.score,
|
||||||
|
Query: best.query,
|
||||||
|
Matched: false,
|
||||||
|
Reason: reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Matcher) searchCached(ctx context.Context, q string) ([]qobuz.Track, error) {
|
||||||
|
q = strings.TrimSpace(q)
|
||||||
|
if q == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cacheMu.RLock()
|
||||||
|
if v, ok := m.cache[q]; ok {
|
||||||
|
m.cacheMu.RUnlock()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
m.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
res, err := m.searcher.SearchTracks(ctx, q, 20)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cacheMu.Lock()
|
||||||
|
m.cache[q] = res
|
||||||
|
m.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Matcher) buildQueries(src model.Track) []string {
|
||||||
|
title := strings.TrimSpace(src.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
artist := ""
|
||||||
|
if len(src.Artists) > 0 {
|
||||||
|
artist = src.Artists[0]
|
||||||
|
}
|
||||||
|
latinTitle := strings.TrimSpace(transliterateToLatin(title))
|
||||||
|
latinArtist := strings.TrimSpace(transliterateToLatin(artist))
|
||||||
|
|
||||||
|
queries := []string{}
|
||||||
|
if src.ISRC != "" {
|
||||||
|
queries = append(queries, src.ISRC)
|
||||||
|
}
|
||||||
|
queries = append(queries, strings.TrimSpace(title+" "+artist))
|
||||||
|
if latinTitle != "" {
|
||||||
|
queries = append(queries, strings.TrimSpace(latinTitle+" "+latinArtist))
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTitle := cleanTitle(title)
|
||||||
|
if cleanTitle != title {
|
||||||
|
queries = append(queries, strings.TrimSpace(cleanTitle+" "+artist))
|
||||||
|
latinClean := strings.TrimSpace(transliterateToLatin(cleanTitle))
|
||||||
|
if latinClean != "" {
|
||||||
|
queries = append(queries, strings.TrimSpace(latinClean+" "+latinArtist))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queries = append(queries, title)
|
||||||
|
if latinTitle != "" {
|
||||||
|
queries = append(queries, latinTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
uniq := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(queries))
|
||||||
|
for _, q := range queries {
|
||||||
|
q = strings.TrimSpace(q)
|
||||||
|
if q == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := uniq[q]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uniq[q] = struct{}{}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreCandidate(src model.Track, dst qobuz.Track) float64 {
|
||||||
|
score := 0.0
|
||||||
|
|
||||||
|
if src.ISRC != "" && strings.EqualFold(src.ISRC, dst.ISRC) {
|
||||||
|
score += 60
|
||||||
|
}
|
||||||
|
|
||||||
|
score += 25 * similarity(normalize(src.Title), normalize(joinTitle(dst.Title, dst.Version)))
|
||||||
|
|
||||||
|
primaryArtist := ""
|
||||||
|
if len(src.Artists) > 0 {
|
||||||
|
primaryArtist = src.Artists[0]
|
||||||
|
}
|
||||||
|
if primaryArtist != "" {
|
||||||
|
score += 20 * similarity(normalize(primaryArtist), normalize(dst.Artist))
|
||||||
|
}
|
||||||
|
|
||||||
|
if src.DurationMS > 0 && dst.Duration > 0 {
|
||||||
|
delta := math.Abs(float64(src.DurationMS/1000 - dst.Duration))
|
||||||
|
switch {
|
||||||
|
case delta <= 2:
|
||||||
|
score += 10
|
||||||
|
case delta <= 5:
|
||||||
|
score += 7
|
||||||
|
case delta <= 10:
|
||||||
|
score += 4
|
||||||
|
case delta > 25:
|
||||||
|
score -= 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nt := normalize(src.Title)
|
||||||
|
dt := normalize(joinTitle(dst.Title, dst.Version))
|
||||||
|
if !strings.Contains(nt, "live") && strings.Contains(dt, "live") {
|
||||||
|
score -= 8
|
||||||
|
}
|
||||||
|
if !strings.Contains(nt, "remix") && strings.Contains(dt, "remix") {
|
||||||
|
score -= 6
|
||||||
|
}
|
||||||
|
if strings.Contains(dt, "karaoke") {
|
||||||
|
score -= 12
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinTitle(title, version string) string {
|
||||||
|
v := strings.TrimSpace(version)
|
||||||
|
if v == "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
return title + " " + v
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
func normalize(s string) string {
|
||||||
|
s = transliterateToLatin(s)
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
s = strings.ReplaceAll(s, "&", " and ")
|
||||||
|
s = nonAlphaNum.ReplaceAllString(s, " ")
|
||||||
|
tokens := strings.Fields(s)
|
||||||
|
return strings.Join(tokens, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var cyrillicToLatin = map[rune]string{
|
||||||
|
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", 'и': "i", 'й': "i",
|
||||||
|
'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u", 'ф': "f",
|
||||||
|
'х': "h", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya",
|
||||||
|
'і': "i", 'ї': "yi", 'є': "ye", 'ґ': "g",
|
||||||
|
'А': "a", 'Б': "b", 'В': "v", 'Г': "g", 'Д': "d", 'Е': "e", 'Ё': "e", 'Ж': "zh", 'З': "z", 'И': "i", 'Й': "i",
|
||||||
|
'К': "k", 'Л': "l", 'М': "m", 'Н': "n", 'О': "o", 'П': "p", 'Р': "r", 'С': "s", 'Т': "t", 'У': "u", 'Ф': "f",
|
||||||
|
'Х': "h", 'Ц': "ts", 'Ч': "ch", 'Ш': "sh", 'Щ': "shch", 'Ъ': "", 'Ы': "y", 'Ь': "", 'Э': "e", 'Ю': "yu", 'Я': "ya",
|
||||||
|
'І': "i", 'Ї': "yi", 'Є': "ye", 'Ґ': "g",
|
||||||
|
}
|
||||||
|
|
||||||
|
func transliterateToLatin(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.Grow(len(s) + 8)
|
||||||
|
for _, r := range s {
|
||||||
|
if v, ok := cyrillicToLatin[r]; ok {
|
||||||
|
b.WriteString(v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanupRe = regexp.MustCompile(`(?i)\s*\(([^)]*(remaster|remastered|live|mono|stereo|version|deluxe|explicit|clean|bonus)[^)]*)\)|\s*-\s*(remaster(ed)?|live|version|edit|radio edit).*`)
|
||||||
|
|
||||||
|
func cleanTitle(s string) string {
|
||||||
|
clean := cleanupRe.ReplaceAllString(s, "")
|
||||||
|
clean = strings.TrimSpace(clean)
|
||||||
|
if clean == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func similarity(a, b string) float64 {
|
||||||
|
if a == "" || b == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ta := tokenSet(a)
|
||||||
|
tb := tokenSet(b)
|
||||||
|
if len(ta) == 0 || len(tb) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
inter := 0
|
||||||
|
for t := range ta {
|
||||||
|
if _, ok := tb[t]; ok {
|
||||||
|
inter++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inter == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
jaccard := float64(inter) / float64(len(ta)+len(tb)-inter)
|
||||||
|
lev := levenshteinRatio(a, b)
|
||||||
|
return (jaccard * 0.6) + (lev * 0.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenSet(s string) map[string]struct{} {
|
||||||
|
parts := strings.Fields(s)
|
||||||
|
set := make(map[string]struct{}, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
set[p] = struct{}{}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
func levenshteinRatio(a, b string) float64 {
|
||||||
|
ar := []rune(a)
|
||||||
|
br := []rune(b)
|
||||||
|
if len(ar) == 0 || len(br) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
d := levenshtein(ar, br)
|
||||||
|
maxLen := len(ar)
|
||||||
|
if len(br) > maxLen {
|
||||||
|
maxLen = len(br)
|
||||||
|
}
|
||||||
|
return 1 - float64(d)/float64(maxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func levenshtein(a, b []rune) int {
|
||||||
|
dp := make([]int, len(b)+1)
|
||||||
|
for j := 0; j <= len(b); j++ {
|
||||||
|
dp[j] = j
|
||||||
|
}
|
||||||
|
for i := 1; i <= len(a); i++ {
|
||||||
|
prev := dp[0]
|
||||||
|
dp[0] = i
|
||||||
|
for j := 1; j <= len(b); j++ {
|
||||||
|
tmp := dp[j]
|
||||||
|
cost := 0
|
||||||
|
if a[i-1] != b[j-1] {
|
||||||
|
cost = 1
|
||||||
|
}
|
||||||
|
dp[j] = min3(
|
||||||
|
dp[j]+1,
|
||||||
|
dp[j-1]+1,
|
||||||
|
prev+cost,
|
||||||
|
)
|
||||||
|
prev = tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dp[len(b)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func min3(a, b, c int) int {
|
||||||
|
arr := []int{a, b, c}
|
||||||
|
sort.Ints(arr)
|
||||||
|
return arr[0]
|
||||||
|
}
|
||||||
28
internal/match/matcher_test.go
Normal file
28
internal/match/matcher_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package match
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"qtransfer/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeTransliteratesCyrillic(t *testing.T) {
|
||||||
|
got := normalize("детство")
|
||||||
|
if got != "detstvo" {
|
||||||
|
t.Fatalf("expected detstvo, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildQueriesIncludesLatinVariant(t *testing.T) {
|
||||||
|
m := &Matcher{}
|
||||||
|
q := m.buildQueries(model.Track{
|
||||||
|
Title: "детство",
|
||||||
|
Artists: []string{"Rauf & Faik"},
|
||||||
|
})
|
||||||
|
|
||||||
|
joined := strings.Join(q, "\n")
|
||||||
|
if !strings.Contains(strings.ToLower(joined), "detstvo") {
|
||||||
|
t.Fatalf("expected transliterated query to include detstvo, got %v", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
internal/model/model.go
Normal file
53
internal/model/model.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type Track struct {
|
||||||
|
SourceID string `json:"source_id,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artists []string `json:"artists"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
DurationMS int `json:"duration_ms,omitempty"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
Explicit bool `json:"explicit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Playlist struct {
|
||||||
|
SourceID string `json:"source_id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Tracks []Track `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Library struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Playlists []Playlist `json:"playlists"`
|
||||||
|
LikedSongs []Track `json:"liked_songs"`
|
||||||
|
LikedName string `json:"liked_name"`
|
||||||
|
SourceSystem string `json:"source_system"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchedTrack struct {
|
||||||
|
Source Track `json:"source"`
|
||||||
|
QobuzID int64 `json:"qobuz_id,omitempty"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Matched bool `json:"matched"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaylistTransferResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
TargetID int64 `json:"target_id,omitempty"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
MatchedTracks int `json:"matched_tracks"`
|
||||||
|
AddedTracks int `json:"added_tracks"`
|
||||||
|
Unmatched []MatchedTrack `json:"unmatched"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransferReport struct {
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
EndedAt string `json:"ended_at"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
Results []PlaylistTransferResult `json:"results"`
|
||||||
|
}
|
||||||
372
internal/qobuz/client.go
Normal file
372
internal/qobuz/client.go
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
package qobuz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseURL = "https://www.qobuz.com/api.json/0.2"
|
||||||
|
|
||||||
|
const defaultUA = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717"
|
||||||
|
const defaultAppVersion = "9.7.0.3"
|
||||||
|
const defaultDevicePlatform = "android"
|
||||||
|
const defaultDeviceModel = "Nexus 6P"
|
||||||
|
const defaultDeviceOSVersion = "9"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
appID string
|
||||||
|
appSecret string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Track struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
Version string
|
||||||
|
Duration int
|
||||||
|
ISRC string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(appID, appSecret string) *Client {
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
appID: appID,
|
||||||
|
appSecret: appSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetToken(token string) {
|
||||||
|
c.token = strings.TrimSpace(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Token() string {
|
||||||
|
return c.token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Login(ctx context.Context, username, password string) error {
|
||||||
|
type oauthResponse struct {
|
||||||
|
OAuth2 struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
} `json:"oauth2"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
rawPassword := strings.TrimSpace(password)
|
||||||
|
md5Password := md5Hex(rawPassword)
|
||||||
|
|
||||||
|
attempts := []struct {
|
||||||
|
Method string
|
||||||
|
Password string
|
||||||
|
}{
|
||||||
|
{Method: http.MethodGet, Password: md5Password},
|
||||||
|
{Method: http.MethodGet, Password: rawPassword},
|
||||||
|
{Method: http.MethodPost, Password: md5Password},
|
||||||
|
{Method: http.MethodPost, Password: rawPassword},
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, a := range attempts {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("username", username)
|
||||||
|
params.Set("password", a.Password)
|
||||||
|
|
||||||
|
var out oauthResponse
|
||||||
|
var err error
|
||||||
|
if a.Method == http.MethodPost {
|
||||||
|
err = c.postFormSigned(ctx, "/oauth2/login", params, &out)
|
||||||
|
} else {
|
||||||
|
err = c.getSigned(ctx, "/oauth2/login", params, &out)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(out.OAuth2.AccessToken)
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(out.AccessToken)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
lastErr = fmt.Errorf("qobuz login response missing access_token")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.token = token
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("qobuz login failed")
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) VerifyAuth(ctx context.Context) error {
|
||||||
|
var out map[string]any
|
||||||
|
if err := c.getUnsigned(ctx, "/user/get", url.Values{}, &out); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := c.getSigned(ctx, "/user/get", url.Values{}, &out); err != nil {
|
||||||
|
return fmt.Errorf("verify auth failed for both unsigned and signed user/get: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SearchTracks(ctx context.Context, query string, limit int) ([]Track, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 8
|
||||||
|
}
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("query", query)
|
||||||
|
params.Set("limit", strconv.Itoa(limit))
|
||||||
|
params.Set("offset", "0")
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Performer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"performer"`
|
||||||
|
Album struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"album"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var out response
|
||||||
|
if err := c.getSigned(ctx, "/track/search", params, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make([]Track, 0, len(out.Tracks.Items))
|
||||||
|
for _, it := range out.Tracks.Items {
|
||||||
|
res = append(res, Track{
|
||||||
|
ID: it.ID,
|
||||||
|
Title: it.Title,
|
||||||
|
Version: it.Version,
|
||||||
|
Duration: it.Duration,
|
||||||
|
ISRC: strings.ToUpper(strings.TrimSpace(it.ISRC)),
|
||||||
|
Artist: it.Performer.Name,
|
||||||
|
Album: it.Album.Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreatePlaylist(ctx context.Context, name, description string, isPublic bool) (int64, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", name)
|
||||||
|
form.Set("description", description)
|
||||||
|
if isPublic {
|
||||||
|
form.Set("is_public", "true")
|
||||||
|
} else {
|
||||||
|
form.Set("is_public", "false")
|
||||||
|
}
|
||||||
|
form.Set("is_collaborative", "false")
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
if err := c.postFormSigned(ctx, "/playlist/create", form, &out); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if out.ID == 0 {
|
||||||
|
return 0, fmt.Errorf("playlist/create returned empty playlist id")
|
||||||
|
}
|
||||||
|
return out.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID int64, trackIDs []int64) error {
|
||||||
|
if len(trackIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
chunks := chunk(trackIDs, 100)
|
||||||
|
for _, ch := range chunks {
|
||||||
|
ids := make([]string, 0, len(ch))
|
||||||
|
for _, id := range ch {
|
||||||
|
ids = append(ids, strconv.FormatInt(id, 10))
|
||||||
|
}
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("playlist_id", strconv.FormatInt(playlistID, 10))
|
||||||
|
form.Set("track_ids", strings.Join(ids, ","))
|
||||||
|
form.Set("no_duplicate", "true")
|
||||||
|
|
||||||
|
var out map[string]any
|
||||||
|
if err := c.postFormSigned(ctx, "/playlist/addTracks", form, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeletePlaylist(ctx context.Context, playlistID int64) error {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("playlist_id", strconv.FormatInt(playlistID, 10))
|
||||||
|
var out map[string]any
|
||||||
|
if err := c.getSigned(ctx, "/playlist/delete", params, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getSigned(ctx context.Context, path string, params url.Values, out any) error {
|
||||||
|
query := cloneValues(params)
|
||||||
|
ts, sig := signGet(path, c.appSecret, query)
|
||||||
|
query.Set("app_id", c.appID)
|
||||||
|
query.Set("request_ts", ts)
|
||||||
|
query.Set("request_sig", sig)
|
||||||
|
|
||||||
|
return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getUnsigned(ctx context.Context, path string, params url.Values, out any) error {
|
||||||
|
query := cloneValues(params)
|
||||||
|
query.Set("app_id", c.appID)
|
||||||
|
return c.doJSON(ctx, http.MethodGet, path, query, url.Values{}, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) postFormSigned(ctx context.Context, path string, form url.Values, out any) error {
|
||||||
|
for _, includeValues := range []bool{false, true} {
|
||||||
|
query := url.Values{}
|
||||||
|
ts, sig := signPost(path, c.appSecret, form, includeValues)
|
||||||
|
query.Set("app_id", c.appID)
|
||||||
|
query.Set("request_ts", ts)
|
||||||
|
query.Set("request_sig", sig)
|
||||||
|
|
||||||
|
err := c.doJSON(ctx, http.MethodPost, path, query, form, out)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isSigError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("qobuz request signature rejected for %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doJSON(ctx context.Context, method, path string, query, form url.Values, out any) error {
|
||||||
|
u := baseURL + path
|
||||||
|
if len(query) > 0 {
|
||||||
|
u += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
bodyEncoded := ""
|
||||||
|
if method == http.MethodPost {
|
||||||
|
bodyEncoded = form.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= 4; attempt++ {
|
||||||
|
var body io.Reader
|
||||||
|
if method == http.MethodPost {
|
||||||
|
body = strings.NewReader(bodyEncoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", defaultUA)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("X-App-Id", c.appID)
|
||||||
|
req.Header.Set("X-App-Version", defaultAppVersion)
|
||||||
|
req.Header.Set("X-Device-Platform", defaultDevicePlatform)
|
||||||
|
req.Header.Set("X-Device-Model", defaultDeviceModel)
|
||||||
|
req.Header.Set("X-Device-Os-Version", defaultDeviceOSVersion)
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("X-User-Auth-Token", c.token)
|
||||||
|
}
|
||||||
|
if method == http.MethodPost {
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||||||
|
resp.Body.Close()
|
||||||
|
time.Sleep(time.Duration(attempt) * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return fmt.Errorf("qobuz api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if out == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("qobuz request failed after retries")
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunk(ids []int64, size int) [][]int64 {
|
||||||
|
if size <= 0 {
|
||||||
|
size = 100
|
||||||
|
}
|
||||||
|
out := make([][]int64, 0, (len(ids)+size-1)/size)
|
||||||
|
for i := 0; i < len(ids); i += size {
|
||||||
|
j := i + size
|
||||||
|
if j > len(ids) {
|
||||||
|
j = len(ids)
|
||||||
|
}
|
||||||
|
out = append(out, ids[i:j])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneValues(v url.Values) url.Values {
|
||||||
|
res := url.Values{}
|
||||||
|
for k, values := range v {
|
||||||
|
cp := make([]string, len(values))
|
||||||
|
copy(cp, values)
|
||||||
|
res[k] = cp
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSigError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "signature") || strings.Contains(msg, "request_sig")
|
||||||
|
}
|
||||||
|
|
||||||
|
func md5Hex(s string) string {
|
||||||
|
h := md5.Sum([]byte(s))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
48
internal/qobuz/client_integration_test.go
Normal file
48
internal/qobuz/client_integration_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package qobuz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLiveLoginVerifyAndSearch(t *testing.T) {
|
||||||
|
username := os.Getenv("QOBUZ_IT_USERNAME")
|
||||||
|
password := os.Getenv("QOBUZ_IT_PASSWORD")
|
||||||
|
if username == "" || password == "" {
|
||||||
|
t.Skip("set QOBUZ_IT_USERNAME and QOBUZ_IT_PASSWORD to run live integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
appID := os.Getenv("QOBUZ_IT_APP_ID")
|
||||||
|
if appID == "" {
|
||||||
|
appID = "312369995"
|
||||||
|
}
|
||||||
|
appSecret := os.Getenv("QOBUZ_IT_APP_SECRET")
|
||||||
|
if appSecret == "" {
|
||||||
|
appSecret = "e79f8b9be485692b0e5f9dd895826368"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
c := NewClient(appID, appSecret)
|
||||||
|
if err := c.Login(ctx, username, password); err != nil {
|
||||||
|
t.Fatalf("login failed: %v", err)
|
||||||
|
}
|
||||||
|
if c.token == "" {
|
||||||
|
t.Fatalf("login succeeded but token is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.VerifyAuth(ctx); err != nil {
|
||||||
|
t.Fatalf("verify auth failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := c.SearchTracks(ctx, "Daft Punk One More Time", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
t.Fatalf("search returned no results")
|
||||||
|
}
|
||||||
|
}
|
||||||
101
internal/qobuz/signer.go
Normal file
101
internal/qobuz/signer.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package qobuz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func signGet(path, appSecret string, query url.Values) (requestTS, requestSig string) {
|
||||||
|
ts := nowTS()
|
||||||
|
method := methodName(path)
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(query))
|
||||||
|
for k := range query {
|
||||||
|
if k == "app_id" || k == "request_ts" || k == "request_sig" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.WriteString(method)
|
||||||
|
for _, k := range keys {
|
||||||
|
vals := query[k]
|
||||||
|
if len(vals) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(k)
|
||||||
|
b.WriteString(vals[0])
|
||||||
|
}
|
||||||
|
b.WriteString(ts)
|
||||||
|
b.WriteString(appSecret)
|
||||||
|
|
||||||
|
h := md5.Sum([]byte(b.String()))
|
||||||
|
return ts, hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func signPost(path, appSecret string, form url.Values, includeValues bool) (requestTS, requestSig string) {
|
||||||
|
ts := nowTS()
|
||||||
|
method := methodName(path)
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(form))
|
||||||
|
for k := range form {
|
||||||
|
if k == "app_id" || k == "request_ts" || k == "request_sig" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.WriteString(method)
|
||||||
|
for _, k := range keys {
|
||||||
|
b.WriteString(k)
|
||||||
|
if includeValues {
|
||||||
|
vals := form[k]
|
||||||
|
if len(vals) > 0 {
|
||||||
|
b.WriteString(vals[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(ts)
|
||||||
|
b.WriteString(appSecret)
|
||||||
|
|
||||||
|
h := md5.Sum([]byte(b.String()))
|
||||||
|
return ts, hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func methodName(path string) string {
|
||||||
|
return strings.ReplaceAll(strings.Trim(path, "/"), "/", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func nowTS() string {
|
||||||
|
return strconvI64(time.Now().Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func strconvI64(v int64) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := v < 0
|
||||||
|
if neg {
|
||||||
|
v = -v
|
||||||
|
}
|
||||||
|
buf := [20]byte{}
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
24
internal/report/report.go
Normal file
24
internal/report/report.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"qtransfer/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Write(path string, rep model.TransferReport) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create report file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
enc := json.NewEncoder(f)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(rep); err != nil {
|
||||||
|
return fmt.Errorf("encode report: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
98
internal/session/session.go
Normal file
98
internal/session/session.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Data struct {
|
||||||
|
Spotify SpotifyState `json:"spotify"`
|
||||||
|
Qobuz QobuzState `json:"qobuz"`
|
||||||
|
Monitor map[string]string `json:"monitor"`
|
||||||
|
Meta map[string]string `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyState struct {
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
AccessToken string `json:"access_token,omitempty"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzState struct {
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
AccessToken string `json:"access_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || strings.TrimSpace(home) == "" {
|
||||||
|
return ".qtransfer-session.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "qtransfer", "session.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolvePath(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return DefaultPath()
|
||||||
|
}
|
||||||
|
if path == "~" {
|
||||||
|
return DefaultPath()
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "~/") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil && home != "" {
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (Data, error) {
|
||||||
|
path = ResolvePath(path)
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return Data{}, nil
|
||||||
|
}
|
||||||
|
return Data{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var d Data
|
||||||
|
if err := json.Unmarshal(b, &d); err != nil {
|
||||||
|
return Data{}, fmt.Errorf("parse session file: %w", err)
|
||||||
|
}
|
||||||
|
if d.Monitor == nil {
|
||||||
|
d.Monitor = map[string]string{}
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(path string, data Data) error {
|
||||||
|
path = ResolvePath(path)
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create session directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write session temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
return fmt.Errorf("replace session file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
332
internal/spotify/auth.go
Normal file
332
internal/spotify/auth.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
ClientID string
|
||||||
|
RedirectURI string
|
||||||
|
Scopes []string
|
||||||
|
ManualCode bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoginWithPKCE(ctx context.Context, cfg AuthConfig) (Token, error) {
|
||||||
|
redirectURL, err := url.Parse(cfg.RedirectURI)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, fmt.Errorf("invalid redirect URI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
codeVerifier, err := randomURLSafe(64)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
state, err := randomURLSafe(24)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte(codeVerifier))
|
||||||
|
codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
|
||||||
|
|
||||||
|
authURL := "https://accounts.spotify.com/authorize?" + url.Values{
|
||||||
|
"response_type": []string{"code"},
|
||||||
|
"client_id": []string{cfg.ClientID},
|
||||||
|
"scope": []string{strings.Join(cfg.Scopes, " ")},
|
||||||
|
"redirect_uri": []string{cfg.RedirectURI},
|
||||||
|
"state": []string{state},
|
||||||
|
"code_challenge_method": []string{"S256"},
|
||||||
|
"code_challenge": []string{codeChallenge},
|
||||||
|
}.Encode()
|
||||||
|
|
||||||
|
if cfg.ManualCode {
|
||||||
|
return loginManual(ctx, cfg, authURL, state, codeVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := loginWithCallbackServer(ctx, cfg, redirectURL, authURL, state, codeVerifier)
|
||||||
|
if err == nil {
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "listen callback server") {
|
||||||
|
fmt.Println("Local callback server unavailable; falling back to manual code entry.")
|
||||||
|
return loginManual(ctx, cfg, authURL, state, codeVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginWithCallbackServer(ctx context.Context, cfg AuthConfig, redirectURL *url.URL, authURL, state, codeVerifier string) (Token, error) {
|
||||||
|
|
||||||
|
codeCh := make(chan string, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: redirectURL.Host,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if q.Get("state") != state {
|
||||||
|
http.Error(w, "Invalid state", http.StatusBadRequest)
|
||||||
|
select {
|
||||||
|
case errCh <- fmt.Errorf("state mismatch in spotify callback"):
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := q.Get("error"); e != "" {
|
||||||
|
http.Error(w, "Spotify authorization failed", http.StatusBadRequest)
|
||||||
|
select {
|
||||||
|
case errCh <- fmt.Errorf("spotify auth error: %s", e):
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := q.Get("code")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "Missing authorization code", http.StatusBadRequest)
|
||||||
|
select {
|
||||||
|
case errCh <- fmt.Errorf("spotify callback missing code"):
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = io.WriteString(w, "Spotify authorization complete. You can close this tab.")
|
||||||
|
select {
|
||||||
|
case codeCh <- code:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", redirectURL.Host)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, fmt.Errorf("listen callback server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if serveErr := server.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed {
|
||||||
|
select {
|
||||||
|
case errCh <- serveErr:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = openBrowser(authURL)
|
||||||
|
fmt.Printf("Open this URL in your browser if it did not open automatically:\n%s\n\n", authURL)
|
||||||
|
|
||||||
|
var code string
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = server.Shutdown(context.Background())
|
||||||
|
return Token{}, ctx.Err()
|
||||||
|
case e := <-errCh:
|
||||||
|
_ = server.Shutdown(context.Background())
|
||||||
|
return Token{}, e
|
||||||
|
case code = <-codeCh:
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = server.Shutdown(shutdownCtx)
|
||||||
|
|
||||||
|
return exchangeCode(ctx, cfg, code, codeVerifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginManual(ctx context.Context, cfg AuthConfig, authURL, expectedState, codeVerifier string) (Token, error) {
|
||||||
|
_ = openBrowser(authURL)
|
||||||
|
fmt.Printf("Open this URL in your browser if it did not open automatically:\n%s\n\n", authURL)
|
||||||
|
fmt.Printf("After Spotify redirects to %s, copy the full URL from your browser and paste it here.\n", cfg.RedirectURI)
|
||||||
|
fmt.Println("If your browser only shows a code, you can paste that code directly.")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
for {
|
||||||
|
fmt.Print("Paste callback URL/code: ")
|
||||||
|
if !scanner.Scan() {
|
||||||
|
if scanner.Err() != nil {
|
||||||
|
return Token{}, scanner.Err()
|
||||||
|
}
|
||||||
|
return Token{}, fmt.Errorf("stdin closed before spotify auth code was provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return Token{}, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
input := strings.TrimSpace(scanner.Text())
|
||||||
|
if input == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := extractAuthCode(input, expectedState)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Could not parse code: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return exchangeCode(ctx, cfg, code, codeVerifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAuthCode(input, expectedState string) (string, error) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
|
||||||
|
if !strings.Contains(input, "code=") && !strings.Contains(input, "://") {
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
queryString := ""
|
||||||
|
if strings.Contains(input, "://") {
|
||||||
|
u, err := url.Parse(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid callback URL")
|
||||||
|
}
|
||||||
|
queryString = u.RawQuery
|
||||||
|
} else {
|
||||||
|
queryString = strings.TrimPrefix(input, "?")
|
||||||
|
}
|
||||||
|
|
||||||
|
q, err := url.ParseQuery(queryString)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid callback query")
|
||||||
|
}
|
||||||
|
|
||||||
|
if e := q.Get("error"); e != "" {
|
||||||
|
return "", fmt.Errorf("spotify returned error: %s", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotState := q.Get("state"); expectedState != "" && gotState != "" && gotState != expectedState {
|
||||||
|
return "", fmt.Errorf("state mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
code := strings.TrimSpace(q.Get("code"))
|
||||||
|
if code == "" {
|
||||||
|
return "", fmt.Errorf("missing code parameter")
|
||||||
|
}
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeCode(ctx context.Context, cfg AuthConfig, code, codeVerifier string) (Token, error) {
|
||||||
|
body := url.Values{
|
||||||
|
"client_id": []string{cfg.ClientID},
|
||||||
|
"grant_type": []string{"authorization_code"},
|
||||||
|
"code": []string{code},
|
||||||
|
"redirect_uri": []string{cfg.RedirectURI},
|
||||||
|
"code_verifier": []string{codeVerifier},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://accounts.spotify.com/api/token", strings.NewReader(body.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return Token{}, fmt.Errorf("spotify token exchange failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tok Token
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
if tok.AccessToken == "" {
|
||||||
|
return Token{}, fmt.Errorf("spotify token response missing access_token")
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshAccessToken(ctx context.Context, clientID, refreshToken string) (Token, error) {
|
||||||
|
body := url.Values{
|
||||||
|
"client_id": []string{clientID},
|
||||||
|
"grant_type": []string{"refresh_token"},
|
||||||
|
"refresh_token": []string{refreshToken},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://accounts.spotify.com/api/token", strings.NewReader(body.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return Token{}, fmt.Errorf("spotify refresh failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tok Token
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
if tok.AccessToken == "" {
|
||||||
|
return Token{}, fmt.Errorf("spotify refresh response missing access_token")
|
||||||
|
}
|
||||||
|
if tok.RefreshToken == "" {
|
||||||
|
tok.RefreshToken = refreshToken
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomURLSafe(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(u string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
cmd = exec.Command("xdg-open", u)
|
||||||
|
case "darwin":
|
||||||
|
cmd = exec.Command("open", u)
|
||||||
|
case "windows":
|
||||||
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported OS for auto-open")
|
||||||
|
}
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
375
internal/spotify/client.go
Normal file
375
internal/spotify/client.go
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"qtransfer/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseURL = "https://api.spotify.com/v1"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
token string
|
||||||
|
progress ProgressFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressFunc func(message string)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(token string) *Client {
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
token: token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetProgress(fn ProgressFunc) {
|
||||||
|
c.progress = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetCurrentUser(ctx context.Context) (User, error) {
|
||||||
|
var u User
|
||||||
|
err := c.getJSON(ctx, baseURL+"/me", &u)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchLibrary(ctx context.Context, likedName string) (model.Library, error) {
|
||||||
|
c.notifyProgress("Fetching Spotify profile...")
|
||||||
|
user, err := c.GetCurrentUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return model.Library{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.notifyProgress("Fetching Spotify playlists...")
|
||||||
|
pls, err := c.FetchPlaylists(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return model.Library{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.notifyProgress("Fetching Spotify liked songs...")
|
||||||
|
liked, err := c.FetchLikedSongs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return model.Library{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lib := model.Library{
|
||||||
|
UserID: user.ID,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Playlists: pls,
|
||||||
|
LikedSongs: liked,
|
||||||
|
LikedName: likedName,
|
||||||
|
SourceSystem: "spotify",
|
||||||
|
}
|
||||||
|
|
||||||
|
return lib, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchPlaylists(ctx context.Context) ([]model.Playlist, error) {
|
||||||
|
type playlistLite struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
type page struct {
|
||||||
|
Items []playlistLite `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []model.Playlist
|
||||||
|
next := baseURL + "/me/playlists?limit=50"
|
||||||
|
loadedPlaylists := 0
|
||||||
|
totalPlaylists := 0
|
||||||
|
for next != "" {
|
||||||
|
var p page
|
||||||
|
if err := c.getJSON(ctx, next, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.Total > 0 {
|
||||||
|
totalPlaylists = p.Total
|
||||||
|
}
|
||||||
|
for _, item := range p.Items {
|
||||||
|
loadedPlaylists++
|
||||||
|
if totalPlaylists > 0 {
|
||||||
|
c.notifyProgress(fmt.Sprintf("Spotify playlists: %d/%d", loadedPlaylists, totalPlaylists))
|
||||||
|
} else {
|
||||||
|
c.notifyProgress(fmt.Sprintf("Spotify playlists: %d", loadedPlaylists))
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := c.fetchPlaylistTracks(ctx, item.ID, item.Name, loadedPlaylists, totalPlaylists)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch playlist tracks %s: %w", item.Name, err)
|
||||||
|
}
|
||||||
|
out = append(out, model.Playlist{
|
||||||
|
SourceID: item.ID,
|
||||||
|
Name: item.Name,
|
||||||
|
Description: item.Description,
|
||||||
|
Tracks: tracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
next = p.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchPlaylistsByID(ctx context.Context, ids []string) ([]model.Playlist, error) {
|
||||||
|
out := make([]model.Playlist, 0, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
pl, err := c.FetchPlaylistByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.notifyProgress(fmt.Sprintf("Spotify playlist urls: %d/%d", i+1, len(ids)))
|
||||||
|
out = append(out, pl)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchPlaylistByID(ctx context.Context, playlistID string) (model.Playlist, error) {
|
||||||
|
type response struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/playlists/%s?fields=id,name,description", baseURL, url.PathEscape(playlistID))
|
||||||
|
var meta response
|
||||||
|
if err := c.getJSON(ctx, endpoint, &meta); err != nil {
|
||||||
|
return model.Playlist{}, fmt.Errorf("fetch playlist metadata %s: %w", playlistID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := c.fetchPlaylistTracks(ctx, meta.ID, meta.Name, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return model.Playlist{}, fmt.Errorf("fetch playlist tracks %s: %w", meta.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.Playlist{
|
||||||
|
SourceID: meta.ID,
|
||||||
|
Name: meta.Name,
|
||||||
|
Description: meta.Description,
|
||||||
|
Tracks: tracks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchPlaylistTracks(ctx context.Context, playlistID, playlistName string, playlistIndex, playlistTotal int) ([]model.Track, error) {
|
||||||
|
type trackObj struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
ExternalIDs struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
} `json:"external_ids"`
|
||||||
|
Album struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"album"`
|
||||||
|
Artists []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artists"`
|
||||||
|
}
|
||||||
|
type item struct {
|
||||||
|
Track *trackObj `json:"track"`
|
||||||
|
}
|
||||||
|
type page struct {
|
||||||
|
Items []item `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []model.Track
|
||||||
|
next := fmt.Sprintf("%s/playlists/%s/tracks?limit=100", baseURL, url.PathEscape(playlistID))
|
||||||
|
loadedTracks := 0
|
||||||
|
totalTracks := 0
|
||||||
|
for next != "" {
|
||||||
|
var p page
|
||||||
|
if err := c.getJSON(ctx, next, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.Total > 0 {
|
||||||
|
totalTracks = p.Total
|
||||||
|
}
|
||||||
|
for _, it := range p.Items {
|
||||||
|
if it.Track == nil || it.Track.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loadedTracks++
|
||||||
|
out = append(out, toModelTrack(
|
||||||
|
it.Track.ID,
|
||||||
|
it.Track.Name,
|
||||||
|
it.Track.Album.Name,
|
||||||
|
it.Track.DurationMS,
|
||||||
|
it.Track.ExternalIDs.ISRC,
|
||||||
|
it.Track.Explicit,
|
||||||
|
it.Track.Artists,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := "Playlist"
|
||||||
|
if playlistTotal > 0 {
|
||||||
|
prefix = fmt.Sprintf("Playlist %d/%d", playlistIndex, playlistTotal)
|
||||||
|
} else if playlistIndex > 0 {
|
||||||
|
prefix = fmt.Sprintf("Playlist %d", playlistIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalTracks > 0 {
|
||||||
|
c.notifyProgress(fmt.Sprintf("%s (%s): tracks %d/%d", prefix, playlistName, loadedTracks, totalTracks))
|
||||||
|
} else {
|
||||||
|
c.notifyProgress(fmt.Sprintf("%s (%s): tracks %d", prefix, playlistName, loadedTracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
next = p.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchLikedSongs(ctx context.Context) ([]model.Track, error) {
|
||||||
|
type trackObj struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
ExternalIDs struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
} `json:"external_ids"`
|
||||||
|
Album struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"album"`
|
||||||
|
Artists []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artists"`
|
||||||
|
}
|
||||||
|
type item struct {
|
||||||
|
Track *trackObj `json:"track"`
|
||||||
|
}
|
||||||
|
type page struct {
|
||||||
|
Items []item `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []model.Track
|
||||||
|
next := baseURL + "/me/tracks?limit=50"
|
||||||
|
loaded := 0
|
||||||
|
total := 0
|
||||||
|
for next != "" {
|
||||||
|
var p page
|
||||||
|
if err := c.getJSON(ctx, next, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.Total > 0 {
|
||||||
|
total = p.Total
|
||||||
|
}
|
||||||
|
for _, it := range p.Items {
|
||||||
|
if it.Track == nil || it.Track.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loaded++
|
||||||
|
out = append(out, toModelTrack(
|
||||||
|
it.Track.ID,
|
||||||
|
it.Track.Name,
|
||||||
|
it.Track.Album.Name,
|
||||||
|
it.Track.DurationMS,
|
||||||
|
it.Track.ExternalIDs.ISRC,
|
||||||
|
it.Track.Explicit,
|
||||||
|
it.Track.Artists,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if total > 0 {
|
||||||
|
c.notifyProgress(fmt.Sprintf("Liked songs: %d/%d", loaded, total))
|
||||||
|
} else {
|
||||||
|
c.notifyProgress(fmt.Sprintf("Liked songs: %d", loaded))
|
||||||
|
}
|
||||||
|
|
||||||
|
next = p.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) notifyProgress(msg string) {
|
||||||
|
if c.progress != nil {
|
||||||
|
c.progress(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toModelTrack(id, name, album string, durationMS int, isrc string, explicit bool, artists []struct {
|
||||||
|
Name string "json:\"name\""
|
||||||
|
}) model.Track {
|
||||||
|
artistNames := make([]string, 0, len(artists))
|
||||||
|
for _, a := range artists {
|
||||||
|
if strings.TrimSpace(a.Name) != "" {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.Track{
|
||||||
|
SourceID: id,
|
||||||
|
Title: name,
|
||||||
|
Artists: artistNames,
|
||||||
|
Album: album,
|
||||||
|
DurationMS: durationMS,
|
||||||
|
ISRC: strings.ToUpper(strings.TrimSpace(isrc)),
|
||||||
|
Explicit: explicit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getJSON(ctx context.Context, endpoint string, out any) error {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= 4; attempt++ {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
resp.Body.Close()
|
||||||
|
time.Sleep(time.Duration(attempt) * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
resp.Body.Close()
|
||||||
|
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return fmt.Errorf("spotify api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("spotify request failed after retries")
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
43
internal/spotify/playlist_url.go
Normal file
43
internal/spotify/playlist_url.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParsePlaylistID(input string) (string, error) {
|
||||||
|
s := strings.TrimSpace(input)
|
||||||
|
if s == "" {
|
||||||
|
return "", fmt.Errorf("empty playlist input")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(s, "spotify:playlist:") {
|
||||||
|
id := strings.TrimSpace(strings.TrimPrefix(s, "spotify:playlist:"))
|
||||||
|
if id == "" {
|
||||||
|
return "", fmt.Errorf("invalid spotify URI")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(s, "://") {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid playlist URL")
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||||
|
for i := 0; i < len(parts)-1; i++ {
|
||||||
|
if parts[i] == "playlist" {
|
||||||
|
id := strings.TrimSpace(parts[i+1])
|
||||||
|
if id == "" {
|
||||||
|
return "", fmt.Errorf("missing playlist id in URL")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not find playlist id in URL")
|
||||||
|
}
|
||||||
224
internal/transfer/transfer.go
Normal file
224
internal/transfer/transfer.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package transfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"qtransfer/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QobuzWriter interface {
|
||||||
|
CreatePlaylist(ctx context.Context, name, description string, isPublic bool) (int64, error)
|
||||||
|
AddTracksToPlaylist(ctx context.Context, playlistID int64, trackIDs []int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackMatcher interface {
|
||||||
|
MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DryRun bool
|
||||||
|
PublicPlaylists bool
|
||||||
|
Concurrency int
|
||||||
|
LikedName string
|
||||||
|
Progress ProgressFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressFunc func(message string)
|
||||||
|
|
||||||
|
func Run(ctx context.Context, cfg Config, writer QobuzWriter, matcher TrackMatcher, playlists []model.Playlist, likedSongs []model.Track, includeLiked bool) (model.TransferReport, error) {
|
||||||
|
rep := model.TransferReport{
|
||||||
|
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
DryRun: cfg.DryRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
all := make([]model.Playlist, 0, len(playlists)+1)
|
||||||
|
all = append(all, playlists...)
|
||||||
|
if includeLiked {
|
||||||
|
all = append(all, model.Playlist{
|
||||||
|
Name: cfg.LikedName,
|
||||||
|
Tracks: likedSongs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPlaylists := len(all)
|
||||||
|
for i, pl := range all {
|
||||||
|
result := processPlaylist(ctx, cfg, writer, matcher, pl, i+1, totalPlaylists)
|
||||||
|
rep.Results = append(rep.Results, result)
|
||||||
|
notify(cfg, fmt.Sprintf(
|
||||||
|
"Transfer %d/%d done: %s | matched %d/%d | added %d | unmatched %d",
|
||||||
|
i+1,
|
||||||
|
totalPlaylists,
|
||||||
|
shortName(pl.Name),
|
||||||
|
result.MatchedTracks,
|
||||||
|
result.TotalTracks,
|
||||||
|
result.AddedTracks,
|
||||||
|
len(result.Unmatched),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
rep.EndedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
notify(cfg, "Transfer processing complete")
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processPlaylist(ctx context.Context, cfg Config, writer QobuzWriter, matcher TrackMatcher, pl model.Playlist, playlistIndex, playlistTotal int) model.PlaylistTransferResult {
|
||||||
|
res := model.PlaylistTransferResult{
|
||||||
|
Name: pl.Name,
|
||||||
|
TotalTracks: len(pl.Tracks),
|
||||||
|
Errors: []string{},
|
||||||
|
Unmatched: []model.MatchedTrack{},
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(cfg, fmt.Sprintf("Transfer %d/%d matching: %s (0/%d)", playlistIndex, playlistTotal, shortName(pl.Name), len(pl.Tracks)))
|
||||||
|
|
||||||
|
matched, unmatched := matchTracks(ctx, matcher, pl.Tracks, cfg.Concurrency, func(done, total int) {
|
||||||
|
notify(cfg, fmt.Sprintf("Transfer %d/%d matching: %s (%d/%d)", playlistIndex, playlistTotal, shortName(pl.Name), done, total))
|
||||||
|
})
|
||||||
|
res.MatchedTracks = len(matched)
|
||||||
|
res.Unmatched = unmatched
|
||||||
|
|
||||||
|
if cfg.DryRun {
|
||||||
|
res.AddedTracks = len(uniqueIDs(matched))
|
||||||
|
notify(cfg, fmt.Sprintf("Transfer %d/%d dry-run: %s resolved %d matches", playlistIndex, playlistTotal, shortName(pl.Name), res.AddedTracks))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(cfg, fmt.Sprintf("Transfer %d/%d creating playlist: %s", playlistIndex, playlistTotal, shortName(pl.Name)))
|
||||||
|
playlistID, err := writer.CreatePlaylist(ctx, pl.Name, sanitizeDescription(pl.Description), cfg.PublicPlaylists)
|
||||||
|
if err != nil {
|
||||||
|
res.Errors = append(res.Errors, fmt.Sprintf("create playlist: %v", err))
|
||||||
|
notify(cfg, fmt.Sprintf("Transfer %d/%d failed creating playlist: %s", playlistIndex, playlistTotal, shortName(pl.Name)))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.TargetID = playlistID
|
||||||
|
|
||||||
|
ids := uniqueIDs(matched)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
notify(cfg, fmt.Sprintf("Transfer %d/%d no matched tracks to add: %s", playlistIndex, playlistTotal, shortName(pl.Name)))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(cfg, fmt.Sprintf("Transfer %d/%d adding %d track(s): %s", playlistIndex, playlistTotal, len(ids), shortName(pl.Name)))
|
||||||
|
if err := writer.AddTracksToPlaylist(ctx, playlistID, ids); err != nil {
|
||||||
|
res.Errors = append(res.Errors, fmt.Sprintf("add tracks: %v", err))
|
||||||
|
notify(cfg, fmt.Sprintf("Transfer %d/%d failed adding tracks: %s", playlistIndex, playlistTotal, shortName(pl.Name)))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
res.AddedTracks = len(ids)
|
||||||
|
notify(cfg, fmt.Sprintf("Transfer %d/%d added %d track(s): %s", playlistIndex, playlistTotal, res.AddedTracks, shortName(pl.Name)))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchTracks(ctx context.Context, matcher TrackMatcher, 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 {
|
||||||
|
if 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 notify(cfg Config, msg string) {
|
||||||
|
if cfg.Progress != nil {
|
||||||
|
cfg.Progress(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortName(s string) string {
|
||||||
|
const limit = 48
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) <= limit {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if limit <= 3 {
|
||||||
|
return s[:limit]
|
||||||
|
}
|
||||||
|
return s[:limit-3] + "..."
|
||||||
|
}
|
||||||
174
internal/ui/select.go
Normal file
174
internal/ui/select.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"qtransfer/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ResolveSelection(lib model.Library, allPlaylists, includeLiked, nonInteractive bool, names []string) ([]model.Playlist, bool, error) {
|
||||||
|
if allPlaylists || len(names) > 0 || nonInteractive {
|
||||||
|
selected, liked, err := selectByFlags(lib, allPlaylists, includeLiked, names)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if nonInteractive && len(selected) == 0 && !liked {
|
||||||
|
return nil, false, fmt.Errorf("no selection provided in non-interactive mode (use --all, --playlist, or --liked)")
|
||||||
|
}
|
||||||
|
if len(selected) == 0 && !liked && !nonInteractive {
|
||||||
|
return nil, false, fmt.Errorf("no playlists selected")
|
||||||
|
}
|
||||||
|
return selected, liked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return interactiveSelection(lib)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectByFlags(lib model.Library, allPlaylists, includeLiked bool, names []string) ([]model.Playlist, bool, error) {
|
||||||
|
if allPlaylists {
|
||||||
|
return lib.Playlists, includeLiked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, includeLiked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := map[string]model.Playlist{}
|
||||||
|
for _, p := range lib.Playlists {
|
||||||
|
lookup[strings.ToLower(strings.TrimSpace(p.Name))] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := make([]model.Playlist, 0, len(names))
|
||||||
|
missing := []string{}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, n := range names {
|
||||||
|
k := strings.ToLower(strings.TrimSpace(n))
|
||||||
|
p, ok := lookup[k]
|
||||||
|
if !ok {
|
||||||
|
missing = append(missing, n)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[p.SourceID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p.SourceID] = struct{}{}
|
||||||
|
selected = append(selected, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return nil, false, fmt.Errorf("playlist(s) not found: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected, includeLiked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func interactiveSelection(lib model.Library) ([]model.Playlist, bool, error) {
|
||||||
|
fmt.Println("Fetched Spotify data:")
|
||||||
|
for i, p := range lib.Playlists {
|
||||||
|
fmt.Printf(" %2d) %s (%d tracks)\n", i+1, p.Name, len(p.Tracks))
|
||||||
|
}
|
||||||
|
fmt.Printf(" L) %s (%d tracks)\n", lib.LikedName, len(lib.LikedSongs))
|
||||||
|
fmt.Println("\nSelect playlists to transfer. Examples: 1,2,5-8,L or A for all playlists + liked songs")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
for {
|
||||||
|
fmt.Print("Selection: ")
|
||||||
|
if !scanner.Scan() {
|
||||||
|
if scanner.Err() != nil {
|
||||||
|
return nil, false, scanner.Err()
|
||||||
|
}
|
||||||
|
return nil, false, fmt.Errorf("input closed")
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
idxs, liked, all, err := parseSelection(line, len(lib.Playlists))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Invalid selection: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if all {
|
||||||
|
return lib.Playlists, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := make([]model.Playlist, 0, len(idxs))
|
||||||
|
for _, idx := range idxs {
|
||||||
|
selected = append(selected, lib.Playlists[idx-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(selected) == 0 && !liked {
|
||||||
|
fmt.Println("No playlists selected. Please choose at least one playlist or L.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return selected, liked, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSelection(s string, max int) ([]int, bool, bool, error) {
|
||||||
|
parts := strings.Split(strings.ToUpper(s), ",")
|
||||||
|
set := map[int]struct{}{}
|
||||||
|
liked := false
|
||||||
|
all := false
|
||||||
|
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch p {
|
||||||
|
case "A", "ALL":
|
||||||
|
all = true
|
||||||
|
liked = true
|
||||||
|
continue
|
||||||
|
case "L", "LIKED":
|
||||||
|
liked = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(p, "-") {
|
||||||
|
r := strings.SplitN(p, "-", 2)
|
||||||
|
if len(r) != 2 {
|
||||||
|
return nil, false, false, fmt.Errorf("invalid range %q", p)
|
||||||
|
}
|
||||||
|
start, err := strconv.Atoi(strings.TrimSpace(r[0]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, false, fmt.Errorf("invalid range start in %q", p)
|
||||||
|
}
|
||||||
|
end, err := strconv.Atoi(strings.TrimSpace(r[1]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, false, fmt.Errorf("invalid range end in %q", p)
|
||||||
|
}
|
||||||
|
if start < 1 || end < 1 || start > max || end > max || end < start {
|
||||||
|
return nil, false, false, fmt.Errorf("range out of bounds: %q", p)
|
||||||
|
}
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
set[i] = struct{}{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, false, fmt.Errorf("invalid token %q", p)
|
||||||
|
}
|
||||||
|
if n < 1 || n > max {
|
||||||
|
return nil, false, false, fmt.Errorf("playlist index out of bounds: %d", n)
|
||||||
|
}
|
||||||
|
set[n] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
idxs := make([]int, 0, len(set))
|
||||||
|
for i := range set {
|
||||||
|
idxs = append(idxs, i)
|
||||||
|
}
|
||||||
|
sort.Ints(idxs)
|
||||||
|
return idxs, liked, all, nil
|
||||||
|
}
|
||||||
2767
transfer-report.json
Normal file
2767
transfer-report.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user