track CLI entrypoint and narrow ignore pattern
This commit is contained in:
421
cmd/navimigrate/main.go
Normal file
421
cmd/navimigrate/main.go
Normal file
@@ -0,0 +1,421 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user