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) }