diff --git a/database/music_library.db-shm b/database/music_library.db-shm
new file mode 100644
index 00000000..2dca81bb
Binary files /dev/null and b/database/music_library.db-shm differ
diff --git a/database/music_library.db-wal b/database/music_library.db-wal
new file mode 100644
index 00000000..4dba5675
Binary files /dev/null and b/database/music_library.db-wal differ
diff --git a/web_server.py b/web_server.py
index 003d8e23..b0b00321 100644
--- a/web_server.py
+++ b/web_server.py
@@ -13724,6 +13724,9 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
print(f"🔄 [Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing")
for i, track_data in enumerate(tracks_json):
+ # Use original table index if provided (for partial track selection),
+ # otherwise fall back to enumeration index
+ track_index = track_data.get('_original_index', i)
track_name = track_data.get('name', '')
artists = track_data.get('artists', [])
found, confidence = False, 0.0
@@ -13749,7 +13752,7 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
break
analysis_results.append({
- 'track_index': i, 'track': track_data, 'found': found, 'confidence': confidence
+ 'track_index': track_index, 'track': track_data, 'found': found, 'confidence': confidence
})
# WISHLIST REMOVAL: If track is found in database, check if it should be removed from wishlist
diff --git a/webui/static/mobile.css b/webui/static/mobile.css
index b58362ac..6b409853 100644
--- a/webui/static/mobile.css
+++ b/webui/static/mobile.css
@@ -1141,6 +1141,10 @@
font-size: 13px;
}
+ .track-selection-count {
+ font-size: 10px;
+ }
+
.download-tracks-table {
table-layout: auto;
}
@@ -1154,17 +1158,38 @@
text-overflow: ellipsis;
}
+ /* Checkbox column sizing on mobile */
+ .track-select-header,
+ .track-select-cell {
+ width: auto;
+ padding: 6px 4px !important;
+ }
+
+ .track-select-header input[type="checkbox"],
+ .track-select-cell input[type="checkbox"] {
+ width: 14px;
+ height: 14px;
+ }
+
/* Hide # and Duration columns on mobile */
- .download-tracks-table th:nth-child(1),
.download-tracks-table td.track-number {
display: none;
}
- .download-tracks-table th:nth-child(4),
.download-tracks-table td.track-duration {
display: none;
}
+ /* Hide matching th headers — different positions depending on checkbox column */
+ .download-tracks-table:has(.track-select-header) th:nth-child(2),
+ .download-tracks-table:not(:has(.track-select-header)) th:nth-child(1) {
+ display: none;
+ }
+
+ .download-tracks-table:has(.track-select-header) th:nth-child(5) {
+ display: none;
+ }
+
.track-name {
width: auto;
max-width: 120px;
@@ -1810,11 +1835,15 @@
}
/* Hide Library Match column too at 480px */
- .download-tracks-table th:nth-child(5),
.download-tracks-table td.track-match-status {
display: none;
}
+ .download-tracks-table:has(.track-select-header) th:nth-child(6),
+ .download-tracks-table:not(:has(.track-select-header)) th:nth-child(4) {
+ display: none;
+ }
+
.track-name {
max-width: 100px;
}
diff --git a/webui/static/script.js b/webui/static/script.js
index c7f17de5..21fa2ea2 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -6007,11 +6007,17 @@ async function openDownloadMissingModal(playlistId) {
+
| # |
Track |
Artist |
@@ -6024,6 +6030,11 @@ async function openDownloadMissingModal(playlistId) {
${tracks.map((track, index) => `
+ |
+
+ |
${index + 1} |
${escapeHtml(track.name)} |
${escapeHtml(formatArtists(track.artists))} |
@@ -6394,11 +6405,17 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
+
| # |
Track |
Artist |
@@ -6411,6 +6428,11 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
${spotifyTracks.map((track, index) => `
+ |
+
+ |
${index + 1} |
${escapeHtml(track.name)} |
${escapeHtml(formatArtists(track.artists))} |
@@ -7886,9 +7908,34 @@ async function startMissingTracksProcess(playlistId) {
forceToggleContainer.style.display = 'none';
}
+ // Filter tracks based on checkbox selection (if checkboxes exist in this modal)
+ const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
+ let selectedTracks = process.tracks;
+ if (tbody) {
+ const allCbs = tbody.querySelectorAll('.track-select-cb');
+ if (allCbs.length > 0) {
+ // Checkboxes exist — filter to only checked tracks
+ const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked');
+ const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex)));
+ console.log(`🔲 [Track Selection] Total checkboxes: ${allCbs.length}, Checked: ${checkedCbs.length}`);
+ console.log(`🔲 [Track Selection] Checked indices:`, [...selectedIndices]);
+ console.log(`🔲 [Track Selection] process.tracks has ${process.tracks.length} items, first: "${process.tracks[0]?.name}", last: "${process.tracks[process.tracks.length-1]?.name}"`);
+ // Stamp each selected track with its original table index so the backend
+ // maps status updates back to the correct modal row
+ selectedTracks = process.tracks
+ .map((track, i) => ({ ...track, _original_index: i }))
+ .filter(track => selectedIndices.has(track._original_index));
+ console.log(`🔲 [Track Selection] Filtered to ${selectedTracks.length} tracks:`, selectedTracks.map(t => `[${t._original_index}] ${t.name}`));
+ // Disable checkboxes once analysis starts
+ allCbs.forEach(cb => { cb.disabled = true; });
+ }
+ }
+ const selectAllCb = document.getElementById(`select-all-${playlistId}`);
+ if (selectAllCb) selectAllCb.disabled = true;
+
// Prepare request body - add album/artist context for artist album downloads
const requestBody = {
- tracks: process.tracks,
+ tracks: selectedTracks,
force_download_all: forceDownloadAll
};
@@ -8793,6 +8840,52 @@ async function updateModalWithLiveDownloadProgress() {
}
}
+function toggleAllTrackSelections(playlistId, checked) {
+ const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
+ if (!tbody) return;
+ const checkboxes = tbody.querySelectorAll('.track-select-cb');
+ checkboxes.forEach(cb => { cb.checked = checked; });
+ updateTrackSelectionCount(playlistId);
+}
+
+function updateTrackSelectionCount(playlistId) {
+ const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
+ if (!tbody) return;
+ const allCbs = tbody.querySelectorAll('.track-select-cb');
+ const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked');
+ const total = allCbs.length;
+ const selected = checkedCbs.length;
+
+ // Update selection count label
+ const countLabel = document.getElementById(`track-selection-count-${playlistId}`);
+ if (countLabel) {
+ countLabel.textContent = `${selected} / ${total} tracks selected`;
+ }
+
+ // Update select-all checkbox state
+ const selectAll = document.getElementById(`select-all-${playlistId}`);
+ if (selectAll) {
+ selectAll.checked = selected === total;
+ selectAll.indeterminate = selected > 0 && selected < total;
+ }
+
+ // Update row dimming
+ allCbs.forEach(cb => {
+ const row = cb.closest('tr');
+ if (row) row.classList.toggle('track-deselected', !cb.checked);
+ });
+
+ // Disable Begin Analysis and Add to Wishlist buttons when 0 selected
+ const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
+ if (beginBtn) {
+ beginBtn.disabled = selected === 0;
+ }
+ const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
+ if (wishlistBtn) {
+ wishlistBtn.disabled = selected === 0;
+ }
+}
+
async function cancelAllOperations(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) return;
@@ -11573,7 +11666,18 @@ async function addModalTracksToWishlist(playlistId) {
return;
}
- const tracks = process.tracks;
+ // Filter tracks based on checkbox selection (if checkboxes exist in this modal)
+ const wishlistTbody = document.getElementById(`download-tracks-tbody-${playlistId}`);
+ let tracks = process.tracks;
+ if (wishlistTbody) {
+ const allCbs = wishlistTbody.querySelectorAll('.track-select-cb');
+ if (allCbs.length > 0) {
+ const checkedCbs = wishlistTbody.querySelectorAll('.track-select-cb:checked');
+ const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex)));
+ tracks = process.tracks.filter((_, i) => selectedIndices.has(i));
+ }
+ }
+
// Get artist/album context if available (for artist album downloads)
const artist = process.artist || { name: 'Unknown Artist', id: null };
const album = process.album || process.playlist || { name: 'Playlist', id: playlistId };
@@ -15406,11 +15510,17 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
+
| # |
Track |
Artist |
@@ -15423,6 +15533,11 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
${spotifyTracks.map((track, index) => `
+ |
+
+ |
${index + 1} |
${escapeHtml(track.name)} |
${escapeHtml(formatArtists(track.artists))} |
@@ -22498,11 +22613,17 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
+
| # |
Track Name |
Artist(s) |
@@ -22515,6 +22636,11 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
${spotifyTracks.map((track, index) => `
+ |
+
+ |
${index + 1} |
${escapeHtml(track.name)} |
${escapeHtml(formatArtists(track.artists))} |
diff --git a/webui/static/style.css b/webui/static/style.css
index 88815ec3..a2682109 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -10518,6 +10518,9 @@ body {
background: linear-gradient(135deg,
rgba(40, 40, 40, 0.8) 0%,
rgba(30, 30, 30, 0.9) 100%);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
}
.download-tracks-title {
@@ -10578,16 +10581,16 @@ body {
.track-number {
color: #888888;
font-weight: 500;
- width: 5%;
- /* 5% for track numbers */
+ width: 4%;
+ /* 4% for track numbers */
text-align: center;
}
.track-name {
font-weight: 600;
color: #ffffff;
- width: 25%;
- /* 25% for track names */
+ width: 24%;
+ /* 24% for track names */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -10633,8 +10636,8 @@ body {
.track-download-status {
text-align: center;
- width: 20%;
- /* 20% for download status with progress */
+ width: 19%;
+ /* 19% for download status with progress */
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
@@ -10667,6 +10670,43 @@ body {
width: auto;
}
+.track-select-header,
+.track-select-cell {
+ width: 3%;
+ text-align: center;
+ padding: 12px 6px !important;
+}
+
+.track-select-header input[type="checkbox"],
+.track-select-cell input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ accent-color: #1db954;
+}
+
+.track-select-cell input[type="checkbox"]:disabled {
+ cursor: default;
+ opacity: 0.5;
+}
+
+.track-deselected td:not(.track-select-cell) {
+ opacity: 0.4;
+}
+
+.track-deselected .track-name,
+.track-deselected .track-artist {
+ text-decoration: line-through;
+ text-decoration-color: rgba(255, 255, 255, 0.3);
+}
+
+.track-selection-count {
+ font-size: 12px;
+ color: #999;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
.cancel-track-btn {
background: #f44336;
color: #ffffff;