From d1b1fbd6ecebfcd147cca249eb3522f8e7851705 Mon Sep 17 00:00:00 2001 From: Joren Date: Thu, 9 Apr 2026 03:18:06 +0200 Subject: [PATCH] track CLI entrypoint and narrow ignore pattern --- .gitignore | 2 +- cmd/navimigrate/main.go | 421 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 cmd/navimigrate/main.go diff --git a/.gitignore b/.gitignore index 6d884e2..bd30319 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -navimigrate +/navimigrate transfer-report.json missing-downloads.json diff --git a/cmd/navimigrate/main.go b/cmd/navimigrate/main.go new file mode 100644 index 0000000..a28fa9f --- /dev/null +++ b/cmd/navimigrate/main.go @@ -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) +}