fix(webui): stabilize admin playlists and kept downloads UX

This commit is contained in:
2026-04-07 15:55:03 -04:00
parent 919336b81a
commit 3c02988134
9 changed files with 164 additions and 30 deletions
@@ -139,6 +139,56 @@ public class DownloadsController : ControllerBase
}
}
/// <summary>
/// DELETE /api/admin/downloads/all
/// Deletes all kept audio files and removes empty folders
/// </summary>
[HttpDelete("downloads/all")]
public IActionResult DeleteAllDownloads()
{
try
{
var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"));
if (!Directory.Exists(keptPath))
{
return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" });
}
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
foreach (var filePath in allFiles)
{
System.IO.File.Delete(filePath);
}
// Clean up empty directories under kept root (deepest first)
var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length);
foreach (var directory in allDirectories)
{
if (!Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
}
}
return Ok(new
{
success = true,
deletedCount = allFiles.Count,
message = $"Deleted {allFiles.Count} kept download(s)"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete all kept downloads");
return StatusCode(500, new { error = "Failed to delete all kept downloads" });
}
}
/// <summary>
/// GET /api/admin/downloads/file
/// Downloads a specific file from the kept folder
@@ -245,7 +245,9 @@ public class JellyfinAdminController : ControllerBase
/// Get all playlists from the user's Spotify account
/// </summary>
[HttpGet("jellyfin/playlists")]
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
public async Task<IActionResult> GetJellyfinPlaylists(
[FromQuery] string? userId = null,
[FromQuery] bool includeStats = true)
{
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
{
@@ -330,13 +332,13 @@ public class JellyfinAdminController : ControllerBase
var statsUserId = requestedUserId;
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
if (isConfigured)
if (isConfigured && includeStats)
{
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
}
var actualTrackCount = isConfigured
? trackStats.LocalTracks + trackStats.ExternalTracks
? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount)
: childCount;
playlists.Add(new
@@ -349,6 +351,7 @@ public class JellyfinAdminController : ControllerBase
isLinkedByAnotherUser,
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
allLinkedForPlaylist.FirstOrDefault()?.UserId,
statsPending = isConfigured && !includeStats,
localTracks = trackStats.LocalTracks,
externalTracks = trackStats.ExternalTracks,
externalAvailable = trackStats.ExternalAvailable
+1 -7
View File
@@ -93,13 +93,6 @@
<!-- Dashboard Tab -->
<div class="tab-content active" id="tab-dashboard">
<div class="card" id="download-activity-card">
<h2>Live Download Queue</h2>
<div id="download-activity-list" class="download-queue-list">
<div class="empty-state">No active downloads</div>
</div>
</div>
<div class="grid">
<div class="card">
<h2>Spotify API</h2>
@@ -366,6 +359,7 @@
Kept Downloads
<div class="actions">
<button onclick="downloadAllKept()" class="primary">Download All</button>
<button onclick="deleteAllKept()" class="danger">Delete All</button>
<button onclick="fetchDownloads()">Refresh</button>
</div>
</h2>
+15 -2
View File
@@ -124,6 +124,14 @@ export async function deleteDownload(path) {
);
}
export async function deleteAllDownloads() {
return requestJson(
"/api/admin/downloads/all",
{ method: "DELETE" },
"Failed to delete all downloads",
);
}
export async function fetchConfig() {
return requestJson(
"/api/admin/config",
@@ -144,10 +152,15 @@ export async function fetchJellyfinUsers() {
return requestOptionalJson("/api/admin/jellyfin/users");
}
export async function fetchJellyfinPlaylists(userId = null) {
export async function fetchJellyfinPlaylists(userId = null, includeStats = true) {
let url = "/api/admin/jellyfin/playlists";
const params = [];
if (userId) {
url += "?userId=" + encodeURIComponent(userId);
params.push("userId=" + encodeURIComponent(userId));
}
params.push("includeStats=" + String(Boolean(includeStats)));
if (params.length > 0) {
url += "?" + params.join("&");
}
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
+27 -5
View File
@@ -15,6 +15,7 @@ let onCookieNeedsInit = async () => {};
let setCurrentConfigState = () => {};
let syncConfigUiExtras = () => {};
let loadScrobblingConfig = () => {};
let jellyfinPlaylistRequestToken = 0;
async function fetchStatus() {
try {
@@ -129,6 +130,7 @@ async function fetchMissingTracks() {
missing.forEach((t) => {
missingTracks.push({
playlist: playlist.name,
provider: t.externalProvider || t.provider || "squidwtf",
...t,
});
});
@@ -151,6 +153,7 @@ async function fetchMissingTracks() {
const artist =
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
const searchQuery = `${t.title} ${artist}`;
const provider = t.provider || "squidwtf";
const trackPosition = Number.isFinite(t.position)
? Number(t.position)
: 0;
@@ -163,7 +166,7 @@ async function fetchMissingTracks() {
<td class="mapping-actions-cell">
<button class="map-action-btn map-action-search missing-track-search-btn"
data-query="${escapeHtml(searchQuery)}"
data-provider="squidwtf">🔍 Search</button>
data-provider="${escapeHtml(provider)}">🔍 Search</button>
<button class="map-action-btn map-action-local missing-track-local-btn"
data-playlist="${escapeHtml(t.playlist)}"
data-position="${trackPosition}"
@@ -245,11 +248,28 @@ async function fetchJellyfinPlaylists() {
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
try {
const requestToken = ++jellyfinPlaylistRequestToken;
const userId = isAdminSession()
? document.getElementById("jellyfin-user-select")?.value
: null;
const data = await API.fetchJellyfinPlaylists(userId);
UI.updateJellyfinPlaylistsUI(data);
const baseData = await API.fetchJellyfinPlaylists(userId, false);
if (requestToken !== jellyfinPlaylistRequestToken) {
return;
}
UI.updateJellyfinPlaylistsUI(baseData);
// Enrich counts after initial render so big accounts don't appear empty.
API.fetchJellyfinPlaylists(userId, true)
.then((statsData) => {
if (requestToken !== jellyfinPlaylistRequestToken) {
return;
}
UI.updateJellyfinPlaylistsUI(statsData);
})
.catch((err) => {
console.error("Failed to fetch Jellyfin playlist track stats:", err);
});
} catch (error) {
console.error("Failed to fetch Jellyfin playlists:", error);
tbody.innerHTML =
@@ -346,7 +366,10 @@ function startDashboardRefresh() {
fetchPlaylists();
fetchTrackMappings();
fetchMissingTracks();
fetchDownloads();
const keptTab = document.getElementById("tab-kept");
if (keptTab && keptTab.classList.contains("active")) {
fetchDownloads();
}
const endpointsTab = document.getElementById("tab-endpoints");
if (endpointsTab && endpointsTab.classList.contains("active")) {
@@ -380,7 +403,6 @@ async function loadDashboardData() {
}
startDashboardRefresh();
startDownloadActivityStream();
}
function startDownloadActivityStream() {
+19 -6
View File
@@ -670,13 +670,26 @@ export async function saveLyricsMapping() {
// Search provider (open in new tab)
export async function searchProvider(query, provider) {
try {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl; // Use the actual property name from API
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
const normalizedProvider = (provider || "squidwtf").toLowerCase();
let searchUrl = "";
if (normalizedProvider === "squidwtf" || normalizedProvider === "tidal") {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl;
searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
} else if (normalizedProvider === "deezer") {
searchUrl = `https://www.deezer.com/search/${encodeURIComponent(query)}`;
} else if (normalizedProvider === "qobuz") {
searchUrl = `https://www.qobuz.com/search?query=${encodeURIComponent(query)}`;
} else {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl;
searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
}
window.open(searchUrl, "_blank");
} catch (error) {
console.error("Failed to get SquidWTF base URL:", error);
// Fallback to first encoded URL (triton)
showToast("Failed to get SquidWTF URL, using fallback", "warning");
console.error("Failed to open provider search:", error);
showToast("Failed to open provider search link", "warning");
}
}
+6
View File
@@ -74,6 +74,10 @@ window.switchTab = function (tabName) {
}
content.classList.add("active");
window.location.hash = tabName;
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
window.fetchDownloads();
}
}
};
@@ -147,6 +151,8 @@ window.openManualMap = openManualMap;
window.openExternalMap = openExternalMap;
window.openMapToLocal = openManualMap;
window.openMapToExternal = openExternalMap;
window.openModal = openModal;
window.closeModal = closeModal;
window.searchJellyfinTracks = searchJellyfinTracks;
window.saveLocalMapping = saveLocalMapping;
window.saveManualMapping = saveManualMapping;
+15
View File
@@ -77,6 +77,20 @@ function downloadAllKept() {
}
}
async function deleteAllKept() {
const result = await runAction({
confirmMessage:
"Delete ALL kept downloads?\n\nThis will permanently remove all kept audio files.",
task: () => API.deleteAllDownloads(),
success: (data) => data.message || "All kept downloads deleted",
error: (err) => err.message || "Failed to delete all kept downloads",
});
if (result) {
await fetchDownloads();
}
}
async function deleteDownload(path) {
const result = await runAction({
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
@@ -364,6 +378,7 @@ export function initOperations(options) {
window.deleteTrackMapping = deleteTrackMapping;
window.downloadFile = downloadFile;
window.downloadAllKept = downloadAllKept;
window.deleteAllKept = deleteAllKept;
window.deleteDownload = deleteDownload;
window.refreshPlaylists = refreshPlaylists;
window.refreshPlaylist = refreshPlaylist;
+25 -7
View File
@@ -4,6 +4,7 @@ import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
let rowMenuHandlersBound = false;
let tableRowHandlersBound = false;
const expandedInjectedPlaylistDetails = new Set();
function bindRowMenuHandlers() {
if (rowMenuHandlersBound) {
@@ -118,6 +119,18 @@ function toggleDetailsRow(event, detailsRowId) {
);
if (parentRow) {
parentRow.classList.toggle("expanded", isExpanded);
// Persist Injected Playlists details expansion across auto-refreshes.
if (parentRow.closest("#playlist-table-body")) {
const detailsKey = parentRow.getAttribute("data-details-key");
if (detailsKey) {
if (isExpanded) {
expandedInjectedPlaylistDetails.add(detailsKey);
} else {
expandedInjectedPlaylistDetails.delete(detailsKey);
}
}
}
}
}
@@ -311,6 +324,7 @@ export function updatePlaylistsUI(data) {
const playlists = data.playlists || [];
if (playlists.length === 0) {
expandedInjectedPlaylistDetails.clear();
tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
renderGuidance("playlists-guidance", [
@@ -369,9 +383,12 @@ export function updatePlaylistsUI(data) {
const summary = getPlaylistStatusSummary(playlist);
const detailsRowId = `playlist-details-${index}`;
const menuId = `playlist-menu-${index}`;
const detailsKey = `${playlist.id || playlist.name || index}`;
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
const escapedPlaylistName = escapeHtml(playlist.name);
const escapedSyncSchedule = escapeHtml(syncSchedule);
const escapedDetailsKey = escapeHtml(detailsKey);
const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
@@ -385,7 +402,7 @@ export function updatePlaylistsUI(data) {
}
return `
<tr class="compact-row" data-details-row="${detailsRowId}">
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
<td>
<div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong>
@@ -398,7 +415,7 @@ export function updatePlaylistsUI(data) {
</td>
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
<td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false">Details</button>
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
<div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
@@ -414,7 +431,7 @@ export function updatePlaylistsUI(data) {
</div>
</td>
</tr>
<tr id="${detailsRowId}" class="details-row" hidden>
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
<td colspan="4">
<div class="details-panel">
<div class="details-grid">
@@ -673,6 +690,7 @@ export function updateJellyfinPlaylistsUI(data) {
.map((playlist, index) => {
const detailsRowId = `jellyfin-details-${index}`;
const menuId = `jellyfin-menu-${index}`;
const statsPending = Boolean(playlist.statsPending);
const localCount = playlist.localTracks || 0;
const externalCount = playlist.externalTracks || 0;
const externalAvailable = playlist.externalAvailable || 0;
@@ -700,8 +718,8 @@ export function updateJellyfinPlaylistsUI(data) {
</div>
</td>
<td>
<span class="track-count">${localCount + externalAvailable}</span>
<div class="meta-text">L ${localCount} • E ${externalAvailable}/${externalCount}</div>
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
</td>
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
<td class="row-controls">
@@ -721,11 +739,11 @@ export function updateJellyfinPlaylistsUI(data) {
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Local Tracks</span>
<span class="detail-value">${localCount}</span>
<span class="detail-value">${statsPending ? "..." : localCount}</span>
</div>
<div class="detail-item">
<span class="detail-label">External Tracks</span>
<span class="detail-value">${externalAvailable}/${externalCount}</span>
<span class="detail-value">${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}</span>
</div>
<div class="detail-item">
<span class="detail-label">Linked Spotify ID</span>