track CLI entrypoint and narrow ignore pattern

This commit is contained in:
2026-04-09 03:18:06 +02:00
parent c1360a6423
commit d1b1fbd6ec
2 changed files with 422 additions and 1 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
navimigrate
/navimigrate
transfer-report.json
missing-downloads.json

421
cmd/navimigrate/main.go Normal file
View 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)
}