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]}";
}
}