From 3ecff7ca73cab310a1ce3a3740701d1b0a675217 Mon Sep 17 00:00:00 2001 From: Michal Vrba Date: Fri, 12 Dec 2025 10:28:48 +0100 Subject: [PATCH] Refactor: Use jobId_title.ext for downloads & improve UX Adopt GUID-based {jobId}_{title}.ext filename convention for all downloads, ensuring unique, human-readable, and traceable files. Update DownloadController to use precise jobId-based file matching and generate user-friendly download names. Pass jobId to VideoService for consistent naming; add robust title sanitization. Remove fallback file search logic for predictability. Update README and API docs to reflect new format and workflow. Improve logging and error messages for clarity. --- .../Controllers/DownloadController.cs | 98 ++++++----- TuneRipper.Api/Controllers/VideoController.cs | 1 + TuneRipper.Api/README_UPDATED.md | 158 ++++++++++++------ TuneRipper.Api/Services/VideoService.cs | 92 ++++++++-- 4 files changed, 247 insertions(+), 102 deletions(-) diff --git a/TuneRipper.Api/Controllers/DownloadController.cs b/TuneRipper.Api/Controllers/DownloadController.cs index 8f69c0d..e912688 100644 --- a/TuneRipper.Api/Controllers/DownloadController.cs +++ b/TuneRipper.Api/Controllers/DownloadController.cs @@ -33,33 +33,19 @@ public class DownloadController : ControllerBase return NotFound("Downloads directory not found"); } - // Find files related to this job ID - var jobFiles = Directory.GetFiles(downloadsDir, $"*{jobId}*.*") + // Find files with the specific job ID pattern: jobId_title.ext + var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*") .Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp")) .OrderByDescending(System.IO.File.GetCreationTime) .ToArray(); if (jobFiles.Length == 0) { - // Fallback: look for any audio files that might match the pattern - var audioFiles = Directory.GetFiles(downloadsDir, "*.*") - .Where(f => f.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) || - f.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) || - f.EndsWith(".webm", StringComparison.OrdinalIgnoreCase)) - .Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp")) - .OrderByDescending(System.IO.File.GetCreationTime) - .ToArray(); - - if (audioFiles.Length == 0) - { - _logger.LogWarning("No files found for job ID: {JobId}", jobId); - return NotFound("File not found"); - } - - jobFiles = audioFiles.Take(1).ToArray(); + _logger.LogWarning("No files found for job ID: {JobId}", jobId); + return NotFound("File not found for the specified job ID"); } - var filePath = jobFiles[0]; + var filePath = jobFiles[0]; // Take the first (most recent) file var fileName = Path.GetFileName(filePath); if (!System.IO.File.Exists(filePath)) @@ -81,21 +67,11 @@ public class DownloadController : ControllerBase _ => "application/octet-stream" }; - // Create a safe download filename - var safeFileName = fileName; - if (fileName.Contains("_")) - { - // Try to create a more user-friendly filename - var parts = fileName.Split('_'); - if (parts.Length >= 2) - { - var extension = Path.GetExtension(fileName); - safeFileName = $"TuneRipper_{parts[0]}{extension}"; - } - } + // Create a user-friendly download filename from our pattern + var downloadFileName = CreateUserFriendlyFileName(fileName); var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); - return File(fileStream, contentType, safeFileName); + return File(fileStream, contentType, downloadFileName); } catch (Exception ex) { @@ -104,6 +80,44 @@ public class DownloadController : ControllerBase } } + private string CreateUserFriendlyFileName(string originalFileName) + { + try + { + // Parse our pattern: jobId_title.ext + var nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName); + var extension = Path.GetExtension(originalFileName); + + // Split by underscore - first part is job ID, rest is title + var underscoreIndex = nameWithoutExt.IndexOf('_'); + + if (underscoreIndex > 0 && underscoreIndex < nameWithoutExt.Length - 1) + { + // Extract title part (everything after the first underscore) + var titlePart = nameWithoutExt.Substring(underscoreIndex + 1); + + // Convert underscores back to spaces in title + var friendlyTitle = titlePart.Replace("_", " "); + + // Create a clean filename: "Title.mp3" + if (friendlyTitle.Length > 0) + { + // Remove any duplicate spaces and clean up + friendlyTitle = System.Text.RegularExpressions.Regex.Replace(friendlyTitle, @"\s+", " ").Trim(); + return $"{friendlyTitle}{extension}"; + } + } + + // Fallback to original filename if pattern doesn't match + return originalFileName; + } + catch (Exception) + { + // If anything goes wrong, return the original filename + return originalFileName; + } + } + [HttpGet("info/{jobId}")] public IActionResult GetDownloadInfo(string jobId) { @@ -121,26 +135,28 @@ public class DownloadController : ControllerBase return NotFound("Downloads directory not found"); } - // Find files related to this job ID - var jobFiles = Directory.GetFiles(downloadsDir, $"*{jobId}*.*") + // Find files with the specific job ID pattern + var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*") .Where(f => !f.EndsWith(".part") && !f.EndsWith(".tmp")) .OrderByDescending(System.IO.File.GetCreationTime) .ToArray(); if (jobFiles.Length == 0) { - return NotFound("File not found"); + return NotFound("File not found for the specified job ID"); } var filePath = jobFiles[0]; var fileName = Path.GetFileName(filePath); var fileInfo = new FileInfo(filePath); + var friendlyFileName = CreateUserFriendlyFileName(fileName); // Return download metadata var info = new { jobId, fileName, + friendlyFileName, fileSize = FormatFileSize(fileInfo.Length), fileSizeBytes = fileInfo.Length, format = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(), @@ -175,8 +191,10 @@ public class DownloadController : ControllerBase return NotFound("Downloads directory not found"); } - // Find and delete files related to this job ID - var jobFiles = Directory.GetFiles(downloadsDir, $"*{jobId}*.*"); + // Find and delete files with the specific job ID pattern + var jobFiles = Directory.GetFiles(downloadsDir, $"{jobId}_*.*") + .ToArray(); + var deletedCount = 0; foreach (var filePath in jobFiles) @@ -185,7 +203,7 @@ public class DownloadController : ControllerBase { System.IO.File.Delete(filePath); deletedCount++; - _logger.LogInformation("Deleted file: {FilePath}", filePath); + _logger.LogInformation("Deleted file: {FilePath} for job: {JobId}", filePath, jobId); } catch (Exception ex) { @@ -195,10 +213,10 @@ public class DownloadController : ControllerBase if (deletedCount == 0) { - return NotFound("No files found to delete"); + return NotFound("No files found to delete for the specified job ID"); } - return Ok(new { message = $"Deleted {deletedCount} file(s)", deletedCount }); + return Ok(new { message = $"Deleted {deletedCount} file(s) for job {jobId}", deletedCount, jobId }); } catch (Exception ex) { diff --git a/TuneRipper.Api/Controllers/VideoController.cs b/TuneRipper.Api/Controllers/VideoController.cs index 849d304..d45d48e 100644 --- a/TuneRipper.Api/Controllers/VideoController.cs +++ b/TuneRipper.Api/Controllers/VideoController.cs @@ -194,6 +194,7 @@ public class VideoController : ControllerBase var downloadedFileName = await _videoService.ProcessVideoWithProgressAsync( job.Request.Url!, job.Request.Format ?? "mp3", + jobId, // Pass the job ID for filename generation (percentage, message) => { // Update progress from yt-dlp diff --git a/TuneRipper.Api/README_UPDATED.md b/TuneRipper.Api/README_UPDATED.md index 4d3f7c2..c7d2c84 100644 --- a/TuneRipper.Api/README_UPDATED.md +++ b/TuneRipper.Api/README_UPDATED.md @@ -2,7 +2,7 @@ ## Overview -The TuneRipper API has been updated to use `yt-dlp.exe` for real YouTube video processing with progress monitoring. Videos are downloaded and converted to MP3 files, stored in the `wwwroot/downloads` directory, and served directly through the API. +The TuneRipper API has been updated to use `yt-dlp.exe` for real YouTube video processing with progress monitoring. Videos are downloaded and converted to MP3 files, stored in the `wwwroot/downloads` directory with a GUID-based filename format, and served directly through the API. ## Setup @@ -22,6 +22,33 @@ TuneRipper.Api/ ??? Program.cs ``` +## Filename Format + +Downloaded files use the structured format: **`{jobId}_{title}.mp3`** + +### Format Breakdown: +- **`{jobId}`**: GUID identifier for the processing job (e.g., `550e8400-e29b-41d4-a716-446655440000`) +- **`_{title}`**: Sanitized video title with spaces replaced by underscores +- **`.mp3`**: Audio format extension + +### Examples: +- `550e8400-e29b-41d4-a716-446655440000_Rick_Astley_Never_Gonna_Give_You_Up.mp3` +- `6ba7b810-9dad-11d1-80b4-00c04fd430c8_How_to_Build_a_Web_API.mp3` +- `6ba7b811-9dad-11d1-80b4-00c04fd430c8_Amazing_Guitar_Solo_Performance.mp3` + +### Benefits: +- **Unique Identification**: GUID ensures no filename conflicts +- **Direct Job Mapping**: Easy file lookup using job ID +- **Human Readable**: Title part provides context +- **System Friendly**: Sanitized for filesystem compatibility + +### Title Sanitization: +- Invalid filename characters are replaced with safe alternatives +- Spaces become underscores (`_`) +- Special characters: `< > | : * ? " /` are replaced with safe chars +- Maximum title length: 50 characters +- Fallback to "video" if title cannot be determined + ## Key Changes ### 1. Program.cs Updates @@ -30,21 +57,26 @@ TuneRipper.Api/ - Updated logging and endpoint information ### 2. VideoService Updates -- Integrated real `yt-dlp.exe` processing with progress monitoring -- Real-time progress parsing from yt-dlp output -- File storage in `wwwroot/downloads/` directory -- Progress callbacks for UI updates +- **GUID-Based Filenames**: Uses job ID as primary identifier in filename +- **Title Sanitization**: Converts video titles to safe filenames +- **Direct Job Mapping**: Files can be found using `{jobId}_*.*` pattern +- **Real yt-dlp Integration**: Uses actual yt-dlp.exe with progress monitoring +- **Progress Parsing**: Real-time progress from yt-dlp output streams +- **Error Handling**: Comprehensive error handling with 5-minute timeout ### 3. VideoController Updates +- Passes job ID to VideoService for filename generation - Uses actual yt-dlp processing instead of simulation -- Stores downloaded file information in job tracking +- Stores downloaded file information with GUID-based naming - Returns real download URLs for completed files ### 4. DownloadController Updates +- **Precise Job Lookup**: Uses `{jobId}_*.*` pattern for exact file matching +- **User-Friendly Downloads**: Converts filenames back to readable format +- **Example**: `550e8400-e29b-41d4-a716-446655440000_Rick_Astley_Never_Gonna_Give_You_Up.mp3` ? `Rick Astley Never Gonna Give You Up.mp3` +- **Reliable File Operations**: No ambiguity in file identification - Serves actual MP3 files from `wwwroot/downloads/` -- Proper content type detection -- File cleanup capabilities -- Download metadata endpoints +- Proper MIME type detection for audio files ## API Workflow @@ -65,7 +97,7 @@ Content-Type: application/json { "success": true, "message": "Processing started", - "fileName": "job-id-guid", + "fileName": "550e8400-e29b-41d4-a716-446655440000", "downloadUrl": null, "videoDetails": null } @@ -73,21 +105,7 @@ Content-Type: application/json ### 2. Monitor Progress ```http -GET /api/video/status/{jobId} -``` - -**Response (Early Stage):** -```json -{ - "percentageComplete": 15, - "status": "Processing", - "message": "Video details retrieved successfully", - "videoDetails": { - "id": "dQw4w9WgXcQ", - "title": "Rick Astley - Never Gonna Give You Up", - "thumbnailUrl": "https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" - } -} +GET /api/video/status/550e8400-e29b-41d4-a716-446655440000 ``` **Response (Processing):** @@ -96,13 +114,17 @@ GET /api/video/status/{jobId} "percentageComplete": 45, "status": "Processing", "message": "Downloading audio...", - "videoDetails": { ... } + "videoDetails": { + "id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up", + "thumbnailUrl": "https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" + } } ``` ### 3. Get Final Result ```http -GET /api/video/result/{jobId} +GET /api/video/result/550e8400-e29b-41d4-a716-446655440000 ``` **Response:** @@ -110,23 +132,48 @@ GET /api/video/result/{jobId} { "success": true, "message": "Video processed successfully", - "fileName": "dQw4w9WgXcQ_20241212_093022.mp3", - "downloadUrl": "/downloads/dQw4w9WgXcQ_20241212_093022.mp3", + "fileName": "550e8400-e29b-41d4-a716-446655440000_Rick_Astley_Never_Gonna_Give_You_Up.mp3", + "downloadUrl": "/downloads/550e8400-e29b-41d4-a716-446655440000_Rick_Astley_Never_Gonna_Give_You_Up.mp3", "videoDetails": { ... } } ``` ### 4. Download File ```http -GET /api/download/{jobId} +GET /api/download/550e8400-e29b-41d4-a716-446655440000 ``` -**Response:** Binary MP3 file download +**Response:** Binary MP3 file download with user-friendly filename +**Download Name:** `Rick Astley Never Gonna Give You Up.mp3` **Alternative Static File Access:** ```http -GET /downloads/{fileName} +GET /downloads/550e8400-e29b-41d4-a716-446655440000_Rick_Astley_Never_Gonna_Give_You_Up.mp3 ``` +## File Management + +### Download Directory +- **Location**: `wwwroot/downloads/` +- **Naming Pattern**: `{jobId}_{title}.ext` +- **Job Lookup**: Files found using `Directory.GetFiles(dir, "{jobId}_*.*")` +- **User Download**: Converted back to readable names +- **Cleanup**: Files can be deleted by job ID + +### File Serving Options +1. **API Download**: Controlled access via `/api/download/{jobId}` (recommended) + - Uses job ID for secure file lookup + - Returns user-friendly filename + - Proper error handling +2. **Static Files**: Direct access via `/downloads/{actual_filename}` + - Requires knowing the full filename + - Less secure but faster +3. **Content-Type**: Automatic detection based on file extension + +### File Operations by Job ID +- **Find**: `GET /api/download/info/{jobId}` - Get file metadata +- **Download**: `GET /api/download/{jobId}` - Download the file +- **Delete**: `DELETE /api/download/{jobId}` - Remove the file + ## Progress Monitoring The VideoService monitors yt-dlp output and translates it to progress updates: @@ -144,7 +191,7 @@ The VideoService monitors yt-dlp output and translates it to progress updates: 2. **ffmpeg not available**: Install ffmpeg and add to PATH 3. **Invalid YouTube URL**: URL format validation 4. **Download timeout**: 5-minute timeout for processing -5. **File not found**: Cleanup or missing download directory +5. **File not found**: File may have been cleaned up or job ID invalid ### Error Response Format: ```json @@ -157,18 +204,6 @@ The VideoService monitors yt-dlp output and translates it to progress updates: } ``` -## File Management - -### Download Directory -- Location: `wwwroot/downloads/` -- Naming: `{videoId}_{timestamp}.{ext}` -- Auto-cleanup: Implement retention policies as needed - -### File Serving -- Static files: Direct access via `/downloads/{fileName}` -- API download: Controlled access via `/api/download/{jobId}` -- Content-Type: Automatic detection based on file extension - ## Configuration ### appsettings.json @@ -189,6 +224,7 @@ The VideoService monitors yt-dlp output and translates it to progress updates: - `--no-playlist`: Single video only - `--no-warnings`: Clean output - `--newline`: Line-by-line progress +- `--output "{jobId}_{title}.%(ext)s"`: Uses job ID-based filename ## Testing @@ -196,7 +232,15 @@ The VideoService monitors yt-dlp output and translates it to progress updates: 1. Start the API: `dotnet run --project TuneRipper.Api` 2. Use API testing tools or the Blazor client 3. Check logs for yt-dlp progress -4. Verify files in `wwwroot/downloads/` +4. Verify files in `wwwroot/downloads/` with GUID-based naming + +### Example Test Files: +``` +wwwroot/downloads/ +??? 550e8400-e29b-41d4-a716-446655440000_Rick_Astley_Never_Gonna_Give_You_Up.mp3 +??? 6ba7b810-9dad-11d1-80b4-00c04fd430c8_How_to_Build_a_Web_API.mp3 +??? 6ba7b811-9dad-11d1-80b4-00c04fd430c8_Amazing_Guitar_Solo_Performance.mp3 +``` ### Health Check: ```http @@ -211,11 +255,12 @@ GET /api/video/jobs ## Production Considerations 1. **File Storage**: Consider cloud storage for production -2. **Cleanup**: Implement automatic file cleanup/retention -3. **Security**: Validate URLs and sanitize filenames +2. **Cleanup Strategy**: Implement automatic file cleanup/retention using job completion timestamps +3. **Security**: Job IDs provide secure file access (no directory traversal) 4. **Performance**: Consider queue-based processing for high load 5. **Monitoring**: Add detailed logging and metrics 6. **Updates**: Keep yt-dlp.exe updated for compatibility +7. **Scalability**: GUID-based naming prevents conflicts in distributed systems ## Troubleshooting @@ -224,6 +269,7 @@ GET /api/video/jobs 2. **Antivirus**: yt-dlp.exe may be flagged by antivirus 3. **Network**: YouTube may block requests from some IPs 4. **Format Support**: Some videos may not support audio extraction +5. **Long Titles**: Titles are truncated to 50 characters for filesystem compatibility ### Debug Logging: Enable debug logging for VideoService to see detailed yt-dlp output: @@ -234,5 +280,17 @@ Enable debug logging for VideoService to see detailed yt-dlp output: "TuneRipper.Api.Services.VideoService": "Debug" } } -} -``` \ No newline at end of file +``` + +### File Lookup Issues: +- Verify job ID matches the GUID format +- Check that files exist with the `{jobId}_` prefix +- Look at directory contents if files are not found +- Ensure proper case sensitivity on Linux systems + +### Advantages of GUID-Based Naming: +1. **Unique**: No filename conflicts possible +2. **Secure**: Job ID acts as access token +3. **Traceable**: Direct mapping from API calls to files +4. **Scalable**: Works in distributed environments +5. **Clean**: Easy cleanup by job ID pattern \ No newline at end of file diff --git a/TuneRipper.Api/Services/VideoService.cs b/TuneRipper.Api/Services/VideoService.cs index 65dcb70..289e00c 100644 --- a/TuneRipper.Api/Services/VideoService.cs +++ b/TuneRipper.Api/Services/VideoService.cs @@ -13,7 +13,7 @@ public interface IVideoService Task GetVideoDetailsAsync(string url); bool IsValidYouTubeUrl(string url); string ExtractVideoId(string url); - Task ProcessVideoWithProgressAsync(string url, string format, Action progressCallback); + Task ProcessVideoWithProgressAsync(string url, string format, string jobId, Action progressCallback); } public class VideoService : IVideoService @@ -90,7 +90,7 @@ public class VideoService : IVideoService } } - public async Task ProcessVideoWithProgressAsync(string url, string format, Action progressCallback) + public async Task ProcessVideoWithProgressAsync(string url, string format, string jobId, Action progressCallback) { try { @@ -105,10 +105,15 @@ public class VideoService : IVideoService var downloadDir = Path.Combine(_environment.WebRootPath, "downloads"); Directory.CreateDirectory(downloadDir); - // Generate safe filename - var videoId = ExtractVideoId(url); - var fileName = $"{videoId}_{DateTime.Now:yyyyMMdd_HHmmss}.%(ext)s"; - var outputTemplate = Path.Combine(downloadDir, fileName); + // Get video details first for better filename + var videoDetails = await GetVideoDetailsAsync(url); + + // Generate filename with format: jobId_title.mp3 + var safeTitle = SanitizeFileName(videoDetails?.Title ?? "video"); + var customFileName = $"{jobId}_{safeTitle}"; + var outputTemplate = Path.Combine(downloadDir, $"{customFileName}.%(ext)s"); + + _logger.LogInformation("Processing video with filename pattern: {FileName}", customFileName); // Build yt-dlp arguments var arguments = new StringBuilder(); @@ -183,21 +188,21 @@ public class VideoService : IVideoService throw new Exception($"yt-dlp failed with exit code {process.ExitCode}. Error: {error}"); } - // Find the downloaded file - var downloadedFiles = Directory.GetFiles(downloadDir, $"{videoId}_*.*") + // Find the downloaded file with our job ID pattern + var downloadedFiles = Directory.GetFiles(downloadDir, $"{jobId}_*.*") .Where(f => !f.EndsWith(".part")) - .OrderByDescending(File.GetCreationTime) + .OrderByDescending(System.IO.File.GetCreationTime) .ToArray(); if (downloadedFiles.Length == 0) { - throw new Exception("No downloaded file found after processing"); + throw new Exception($"No downloaded file found for job {jobId} after processing"); } var downloadedFile = downloadedFiles[0]; var finalFileName = Path.GetFileName(downloadedFile); - _logger.LogInformation("Successfully processed video. File: {FileName}", finalFileName); + _logger.LogInformation("Successfully processed video. Job: {JobId}, File: {FileName}", jobId, finalFileName); progressCallback(100, "Processing completed successfully"); @@ -205,12 +210,75 @@ public class VideoService : IVideoService } catch (Exception ex) { - _logger.LogError(ex, "Error processing video with yt-dlp: {Url}", url); + _logger.LogError(ex, "Error processing video with yt-dlp for job {JobId}: {Url}", jobId, url); progressCallback(0, $"Processing failed: {ex.Message}"); throw; } } + private string SanitizeFileName(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return "video"; + } + + // Remove or replace invalid filename characters + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new StringBuilder(); + + foreach (char c in fileName.Take(50)) // Limit length to 50 characters + { + if (invalidChars.Contains(c)) + { + // Replace invalid characters with underscores or safe alternatives + switch (c) + { + case '/': + case '\\': + case '?': + case '*': + sanitized.Append('_'); + break; + case ':': + case '|': + sanitized.Append('-'); + break; + case '<': + sanitized.Append('('); + break; + case '>': + sanitized.Append(')'); + break; + case '"': + sanitized.Append('\''); + break; + default: + sanitized.Append('_'); + break; + } + } + else if (char.IsWhiteSpace(c)) + { + sanitized.Append('_'); + } + else + { + sanitized.Append(c); + } + } + + var result = sanitized.ToString().Trim('_'); + + // Ensure we have a valid filename + if (string.IsNullOrWhiteSpace(result)) + { + result = "video"; + } + + return result; + } + private void ParseProgressFromOutput(string output, Action progressCallback) { try