422 lines
12 KiB
Go
422 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"navimigrate/internal/config"
|
|
"navimigrate/internal/match"
|
|
"navimigrate/internal/model"
|
|
"navimigrate/internal/navidrome"
|
|
"navimigrate/internal/qobuz"
|
|
"navimigrate/internal/recovery"
|
|
"navimigrate/internal/report"
|
|
"navimigrate/internal/session"
|
|
"navimigrate/internal/spotify"
|
|
"navimigrate/internal/transfer"
|
|
)
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
nd := navidrome.NewClient(cfg.NavidromeURL, cfg.NavidromeUsername, cfg.NavidromePassword)
|
|
|
|
if cfg.NavidromeSelfTest {
|
|
return runNavidromeSelfTest(ctx, cfg, nd)
|
|
}
|
|
|
|
if strings.TrimSpace(cfg.AddDownloaded) != "" {
|
|
fmt.Println("Authenticating with Navidrome...")
|
|
if err := nd.Ping(ctx); err != nil {
|
|
return fmt.Errorf("navidrome auth failed: %w", err)
|
|
}
|
|
fmt.Println("Navidrome auth check: OK")
|
|
return runAddDownloaded(ctx, cfg, nd)
|
|
}
|
|
|
|
tok, err := authenticateSpotify(ctx, cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("spotify auth failed: %w", err)
|
|
}
|
|
|
|
sp := spotify.NewClient(tok.AccessToken)
|
|
sp.SetProgress(func(msg string) {
|
|
fmt.Printf("\r%-120s", msg)
|
|
})
|
|
|
|
fmt.Println("Fetching Spotify sources...")
|
|
playlists := make([]model.Playlist, 0, len(cfg.PlaylistURLs)+1)
|
|
|
|
if len(cfg.PlaylistURLs) > 0 {
|
|
ids, err := resolvePlaylistIDs(cfg.PlaylistURLs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fetched, err := sp.FetchPlaylistsByID(ctx, ids)
|
|
if err != nil {
|
|
fmt.Print("\r")
|
|
return fmt.Errorf("spotify fetch playlists failed: %w", err)
|
|
}
|
|
playlists = append(playlists, fetched...)
|
|
}
|
|
|
|
if cfg.IncludeLiked {
|
|
liked, err := sp.FetchLikedSongs(ctx)
|
|
if err != nil {
|
|
fmt.Print("\r")
|
|
return fmt.Errorf("spotify fetch liked songs failed: %w", err)
|
|
}
|
|
playlists = append(playlists, model.Playlist{
|
|
Name: cfg.LikedPlaylist,
|
|
Tracks: liked,
|
|
})
|
|
}
|
|
|
|
fmt.Print("\r")
|
|
fmt.Printf("Fetched %d Spotify playlist(s)", len(playlists))
|
|
if cfg.IncludeLiked {
|
|
fmt.Print(" (including liked songs)")
|
|
}
|
|
fmt.Println(".")
|
|
|
|
fmt.Println("Authenticating with Navidrome...")
|
|
if err := nd.Ping(ctx); err != nil {
|
|
return fmt.Errorf("navidrome auth failed: %w", err)
|
|
}
|
|
fmt.Println("Navidrome auth check: OK")
|
|
|
|
matcher := match.NewMatcher(nd, cfg.MatchThreshold)
|
|
transferCfg := transfer.Config{
|
|
DryRun: cfg.DryRun,
|
|
Concurrency: cfg.Concurrency,
|
|
Progress: func(msg string) {
|
|
fmt.Printf("\r%-140s", msg)
|
|
},
|
|
}
|
|
|
|
fmt.Println("Starting transfer...")
|
|
start := time.Now()
|
|
rep, err := transfer.Run(ctx, transferCfg, nd, matcher, playlists)
|
|
if err != nil {
|
|
return fmt.Errorf("transfer failed: %w", err)
|
|
}
|
|
fmt.Print("\r")
|
|
fmt.Println("Transfer processing complete. ")
|
|
|
|
if err := report.Write(cfg.ReportPath, rep); err != nil {
|
|
return err
|
|
}
|
|
|
|
printSummary(rep, cfg.ReportPath, time.Since(start), cfg.DryRun)
|
|
|
|
if cfg.QobuzDownloadMissing {
|
|
if err := runQobuzMissingDownloads(ctx, cfg, rep); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runQobuzMissingDownloads(ctx context.Context, cfg config.Config, rep model.TransferReport) error {
|
|
totalUnmatched := 0
|
|
for _, r := range rep.Results {
|
|
totalUnmatched += len(r.Unmatched)
|
|
}
|
|
if totalUnmatched == 0 {
|
|
fmt.Println("No unmatched tracks found; skipping Qobuz recovery.")
|
|
return nil
|
|
}
|
|
|
|
fmt.Println("Processing unmatched tracks via Qobuz...")
|
|
qb := qobuz.NewClient(cfg.QobuzAppID, cfg.QobuzAppSecret)
|
|
if err := qb.Login(ctx, cfg.QobuzUsername, cfg.QobuzPassword); err != nil {
|
|
return fmt.Errorf("qobuz login failed: %w", err)
|
|
}
|
|
if err := qb.VerifyAuth(ctx); err != nil {
|
|
return fmt.Errorf("qobuz auth verification failed: %w", err)
|
|
}
|
|
|
|
manifest, err := recovery.BuildManifestFromReport(ctx, rep, qb, recovery.BuildOptions{
|
|
DownloadMissing: true,
|
|
Downloader: recovery.QobuzDL{
|
|
Path: cfg.QobuzDLPath,
|
|
OutputDir: cfg.QobuzOutputPath,
|
|
Username: cfg.QobuzUsername,
|
|
Password: cfg.QobuzPassword,
|
|
Quality: cfg.QobuzQuality,
|
|
},
|
|
Progress: func(msg string) {
|
|
fmt.Printf("\r%-140s", msg)
|
|
},
|
|
})
|
|
fmt.Print("\r")
|
|
if err != nil {
|
|
return fmt.Errorf("qobuz missing-track processing failed: %w", err)
|
|
}
|
|
|
|
manifestPath := cfg.QobuzManifestPath
|
|
if !filepath.IsAbs(manifestPath) {
|
|
manifestPath = filepath.Clean(manifestPath)
|
|
}
|
|
if err := recovery.Save(manifestPath, manifest); err != nil {
|
|
return fmt.Errorf("write qobuz manifest: %w", err)
|
|
}
|
|
|
|
summary := summarizeManifest(manifest)
|
|
fmt.Printf("Qobuz lookup complete: %d unmatched, %d candidates, %d lookup errors, %d album downloads ok, %d album downloads failed\n", summary.unmatched, summary.withAlbum, summary.lookupErrs, summary.downloaded, summary.failed)
|
|
fmt.Printf("Missing-download manifest written to %s\n", manifestPath)
|
|
fmt.Printf("After Navidrome library rescan, run: --add-downloaded-manifest %s\n", manifestPath)
|
|
return nil
|
|
}
|
|
|
|
func runAddDownloaded(ctx context.Context, cfg config.Config, nd *navidrome.Client) error {
|
|
manifestPath := cfg.AddDownloaded
|
|
manifest, err := recovery.Load(manifestPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
matcher := match.NewMatcher(nd, cfg.MatchThreshold)
|
|
summary, err := recovery.ReaddDownloadedToPlaylists(ctx, &manifest, matcher, nd, recovery.ReaddOptions{
|
|
Force: cfg.AddDownloadedForce,
|
|
Progress: func(msg string) {
|
|
fmt.Printf("\r%-140s", msg)
|
|
},
|
|
})
|
|
fmt.Print("\r")
|
|
if err != nil {
|
|
return fmt.Errorf("re-add downloaded failed: %w", err)
|
|
}
|
|
|
|
if err := recovery.Save(manifestPath, manifest); err != nil {
|
|
return fmt.Errorf("update manifest after re-add: %w", err)
|
|
}
|
|
|
|
fmt.Printf("Re-add complete: playlists=%d candidates=%d matched=%d added=%d errors=%d\n", summary.Playlists, summary.Candidates, summary.Matched, summary.Added, summary.Errors)
|
|
fmt.Printf("Updated manifest: %s\n", manifestPath)
|
|
return nil
|
|
}
|
|
|
|
type manifestSummary struct {
|
|
unmatched int
|
|
withAlbum int
|
|
lookupErrs int
|
|
downloaded int
|
|
failed int
|
|
}
|
|
|
|
func summarizeManifest(m recovery.Manifest) manifestSummary {
|
|
s := manifestSummary{}
|
|
seenAlbumResult := map[string]bool{}
|
|
for _, pl := range m.Playlists {
|
|
for _, tr := range pl.Tracks {
|
|
s.unmatched++
|
|
if strings.TrimSpace(tr.LookupError) != "" {
|
|
s.lookupErrs++
|
|
}
|
|
if strings.TrimSpace(tr.QobuzAlbumID) == "" {
|
|
continue
|
|
}
|
|
s.withAlbum++
|
|
if _, ok := seenAlbumResult[tr.QobuzAlbumID]; ok {
|
|
continue
|
|
}
|
|
seenAlbumResult[tr.QobuzAlbumID] = true
|
|
if tr.Downloaded {
|
|
s.downloaded++
|
|
} else if tr.DownloadAttempted {
|
|
s.failed++
|
|
}
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func authenticateSpotify(ctx context.Context, cfg config.Config) (spotify.Token, error) {
|
|
if cfg.RememberSpotify {
|
|
sess, err := session.Load(cfg.SessionFile)
|
|
if err != nil {
|
|
fmt.Printf("Warning: could not read session file: %v\n", err)
|
|
} else {
|
|
stored := sess.Spotify
|
|
if strings.TrimSpace(stored.ClientID) == strings.TrimSpace(cfg.SpotifyClientID) {
|
|
exp := stored.ExpiresAtTime()
|
|
if strings.TrimSpace(stored.AccessToken) != "" && !exp.IsZero() && exp.After(time.Now().UTC().Add(2*time.Minute)) {
|
|
fmt.Println("Using saved Spotify access token.")
|
|
return spotify.Token{
|
|
AccessToken: stored.AccessToken,
|
|
RefreshToken: stored.RefreshToken,
|
|
TokenType: stored.TokenType,
|
|
Scope: stored.Scope,
|
|
ExpiresIn: int(time.Until(exp).Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
if strings.TrimSpace(stored.RefreshToken) != "" {
|
|
fmt.Println("Refreshing saved Spotify session...")
|
|
tok, err := spotify.RefreshAccessToken(ctx, cfg.SpotifyClientID, stored.RefreshToken)
|
|
if err == nil {
|
|
saveSpotifySession(cfg, tok)
|
|
fmt.Println("Spotify session refreshed.")
|
|
return tok, nil
|
|
}
|
|
fmt.Printf("Saved Spotify session refresh failed: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Println("Starting Spotify authentication...")
|
|
tok, err := spotify.LoginWithPKCE(ctx, spotify.AuthConfig{
|
|
ClientID: cfg.SpotifyClientID,
|
|
RedirectURI: cfg.SpotifyRedirect,
|
|
Scopes: cfg.SpotifyScopes,
|
|
ManualCode: cfg.SpotifyManual,
|
|
})
|
|
if err != nil {
|
|
return spotify.Token{}, err
|
|
}
|
|
|
|
saveSpotifySession(cfg, tok)
|
|
return tok, nil
|
|
}
|
|
|
|
func saveSpotifySession(cfg config.Config, tok spotify.Token) {
|
|
if !cfg.RememberSpotify {
|
|
return
|
|
}
|
|
expiresAt := ""
|
|
if tok.ExpiresIn > 0 {
|
|
expiresAt = time.Now().UTC().Add(time.Duration(tok.ExpiresIn) * time.Second).Format(time.RFC3339)
|
|
}
|
|
err := session.Save(cfg.SessionFile, session.Data{
|
|
Spotify: session.SpotifyState{
|
|
ClientID: strings.TrimSpace(cfg.SpotifyClientID),
|
|
AccessToken: strings.TrimSpace(tok.AccessToken),
|
|
RefreshToken: strings.TrimSpace(tok.RefreshToken),
|
|
TokenType: strings.TrimSpace(tok.TokenType),
|
|
Scope: strings.TrimSpace(tok.Scope),
|
|
ExpiresAt: expiresAt,
|
|
},
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("Warning: could not save session file: %v\n", err)
|
|
}
|
|
}
|
|
|
|
func runNavidromeSelfTest(ctx context.Context, cfg config.Config, nd *navidrome.Client) error {
|
|
fmt.Println("Running Navidrome self-test...")
|
|
|
|
if err := nd.Ping(ctx); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("- Ping/auth check: OK")
|
|
|
|
results, err := nd.SearchTracks(ctx, cfg.NavidromeSelfTestQuery, 5)
|
|
if err != nil {
|
|
return fmt.Errorf("navidrome search failed: %w", err)
|
|
}
|
|
fmt.Printf("- Search '%s': %d result(s)\n", cfg.NavidromeSelfTestQuery, len(results))
|
|
|
|
if cfg.NavidromeSelfTestWrite {
|
|
name := fmt.Sprintf("NaviMigrate SelfTest %d", time.Now().Unix())
|
|
playlistID, err := nd.CreatePlaylist(ctx, name)
|
|
if err != nil {
|
|
return fmt.Errorf("navidrome create playlist failed: %w", err)
|
|
}
|
|
fmt.Printf("- Create playlist: OK (id=%s)\n", playlistID)
|
|
|
|
if len(results) > 0 {
|
|
if err := nd.AddTracksToPlaylist(ctx, playlistID, []string{results[0].ID}); err != nil {
|
|
_ = nd.DeletePlaylist(context.Background(), playlistID)
|
|
return fmt.Errorf("navidrome add tracks failed: %w", err)
|
|
}
|
|
fmt.Println("- Add first search result to playlist: OK")
|
|
} else {
|
|
fmt.Println("- Add first search result: skipped (no search results)")
|
|
}
|
|
|
|
if err := nd.DeletePlaylist(ctx, playlistID); err != nil {
|
|
fmt.Printf("- Cleanup test playlist: failed (%v)\n", err)
|
|
} else {
|
|
fmt.Println("- Cleanup test playlist: OK")
|
|
}
|
|
}
|
|
|
|
fmt.Println("Navidrome self-test passed.")
|
|
return nil
|
|
}
|
|
|
|
func resolvePlaylistIDs(inputs []string) ([]string, error) {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(inputs))
|
|
for _, in := range inputs {
|
|
id, err := spotify.ParsePlaylistID(in)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid playlist-url %q: %w", in, err)
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("no playlist URLs provided")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func printSummary(rep model.TransferReport, reportPath string, elapsed time.Duration, dryRun bool) {
|
|
mode := "TRANSFER"
|
|
if dryRun {
|
|
mode = "DRY-RUN"
|
|
}
|
|
|
|
fmt.Printf("\n%s complete in %s\n", mode, elapsed.Round(time.Second))
|
|
totalMatched := 0
|
|
totalAdded := 0
|
|
totalUnmatched := 0
|
|
totalErrors := 0
|
|
|
|
for _, r := range rep.Results {
|
|
unmatched := len(r.Unmatched)
|
|
totalMatched += r.MatchedTracks
|
|
totalAdded += r.AddedTracks
|
|
totalUnmatched += unmatched
|
|
totalErrors += len(r.Errors)
|
|
|
|
targetInfo := ""
|
|
if strings.TrimSpace(r.TargetID) != "" {
|
|
targetInfo = fmt.Sprintf(" -> Navidrome %s", r.TargetID)
|
|
}
|
|
fmt.Printf("- %s%s: %d total, %d matched, %d added, %d unmatched\n", r.Name, targetInfo, r.TotalTracks, r.MatchedTracks, r.AddedTracks, unmatched)
|
|
if len(r.Errors) > 0 {
|
|
fmt.Printf(" errors: %s\n", strings.Join(r.Errors, " | "))
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\nTotals: matched=%d added=%d unmatched=%d errors=%d\n", totalMatched, totalAdded, totalUnmatched, totalErrors)
|
|
fmt.Printf("Report written to %s\n", reportPath)
|
|
}
|