mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -3302,3 +3302,187 @@ public class LinkPlaylistRequest
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string SpotifyPlaylistId { 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]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -755,6 +755,47 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Configuration Tab -->
|
<!-- 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() {
|
async function fetchConfig() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await fetch('/api/admin/config');
|
||||||
@@ -2634,6 +2753,7 @@
|
|||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
fetchTrackMappings();
|
fetchTrackMappings();
|
||||||
fetchMissingTracks();
|
fetchMissingTracks();
|
||||||
|
fetchDownloads();
|
||||||
fetchJellyfinUsers();
|
fetchJellyfinUsers();
|
||||||
fetchJellyfinPlaylists();
|
fetchJellyfinPlaylists();
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
@@ -2644,6 +2764,7 @@
|
|||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
fetchTrackMappings();
|
fetchTrackMappings();
|
||||||
fetchMissingTracks();
|
fetchMissingTracks();
|
||||||
|
fetchDownloads();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user