feat: add kept downloads section to admin UI

- List all downloaded files with artist/album/file info
- Download button to save files locally
- Delete button with live row removal
- Shows total file count and size
- Auto-refreshes every 30 seconds
- Security: path validation to prevent directory traversal
This commit is contained in:
2026-02-06 22:29:28 -05:00
parent ac1fbd4b34
commit a2b1eace5f
2 changed files with 305 additions and 0 deletions

View File

@@ -3302,3 +3302,187 @@ public class LinkPlaylistRequest
public string Name { get; set; } = string.Empty;
public string SpotifyPlaylistId { get; set; } = string.Empty;
}
/// <summary>
/// GET /api/admin/downloads
/// Lists all downloaded files in the downloads directory
/// </summary>
[HttpGet("downloads")]
public IActionResult GetDownloads()
{
try
{
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
if (!Directory.Exists(downloadPath))
{
return Ok(new { files = new List<object>(), totalSize = 0 });
}
var files = new List<object>();
long totalSize = 0;
// Recursively get all audio files
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(downloadPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
foreach (var filePath in allFiles)
{
var fileInfo = new FileInfo(filePath);
var relativePath = Path.GetRelativePath(downloadPath, filePath);
// Parse artist/album/track from path structure
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var artist = parts.Length > 0 ? parts[0] : "";
var album = parts.Length > 1 ? parts[1] : "";
var fileName = parts.Length > 2 ? parts[^1] : Path.GetFileName(filePath);
files.Add(new
{
path = relativePath,
fullPath = filePath,
artist,
album,
fileName,
size = fileInfo.Length,
sizeFormatted = FormatFileSize(fileInfo.Length),
lastModified = fileInfo.LastWriteTimeUtc,
extension = fileInfo.Extension
});
totalSize += fileInfo.Length;
}
return Ok(new
{
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
totalSize,
totalSizeFormatted = FormatFileSize(totalSize),
count = files.Count
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list downloads");
return StatusCode(500, new { error = "Failed to list downloads" });
}
}
/// <summary>
/// DELETE /api/admin/downloads
/// Deletes a specific downloaded file
/// </summary>
[HttpDelete("downloads")]
public IActionResult DeleteDownload([FromQuery] string path)
{
try
{
if (string.IsNullOrEmpty(path))
{
return BadRequest(new { error = "Path is required" });
}
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
var fullPath = Path.Combine(downloadPath, path);
// Security: Ensure the path is within the download directory
var normalizedFullPath = Path.GetFullPath(fullPath);
var normalizedDownloadPath = Path.GetFullPath(downloadPath);
if (!normalizedFullPath.StartsWith(normalizedDownloadPath))
{
return BadRequest(new { error = "Invalid path" });
}
if (!System.IO.File.Exists(fullPath))
{
return NotFound(new { error = "File not found" });
}
System.IO.File.Delete(fullPath);
_logger.LogInformation("Deleted download: {Path}", path);
// Clean up empty directories
var directory = Path.GetDirectoryName(fullPath);
while (directory != null && directory != downloadPath)
{
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
_logger.LogDebug("Deleted empty directory: {Dir}", directory);
}
else
{
break;
}
directory = Path.GetDirectoryName(directory);
}
return Ok(new { success = true, message = "File deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete download: {Path}", path);
return StatusCode(500, new { error = "Failed to delete file" });
}
}
/// <summary>
/// GET /api/admin/downloads/file
/// Downloads a specific file
/// </summary>
[HttpGet("downloads/file")]
public IActionResult DownloadFile([FromQuery] string path)
{
try
{
if (string.IsNullOrEmpty(path))
{
return BadRequest(new { error = "Path is required" });
}
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
var fullPath = Path.Combine(downloadPath, path);
// Security: Ensure the path is within the download directory
var normalizedFullPath = Path.GetFullPath(fullPath);
var normalizedDownloadPath = Path.GetFullPath(downloadPath);
if (!normalizedFullPath.StartsWith(normalizedDownloadPath))
{
return BadRequest(new { error = "Invalid path" });
}
if (!System.IO.File.Exists(fullPath))
{
return NotFound(new { error = "File not found" });
}
var fileName = Path.GetFileName(fullPath);
var fileStream = System.IO.File.OpenRead(fullPath);
return File(fileStream, "application/octet-stream", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download file: {Path}", path);
return StatusCode(500, new { error = "Failed to download file" });
}
}
private static string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}

View File

@@ -755,6 +755,47 @@
</tbody>
</table>
</div>
<!-- Kept Downloads Section -->
<div class="card">
<h2>
Kept Downloads
<div class="actions">
<button onclick="fetchDownloads()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Downloaded files stored permanently. Download or delete individual tracks.
</p>
<div id="downloads-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span style="color: var(--text-secondary);">Total Files:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-count">0</span>
</div>
<div>
<span style="color: var(--text-secondary);">Total Size:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0 B</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Artist</th>
<th>Album</th>
<th>File</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="downloads-table-body">
<tr>
<td colspan="5" class="loading">
<span class="spinner"></span> Loading downloads...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Configuration Tab -->
@@ -1526,6 +1567,84 @@
}
}
async function fetchDownloads() {
try {
const res = await fetch('/api/admin/downloads');
const data = await res.json();
const tbody = document.getElementById('downloads-table-body');
// Update summary
document.getElementById('downloads-count').textContent = data.count;
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
if (data.count === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
return;
}
tbody.innerHTML = data.files.map(f => {
return `
<tr data-path="${escapeHtml(f.path)}">
<td><strong>${escapeHtml(f.artist)}</strong></td>
<td>${escapeHtml(f.album)}</td>
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
<td>
<button onclick="downloadFile('${escapeJs(f.path)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button onclick="deleteDownload('${escapeJs(f.path)}')"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch downloads:', error);
showToast('Failed to fetch downloads', 'error');
}
}
async function downloadFile(path) {
try {
window.open(`/api/admin/downloads/file?path=${encodeURIComponent(path)}`, '_blank');
} catch (error) {
console.error('Failed to download file:', error);
showToast('Failed to download file', 'error');
}
}
async function deleteDownload(path) {
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
return;
}
try {
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
method: 'DELETE'
});
if (res.ok) {
showToast('File deleted successfully', 'success');
// Remove the row immediately for live update
const row = document.querySelector(`tr[data-path="${CSS.escape(path)}"]`);
if (row) {
row.remove();
}
// Refresh to update counts
await fetchDownloads();
} else {
const error = await res.json();
showToast(error.error || 'Failed to delete file', 'error');
}
} catch (error) {
console.error('Failed to delete file:', error);
showToast('Failed to delete file', 'error');
}
}
async function fetchConfig() {
try {
const res = await fetch('/api/admin/config');
@@ -2634,6 +2753,7 @@
fetchPlaylists();
fetchTrackMappings();
fetchMissingTracks();
fetchDownloads();
fetchJellyfinUsers();
fetchJellyfinPlaylists();
fetchConfig();
@@ -2644,6 +2764,7 @@
fetchPlaylists();
fetchTrackMappings();
fetchMissingTracks();
fetchDownloads();
}, 30000);
</script>
</body>