add spotify import progress bar

This commit is contained in:
2026-02-09 19:51:30 -08:00
parent 7fe4d02721
commit c4314456ae
4 changed files with 219 additions and 110 deletions

View File

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

83
static/import.js Normal file
View File

@@ -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');

View File

@@ -19,10 +19,21 @@
<div class="import-section">
<h2>Spotify</h2>
<p>Import your Spotify listening history from your data export.</p>
<form method="POST" action="/import/spotify" enctype="multipart/form-data">
<form id="spotify-form" method="POST" action="/import/spotify" enctype="multipart/form-data">
<input type="file" name="json_files" accept=".json,application/json" multiple required>
<button type="submit">Upload Spotify Data</button>
</form>
<div id="spotify-progress" class="progress-container" style="display: none;">
<div class="progress-status" id="spotify-progress-status">Initializing...</div>
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" id="spotify-progress-fill"></div>
<div class="progress-text" id="spotify-progress-text">0%</div>
</div>
<div class="progress-tracks" id="spotify-progress-tracks"></div>
<div class="progress-error" id="spotify-progress-error"></div>
<div class="progress-success" id="spotify-progress-success"></div>
</div>
</div>
<div class="import-section">
@@ -35,114 +46,18 @@
</form>
<div id="lastfm-progress" class="progress-container" style="display: none;">
<div class="progress-status" id="progress-status">Initializing...</div>
<div class="progress-status" id="lastfm-progress-status">Initializing...</div>
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" id="progress-fill"></div>
<div class="progress-text" id="progress-text">0%</div>
<div class="progress-bar-fill" id="lastfm-progress-fill"></div>
<div class="progress-text" id="lastfm-progress-text">0%</div>
</div>
<div class="progress-tracks" id="progress-tracks"></div>
<div class="progress-error" id="progress-error"></div>
<div class="progress-success" id="progress-success"></div>
<div class="progress-tracks" id="lastfm-progress-tracks"></div>
<div class="progress-error" id="lastfm-progress-error"></div>
<div class="progress-success" id="lastfm-progress-success"></div>
</div>
</div>
</div>
<script>
document.getElementById('lastfm-form').addEventListener('submit', async function(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const progressContainer = document.getElementById('lastfm-progress');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const progressStatus = document.getElementById('progress-status');
const progressTracks = document.getElementById('progress-tracks');
const progressError = document.getElementById('progress-error');
const progressSuccess = document.getElementById('progress-success');
// Reset and show progress container
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 {
// Convert FormData to URLSearchParams for proper form encoding
const params = new URLSearchParams();
for (const [key, value] of formData) {
params.append(key, value);
}
// Start the import
const response = await fetch('/import/lastfm', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params
});
if (!response.ok) {
throw new Error('Failed to start import: ' + response.statusText);
}
const data = await response.json();
const jobId = data.job_id;
// Connect to SSE endpoint
const eventSource = new EventSource('/import/lastfm/progress?job=' + jobId);
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 page ' + 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 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(err) {
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;
}
});
</script>
<script src="/files/import.js"></script>
</body>
</html>

View File

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