add scrobble removal

This commit is contained in:
2026-03-03 00:35:39 -08:00
parent 0dbbaf38ad
commit f7baf2ee40
4 changed files with 126 additions and 3 deletions

View File

@@ -794,6 +794,7 @@ func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry,
}
type ScrobbleEntry struct {
Id int
Timestamp time.Time
SongName string
ArtistName string
@@ -958,7 +959,7 @@ func GetHistoryForSongs(userId int, songIds []int, limit, offset int) ([]Scrobbl
return []ScrobbleEntry{}, nil
}
rows, err := Pool.Query(context.Background(),
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
`SELECT h.id, h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name,
h.artist_ids
FROM history h WHERE h.user_id = $1 AND h.song_id = ANY($2)
@@ -972,7 +973,7 @@ func GetHistoryForSongs(userId int, songIds []int, limit, offset int) ([]Scrobbl
var entries []ScrobbleEntry
for rows.Next() {
var e ScrobbleEntry
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName, &e.ArtistIds)
err := rows.Scan(&e.Id, &e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName, &e.ArtistIds)
if err != nil {
return nil, err
}
@@ -980,3 +981,17 @@ func GetHistoryForSongs(userId int, songIds []int, limit, offset int) ([]Scrobbl
}
return entries, nil
}
func DeleteHistoryByIds(userId int, ids []int) error {
if len(ids) == 0 {
return nil
}
_, err := Pool.Exec(context.Background(),
`DELETE FROM history WHERE user_id = $1 AND id = ANY($2)`,
userId, ids)
if err != nil {
fmt.Fprintf(os.Stderr, "Error deleting history: %v\n", err)
return err
}
return nil
}

View File

@@ -5,6 +5,7 @@
{{.Song.Title}}
{{if eq .LoggedInUsername .Username}}
<button class="edit-btn" onclick="openEditModal()">Edit</button>
<button class="edit-btn" id="removeScrobblesBtn" onclick="toggleRemoveMode()">Remove</button>
{{end}}
</h1>
{{if .ArtistNames}}
@@ -23,9 +24,16 @@
</div>
</div>
<div class="history">
<h3>Scrobbles</h3>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3>Scrobbles</h3>
<div id="removeControls" style="display: none;">
<button class="edit-btn" onclick="cancelRemoveMode()">Cancel</button>
<button class="edit-btn" onclick="deleteSelectedScrobbles()" style="background: #c44;">Delete Selected</button>
</div>
</div>
<table>
<tr>
<th class="remove-checkbox-col" style="display: none;"><input type="checkbox" id="selectAllCheckboxes"></th>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
@@ -34,6 +42,9 @@
{{$username := .Username}}
{{range .Times}}
<tr>
<td class="remove-checkbox-col" style="display: none;">
<input type="checkbox" class="scrobble-checkbox" value="{{.Id}}">
</td>
<td>
{{- $artistNames := getArtistNames .ArtistIds}}
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
@@ -68,4 +79,67 @@
</div>
</div>
{{end}}
<script>
function toggleRemoveMode() {
var checkboxes = document.querySelectorAll('.remove-checkbox-col');
checkboxes.forEach(function(col) {
col.style.display = '';
});
document.getElementById('removeScrobblesBtn').style.display = 'none';
document.getElementById('removeControls').style.display = '';
var selectAll = document.getElementById('selectAllCheckboxes');
selectAll.addEventListener('change', function() {
document.querySelectorAll('.scrobble-checkbox').forEach(function(cb) {
cb.checked = selectAll.checked;
});
});
}
function cancelRemoveMode() {
var checkboxes = document.querySelectorAll('.remove-checkbox-col');
checkboxes.forEach(function(col) {
col.style.display = 'none';
});
document.getElementById('removeScrobblesBtn').style.display = '';
document.getElementById('removeControls').style.display = 'none';
document.querySelectorAll('.scrobble-checkbox').forEach(function(cb) {
cb.checked = false;
});
document.getElementById('selectAllCheckboxes').checked = false;
}
function deleteSelectedScrobbles() {
var checkboxes = document.querySelectorAll('.scrobble-checkbox:checked');
var ids = [];
checkboxes.forEach(function(cb) {
ids.push(parseInt(cb.value));
});
if (ids.length === 0) {
alert('Please select at least one scrobble to delete.');
return;
}
if (!confirm('Are you sure you want to delete ' + ids.length + ' scrobble(s)?')) {
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/scrobble/delete', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
location.reload();
} else {
alert('Error deleting scrobbles: ' + xhr.responseText);
}
}
};
xhr.send(JSON.stringify(ids));
}
</script>
{{end}}

View File

@@ -1002,3 +1002,36 @@ func imageUploadHandler() http.HandlerFunc {
})
}
}
func deleteScrobbleHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
var ids []int
if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
err = db.DeleteHistoryByIds(userId, ids)
if err != nil {
fmt.Fprintf(os.Stderr, "Error deleting scrobbles: %v\n", err)
http.Error(w, "Error deleting scrobbles", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
}

View File

@@ -120,6 +120,7 @@ func Start() {
r.Patch("/api/artist/{id}/batch", artistBatchEditHandler())
r.Patch("/api/song/{id}/batch", songBatchEditHandler())
r.Patch("/api/album/{id}/batch", albumBatchEditHandler())
r.Post("/api/scrobble/delete", deleteScrobbleHandler())
r.Post("/api/upload/image", imageUploadHandler())
r.Get("/search", searchHandler())
r.Get("/import", importPageHandler())