From c4314456aefd185d5a6d843fdf1c850bf62a73be Mon Sep 17 00:00:00 2001 From: riwiwa Date: Mon, 9 Feb 2026 19:51:30 -0800 Subject: [PATCH] add spotify import progress bar --- migrate/spotify.go | 46 +++++++++++++- static/import.js | 83 ++++++++++++++++++++++++++ templates/import.gohtml | 129 +++++++--------------------------------- web/web.go | 71 +++++++++++++++++++++- 4 files changed, 219 insertions(+), 110 deletions(-) create mode 100644 static/import.js diff --git a/migrate/spotify.go b/migrate/spotify.go index 5b8d755..ef323c1 100644 --- a/migrate/spotify.go +++ b/migrate/spotify.go @@ -215,14 +215,24 @@ func isDuplicateWithinWindow(track SpotifyTrack, existingTimestamps []time.Time) return false } -func ImportSpotify(tracks []SpotifyTrack, userId int) error { +func ImportSpotify(tracks []SpotifyTrack, userId int, progressChan chan ProgressUpdate) error { totalImported := 0 totalTracks := len(tracks) batchStart := 0 + totalBatches := (totalTracks + batchSize - 1) / batchSize + + // Send initial progress update + if progressChan != nil { + progressChan <- ProgressUpdate{ + TotalPages: totalBatches, + Status: "running", + } + } for batchStart < totalTracks { // cap batchEnd at total track count on final batch to prevent OOB error batchEnd := min(batchStart+batchSize, totalTracks) + currentBatch := (batchStart / batchSize) + 1 var validTracks []SpotifyTrack for i := batchStart; i < batchEnd; i++ { @@ -235,6 +245,16 @@ func ImportSpotify(tracks []SpotifyTrack, userId int) error { if len(validTracks) == 0 { batchStart += batchSize + // Send progress update even for empty batches + if progressChan != nil { + progressChan <- ProgressUpdate{ + CurrentPage: currentBatch, + CompletedPages: currentBatch, + TotalPages: totalBatches, + TracksImported: totalImported, + Status: "running", + } + } continue } @@ -273,7 +293,31 @@ func ImportSpotify(tracks []SpotifyTrack, userId int) error { } else { totalImported += int(copyCount) } + + // Send progress update + if progressChan != nil { + progressChan <- ProgressUpdate{ + CurrentPage: currentBatch, + CompletedPages: currentBatch, + TotalPages: totalBatches, + TracksImported: totalImported, + Status: "running", + } + } + batchStart += batchSize } + + // Send completion update + if progressChan != nil { + progressChan <- ProgressUpdate{ + CurrentPage: totalBatches, + CompletedPages: totalBatches, + TotalPages: totalBatches, + TracksImported: totalImported, + Status: "completed", + } + } + return nil } diff --git a/static/import.js b/static/import.js new file mode 100644 index 0000000..dd272a3 --- /dev/null +++ b/static/import.js @@ -0,0 +1,83 @@ +function handleImport(formId, progressPrefix, endpoint, progressUrl, formatLabel) { + const form = document.getElementById(formId); + const progressContainer = document.getElementById(progressPrefix + '-progress'); + const progressFill = document.getElementById(progressPrefix + '-progress-fill'); + const progressText = document.getElementById(progressPrefix + '-progress-text'); + const progressStatus = document.getElementById(progressPrefix + '-progress-status'); + const progressTracks = document.getElementById(progressPrefix + '-progress-tracks'); + const progressError = document.getElementById(progressPrefix + '-progress-error'); + const progressSuccess = document.getElementById(progressPrefix + '-progress-success'); + + form.addEventListener('submit', async function(e) { + e.preventDefault(); + + // Reset and show progress + progressFill.style.width = '0%'; + progressFill.classList.add('animating'); + progressText.textContent = '0%'; + progressStatus.textContent = 'Starting import...'; + progressTracks.textContent = ''; + progressError.textContent = ''; + progressSuccess.textContent = ''; + progressContainer.style.display = 'block'; + + try { + const response = await fetch(endpoint, { + method: 'POST', + body: progressPrefix === 'lastfm' + ? new URLSearchParams(new FormData(form)) + : new FormData(form) + }); + + if (!response.ok) throw new Error('Failed to start import: ' + response.statusText); + + const { job_id } = await response.json(); + const eventSource = new EventSource(progressUrl + job_id); + + eventSource.onmessage = function(event) { + const update = JSON.parse(event.data); + if (update.status === 'connected') return; + + if (update.total_pages > 0) { + const completed = update.completed_pages || update.current_page || 0; + const percent = Math.round((completed / update.total_pages) * 100); + progressFill.style.width = percent + '%'; + progressText.textContent = percent + '%'; + progressStatus.textContent = 'Processing ' + formatLabel + ' ' + completed + ' of ' + update.total_pages; + } + + if (update.tracks_imported !== undefined) { + progressTracks.textContent = update.tracks_imported.toLocaleString() + ' tracks imported'; + } + + if (update.status === 'completed') { + progressFill.classList.remove('animating'); + progressStatus.textContent = 'Import completed!'; + progressSuccess.textContent = 'Successfully imported ' + update.tracks_imported.toLocaleString() + ' tracks from ' + (progressPrefix === 'spotify' ? 'Spotify' : 'Last.fm'); + eventSource.close(); + form.reset(); + } else if (update.status === 'error') { + progressFill.classList.remove('animating'); + progressStatus.textContent = 'Import failed'; + progressError.textContent = 'Error: ' + (update.error || 'Unknown error'); + eventSource.close(); + } + }; + + eventSource.onerror = function() { + progressFill.classList.remove('animating'); + progressStatus.textContent = 'Connection error'; + progressError.textContent = 'Lost connection to server. The import may still be running in the background.'; + eventSource.close(); + }; + + } catch (err) { + progressFill.classList.remove('animating'); + progressStatus.textContent = 'Import failed'; + progressError.textContent = 'Error: ' + err.message; + } + }); +} + +handleImport('spotify-form', 'spotify', '/import/spotify', '/import/spotify/progress?job=', 'batch'); +handleImport('lastfm-form', 'lastfm', '/import/lastfm', '/import/lastfm/progress?job=', 'page'); diff --git a/templates/import.gohtml b/templates/import.gohtml index 449e7d7..a34ec8b 100644 --- a/templates/import.gohtml +++ b/templates/import.gohtml @@ -15,16 +15,27 @@

Import Your Listening Data

Welcome, {{.Username}}!

- +

Spotify

Import your Spotify listening history from your data export.

-
+
+ +
- +

Last.fm

Import your Last.fm scrobbles.

@@ -33,116 +44,20 @@ - +
- + diff --git a/web/web.go b/web/web.go index b6c8b13..b210b4a 100644 --- a/web/web.go +++ b/web/web.go @@ -506,10 +506,32 @@ func importSpotifyHandler(w http.ResponseWriter, r *http.Request) { return } - if err := migrate.ImportSpotify(allTracks, userId); err != nil { - http.Error(w, "Failed to process tracks", http.StatusInternalServerError) + jobID, err := generateID() + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err) + http.Error(w, "Error generating jobID", http.StatusBadRequest) return } + progressChan := make(chan migrate.ProgressUpdate, 100) + + jobsMu.Lock() + importJobs[jobID] = progressChan + jobsMu.Unlock() + + go func() { + migrate.ImportSpotify(allTracks, userId, progressChan) + + jobsMu.Lock() + delete(importJobs, jobID) + jobsMu.Unlock() + close(progressChan) + }() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "job_id": jobID, + "status": "started", + }) } func importLastFMHandler(w http.ResponseWriter, r *http.Request) { @@ -617,6 +639,50 @@ func importLastFMProgressHandler(w http.ResponseWriter, r *http.Request) { } } +func importSpotifyProgressHandler(w http.ResponseWriter, r *http.Request) { + jobID := r.URL.Query().Get("job") + if jobID == "" { + http.Error(w, "Missing job ID", http.StatusBadRequest) + return + } + + jobsMu.RLock() + job, exists := importJobs[jobID] + jobsMu.RUnlock() + + if !exists { + http.Error(w, "Job not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming not supported", http.StatusInternalServerError) + return + } + + fmt.Fprintf(w, "data: %s\n\n", `{"status":"connected"}`) + flusher.Flush() + + for update := range job { + data, err := json.Marshal(update) + if err != nil { + continue + } + fmt.Fprintf(w, "data: %s\n\n", string(data)) + flusher.Flush() + + if update.Status == "completed" || update.Status == "error" { + return + } + } +} + func Start() { addr := ":1234" r := chi.NewRouter() @@ -632,6 +698,7 @@ func Start() { r.Post("/import/lastfm", importLastFMHandler) r.Post("/import/spotify", importSpotifyHandler) r.Get("/import/lastfm/progress", importLastFMProgressHandler) + r.Get("/import/spotify/progress", importSpotifyProgressHandler) fmt.Printf("WebUI starting on %s\n", addr) prot := http.NewCrossOriginProtection() http.ListenAndServe(addr, prot.Handler(r))