mirror of
https://github.com/riwiwa/muzi.git
synced 2026-02-28 11:56:57 -08:00
add spotify import progress bar
This commit is contained in:
@@ -215,14 +215,24 @@ func isDuplicateWithinWindow(track SpotifyTrack, existingTimestamps []time.Time)
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func ImportSpotify(tracks []SpotifyTrack, userId int) error {
|
func ImportSpotify(tracks []SpotifyTrack, userId int, progressChan chan ProgressUpdate) error {
|
||||||
totalImported := 0
|
totalImported := 0
|
||||||
totalTracks := len(tracks)
|
totalTracks := len(tracks)
|
||||||
batchStart := 0
|
batchStart := 0
|
||||||
|
totalBatches := (totalTracks + batchSize - 1) / batchSize
|
||||||
|
|
||||||
|
// Send initial progress update
|
||||||
|
if progressChan != nil {
|
||||||
|
progressChan <- ProgressUpdate{
|
||||||
|
TotalPages: totalBatches,
|
||||||
|
Status: "running",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for batchStart < totalTracks {
|
for batchStart < totalTracks {
|
||||||
// cap batchEnd at total track count on final batch to prevent OOB error
|
// cap batchEnd at total track count on final batch to prevent OOB error
|
||||||
batchEnd := min(batchStart+batchSize, totalTracks)
|
batchEnd := min(batchStart+batchSize, totalTracks)
|
||||||
|
currentBatch := (batchStart / batchSize) + 1
|
||||||
|
|
||||||
var validTracks []SpotifyTrack
|
var validTracks []SpotifyTrack
|
||||||
for i := batchStart; i < batchEnd; i++ {
|
for i := batchStart; i < batchEnd; i++ {
|
||||||
@@ -235,6 +245,16 @@ func ImportSpotify(tracks []SpotifyTrack, userId int) error {
|
|||||||
|
|
||||||
if len(validTracks) == 0 {
|
if len(validTracks) == 0 {
|
||||||
batchStart += batchSize
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +293,31 @@ func ImportSpotify(tracks []SpotifyTrack, userId int) error {
|
|||||||
} else {
|
} else {
|
||||||
totalImported += int(copyCount)
|
totalImported += int(copyCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send progress update
|
||||||
|
if progressChan != nil {
|
||||||
|
progressChan <- ProgressUpdate{
|
||||||
|
CurrentPage: currentBatch,
|
||||||
|
CompletedPages: currentBatch,
|
||||||
|
TotalPages: totalBatches,
|
||||||
|
TracksImported: totalImported,
|
||||||
|
Status: "running",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
batchStart += batchSize
|
batchStart += batchSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send completion update
|
||||||
|
if progressChan != nil {
|
||||||
|
progressChan <- ProgressUpdate{
|
||||||
|
CurrentPage: totalBatches,
|
||||||
|
CompletedPages: totalBatches,
|
||||||
|
TotalPages: totalBatches,
|
||||||
|
TracksImported: totalImported,
|
||||||
|
Status: "completed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
83
static/import.js
Normal file
83
static/import.js
Normal 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');
|
||||||
@@ -19,10 +19,21 @@
|
|||||||
<div class="import-section">
|
<div class="import-section">
|
||||||
<h2>Spotify</h2>
|
<h2>Spotify</h2>
|
||||||
<p>Import your Spotify listening history from your data export.</p>
|
<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>
|
<input type="file" name="json_files" accept=".json,application/json" multiple required>
|
||||||
<button type="submit">Upload Spotify Data</button>
|
<button type="submit">Upload Spotify Data</button>
|
||||||
</form>
|
</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>
|
||||||
|
|
||||||
<div class="import-section">
|
<div class="import-section">
|
||||||
@@ -35,114 +46,18 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="lastfm-progress" class="progress-container" style="display: none;">
|
<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-wrapper">
|
||||||
<div class="progress-bar-fill" id="progress-fill"></div>
|
<div class="progress-bar-fill" id="lastfm-progress-fill"></div>
|
||||||
<div class="progress-text" id="progress-text">0%</div>
|
<div class="progress-text" id="lastfm-progress-text">0%</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-tracks" id="progress-tracks"></div>
|
<div class="progress-tracks" id="lastfm-progress-tracks"></div>
|
||||||
<div class="progress-error" id="progress-error"></div>
|
<div class="progress-error" id="lastfm-progress-error"></div>
|
||||||
<div class="progress-success" id="progress-success"></div>
|
<div class="progress-success" id="lastfm-progress-success"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/files/import.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
71
web/web.go
71
web/web.go
@@ -506,10 +506,32 @@ func importSpotifyHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := migrate.ImportSpotify(allTracks, userId); err != nil {
|
jobID, err := generateID()
|
||||||
http.Error(w, "Failed to process tracks", http.StatusInternalServerError)
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err)
|
||||||
|
http.Error(w, "Error generating jobID", http.StatusBadRequest)
|
||||||
return
|
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) {
|
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() {
|
func Start() {
|
||||||
addr := ":1234"
|
addr := ":1234"
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -632,6 +698,7 @@ func Start() {
|
|||||||
r.Post("/import/lastfm", importLastFMHandler)
|
r.Post("/import/lastfm", importLastFMHandler)
|
||||||
r.Post("/import/spotify", importSpotifyHandler)
|
r.Post("/import/spotify", importSpotifyHandler)
|
||||||
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
|
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
|
||||||
|
r.Get("/import/spotify/progress", importSpotifyProgressHandler)
|
||||||
fmt.Printf("WebUI starting on %s\n", addr)
|
fmt.Printf("WebUI starting on %s\n", addr)
|
||||||
prot := http.NewCrossOriginProtection()
|
prot := http.NewCrossOriginProtection()
|
||||||
http.ListenAndServe(addr, prot.Handler(r))
|
http.ListenAndServe(addr, prot.Handler(r))
|
||||||
|
|||||||
Reference in New Issue
Block a user