diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index a537d1e..c957470 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -3302,3 +3302,187 @@ public class LinkPlaylistRequest public string Name { get; set; } = string.Empty; public string SpotifyPlaylistId { get; set; } = string.Empty; } + + + /// + /// GET /api/admin/downloads + /// Lists all downloaded files in the downloads directory + /// + [HttpGet("downloads")] + public IActionResult GetDownloads() + { + try + { + var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads"; + + if (!Directory.Exists(downloadPath)) + { + return Ok(new { files = new List(), totalSize = 0 }); + } + + var files = new List(); + 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" }); + } + } + + /// + /// DELETE /api/admin/downloads + /// Deletes a specific downloaded file + /// + [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" }); + } + } + + /// + /// GET /api/admin/downloads/file + /// Downloads a specific file + /// + [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]}"; + } +} diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 7453d25..8419f8d 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -755,6 +755,47 @@ + + +
+

+ Kept Downloads +
+ +
+

+

+ Downloaded files stored permanently. Download or delete individual tracks. +

+
+
+ Total Files: + 0 +
+
+ Total Size: + 0 B +
+
+ + + + + + + + + + + + + + + +
ArtistAlbumFileSizeActions
+ Loading downloads... +
+
@@ -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 = 'No downloaded files found.'; + return; + } + + tbody.innerHTML = data.files.map(f => { + return ` + + ${escapeHtml(f.artist)} + ${escapeHtml(f.album)} + ${escapeHtml(f.fileName)} + ${f.sizeFormatted} + + + + + + `; + }).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);