mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-25 03:12:54 -04:00
fix(webui): stabilize admin playlists and kept downloads UX
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user