mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -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>
|
/// <summary>
|
||||||
/// GET /api/admin/downloads/file
|
/// GET /api/admin/downloads/file
|
||||||
/// Downloads a specific file from the kept folder
|
/// 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
|
/// Get all playlists from the user's Spotify account
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("jellyfin/playlists")]
|
[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))
|
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||||
{
|
{
|
||||||
@@ -330,13 +332,13 @@ public class JellyfinAdminController : ControllerBase
|
|||||||
|
|
||||||
var statsUserId = requestedUserId;
|
var statsUserId = requestedUserId;
|
||||||
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||||
if (isConfigured)
|
if (isConfigured && includeStats)
|
||||||
{
|
{
|
||||||
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
|
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var actualTrackCount = isConfigured
|
var actualTrackCount = isConfigured
|
||||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount)
|
||||||
: childCount;
|
: childCount;
|
||||||
|
|
||||||
playlists.Add(new
|
playlists.Add(new
|
||||||
@@ -349,6 +351,7 @@ public class JellyfinAdminController : ControllerBase
|
|||||||
isLinkedByAnotherUser,
|
isLinkedByAnotherUser,
|
||||||
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
|
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
|
||||||
allLinkedForPlaylist.FirstOrDefault()?.UserId,
|
allLinkedForPlaylist.FirstOrDefault()?.UserId,
|
||||||
|
statsPending = isConfigured && !includeStats,
|
||||||
localTracks = trackStats.LocalTracks,
|
localTracks = trackStats.LocalTracks,
|
||||||
externalTracks = trackStats.ExternalTracks,
|
externalTracks = trackStats.ExternalTracks,
|
||||||
externalAvailable = trackStats.ExternalAvailable
|
externalAvailable = trackStats.ExternalAvailable
|
||||||
|
|||||||
@@ -93,13 +93,6 @@
|
|||||||
|
|
||||||
<!-- Dashboard Tab -->
|
<!-- Dashboard Tab -->
|
||||||
<div class="tab-content active" id="tab-dashboard">
|
<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="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Spotify API</h2>
|
<h2>Spotify API</h2>
|
||||||
@@ -366,6 +359,7 @@
|
|||||||
Kept Downloads
|
Kept Downloads
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="downloadAllKept()" class="primary">Download All</button>
|
<button onclick="downloadAllKept()" class="primary">Download All</button>
|
||||||
|
<button onclick="deleteAllKept()" class="danger">Delete All</button>
|
||||||
<button onclick="fetchDownloads()">Refresh</button>
|
<button onclick="fetchDownloads()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</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() {
|
export async function fetchConfig() {
|
||||||
return requestJson(
|
return requestJson(
|
||||||
"/api/admin/config",
|
"/api/admin/config",
|
||||||
@@ -144,10 +152,15 @@ export async function fetchJellyfinUsers() {
|
|||||||
return requestOptionalJson("/api/admin/jellyfin/users");
|
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";
|
let url = "/api/admin/jellyfin/playlists";
|
||||||
|
const params = [];
|
||||||
if (userId) {
|
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");
|
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ let onCookieNeedsInit = async () => {};
|
|||||||
let setCurrentConfigState = () => {};
|
let setCurrentConfigState = () => {};
|
||||||
let syncConfigUiExtras = () => {};
|
let syncConfigUiExtras = () => {};
|
||||||
let loadScrobblingConfig = () => {};
|
let loadScrobblingConfig = () => {};
|
||||||
|
let jellyfinPlaylistRequestToken = 0;
|
||||||
|
|
||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -129,6 +130,7 @@ async function fetchMissingTracks() {
|
|||||||
missing.forEach((t) => {
|
missing.forEach((t) => {
|
||||||
missingTracks.push({
|
missingTracks.push({
|
||||||
playlist: playlist.name,
|
playlist: playlist.name,
|
||||||
|
provider: t.externalProvider || t.provider || "squidwtf",
|
||||||
...t,
|
...t,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -151,6 +153,7 @@ async function fetchMissingTracks() {
|
|||||||
const artist =
|
const artist =
|
||||||
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
|
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
|
||||||
const searchQuery = `${t.title} ${artist}`;
|
const searchQuery = `${t.title} ${artist}`;
|
||||||
|
const provider = t.provider || "squidwtf";
|
||||||
const trackPosition = Number.isFinite(t.position)
|
const trackPosition = Number.isFinite(t.position)
|
||||||
? Number(t.position)
|
? Number(t.position)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -163,7 +166,7 @@ async function fetchMissingTracks() {
|
|||||||
<td class="mapping-actions-cell">
|
<td class="mapping-actions-cell">
|
||||||
<button class="map-action-btn map-action-search missing-track-search-btn"
|
<button class="map-action-btn map-action-search missing-track-search-btn"
|
||||||
data-query="${escapeHtml(searchQuery)}"
|
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"
|
<button class="map-action-btn map-action-local missing-track-local-btn"
|
||||||
data-playlist="${escapeHtml(t.playlist)}"
|
data-playlist="${escapeHtml(t.playlist)}"
|
||||||
data-position="${trackPosition}"
|
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>';
|
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const requestToken = ++jellyfinPlaylistRequestToken;
|
||||||
const userId = isAdminSession()
|
const userId = isAdminSession()
|
||||||
? document.getElementById("jellyfin-user-select")?.value
|
? document.getElementById("jellyfin-user-select")?.value
|
||||||
: null;
|
: null;
|
||||||
const data = await API.fetchJellyfinPlaylists(userId);
|
const baseData = await API.fetchJellyfinPlaylists(userId, false);
|
||||||
UI.updateJellyfinPlaylistsUI(data);
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch Jellyfin playlists:", error);
|
console.error("Failed to fetch Jellyfin playlists:", error);
|
||||||
tbody.innerHTML =
|
tbody.innerHTML =
|
||||||
@@ -346,7 +366,10 @@ function startDashboardRefresh() {
|
|||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
fetchTrackMappings();
|
fetchTrackMappings();
|
||||||
fetchMissingTracks();
|
fetchMissingTracks();
|
||||||
fetchDownloads();
|
const keptTab = document.getElementById("tab-kept");
|
||||||
|
if (keptTab && keptTab.classList.contains("active")) {
|
||||||
|
fetchDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
const endpointsTab = document.getElementById("tab-endpoints");
|
const endpointsTab = document.getElementById("tab-endpoints");
|
||||||
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
||||||
@@ -380,7 +403,6 @@ async function loadDashboardData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startDashboardRefresh();
|
startDashboardRefresh();
|
||||||
startDownloadActivityStream();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDownloadActivityStream() {
|
function startDownloadActivityStream() {
|
||||||
|
|||||||
@@ -670,13 +670,26 @@ export async function saveLyricsMapping() {
|
|||||||
// Search provider (open in new tab)
|
// Search provider (open in new tab)
|
||||||
export async function searchProvider(query, provider) {
|
export async function searchProvider(query, provider) {
|
||||||
try {
|
try {
|
||||||
const data = await API.getSquidWTFBaseUrl();
|
const normalizedProvider = (provider || "squidwtf").toLowerCase();
|
||||||
const baseUrl = data.baseUrl; // Use the actual property name from API
|
let searchUrl = "";
|
||||||
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
|
|
||||||
|
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");
|
window.open(searchUrl, "_blank");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get SquidWTF base URL:", error);
|
console.error("Failed to open provider search:", error);
|
||||||
// Fallback to first encoded URL (triton)
|
showToast("Failed to open provider search link", "warning");
|
||||||
showToast("Failed to get SquidWTF URL, using fallback", "warning");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ window.switchTab = function (tabName) {
|
|||||||
}
|
}
|
||||||
content.classList.add("active");
|
content.classList.add("active");
|
||||||
window.location.hash = tabName;
|
window.location.hash = tabName;
|
||||||
|
|
||||||
|
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
|
||||||
|
window.fetchDownloads();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,6 +151,8 @@ window.openManualMap = openManualMap;
|
|||||||
window.openExternalMap = openExternalMap;
|
window.openExternalMap = openExternalMap;
|
||||||
window.openMapToLocal = openManualMap;
|
window.openMapToLocal = openManualMap;
|
||||||
window.openMapToExternal = openExternalMap;
|
window.openMapToExternal = openExternalMap;
|
||||||
|
window.openModal = openModal;
|
||||||
|
window.closeModal = closeModal;
|
||||||
window.searchJellyfinTracks = searchJellyfinTracks;
|
window.searchJellyfinTracks = searchJellyfinTracks;
|
||||||
window.saveLocalMapping = saveLocalMapping;
|
window.saveLocalMapping = saveLocalMapping;
|
||||||
window.saveManualMapping = saveManualMapping;
|
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) {
|
async function deleteDownload(path) {
|
||||||
const result = await runAction({
|
const result = await runAction({
|
||||||
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
|
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.deleteTrackMapping = deleteTrackMapping;
|
||||||
window.downloadFile = downloadFile;
|
window.downloadFile = downloadFile;
|
||||||
window.downloadAllKept = downloadAllKept;
|
window.downloadAllKept = downloadAllKept;
|
||||||
|
window.deleteAllKept = deleteAllKept;
|
||||||
window.deleteDownload = deleteDownload;
|
window.deleteDownload = deleteDownload;
|
||||||
window.refreshPlaylists = refreshPlaylists;
|
window.refreshPlaylists = refreshPlaylists;
|
||||||
window.refreshPlaylist = refreshPlaylist;
|
window.refreshPlaylist = refreshPlaylist;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
|
|||||||
|
|
||||||
let rowMenuHandlersBound = false;
|
let rowMenuHandlersBound = false;
|
||||||
let tableRowHandlersBound = false;
|
let tableRowHandlersBound = false;
|
||||||
|
const expandedInjectedPlaylistDetails = new Set();
|
||||||
|
|
||||||
function bindRowMenuHandlers() {
|
function bindRowMenuHandlers() {
|
||||||
if (rowMenuHandlersBound) {
|
if (rowMenuHandlersBound) {
|
||||||
@@ -118,6 +119,18 @@ function toggleDetailsRow(event, detailsRowId) {
|
|||||||
);
|
);
|
||||||
if (parentRow) {
|
if (parentRow) {
|
||||||
parentRow.classList.toggle("expanded", isExpanded);
|
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 || [];
|
const playlists = data.playlists || [];
|
||||||
|
|
||||||
if (playlists.length === 0) {
|
if (playlists.length === 0) {
|
||||||
|
expandedInjectedPlaylistDetails.clear();
|
||||||
tbody.innerHTML =
|
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>';
|
'<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", [
|
renderGuidance("playlists-guidance", [
|
||||||
@@ -369,9 +383,12 @@ export function updatePlaylistsUI(data) {
|
|||||||
const summary = getPlaylistStatusSummary(playlist);
|
const summary = getPlaylistStatusSummary(playlist);
|
||||||
const detailsRowId = `playlist-details-${index}`;
|
const detailsRowId = `playlist-details-${index}`;
|
||||||
const menuId = `playlist-menu-${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 syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
||||||
const escapedPlaylistName = escapeHtml(playlist.name);
|
const escapedPlaylistName = escapeHtml(playlist.name);
|
||||||
const escapedSyncSchedule = escapeHtml(syncSchedule);
|
const escapedSyncSchedule = escapeHtml(syncSchedule);
|
||||||
|
const escapedDetailsKey = escapeHtml(detailsKey);
|
||||||
|
|
||||||
const breakdownBadges = [
|
const breakdownBadges = [
|
||||||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
||||||
@@ -385,7 +402,7 @@ export function updatePlaylistsUI(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="compact-row" data-details-row="${detailsRowId}">
|
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
|
||||||
<td>
|
<td>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<strong>${escapeHtml(playlist.name)}</strong>
|
<strong>${escapeHtml(playlist.name)}</strong>
|
||||||
@@ -398,7 +415,7 @@ export function updatePlaylistsUI(data) {
|
|||||||
</td>
|
</td>
|
||||||
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
||||||
<td class="row-controls">
|
<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">
|
<div class="row-actions-wrap">
|
||||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||||||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||||
@@ -414,7 +431,7 @@ export function updatePlaylistsUI(data) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="${detailsRowId}" class="details-row" hidden>
|
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<div class="details-panel">
|
<div class="details-panel">
|
||||||
<div class="details-grid">
|
<div class="details-grid">
|
||||||
@@ -673,6 +690,7 @@ export function updateJellyfinPlaylistsUI(data) {
|
|||||||
.map((playlist, index) => {
|
.map((playlist, index) => {
|
||||||
const detailsRowId = `jellyfin-details-${index}`;
|
const detailsRowId = `jellyfin-details-${index}`;
|
||||||
const menuId = `jellyfin-menu-${index}`;
|
const menuId = `jellyfin-menu-${index}`;
|
||||||
|
const statsPending = Boolean(playlist.statsPending);
|
||||||
const localCount = playlist.localTracks || 0;
|
const localCount = playlist.localTracks || 0;
|
||||||
const externalCount = playlist.externalTracks || 0;
|
const externalCount = playlist.externalTracks || 0;
|
||||||
const externalAvailable = playlist.externalAvailable || 0;
|
const externalAvailable = playlist.externalAvailable || 0;
|
||||||
@@ -700,8 +718,8 @@ export function updateJellyfinPlaylistsUI(data) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="track-count">${localCount + externalAvailable}</span>
|
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
|
||||||
<div class="meta-text">L ${localCount} • E ${externalAvailable}/${externalCount}</div>
|
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
|
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
|
||||||
<td class="row-controls">
|
<td class="row-controls">
|
||||||
@@ -721,11 +739,11 @@ export function updateJellyfinPlaylistsUI(data) {
|
|||||||
<div class="details-grid">
|
<div class="details-grid">
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Local Tracks</span>
|
<span class="detail-label">Local Tracks</span>
|
||||||
<span class="detail-value">${localCount}</span>
|
<span class="detail-value">${statsPending ? "..." : localCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">External Tracks</span>
|
<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>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Linked Spotify ID</span>
|
<span class="detail-label">Linked Spotify ID</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user