build spotify-to-navidrome migrator with recovery flow
This commit is contained in:
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_REDIRECT_URI=http://127.0.0.1:8888/callback
|
||||||
|
SPOTIFY_SCOPES=playlist-read-private,playlist-read-collaborative,user-library-read
|
||||||
|
NAVIMIGRATE_SPOTIFY_MANUAL_CODE=true
|
||||||
|
NAVIMIGRATE_REMEMBER_SPOTIFY=true
|
||||||
|
NAVIMIGRATE_SESSION_FILE=~/.config/navimigrate/session.json
|
||||||
|
NAVIMIGRATE_INCLUDE_LIKED=false
|
||||||
|
NAVIMIGRATE_LIKED_NAME=Spotify Liked Songs
|
||||||
|
|
||||||
|
NAVIDROME_URL=https://music.lofitrek.com
|
||||||
|
NAVIDROME_USERNAME=test_user
|
||||||
|
NAVIDROME_PASSWORD=test_user
|
||||||
|
|
||||||
|
NAVIMIGRATE_DRY_RUN=false
|
||||||
|
NAVIMIGRATE_CONCURRENCY=4
|
||||||
|
NAVIMIGRATE_MATCH_THRESHOLD=45
|
||||||
|
NAVIMIGRATE_REPORT=transfer-report.json
|
||||||
|
|
||||||
|
NAVIMIGRATE_QOBUZ_DOWNLOAD_MISSING=false
|
||||||
|
NAVIMIGRATE_QOBUZ_MANIFEST=missing-downloads.json
|
||||||
|
NAVIMIGRATE_QOBUZ_DL_PATH=/home/joren/dev/qobuz-dl
|
||||||
|
NAVIMIGRATE_QOBUZ_OUTPUT=
|
||||||
|
NAVIMIGRATE_QOBUZ_QUALITY=6
|
||||||
|
QOBUZ_USERNAME=
|
||||||
|
QOBUZ_PASSWORD=
|
||||||
|
QOBUZ_APP_ID=312369995
|
||||||
|
QOBUZ_APP_SECRET=e79f8b9be485692b0e5f9dd895826368
|
||||||
|
|
||||||
|
NAVIMIGRATE_ADD_DOWNLOADED_MANIFEST=
|
||||||
|
NAVIMIGRATE_ADD_DOWNLOADED_FORCE=false
|
||||||
|
|
||||||
|
NAVIMIGRATE_NAVIDROME_SELF_TEST=false
|
||||||
|
NAVIMIGRATE_NAVIDROME_SELF_TEST_WRITE=false
|
||||||
|
NAVIMIGRATE_NAVIDROME_SELF_TEST_QUERY=Daft Punk One More Time
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
navimigrate
|
||||||
|
transfer-report.json
|
||||||
|
missing-downloads.json
|
||||||
70
README.md
70
README.md
@@ -6,14 +6,18 @@ Spotify -> Navidrome playlist transfer tool in Go.
|
|||||||
|
|
||||||
- Authenticates with Spotify using Authorization Code + PKCE (no client secret required).
|
- Authenticates with Spotify using Authorization Code + PKCE (no client secret required).
|
||||||
- Fetches one or more Spotify playlists by URL/URI/ID.
|
- Fetches one or more Spotify playlists by URL/URI/ID.
|
||||||
|
- Optionally transfers Spotify liked songs as a dedicated playlist.
|
||||||
- Searches for matching tracks in Navidrome using Subsonic `search3`.
|
- Searches for matching tracks in Navidrome using Subsonic `search3`.
|
||||||
- Creates matching playlists in Navidrome and adds matched tracks.
|
- Creates matching playlists in Navidrome and adds matched tracks.
|
||||||
- Writes a JSON transfer report with unmatched tracks and errors.
|
- Writes a JSON transfer report with unmatched tracks and errors.
|
||||||
|
- Optional recovery mode: for unmatched tracks, search Qobuz and download whole albums using `qobuz-dl`.
|
||||||
|
- Optional re-add mode: after Navidrome rescan, re-match downloaded tracks and add them to existing playlists.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Go 1.22+
|
- Go 1.22+
|
||||||
- Spotify app client ID with redirect URI configured (default: `http://127.0.0.1:8888/callback`)
|
- Spotify app client ID with redirect URI configured (default: `http://127.0.0.1:8888/callback`)
|
||||||
|
- If using `--liked`, Spotify scopes must include `user-library-read`
|
||||||
- Navidrome URL, username, and password
|
- Navidrome URL, username, and password
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
@@ -36,10 +40,32 @@ go build ./cmd/navimigrate
|
|||||||
### Useful flags
|
### Useful flags
|
||||||
|
|
||||||
- `--playlist-url "..."` (repeatable): Spotify playlist URL/URI/ID
|
- `--playlist-url "..."` (repeatable): Spotify playlist URL/URI/ID
|
||||||
|
- `--liked`: include Spotify liked songs in transfer
|
||||||
|
- `--liked-playlist-name "Spotify Liked Songs"`: target playlist name for liked songs
|
||||||
- `--dry-run`: resolve matches only, do not create playlists or add tracks
|
- `--dry-run`: resolve matches only, do not create playlists or add tracks
|
||||||
|
- `--match-threshold 45`: minimum score needed to accept a match
|
||||||
|
- `--remember-spotify=true|false`: save and reuse Spotify refresh token (default true)
|
||||||
|
- `--session-file ~/.config/navimigrate/session.json`: session storage path
|
||||||
- `--report transfer-report.json`: report output path
|
- `--report transfer-report.json`: report output path
|
||||||
- `--concurrency 4`: concurrent track match workers
|
- `--concurrency 4`: concurrent track match workers
|
||||||
- `--spotify-manual-code=true|false`: manual callback URL/code entry or local callback server
|
- `--spotify-manual-code=true|false`: manual callback URL/code entry or local callback server
|
||||||
|
- `--qobuz-download-missing`: search/download albums for unmatched tracks
|
||||||
|
- `--qobuz-manifest missing-downloads.json`: manifest path used by recovery flows
|
||||||
|
- `--qobuz-dl-path qobuz-dl`: binary path, or qobuz-dl project directory
|
||||||
|
- `--qobuz-output /path/to/music`: output directory for qobuz-dl
|
||||||
|
- `--add-downloaded-manifest missing-downloads.json`: re-add tracks from manifest after Navidrome rescan
|
||||||
|
- `--add-downloaded-force`: retry entries already marked added
|
||||||
|
|
||||||
|
Liked-only run (no explicit playlist URLs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./navimigrate \
|
||||||
|
--spotify-client-id "<spotify-client-id>" \
|
||||||
|
--navidrome-url "https://music.lofitrek.com" \
|
||||||
|
--navidrome-username "test_user" \
|
||||||
|
--navidrome-password "test_user" \
|
||||||
|
--liked
|
||||||
|
```
|
||||||
|
|
||||||
### Navidrome self-test
|
### Navidrome self-test
|
||||||
|
|
||||||
@@ -64,18 +90,62 @@ Write-path self-test (creates playlist, adds one track, then deletes playlist):
|
|||||||
--navidrome-self-test-write
|
--navidrome-self-test-write
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Missing-track recovery flow
|
||||||
|
|
||||||
|
Step 1: transfer and download unmatched albums from Qobuz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./navimigrate \
|
||||||
|
--spotify-client-id "<spotify-client-id>" \
|
||||||
|
--navidrome-url "https://music.lofitrek.com" \
|
||||||
|
--navidrome-username "test_user" \
|
||||||
|
--navidrome-password "test_user" \
|
||||||
|
--playlist-url "https://open.spotify.com/playlist/..." \
|
||||||
|
--qobuz-download-missing \
|
||||||
|
--qobuz-username "<qobuz-email>" \
|
||||||
|
--qobuz-password "<qobuz-password>" \
|
||||||
|
--qobuz-dl-path "/home/joren/dev/qobuz-dl" \
|
||||||
|
--qobuz-output "/path/to/downloads"
|
||||||
|
```
|
||||||
|
|
||||||
|
Step 2: after Navidrome rescans your library, re-add from manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./navimigrate \
|
||||||
|
--navidrome-url "https://music.lofitrek.com" \
|
||||||
|
--navidrome-username "test_user" \
|
||||||
|
--navidrome-password "test_user" \
|
||||||
|
--add-downloaded-manifest "missing-downloads.json"
|
||||||
|
```
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
- `SPOTIFY_CLIENT_ID`
|
- `SPOTIFY_CLIENT_ID`
|
||||||
- `SPOTIFY_REDIRECT_URI` (optional)
|
- `SPOTIFY_REDIRECT_URI` (optional)
|
||||||
- `SPOTIFY_SCOPES` (optional)
|
- `SPOTIFY_SCOPES` (optional)
|
||||||
- `NAVIMIGRATE_SPOTIFY_MANUAL_CODE` (optional, defaults to true)
|
- `NAVIMIGRATE_SPOTIFY_MANUAL_CODE` (optional, defaults to true)
|
||||||
|
- `NAVIMIGRATE_REMEMBER_SPOTIFY` (optional, defaults to true)
|
||||||
|
- `NAVIMIGRATE_SESSION_FILE` (optional)
|
||||||
|
- `NAVIMIGRATE_INCLUDE_LIKED` (optional)
|
||||||
|
- `NAVIMIGRATE_LIKED_NAME` (optional)
|
||||||
- `NAVIDROME_URL`
|
- `NAVIDROME_URL`
|
||||||
- `NAVIDROME_USERNAME`
|
- `NAVIDROME_USERNAME`
|
||||||
- `NAVIDROME_PASSWORD`
|
- `NAVIDROME_PASSWORD`
|
||||||
- `NAVIMIGRATE_DRY_RUN` (optional)
|
- `NAVIMIGRATE_DRY_RUN` (optional)
|
||||||
- `NAVIMIGRATE_CONCURRENCY` (optional)
|
- `NAVIMIGRATE_CONCURRENCY` (optional)
|
||||||
|
- `NAVIMIGRATE_MATCH_THRESHOLD` (optional)
|
||||||
- `NAVIMIGRATE_REPORT` (optional)
|
- `NAVIMIGRATE_REPORT` (optional)
|
||||||
|
- `NAVIMIGRATE_QOBUZ_DOWNLOAD_MISSING` (optional)
|
||||||
|
- `NAVIMIGRATE_QOBUZ_MANIFEST` (optional)
|
||||||
|
- `NAVIMIGRATE_QOBUZ_DL_PATH` (optional)
|
||||||
|
- `NAVIMIGRATE_QOBUZ_OUTPUT` (optional)
|
||||||
|
- `NAVIMIGRATE_QOBUZ_QUALITY` (optional)
|
||||||
|
- `QOBUZ_USERNAME` (optional, required when qobuz download mode enabled)
|
||||||
|
- `QOBUZ_PASSWORD` (optional, required when qobuz download mode enabled)
|
||||||
|
- `QOBUZ_APP_ID` (optional)
|
||||||
|
- `QOBUZ_APP_SECRET` (optional)
|
||||||
|
- `NAVIMIGRATE_ADD_DOWNLOADED_MANIFEST` (optional)
|
||||||
|
- `NAVIMIGRATE_ADD_DOWNLOADED_FORCE` (optional)
|
||||||
- `NAVIMIGRATE_NAVIDROME_SELF_TEST` (optional)
|
- `NAVIMIGRATE_NAVIDROME_SELF_TEST` (optional)
|
||||||
- `NAVIMIGRATE_NAVIDROME_SELF_TEST_WRITE` (optional)
|
- `NAVIMIGRATE_NAVIDROME_SELF_TEST_WRITE` (optional)
|
||||||
- `NAVIMIGRATE_NAVIDROME_SELF_TEST_QUERY` (optional)
|
- `NAVIMIGRATE_NAVIDROME_SELF_TEST_QUERY` (optional)
|
||||||
|
|||||||
304
internal/config/config.go
Normal file
304
internal/config/config.go
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
SpotifyClientID string
|
||||||
|
SpotifyRedirect string
|
||||||
|
SpotifyScopes []string
|
||||||
|
SpotifyManual bool
|
||||||
|
IncludeLiked bool
|
||||||
|
LikedPlaylist string
|
||||||
|
RememberSpotify bool
|
||||||
|
SessionFile string
|
||||||
|
AddDownloaded string
|
||||||
|
AddDownloadedForce bool
|
||||||
|
|
||||||
|
NavidromeURL string
|
||||||
|
NavidromeUsername string
|
||||||
|
NavidromePassword string
|
||||||
|
|
||||||
|
NavidromeSelfTest bool
|
||||||
|
NavidromeSelfTestWrite bool
|
||||||
|
NavidromeSelfTestQuery string
|
||||||
|
|
||||||
|
DryRun bool
|
||||||
|
ReportPath string
|
||||||
|
Concurrency int
|
||||||
|
MatchThreshold float64
|
||||||
|
PlaylistURLs []string
|
||||||
|
|
||||||
|
QobuzDownloadMissing bool
|
||||||
|
QobuzManifestPath string
|
||||||
|
QobuzDLPath string
|
||||||
|
QobuzOutputPath string
|
||||||
|
QobuzUsername string
|
||||||
|
QobuzPassword string
|
||||||
|
QobuzAppID string
|
||||||
|
QobuzAppSecret string
|
||||||
|
QobuzQuality int
|
||||||
|
}
|
||||||
|
|
||||||
|
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 URL cannot be empty")
|
||||||
|
}
|
||||||
|
*m = append(*m, v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
var playlistURLs multiFlag
|
||||||
|
|
||||||
|
defaultScopes := "playlist-read-private,playlist-read-collaborative,user-library-read"
|
||||||
|
defaultConcurrency := envIntOr("NAVIMIGRATE_CONCURRENCY", 4)
|
||||||
|
const defaultQobuzAppID = "312369995"
|
||||||
|
const defaultQobuzAppSecret = "e79f8b9be485692b0e5f9dd895826368"
|
||||||
|
|
||||||
|
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("NAVIMIGRATE_SPOTIFY_MANUAL_CODE", true), "Enter Spotify callback code/URL manually instead of running local callback server")
|
||||||
|
flag.BoolVar(&cfg.IncludeLiked, "liked", envBoolOr("NAVIMIGRATE_INCLUDE_LIKED", false), "Include Spotify liked songs")
|
||||||
|
flag.StringVar(&cfg.LikedPlaylist, "liked-playlist-name", envOr("NAVIMIGRATE_LIKED_NAME", "Spotify Liked Songs"), "Name of the generated liked-songs playlist on Navidrome")
|
||||||
|
flag.BoolVar(&cfg.RememberSpotify, "remember-spotify", envBoolOr("NAVIMIGRATE_REMEMBER_SPOTIFY", true), "Persist Spotify refresh token and reuse session")
|
||||||
|
flag.StringVar(&cfg.SessionFile, "session-file", envOr("NAVIMIGRATE_SESSION_FILE", "~/.config/navimigrate/session.json"), "Path to saved session file")
|
||||||
|
flag.StringVar(&cfg.AddDownloaded, "add-downloaded-manifest", envOr("NAVIMIGRATE_ADD_DOWNLOADED_MANIFEST", ""), "Re-match and add tracks from previously generated missing-download manifest")
|
||||||
|
flag.BoolVar(&cfg.AddDownloadedForce, "add-downloaded-force", envBoolOr("NAVIMIGRATE_ADD_DOWNLOADED_FORCE", false), "Force re-adding entries already marked as added in manifest")
|
||||||
|
|
||||||
|
flag.StringVar(&cfg.NavidromeURL, "navidrome-url", envOr("NAVIDROME_URL", ""), "Navidrome base URL (example: https://music.example.com)")
|
||||||
|
flag.StringVar(&cfg.NavidromeUsername, "navidrome-username", envOr("NAVIDROME_USERNAME", ""), "Navidrome username")
|
||||||
|
flag.StringVar(&cfg.NavidromePassword, "navidrome-password", envOr("NAVIDROME_PASSWORD", ""), "Navidrome password")
|
||||||
|
|
||||||
|
flag.BoolVar(&cfg.NavidromeSelfTest, "navidrome-self-test", envBoolOr("NAVIMIGRATE_NAVIDROME_SELF_TEST", false), "Run Navidrome ping/search checks and exit (skips Spotify)")
|
||||||
|
flag.BoolVar(&cfg.NavidromeSelfTestWrite, "navidrome-self-test-write", envBoolOr("NAVIMIGRATE_NAVIDROME_SELF_TEST_WRITE", false), "When --navidrome-self-test is set, also create a test playlist and add one track")
|
||||||
|
flag.StringVar(&cfg.NavidromeSelfTestQuery, "navidrome-self-test-query", envOr("NAVIMIGRATE_NAVIDROME_SELF_TEST_QUERY", "Daft Punk One More Time"), "Search query used for --navidrome-self-test")
|
||||||
|
|
||||||
|
flag.BoolVar(&cfg.DryRun, "dry-run", envBoolOr("NAVIMIGRATE_DRY_RUN", false), "Resolve matches only, do not create or mutate Navidrome playlists")
|
||||||
|
flag.StringVar(&cfg.ReportPath, "report", envOr("NAVIMIGRATE_REPORT", "transfer-report.json"), "Report output path")
|
||||||
|
flag.IntVar(&cfg.Concurrency, "concurrency", defaultConcurrency, "Concurrent track matching workers")
|
||||||
|
flag.Float64Var(&cfg.MatchThreshold, "match-threshold", envFloatOr("NAVIMIGRATE_MATCH_THRESHOLD", 45), "Minimum score required for a track match")
|
||||||
|
flag.Var(&playlistURLs, "playlist-url", "Spotify playlist URL/URI/ID to transfer (repeatable)")
|
||||||
|
|
||||||
|
flag.BoolVar(&cfg.QobuzDownloadMissing, "qobuz-download-missing", envBoolOr("NAVIMIGRATE_QOBUZ_DOWNLOAD_MISSING", false), "For unmatched tracks, search Qobuz and download their albums via qobuz-dl")
|
||||||
|
flag.StringVar(&cfg.QobuzManifestPath, "qobuz-manifest", envOr("NAVIMIGRATE_QOBUZ_MANIFEST", "missing-downloads.json"), "Path to missing-download manifest JSON")
|
||||||
|
flag.StringVar(&cfg.QobuzDLPath, "qobuz-dl-path", envOr("NAVIMIGRATE_QOBUZ_DL_PATH", "qobuz-dl"), "Path to qobuz-dl binary, or qobuz-dl project directory (run via go run .)")
|
||||||
|
flag.StringVar(&cfg.QobuzOutputPath, "qobuz-output", envOr("NAVIMIGRATE_QOBUZ_OUTPUT", ""), "Output directory used by qobuz-dl downloads")
|
||||||
|
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", envOr("QOBUZ_APP_ID", defaultQobuzAppID), "Qobuz app ID")
|
||||||
|
flag.StringVar(&cfg.QobuzAppSecret, "qobuz-app-secret", envOr("QOBUZ_APP_SECRET", defaultQobuzAppSecret), "Qobuz app secret")
|
||||||
|
flag.IntVar(&cfg.QobuzQuality, "qobuz-quality", envIntOr("NAVIMIGRATE_QOBUZ_QUALITY", 6), "Quality passed to qobuz-dl (5=MP3,6=CD,7=HiRes96,27=HiRes192)")
|
||||||
|
|
||||||
|
if err := flag.CommandLine.Parse(preprocessArgs(os.Args[1:])); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 strings.TrimSpace(c.NavidromeURL) == "" {
|
||||||
|
return fmt.Errorf("navidrome URL is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.NavidromeUsername) == "" || strings.TrimSpace(c.NavidromePassword) == "" {
|
||||||
|
return fmt.Errorf("navidrome username/password are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.NavidromeSelfTest {
|
||||||
|
if strings.TrimSpace(c.NavidromeSelfTestQuery) == "" {
|
||||||
|
return fmt.Errorf("navidrome self-test query cannot be empty")
|
||||||
|
}
|
||||||
|
if c.Concurrency < 1 {
|
||||||
|
return fmt.Errorf("concurrency must be >= 1")
|
||||||
|
}
|
||||||
|
if c.MatchThreshold < 0 {
|
||||||
|
return fmt.Errorf("match-threshold must be >= 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(c.AddDownloaded) != "" {
|
||||||
|
if c.Concurrency < 1 {
|
||||||
|
return fmt.Errorf("concurrency must be >= 1")
|
||||||
|
}
|
||||||
|
if c.MatchThreshold < 0 {
|
||||||
|
return fmt.Errorf("match-threshold must be >= 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(c.SpotifyClientID) == "" {
|
||||||
|
return fmt.Errorf("spotify client id is required")
|
||||||
|
}
|
||||||
|
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 len(c.PlaylistURLs) == 0 && !c.IncludeLiked {
|
||||||
|
return fmt.Errorf("at least one --playlist-url or --liked is required")
|
||||||
|
}
|
||||||
|
if c.IncludeLiked && strings.TrimSpace(c.LikedPlaylist) == "" {
|
||||||
|
return fmt.Errorf("liked-playlist-name cannot be empty when --liked is set")
|
||||||
|
}
|
||||||
|
if c.IncludeLiked && !containsScope(c.SpotifyScopes, "user-library-read") {
|
||||||
|
return fmt.Errorf("spotify scope user-library-read is required when --liked is set")
|
||||||
|
}
|
||||||
|
if c.RememberSpotify && strings.TrimSpace(c.SessionFile) == "" {
|
||||||
|
return fmt.Errorf("session-file cannot be empty when remember-spotify is enabled")
|
||||||
|
}
|
||||||
|
if c.Concurrency < 1 {
|
||||||
|
return fmt.Errorf("concurrency must be >= 1")
|
||||||
|
}
|
||||||
|
if c.MatchThreshold < 0 {
|
||||||
|
return fmt.Errorf("match-threshold must be >= 0")
|
||||||
|
}
|
||||||
|
if c.QobuzDownloadMissing {
|
||||||
|
if strings.TrimSpace(c.QobuzManifestPath) == "" {
|
||||||
|
return fmt.Errorf("qobuz-manifest cannot be empty when qobuz-download-missing is enabled")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.QobuzOutputPath) == "" {
|
||||||
|
return fmt.Errorf("qobuz-output is required when qobuz-download-missing is enabled")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.QobuzUsername) == "" || strings.TrimSpace(c.QobuzPassword) == "" {
|
||||||
|
return fmt.Errorf("qobuz username/password are required when qobuz-download-missing is enabled")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.QobuzAppID) == "" || strings.TrimSpace(c.QobuzAppSecret) == "" {
|
||||||
|
return fmt.Errorf("qobuz app id/secret are required when qobuz-download-missing is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 containsScope(scopes []string, wanted string) bool {
|
||||||
|
wanted = strings.ToLower(strings.TrimSpace(wanted))
|
||||||
|
for _, s := range scopes {
|
||||||
|
if strings.ToLower(strings.TrimSpace(s)) == wanted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type boolFlag interface {
|
||||||
|
IsBoolFlag() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func preprocessArgs(args []string) []string {
|
||||||
|
boolFlagNames := make(map[string]bool)
|
||||||
|
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
||||||
|
if bf, ok := f.Value.(boolFlag); ok && bf.IsBoolFlag() {
|
||||||
|
boolFlagNames[f.Name] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
var flagArgs []string
|
||||||
|
var positional []string
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(args) {
|
||||||
|
arg := args[i]
|
||||||
|
if !strings.HasPrefix(arg, "-") {
|
||||||
|
positional = append(positional, arg)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
flagArgs = append(flagArgs, arg)
|
||||||
|
i++
|
||||||
|
|
||||||
|
name := strings.TrimLeft(arg, "-")
|
||||||
|
if idx := strings.Index(name, "="); idx >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if boolFlagNames[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i < len(args) && !strings.HasPrefix(args[i], "-") {
|
||||||
|
flagArgs = append(flagArgs, args[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(flagArgs, positional...)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 envFloatOr(key string, fallback float64) float64 {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
358
internal/match/matcher.go
Normal file
358
internal/match/matcher.go
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
package match
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"navimigrate/internal/model"
|
||||||
|
"navimigrate/internal/navidrome"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Searcher interface {
|
||||||
|
SearchTracks(ctx context.Context, query string, limit int) ([]navidrome.Track, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Matcher struct {
|
||||||
|
searcher Searcher
|
||||||
|
threshold float64
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
cache map[string][]navidrome.Track
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMatcher(searcher Searcher, threshold float64) *Matcher {
|
||||||
|
if threshold < 0 {
|
||||||
|
threshold = 45
|
||||||
|
}
|
||||||
|
return &Matcher{
|
||||||
|
searcher: searcher,
|
||||||
|
threshold: threshold,
|
||||||
|
cache: map[string][]navidrome.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 navidrome.Track
|
||||||
|
score float64
|
||||||
|
query string
|
||||||
|
}
|
||||||
|
|
||||||
|
best := scored{score: -999}
|
||||||
|
seen := map[string]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 == "" {
|
||||||
|
return model.MatchedTrack{Source: src, Matched: false, Reason: "no candidates"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best.score >= m.threshold {
|
||||||
|
return model.MatchedTrack{
|
||||||
|
Source: src,
|
||||||
|
TargetID: best.track.ID,
|
||||||
|
Score: best.score,
|
||||||
|
Query: best.query,
|
||||||
|
Matched: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := fmt.Sprintf("best score %.1f below threshold %.1f", best.score, m.threshold)
|
||||||
|
return model.MatchedTrack{
|
||||||
|
Source: src,
|
||||||
|
TargetID: best.track.ID,
|
||||||
|
Score: best.score,
|
||||||
|
Query: best.query,
|
||||||
|
Matched: false,
|
||||||
|
Reason: reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Matcher) searchCached(ctx context.Context, q string) ([]navidrome.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 navidrome.Track) float64 {
|
||||||
|
score := 0.0
|
||||||
|
|
||||||
|
if src.ISRC != "" && hasISRC(dst.ISRCs, src.ISRC) {
|
||||||
|
score += 60
|
||||||
|
}
|
||||||
|
|
||||||
|
score += 25 * similarity(normalize(src.Title), normalize(dst.Title))
|
||||||
|
|
||||||
|
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(dst.Title)
|
||||||
|
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 hasISRC(candidates []string, wanted string) bool {
|
||||||
|
wanted = strings.ToUpper(strings.TrimSpace(wanted))
|
||||||
|
if wanted == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(c), wanted) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
64
internal/match/matcher_test.go
Normal file
64
internal/match/matcher_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package match
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"navimigrate/internal/model"
|
||||||
|
"navimigrate/internal/navidrome"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeSearcher struct {
|
||||||
|
tracks []navidrome.Track
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeSearcher) SearchTracks(context.Context, string, int) ([]navidrome.Track, error) {
|
||||||
|
return f.tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTransliteratesCyrillic(t *testing.T) {
|
||||||
|
got := normalize("детство")
|
||||||
|
if got != "detstvo" {
|
||||||
|
t.Fatalf("expected detstvo, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildQueriesIncludesLatinVariant(t *testing.T) {
|
||||||
|
m := NewMatcher(fakeSearcher{}, 45)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchThresholdIsConfigurable(t *testing.T) {
|
||||||
|
src := model.Track{
|
||||||
|
Title: "One More Time",
|
||||||
|
Artists: []string{"Daft Punk"},
|
||||||
|
DurationMS: 317000,
|
||||||
|
}
|
||||||
|
candidate := navidrome.Track{
|
||||||
|
ID: "track-1",
|
||||||
|
Title: "One More Time",
|
||||||
|
Artist: "Daft Punk",
|
||||||
|
Duration: 317,
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewMatcher(fakeSearcher{tracks: []navidrome.Track{candidate}}, 100)
|
||||||
|
res := m.MatchTrack(context.Background(), src)
|
||||||
|
if res.Matched {
|
||||||
|
t.Fatalf("expected no match with high threshold, score=%.1f", res.Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
m = NewMatcher(fakeSearcher{tracks: []navidrome.Track{candidate}}, 0)
|
||||||
|
res = m.MatchTrack(context.Background(), src)
|
||||||
|
if !res.Matched {
|
||||||
|
t.Fatalf("expected match with low threshold, score=%.1f", res.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/model/model.go
Normal file
44
internal/model/model.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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 MatchedTrack struct {
|
||||||
|
Source Track `json:"source"`
|
||||||
|
TargetID string `json:"target_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 string `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"`
|
||||||
|
}
|
||||||
381
internal/navidrome/client.go
Normal file
381
internal/navidrome/client.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
package navidrome
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const apiVersion = "1.16.1"
|
||||||
|
const defaultClientName = "navimigrate"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
clientName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Track struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
Duration int
|
||||||
|
ISRCs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type subsonicError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type isrcField []string
|
||||||
|
|
||||||
|
func (f *isrcField) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" {
|
||||||
|
*f = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var one string
|
||||||
|
if err := json.Unmarshal(data, &one); err == nil {
|
||||||
|
*f = splitISRC(one)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var many []string
|
||||||
|
if err := json.Unmarshal(data, &many); err == nil {
|
||||||
|
all := make([]string, 0, len(many))
|
||||||
|
for _, part := range many {
|
||||||
|
all = append(all, splitISRC(part)...)
|
||||||
|
}
|
||||||
|
*f = uniqueStrings(all)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("invalid isrc field")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL, username, password string) *Client {
|
||||||
|
baseURL = strings.TrimSpace(baseURL)
|
||||||
|
baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
baseURL: baseURL,
|
||||||
|
username: strings.TrimSpace(username),
|
||||||
|
password: password,
|
||||||
|
clientName: defaultClientName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Ping(ctx context.Context) error {
|
||||||
|
var out struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error *subsonicError `json:"error"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
if err := c.call(ctx, http.MethodGet, "/rest/ping.view", url.Values{}, &out); err != nil {
|
||||||
|
return 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", strings.TrimSpace(query))
|
||||||
|
params.Set("songCount", fmt.Sprintf("%d", limit))
|
||||||
|
params.Set("songOffset", "0")
|
||||||
|
params.Set("artistCount", "0")
|
||||||
|
params.Set("albumCount", "0")
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error *subsonicError `json:"error"`
|
||||||
|
SearchResult3 struct {
|
||||||
|
Song []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
ISRC isrcField `json:"isrc"`
|
||||||
|
} `json:"song"`
|
||||||
|
} `json:"searchResult3"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.call(ctx, http.MethodGet, "/rest/search3.view", params, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make([]Track, 0, len(out.SubsonicResponse.SearchResult3.Song))
|
||||||
|
for _, s := range out.SubsonicResponse.SearchResult3.Song {
|
||||||
|
if s.IsDir || strings.TrimSpace(s.ID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res = append(res, Track{
|
||||||
|
ID: s.ID,
|
||||||
|
Title: s.Title,
|
||||||
|
Artist: s.Artist,
|
||||||
|
Album: s.Album,
|
||||||
|
Duration: s.Duration,
|
||||||
|
ISRCs: []string(s.ISRC),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreatePlaylist(ctx context.Context, name string) (string, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("name", strings.TrimSpace(name))
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error *subsonicError `json:"error"`
|
||||||
|
Playlist struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"playlist"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.call(ctx, http.MethodGet, "/rest/createPlaylist.view", params, &out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(out.SubsonicResponse.Playlist.ID)
|
||||||
|
if id == "" {
|
||||||
|
return "", fmt.Errorf("createPlaylist returned empty playlist ID")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error {
|
||||||
|
if len(trackIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
playlistID = strings.TrimSpace(playlistID)
|
||||||
|
if playlistID == "" {
|
||||||
|
return fmt.Errorf("playlist ID cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := chunk(trackIDs, 200)
|
||||||
|
for _, ch := range chunks {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("playlistId", playlistID)
|
||||||
|
for _, id := range ch {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
params.Add("songIdToAdd", id)
|
||||||
|
}
|
||||||
|
if len(params["songIdToAdd"]) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error *subsonicError `json:"error"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
if err := c.call(ctx, http.MethodPost, "/rest/updatePlaylist.view", params, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeletePlaylist(ctx context.Context, playlistID string) error {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("id", strings.TrimSpace(playlistID))
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error *subsonicError `json:"error"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
if err := c.call(ctx, http.MethodGet, "/rest/deletePlaylist.view", params, &out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) call(ctx context.Context, method, endpoint string, params url.Values, out any) error {
|
||||||
|
params = cloneValues(params)
|
||||||
|
c.addAuth(params)
|
||||||
|
|
||||||
|
var req *http.Request
|
||||||
|
var err error
|
||||||
|
|
||||||
|
fullURL := c.baseURL + endpoint
|
||||||
|
if method == http.MethodPost {
|
||||||
|
req, err = http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(params.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
} else {
|
||||||
|
if len(params) > 0 {
|
||||||
|
fullURL += "?" + params.Encode()
|
||||||
|
}
|
||||||
|
req, err = http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("navidrome api error (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(b, out); err != nil {
|
||||||
|
return fmt.Errorf("decode navidrome response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
failed, serr, err := parseSubsonicStatus(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
if serr != nil {
|
||||||
|
return fmt.Errorf("navidrome api failed (%d): %s", serr.Code, serr.Message)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("navidrome api failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSubsonicStatus(body []byte) (bool, *subsonicError, error) {
|
||||||
|
var root struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error *subsonicError `json:"error"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &root); err != nil {
|
||||||
|
return false, nil, fmt.Errorf("decode subsonic envelope: %w", err)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(root.SubsonicResponse.Status), "ok") {
|
||||||
|
return false, root.SubsonicResponse.Error, nil
|
||||||
|
}
|
||||||
|
return true, root.SubsonicResponse.Error, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) addAuth(params url.Values) {
|
||||||
|
salt := randomSalt(12)
|
||||||
|
params.Set("u", c.username)
|
||||||
|
params.Set("s", salt)
|
||||||
|
params.Set("t", md5Hex(c.password+salt))
|
||||||
|
params.Set("v", apiVersion)
|
||||||
|
params.Set("c", c.clientName)
|
||||||
|
params.Set("f", "json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomSalt(n int) string {
|
||||||
|
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
if n <= 0 {
|
||||||
|
n = 12
|
||||||
|
}
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = letters[rand.Intn(len(letters))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func md5Hex(s string) string {
|
||||||
|
h := md5.Sum([]byte(s))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitISRC(v string) []string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(v, ";")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.ToUpper(strings.TrimSpace(p))
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uniqueStrings(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(in []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
for _, v := range in {
|
||||||
|
if _, ok := seen[v]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v] = struct{}{}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunk(ids []string, size int) [][]string {
|
||||||
|
if size <= 0 {
|
||||||
|
size = 200
|
||||||
|
}
|
||||||
|
out := make([][]string, 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 init() {
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
}
|
||||||
341
internal/qobuz/client.go
Normal file
341
internal/qobuz/client.go
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
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 string
|
||||||
|
Title string
|
||||||
|
Version string
|
||||||
|
Duration int
|
||||||
|
ISRC string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
AlbumID string
|
||||||
|
AlbumArtist string
|
||||||
|
}
|
||||||
|
|
||||||
|
type flexString string
|
||||||
|
|
||||||
|
func (f *flexString) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" {
|
||||||
|
*f = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err == nil {
|
||||||
|
*f = flexString(strings.TrimSpace(s))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var n json.Number
|
||||||
|
if err := json.Unmarshal(data, &n); err == nil {
|
||||||
|
*f = flexString(strings.TrimSpace(n.String()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("invalid flexible id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(appID, appSecret string) *Client {
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
appID: appID,
|
||||||
|
appSecret: appSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 flexString `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 {
|
||||||
|
ID flexString `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artist"`
|
||||||
|
} `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 {
|
||||||
|
trackID := strings.TrimSpace(string(it.ID))
|
||||||
|
albumID := strings.TrimSpace(string(it.Album.ID))
|
||||||
|
if trackID == "" || albumID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res = append(res, Track{
|
||||||
|
ID: trackID,
|
||||||
|
Title: it.Title,
|
||||||
|
Version: it.Version,
|
||||||
|
Duration: it.Duration,
|
||||||
|
ISRC: strings.ToUpper(strings.TrimSpace(it.ISRC)),
|
||||||
|
Artist: it.Performer.Name,
|
||||||
|
Album: it.Album.Title,
|
||||||
|
AlbumID: albumID,
|
||||||
|
AlbumArtist: it.Album.Artist.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return res, 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[:])
|
||||||
|
}
|
||||||
23
internal/qobuz/client_test.go
Normal file
23
internal/qobuz/client_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package qobuz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFlexStringUnmarshal(t *testing.T) {
|
||||||
|
var s flexString
|
||||||
|
if err := json.Unmarshal([]byte(`"0724384960650"`), &s); err != nil {
|
||||||
|
t.Fatalf("unmarshal string id failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(s) != "0724384960650" {
|
||||||
|
t.Fatalf("unexpected value %q", string(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(`12345`), &s); err != nil {
|
||||||
|
t.Fatalf("unmarshal numeric id failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(s) != "12345" {
|
||||||
|
t.Fatalf("unexpected numeric value %q", string(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
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:])
|
||||||
|
}
|
||||||
81
internal/recovery/manifest.go
Normal file
81
internal/recovery/manifest.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"navimigrate/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ManifestVersion = 1
|
||||||
|
|
||||||
|
type Manifest struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
DownloadDir string `json:"download_dir,omitempty"`
|
||||||
|
QobuzDLPath string `json:"qobuz_dl_path,omitempty"`
|
||||||
|
Playlists []PlaylistManifest `json:"playlists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaylistManifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
TargetID string `json:"target_id,omitempty"`
|
||||||
|
Tracks []TrackManifest `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackManifest struct {
|
||||||
|
Source model.Track `json:"source"`
|
||||||
|
LookupError string `json:"lookup_error,omitempty"`
|
||||||
|
|
||||||
|
QobuzQuery string `json:"qobuz_query,omitempty"`
|
||||||
|
QobuzScore float64 `json:"qobuz_score,omitempty"`
|
||||||
|
QobuzTrackID string `json:"qobuz_track_id,omitempty"`
|
||||||
|
QobuzAlbumID string `json:"qobuz_album_id,omitempty"`
|
||||||
|
QobuzAlbumTitle string `json:"qobuz_album_title,omitempty"`
|
||||||
|
QobuzAlbumArtist string `json:"qobuz_album_artist,omitempty"`
|
||||||
|
|
||||||
|
DownloadAttempted bool `json:"download_attempted,omitempty"`
|
||||||
|
Downloaded bool `json:"downloaded,omitempty"`
|
||||||
|
DownloadError string `json:"download_error,omitempty"`
|
||||||
|
|
||||||
|
RematchAttempted bool `json:"rematch_attempted,omitempty"`
|
||||||
|
Rematched bool `json:"rematched,omitempty"`
|
||||||
|
RematchTrackID string `json:"rematch_track_id,omitempty"`
|
||||||
|
RematchReason string `json:"rematch_reason,omitempty"`
|
||||||
|
|
||||||
|
AddAttempted bool `json:"add_attempted,omitempty"`
|
||||||
|
Added bool `json:"added,omitempty"`
|
||||||
|
AddError string `json:"add_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(path string, m Manifest) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create manifest file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
enc := json.NewEncoder(f)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(m); err != nil {
|
||||||
|
return fmt.Errorf("encode manifest: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (Manifest, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("read manifest file: %w", err)
|
||||||
|
}
|
||||||
|
var m Manifest
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("decode manifest: %w", err)
|
||||||
|
}
|
||||||
|
if m.Version == 0 {
|
||||||
|
m.Version = ManifestVersion
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
112
internal/recovery/qobuzdl.go
Normal file
112
internal/recovery/qobuzdl.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QobuzDL struct {
|
||||||
|
Path string
|
||||||
|
OutputDir string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Quality int
|
||||||
|
}
|
||||||
|
|
||||||
|
var cachedBinaries sync.Map
|
||||||
|
|
||||||
|
func (d QobuzDL) DownloadAlbum(ctx context.Context, albumID string) error {
|
||||||
|
albumID = strings.TrimSpace(albumID)
|
||||||
|
if albumID == "" {
|
||||||
|
return fmt.Errorf("album id must not be empty")
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("https://play.qobuz.com/album/%s", albumID)
|
||||||
|
|
||||||
|
args := []string{}
|
||||||
|
if strings.TrimSpace(d.OutputDir) != "" {
|
||||||
|
args = append(args, "--output", d.OutputDir)
|
||||||
|
}
|
||||||
|
if d.Quality > 0 {
|
||||||
|
args = append(args, "--quality", strconv.Itoa(d.Quality))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.Username) != "" {
|
||||||
|
args = append(args, "--user", strings.TrimSpace(d.Username))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.Password) != "" {
|
||||||
|
args = append(args, "--pass", d.Password)
|
||||||
|
}
|
||||||
|
args = append(args, "url", url)
|
||||||
|
|
||||||
|
name, cmdArgs, workdir, err := resolveCommand(ctx, strings.TrimSpace(d.Path), args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, name, cmdArgs...)
|
||||||
|
if workdir != "" {
|
||||||
|
cmd.Dir = workdir
|
||||||
|
}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("qobuz-dl failed: %w: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCommand(ctx context.Context, path string, args []string) (name string, cmdArgs []string, workdir string, err error) {
|
||||||
|
if path == "" {
|
||||||
|
return "qobuz-dl", args, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
bin, err := ensureBuiltBinary(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, "", err
|
||||||
|
}
|
||||||
|
return bin, args, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.Base(path) == "." {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
bin, err := ensureBuiltBinary(ctx, dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, "", err
|
||||||
|
}
|
||||||
|
return bin, args, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, args, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureBuiltBinary(ctx context.Context, dir string) (string, error) {
|
||||||
|
dir = filepath.Clean(dir)
|
||||||
|
if v, ok := cachedBinaries.Load(dir); ok {
|
||||||
|
if bin, ok := v.(string); ok && strings.TrimSpace(bin) != "" {
|
||||||
|
if _, err := os.Stat(bin); err == nil {
|
||||||
|
return bin, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := filepath.Join(os.TempDir(), "navimigrate-qobuzdl")
|
||||||
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create temp build directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := filepath.Join(tmpDir, "qobuz-dl")
|
||||||
|
build := exec.CommandContext(ctx, "go", "build", "-o", out, ".")
|
||||||
|
build.Dir = dir
|
||||||
|
b, err := build.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("build qobuz-dl in %s: %w: %s", dir, err, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedBinaries.Store(dir, out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
438
internal/recovery/recovery.go
Normal file
438
internal/recovery/recovery.go
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"navimigrate/internal/model"
|
||||||
|
"navimigrate/internal/qobuz"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Searcher interface {
|
||||||
|
SearchTracks(ctx context.Context, query string, limit int) ([]qobuz.Track, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackMatcher interface {
|
||||||
|
MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaylistAdder interface {
|
||||||
|
AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressFunc func(message string)
|
||||||
|
|
||||||
|
type BuildOptions struct {
|
||||||
|
DownloadMissing bool
|
||||||
|
Downloader QobuzDL
|
||||||
|
Progress ProgressFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReaddOptions struct {
|
||||||
|
Force bool
|
||||||
|
Progress ProgressFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReaddSummary struct {
|
||||||
|
Playlists int
|
||||||
|
Candidates int
|
||||||
|
Matched int
|
||||||
|
Added int
|
||||||
|
Errors int
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildManifestFromReport(ctx context.Context, rep model.TransferReport, searcher Searcher, opts BuildOptions) (Manifest, error) {
|
||||||
|
manifest := Manifest{
|
||||||
|
Version: ManifestVersion,
|
||||||
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
DownloadDir: opts.Downloader.OutputDir,
|
||||||
|
QobuzDLPath: opts.Downloader.Path,
|
||||||
|
Playlists: make([]PlaylistManifest, 0, len(rep.Results)),
|
||||||
|
}
|
||||||
|
|
||||||
|
type albumJob struct {
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
albums := map[string]albumJob{}
|
||||||
|
totalUnmatched := 0
|
||||||
|
for _, pl := range rep.Results {
|
||||||
|
totalUnmatched += len(pl.Unmatched)
|
||||||
|
}
|
||||||
|
processed := 0
|
||||||
|
|
||||||
|
for _, pl := range rep.Results {
|
||||||
|
if len(pl.Unmatched) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
playlistEntry := PlaylistManifest{
|
||||||
|
Name: pl.Name,
|
||||||
|
TargetID: pl.TargetID,
|
||||||
|
Tracks: make([]TrackManifest, 0, len(pl.Unmatched)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, um := range pl.Unmatched {
|
||||||
|
processed++
|
||||||
|
notify(opts.Progress, fmt.Sprintf("Qobuz lookup %d/%d: %s - %s", processed, totalUnmatched, short(pl.Name, 36), short(um.Source.Title, 44)))
|
||||||
|
entry := TrackManifest{Source: um.Source}
|
||||||
|
cand, ok, err := findQobuzCandidate(ctx, searcher, um.Source)
|
||||||
|
if err != nil {
|
||||||
|
entry.LookupError = err.Error()
|
||||||
|
} else if ok {
|
||||||
|
entry.QobuzQuery = cand.Query
|
||||||
|
entry.QobuzScore = cand.Score
|
||||||
|
entry.QobuzTrackID = cand.TrackID
|
||||||
|
entry.QobuzAlbumID = cand.AlbumID
|
||||||
|
entry.QobuzAlbumTitle = cand.AlbumTitle
|
||||||
|
entry.QobuzAlbumArtist = cand.AlbumArtist
|
||||||
|
if strings.TrimSpace(cand.AlbumID) != "" {
|
||||||
|
albums[cand.AlbumID] = albumJob{ID: cand.AlbumID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playlistEntry.Tracks = append(playlistEntry.Tracks, entry)
|
||||||
|
}
|
||||||
|
manifest.Playlists = append(manifest.Playlists, playlistEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DownloadMissing {
|
||||||
|
albumIDs := make([]string, 0, len(albums))
|
||||||
|
for id := range albums {
|
||||||
|
albumIDs = append(albumIDs, id)
|
||||||
|
}
|
||||||
|
sort.Strings(albumIDs)
|
||||||
|
|
||||||
|
downloadState := map[string]error{}
|
||||||
|
for i, albumID := range albumIDs {
|
||||||
|
notify(opts.Progress, fmt.Sprintf("Qobuz album download %d/%d: %s", i+1, len(albumIDs), albumID))
|
||||||
|
err := opts.Downloader.DownloadAlbum(ctx, albumID)
|
||||||
|
downloadState[albumID] = err
|
||||||
|
}
|
||||||
|
|
||||||
|
for pIdx := range manifest.Playlists {
|
||||||
|
for tIdx := range manifest.Playlists[pIdx].Tracks {
|
||||||
|
entry := &manifest.Playlists[pIdx].Tracks[tIdx]
|
||||||
|
if strings.TrimSpace(entry.QobuzAlbumID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry.DownloadAttempted = true
|
||||||
|
if err := downloadState[entry.QobuzAlbumID]; err != nil {
|
||||||
|
entry.Downloaded = false
|
||||||
|
entry.DownloadError = err.Error()
|
||||||
|
} else {
|
||||||
|
entry.Downloaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(opts.Progress, "Qobuz missing-track processing complete")
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReaddDownloadedToPlaylists(ctx context.Context, manifest *Manifest, matcher TrackMatcher, adder PlaylistAdder, opts ReaddOptions) (ReaddSummary, error) {
|
||||||
|
summary := ReaddSummary{}
|
||||||
|
for pIdx := range manifest.Playlists {
|
||||||
|
pl := &manifest.Playlists[pIdx]
|
||||||
|
if strings.TrimSpace(pl.TargetID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.Playlists++
|
||||||
|
|
||||||
|
toAdd := make([]string, 0)
|
||||||
|
entryIdxs := make([]int, 0)
|
||||||
|
for tIdx := range pl.Tracks {
|
||||||
|
entry := &pl.Tracks[tIdx]
|
||||||
|
if !opts.Force && entry.Added {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !entry.Downloaded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Candidates++
|
||||||
|
entry.RematchAttempted = true
|
||||||
|
res := matcher.MatchTrack(ctx, entry.Source)
|
||||||
|
if !res.Matched || strings.TrimSpace(res.TargetID) == "" {
|
||||||
|
entry.Rematched = false
|
||||||
|
entry.RematchReason = res.Reason
|
||||||
|
if entry.RematchReason == "" {
|
||||||
|
entry.RematchReason = "not found in Navidrome after rescan"
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Rematched = true
|
||||||
|
entry.RematchReason = ""
|
||||||
|
entry.RematchTrackID = res.TargetID
|
||||||
|
toAdd = append(toAdd, res.TargetID)
|
||||||
|
entryIdxs = append(entryIdxs, tIdx)
|
||||||
|
summary.Matched++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toAdd) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toAdd = uniqueIDs(toAdd)
|
||||||
|
notify(opts.Progress, fmt.Sprintf("Re-add to playlist %s: %d track(s)", pl.Name, len(toAdd)))
|
||||||
|
err := adder.AddTracksToPlaylist(ctx, pl.TargetID, toAdd)
|
||||||
|
if err != nil {
|
||||||
|
summary.Errors++
|
||||||
|
for _, idx := range entryIdxs {
|
||||||
|
entry := &pl.Tracks[idx]
|
||||||
|
entry.AddAttempted = true
|
||||||
|
entry.Added = false
|
||||||
|
entry.AddError = err.Error()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, idx := range entryIdxs {
|
||||||
|
entry := &pl.Tracks[idx]
|
||||||
|
entry.AddAttempted = true
|
||||||
|
entry.Added = true
|
||||||
|
entry.AddError = ""
|
||||||
|
summary.Added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
notify(opts.Progress, "Re-add from downloaded manifest complete")
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type qobuzCandidate struct {
|
||||||
|
TrackID string
|
||||||
|
AlbumID string
|
||||||
|
AlbumTitle string
|
||||||
|
AlbumArtist string
|
||||||
|
Score float64
|
||||||
|
Query string
|
||||||
|
}
|
||||||
|
|
||||||
|
func findQobuzCandidate(ctx context.Context, searcher Searcher, src model.Track) (qobuzCandidate, bool, error) {
|
||||||
|
queries := buildQueries(src)
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return qobuzCandidate{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
best := qobuzCandidate{Score: -999}
|
||||||
|
seenTrack := map[string]struct{}{}
|
||||||
|
firstErr := error(nil)
|
||||||
|
errCount := 0
|
||||||
|
for _, q := range queries {
|
||||||
|
res, err := searcher.SearchTracks(ctx, q, 20)
|
||||||
|
if err != nil {
|
||||||
|
errCount++
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, tr := range res {
|
||||||
|
if strings.TrimSpace(tr.ID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenTrack[tr.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenTrack[tr.ID] = struct{}{}
|
||||||
|
sc := score(src, tr)
|
||||||
|
if sc > best.Score {
|
||||||
|
best = qobuzCandidate{
|
||||||
|
TrackID: tr.ID,
|
||||||
|
AlbumID: tr.AlbumID,
|
||||||
|
AlbumTitle: tr.Album,
|
||||||
|
AlbumArtist: tr.AlbumArtist,
|
||||||
|
Score: sc,
|
||||||
|
Query: q,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errCount == len(queries) && firstErr != nil {
|
||||||
|
return qobuzCandidate{}, false, fmt.Errorf("qobuz search failed for all queries: %w", firstErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(best.TrackID) == "" || strings.TrimSpace(best.AlbumID) == "" {
|
||||||
|
return qobuzCandidate{}, false, nil
|
||||||
|
}
|
||||||
|
if best.Score < 45 {
|
||||||
|
return qobuzCandidate{}, false, nil
|
||||||
|
}
|
||||||
|
return best, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQueries(src model.Track) []string {
|
||||||
|
title := strings.TrimSpace(src.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
artist := ""
|
||||||
|
if len(src.Artists) > 0 {
|
||||||
|
artist = strings.TrimSpace(src.Artists[0])
|
||||||
|
}
|
||||||
|
queries := make([]string, 0, 4)
|
||||||
|
if strings.TrimSpace(src.ISRC) != "" {
|
||||||
|
queries = append(queries, strings.ToUpper(strings.TrimSpace(src.ISRC)))
|
||||||
|
}
|
||||||
|
queries = append(queries, strings.TrimSpace(title+" "+artist))
|
||||||
|
queries = append(queries, title)
|
||||||
|
if strings.TrimSpace(src.Album) != "" {
|
||||||
|
queries = append(queries, strings.TrimSpace(title+" "+src.Album+" "+artist))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 score(src model.Track, dst qobuz.Track) float64 {
|
||||||
|
s := 0.0
|
||||||
|
if strings.TrimSpace(src.ISRC) != "" && strings.EqualFold(strings.TrimSpace(src.ISRC), strings.TrimSpace(dst.ISRC)) {
|
||||||
|
s += 60
|
||||||
|
}
|
||||||
|
s += 25 * similarity(normalize(src.Title), normalize(dst.Title))
|
||||||
|
if len(src.Artists) > 0 {
|
||||||
|
s += 20 * similarity(normalize(src.Artists[0]), normalize(dst.Artist))
|
||||||
|
}
|
||||||
|
if src.DurationMS > 0 && dst.Duration > 0 {
|
||||||
|
delta := math.Abs(float64(src.DurationMS/1000 - dst.Duration))
|
||||||
|
switch {
|
||||||
|
case delta <= 2:
|
||||||
|
s += 10
|
||||||
|
case delta <= 5:
|
||||||
|
s += 7
|
||||||
|
case delta <= 10:
|
||||||
|
s += 4
|
||||||
|
case delta > 25:
|
||||||
|
s -= 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
repl := strings.NewReplacer("&", " and ", "'", "", "-", " ")
|
||||||
|
s = repl.Replace(s)
|
||||||
|
parts := strings.Fields(s)
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func similarity(a, b string) float64 {
|
||||||
|
if a == "" || b == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ta := tokenSet(a)
|
||||||
|
tb := tokenSet(b)
|
||||||
|
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{} {
|
||||||
|
set := map[string]struct{}{}
|
||||||
|
for _, p := range strings.Fields(s) {
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueIDs(ids []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func notify(fn ProgressFunc, msg string) {
|
||||||
|
if fn != nil {
|
||||||
|
fn(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func short(s string, max int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if max <= 3 || len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max-3] + "..."
|
||||||
|
}
|
||||||
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"
|
||||||
|
|
||||||
|
"navimigrate/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
|
||||||
|
}
|
||||||
96
internal/session/session.go
Normal file
96
internal/session/session.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Data struct {
|
||||||
|
Spotify SpotifyState `json:"spotify"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyState struct {
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
AccessToken string `json:"access_token,omitempty"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
TokenType string `json:"token_type,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (Data, error) {
|
||||||
|
path, err := expandPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return Data{}, nil
|
||||||
|
}
|
||||||
|
return Data{}, fmt.Errorf("read session file: %w", err)
|
||||||
|
}
|
||||||
|
var d Data
|
||||||
|
if err := json.Unmarshal(b, &d); err != nil {
|
||||||
|
return Data{}, fmt.Errorf("decode session file: %w", err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(path string, d Data) error {
|
||||||
|
path, err := expandPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create session directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.MarshalIndent(d, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode session file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write temp session file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
return fmt.Errorf("replace session file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s SpotifyState) ExpiresAtTime() time.Time {
|
||||||
|
t, err := time.Parse(time.RFC3339, strings.TrimSpace(s.ExpiresAt))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandPath(path string) (string, error) {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return "", fmt.Errorf("session path cannot be empty")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "~/") || path == "~" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
if path == "~" {
|
||||||
|
path = home
|
||||||
|
} else {
|
||||||
|
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Clean(path), nil
|
||||||
|
}
|
||||||
42
internal/session/session_test.go
Normal file
42
internal/session/session_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "session.json")
|
||||||
|
in := Data{
|
||||||
|
Spotify: SpotifyState{
|
||||||
|
ClientID: "abc123",
|
||||||
|
AccessToken: "access-token",
|
||||||
|
RefreshToken: "refresh-token",
|
||||||
|
ExpiresAt: "2026-01-02T03:04:05Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Save(path, in); err != nil {
|
||||||
|
t.Fatalf("Save failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Spotify.ClientID != in.Spotify.ClientID {
|
||||||
|
t.Fatalf("ClientID mismatch: got %q want %q", out.Spotify.ClientID, in.Spotify.ClientID)
|
||||||
|
}
|
||||||
|
if out.Spotify.AccessToken != in.Spotify.AccessToken {
|
||||||
|
t.Fatalf("AccessToken mismatch")
|
||||||
|
}
|
||||||
|
if out.Spotify.RefreshToken != in.Spotify.RefreshToken {
|
||||||
|
t.Fatalf("RefreshToken mismatch")
|
||||||
|
}
|
||||||
|
if out.Spotify.ExpiresAt != in.Spotify.ExpiresAt {
|
||||||
|
t.Fatalf("ExpiresAt mismatch: got %q want %q", out.Spotify.ExpiresAt, in.Spotify.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
314
internal/spotify/auth.go
Normal file
314
internal/spotify/auth.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
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 RefreshAccessToken(ctx context.Context, clientID, refreshToken string) (Token, error) {
|
||||||
|
body := url.Values{
|
||||||
|
"client_id": []string{strings.TrimSpace(clientID)},
|
||||||
|
"grant_type": []string{"refresh_token"},
|
||||||
|
"refresh_token": []string{strings.TrimSpace(refreshToken)},
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := requestToken(ctx, body)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, fmt.Errorf("spotify refresh failed: %w", err)
|
||||||
|
}
|
||||||
|
if tok.RefreshToken == "" {
|
||||||
|
tok.RefreshToken = strings.TrimSpace(refreshToken)
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
return requestToken(ctx, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestToken(ctx context.Context, body url.Values) (Token, error) {
|
||||||
|
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 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()
|
||||||
|
}
|
||||||
277
internal/spotify/client.go
Normal file
277
internal/spotify/client.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"navimigrate/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseURL = "https://api.spotify.com/v1"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
token string
|
||||||
|
progress ProgressFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressFunc func(message string)
|
||||||
|
|
||||||
|
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) 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)
|
||||||
|
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) ([]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,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalTracks > 0 {
|
||||||
|
c.notifyProgress(fmt.Sprintf("Playlist (%s): tracks %d/%d", playlistName, loadedTracks, totalTracks))
|
||||||
|
} else {
|
||||||
|
c.notifyProgress(fmt.Sprintf("Playlist (%s): tracks %d", 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")
|
||||||
|
}
|
||||||
24
internal/spotify/playlist_url_test.go
Normal file
24
internal/spotify/playlist_url_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package spotify
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParsePlaylistID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"spotify:playlist:16DZpOTLqZvdbqxEavLmWk", "16DZpOTLqZvdbqxEavLmWk"},
|
||||||
|
{"https://open.spotify.com/playlist/16DZpOTLqZvdbqxEavLmWk?si=abc", "16DZpOTLqZvdbqxEavLmWk"},
|
||||||
|
{"16DZpOTLqZvdbqxEavLmWk", "16DZpOTLqZvdbqxEavLmWk"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, err := ParsePlaylistID(tt.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParsePlaylistID(%q) returned error: %v", tt.in, err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("ParsePlaylistID(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
206
internal/transfer/transfer.go
Normal file
206
internal/transfer/transfer.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package transfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"navimigrate/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Writer interface {
|
||||||
|
CreatePlaylist(ctx context.Context, name string) (string, error)
|
||||||
|
AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackMatcher interface {
|
||||||
|
MatchTrack(ctx context.Context, src model.Track) model.MatchedTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DryRun bool
|
||||||
|
Concurrency int
|
||||||
|
Progress ProgressFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressFunc func(message string)
|
||||||
|
|
||||||
|
func Run(ctx context.Context, cfg Config, writer Writer, matcher TrackMatcher, playlists []model.Playlist) (model.TransferReport, error) {
|
||||||
|
rep := model.TransferReport{
|
||||||
|
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
DryRun: cfg.DryRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPlaylists := len(playlists)
|
||||||
|
for i, pl := range playlists {
|
||||||
|
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 Writer, 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)
|
||||||
|
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)) ([]string, []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([]string, 0, len(tracks))
|
||||||
|
unmatched := make([]model.MatchedTrack, 0)
|
||||||
|
for _, r := range ordered {
|
||||||
|
if r.Matched && strings.TrimSpace(r.TargetID) != "" {
|
||||||
|
matched = append(matched, r.TargetID)
|
||||||
|
} else {
|
||||||
|
unmatched = append(unmatched, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched, unmatched
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueIDs(ids []string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
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] + "..."
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user